@syncular/client 0.15.48 → 0.16.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 +42 -0
- package/dist/client.d.ts +13 -20
- package/dist/client.js +25 -43
- package/dist/outbox.d.ts +5 -1
- package/dist/outbox.js +26 -3
- package/dist/reactive-store.d.ts +12 -6
- package/dist/reactive-store.js +219 -45
- package/dist/realtime-supervisor.d.ts +1 -1
- package/dist/realtime-supervisor.js +2 -8
- package/dist/worker-entry.js +6 -10
- package/dist/worker-host.d.ts +4 -7
- package/dist/worker-host.js +3 -15
- package/dist/worker-protocol.d.ts +4 -18
- package/package.json +3 -11
- package/src/client.ts +59 -47
- package/src/outbox.ts +39 -8
- package/src/reactive-store.ts +226 -56
- package/src/realtime-supervisor.ts +2 -11
- package/src/worker-entry.ts +7 -10
- package/src/worker-host.ts +5 -22
- package/src/worker-protocol.ts +7 -33
- package/dist/realtime-supervisor-observation.d.ts +0 -8
- package/dist/realtime-supervisor-observation.js +0 -15
- package/src/realtime-supervisor-observation.ts +0 -21
package/src/reactive-store.ts
CHANGED
|
@@ -4,12 +4,14 @@ import {
|
|
|
4
4
|
} from './availability';
|
|
5
5
|
import type {
|
|
6
6
|
CommitOutcome,
|
|
7
|
+
ClientSnapshotReader,
|
|
7
8
|
QueryReadSpec,
|
|
8
9
|
QuerySnapshot,
|
|
9
10
|
WindowCoverage,
|
|
10
11
|
WindowState,
|
|
11
12
|
} from './client';
|
|
12
13
|
import type { SqlValue } from './database';
|
|
14
|
+
import { ClientSyncError } from './errors';
|
|
13
15
|
import type {
|
|
14
16
|
ClientChangeBatch,
|
|
15
17
|
ClientChangeListener,
|
|
@@ -50,24 +52,19 @@ export interface LiveQueryResult<Row> {
|
|
|
50
52
|
readonly availability: SyncAvailability;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
export interface ReactiveQueryClient
|
|
55
|
+
export interface ReactiveQueryClient extends Pick<
|
|
56
|
+
ClientSnapshotReader,
|
|
57
|
+
'statusSnapshot' | 'commitOutcomes'
|
|
58
|
+
> {
|
|
54
59
|
readonly currentSchemaVersion?: number;
|
|
55
60
|
onChange(listener: ClientChangeListener): () => void;
|
|
56
61
|
querySnapshot<Row = Record<string, SqlValue>>(
|
|
57
62
|
spec: QueryReadSpec,
|
|
58
63
|
): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
59
|
-
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
60
64
|
leadershipSnapshot?(): LeadershipState | undefined;
|
|
61
65
|
onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
|
|
62
|
-
readonly
|
|
63
|
-
|
|
64
|
-
| (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
65
|
-
readonly rejections:
|
|
66
|
-
| readonly unknown[]
|
|
67
|
-
| (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
68
|
-
commitOutcomes():
|
|
69
|
-
| readonly CommitOutcome[]
|
|
70
|
-
| Promise<readonly CommitOutcome[]>;
|
|
66
|
+
conflicts(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
67
|
+
rejections(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
71
68
|
setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
|
|
72
69
|
windowState(base: WindowBase): WindowState | Promise<WindowState>;
|
|
73
70
|
}
|
|
@@ -112,14 +109,6 @@ function errorOf(value: unknown): Error {
|
|
|
112
109
|
return value instanceof Error ? value : new Error(String(value));
|
|
113
110
|
}
|
|
114
111
|
|
|
115
|
-
function readCollection(
|
|
116
|
-
value:
|
|
117
|
-
| readonly unknown[]
|
|
118
|
-
| (() => readonly unknown[] | Promise<readonly unknown[]>),
|
|
119
|
-
): readonly unknown[] | Promise<readonly unknown[]> {
|
|
120
|
-
return typeof value === 'function' ? value() : value;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
112
|
function unsupportedCanonicalValue(value: unknown): never {
|
|
124
113
|
const description = Object.prototype.toString.call(value);
|
|
125
114
|
throw new TypeError(
|
|
@@ -303,6 +292,56 @@ function reconcileRows<Row>(
|
|
|
303
292
|
: next;
|
|
304
293
|
}
|
|
305
294
|
|
|
295
|
+
interface CachedObservation {
|
|
296
|
+
onChange(batch: ClientChangeBatch): void;
|
|
297
|
+
reset(): void;
|
|
298
|
+
dispose(): void;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Shared ownership for query and window observations, including abandoned renders. */
|
|
302
|
+
class ObservationCache {
|
|
303
|
+
readonly #entries = new Map<string, CachedObservation>();
|
|
304
|
+
readonly #active = new Set<CachedObservation>();
|
|
305
|
+
get size(): number {
|
|
306
|
+
return this.#entries.size;
|
|
307
|
+
}
|
|
308
|
+
get activeSize(): number {
|
|
309
|
+
return this.#active.size;
|
|
310
|
+
}
|
|
311
|
+
get(key: string): CachedObservation | undefined {
|
|
312
|
+
return this.#entries.get(key);
|
|
313
|
+
}
|
|
314
|
+
add(key: string, entry: CachedObservation): void {
|
|
315
|
+
this.#entries.set(key, entry);
|
|
316
|
+
this.#cleanup(key, entry);
|
|
317
|
+
}
|
|
318
|
+
activate(key: string, candidate: CachedObservation): CachedObservation {
|
|
319
|
+
const entry = this.#entries.get(key) ?? candidate;
|
|
320
|
+
this.#entries.set(key, entry);
|
|
321
|
+
this.#active.add(entry);
|
|
322
|
+
return entry;
|
|
323
|
+
}
|
|
324
|
+
deactivate(key: string, entry: CachedObservation): void {
|
|
325
|
+
this.#active.delete(entry);
|
|
326
|
+
this.#cleanup(key, entry);
|
|
327
|
+
}
|
|
328
|
+
#cleanup(key: string, entry: CachedObservation): void {
|
|
329
|
+
scheduleMicrotask(() => {
|
|
330
|
+
if (this.#active.has(entry)) return;
|
|
331
|
+
if (this.#entries.get(key) === entry) this.#entries.delete(key);
|
|
332
|
+
entry.reset();
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
onChange(batch: ClientChangeBatch): void {
|
|
336
|
+
for (const entry of this.#active) entry.onChange(batch);
|
|
337
|
+
}
|
|
338
|
+
clear(): void {
|
|
339
|
+
for (const entry of this.#entries.values()) entry.dispose();
|
|
340
|
+
this.#entries.clear();
|
|
341
|
+
this.#active.clear();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
306
345
|
class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
307
346
|
readonly #owner = Symbol('query-window-claim');
|
|
308
347
|
readonly #listeners = new Set<() => void>();
|
|
@@ -314,7 +353,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
314
353
|
isRefreshing: false,
|
|
315
354
|
availability: { state: 'ready' },
|
|
316
355
|
};
|
|
317
|
-
#
|
|
356
|
+
#delegate: QueryEntry<Row> | undefined;
|
|
357
|
+
#generation = 0;
|
|
318
358
|
#scheduled = false;
|
|
319
359
|
#running = false;
|
|
320
360
|
#requested = false;
|
|
@@ -325,14 +365,23 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
325
365
|
constructor(
|
|
326
366
|
readonly store: ReactiveClientStore,
|
|
327
367
|
readonly spec: ReactiveQuerySpec<Row>,
|
|
368
|
+
private readonly key: string,
|
|
369
|
+
private readonly cache: ObservationCache,
|
|
328
370
|
) {}
|
|
329
371
|
|
|
330
|
-
getSnapshot = (): LiveQueryResult<Row> =>
|
|
372
|
+
getSnapshot = (): LiveQueryResult<Row> =>
|
|
373
|
+
this.#delegate?.getSnapshot() ?? this.#state;
|
|
331
374
|
|
|
332
375
|
subscribe = (listener: () => void): (() => void) => {
|
|
333
|
-
this
|
|
334
|
-
this
|
|
335
|
-
|
|
376
|
+
const current = this.cache.activate(this.key, this);
|
|
377
|
+
if (current !== this) {
|
|
378
|
+
this.#delegate = current as QueryEntry<Row>;
|
|
379
|
+
return this.#delegate.subscribe(listener);
|
|
380
|
+
}
|
|
381
|
+
this.#delegate = undefined;
|
|
382
|
+
const notify = () => listener();
|
|
383
|
+
this.#listeners.add(notify);
|
|
384
|
+
if (this.#listeners.size === 1) {
|
|
336
385
|
this.#offStatus = this.store.status.subscribe(() =>
|
|
337
386
|
this.#onAvailabilityChange(),
|
|
338
387
|
);
|
|
@@ -349,13 +398,16 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
349
398
|
);
|
|
350
399
|
}
|
|
351
400
|
this.#claimReady = Promise.all(claims).then(() => undefined);
|
|
401
|
+
// A render can lose its last subscriber before the read loop starts.
|
|
402
|
+
// The loop still observes the original rejection while it owns the claim.
|
|
403
|
+
void this.#claimReady.catch(() => undefined);
|
|
352
404
|
}
|
|
353
405
|
this.#requestRead();
|
|
354
406
|
}
|
|
355
407
|
return () => {
|
|
356
|
-
if (!this.#listeners.delete(
|
|
357
|
-
this.#
|
|
358
|
-
|
|
408
|
+
if (!this.#listeners.delete(notify)) return;
|
|
409
|
+
if (this.#listeners.size === 0) {
|
|
410
|
+
this.cache.deactivate(this.key, this);
|
|
359
411
|
this.#offStatus?.();
|
|
360
412
|
this.#offStatus = undefined;
|
|
361
413
|
this.store.releaseWindowClaims(this.#owner);
|
|
@@ -363,14 +415,42 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
363
415
|
};
|
|
364
416
|
};
|
|
365
417
|
|
|
366
|
-
refresh = (): void =>
|
|
418
|
+
refresh = (): void => {
|
|
419
|
+
if (this.#delegate !== undefined) this.#delegate.refresh();
|
|
420
|
+
else this.#requestRead(true);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
reset(): void {
|
|
424
|
+
this.#generation += 1;
|
|
425
|
+
this.#scheduled = false;
|
|
426
|
+
this.#running = false;
|
|
427
|
+
this.#requested = false;
|
|
428
|
+
this.#desiredRevision = 0n;
|
|
429
|
+
this.#claimReady = Promise.resolve();
|
|
430
|
+
this.#state = {
|
|
431
|
+
rows: [],
|
|
432
|
+
phase: 'loading',
|
|
433
|
+
revision: undefined,
|
|
434
|
+
error: undefined,
|
|
435
|
+
isRefreshing: false,
|
|
436
|
+
availability: { state: 'ready' },
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
dispose(): void {
|
|
441
|
+
this.#listeners.clear();
|
|
442
|
+
this.#offStatus?.();
|
|
443
|
+
this.#offStatus = undefined;
|
|
444
|
+
this.store.releaseWindowClaims(this.#owner);
|
|
445
|
+
this.reset();
|
|
446
|
+
}
|
|
367
447
|
|
|
368
448
|
onChange(batch: ClientChangeBatch): void {
|
|
369
449
|
if (!batchMatches(batch, this.spec)) return;
|
|
370
450
|
if (batch.revision > this.#desiredRevision) {
|
|
371
451
|
this.#desiredRevision = batch.revision;
|
|
372
452
|
}
|
|
373
|
-
if (this.#
|
|
453
|
+
if (this.#listeners.size > 0) this.#requestRead();
|
|
374
454
|
}
|
|
375
455
|
|
|
376
456
|
#publish(next: LiveQueryResult<Row>): void {
|
|
@@ -389,6 +469,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
389
469
|
}
|
|
390
470
|
|
|
391
471
|
#requestRead(refreshing = false): void {
|
|
472
|
+
if (this.#listeners.size === 0) return;
|
|
392
473
|
this.#requested = true;
|
|
393
474
|
if (
|
|
394
475
|
refreshing &&
|
|
@@ -399,7 +480,9 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
399
480
|
}
|
|
400
481
|
if (this.#scheduled || this.#running) return;
|
|
401
482
|
this.#scheduled = true;
|
|
483
|
+
const generation = this.#generation;
|
|
402
484
|
scheduleMicrotask(() => {
|
|
485
|
+
if (generation !== this.#generation) return;
|
|
403
486
|
this.#scheduled = false;
|
|
404
487
|
void this.#readLoop();
|
|
405
488
|
});
|
|
@@ -430,13 +513,16 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
430
513
|
}
|
|
431
514
|
|
|
432
515
|
async #readLoop(): Promise<void> {
|
|
433
|
-
if (this.#running || this.#
|
|
516
|
+
if (this.#running || this.#listeners.size === 0) return;
|
|
517
|
+
const generation = this.#generation;
|
|
434
518
|
this.#running = true;
|
|
435
519
|
try {
|
|
436
520
|
do {
|
|
437
521
|
this.#requested = false;
|
|
438
522
|
if (this.store.availabilitySnapshot().state === 'blocked') break;
|
|
439
523
|
await this.#claimReady;
|
|
524
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
525
|
+
return;
|
|
440
526
|
const snapshot = await this.store.client.querySnapshot<
|
|
441
527
|
Record<string, SqlValue>
|
|
442
528
|
>({
|
|
@@ -448,6 +534,8 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
448
534
|
? { coverage: this.spec.coverage }
|
|
449
535
|
: {}),
|
|
450
536
|
});
|
|
537
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
538
|
+
return;
|
|
451
539
|
const availability = this.store.availabilitySnapshot();
|
|
452
540
|
if (availability.state === 'blocked') {
|
|
453
541
|
this.#publish({
|
|
@@ -484,8 +572,9 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
484
572
|
isRefreshing: false,
|
|
485
573
|
availability,
|
|
486
574
|
});
|
|
487
|
-
} while (this.#requested && this.#
|
|
575
|
+
} while (this.#requested && this.#listeners.size > 0);
|
|
488
576
|
} catch (error) {
|
|
577
|
+
if (generation !== this.#generation || this.#listeners.size === 0) return;
|
|
489
578
|
const wrapped = errorOf(error);
|
|
490
579
|
this.#publish({
|
|
491
580
|
...this.#state,
|
|
@@ -495,13 +584,16 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
495
584
|
availability: this.store.availabilitySnapshot(),
|
|
496
585
|
});
|
|
497
586
|
} finally {
|
|
498
|
-
this.#
|
|
499
|
-
|
|
587
|
+
if (generation === this.#generation) {
|
|
588
|
+
this.#running = false;
|
|
589
|
+
if (this.#requested && this.#listeners.size > 0) this.#requestRead();
|
|
590
|
+
}
|
|
500
591
|
}
|
|
501
592
|
}
|
|
502
593
|
}
|
|
503
594
|
|
|
504
595
|
class ValueEntry<T> implements ExternalStoreEntry<T> {
|
|
596
|
+
#generation = 0;
|
|
505
597
|
readonly #listeners = new Set<() => void>();
|
|
506
598
|
constructor(
|
|
507
599
|
private value: T,
|
|
@@ -513,9 +605,16 @@ class ValueEntry<T> implements ExternalStoreEntry<T> {
|
|
|
513
605
|
return () => this.#listeners.delete(listener);
|
|
514
606
|
};
|
|
515
607
|
refresh = (): void => {
|
|
516
|
-
|
|
608
|
+
const generation = ++this.#generation;
|
|
609
|
+
void this.read().then((next) => {
|
|
610
|
+
if (generation === this.#generation) this.set(next);
|
|
611
|
+
});
|
|
517
612
|
};
|
|
613
|
+
invalidate(): void {
|
|
614
|
+
this.#generation += 1;
|
|
615
|
+
}
|
|
518
616
|
set(next: T): void {
|
|
617
|
+
this.invalidate();
|
|
519
618
|
if (next === this.value) return;
|
|
520
619
|
this.value = next;
|
|
521
620
|
for (const listener of this.#listeners) listener();
|
|
@@ -538,6 +637,8 @@ interface WindowClaimGroup {
|
|
|
538
637
|
class WindowEntry implements ExternalStoreEntry<WindowState> {
|
|
539
638
|
readonly #listeners = new Set<() => void>();
|
|
540
639
|
#state: WindowState = { units: [], pending: [] };
|
|
640
|
+
#generation = 0;
|
|
641
|
+
#delegate: WindowEntry | undefined;
|
|
541
642
|
#running = false;
|
|
542
643
|
#requested = false;
|
|
543
644
|
|
|
@@ -545,19 +646,46 @@ class WindowEntry implements ExternalStoreEntry<WindowState> {
|
|
|
545
646
|
readonly store: ReactiveClientStore,
|
|
546
647
|
readonly base: WindowBase,
|
|
547
648
|
readonly baseKey: string,
|
|
649
|
+
private readonly cache: ObservationCache,
|
|
548
650
|
) {}
|
|
549
651
|
|
|
550
|
-
getSnapshot = (): WindowState => this.#state;
|
|
652
|
+
getSnapshot = (): WindowState => this.#delegate?.getSnapshot() ?? this.#state;
|
|
551
653
|
subscribe = (listener: () => void): (() => void) => {
|
|
552
|
-
this
|
|
654
|
+
const current = this.cache.activate(this.baseKey, this);
|
|
655
|
+
if (current !== this) {
|
|
656
|
+
this.#delegate = current as WindowEntry;
|
|
657
|
+
return this.#delegate.subscribe(listener);
|
|
658
|
+
}
|
|
659
|
+
this.#delegate = undefined;
|
|
660
|
+
const notify = () => listener();
|
|
661
|
+
this.#listeners.add(notify);
|
|
553
662
|
if (this.#listeners.size === 1) this.refresh();
|
|
554
|
-
return () =>
|
|
663
|
+
return () => {
|
|
664
|
+
if (this.#listeners.delete(notify) && this.#listeners.size === 0) {
|
|
665
|
+
this.cache.deactivate(this.baseKey, this);
|
|
666
|
+
}
|
|
667
|
+
};
|
|
555
668
|
};
|
|
556
669
|
refresh = (): void => {
|
|
670
|
+
if (this.#delegate !== undefined) {
|
|
671
|
+
this.#delegate.refresh();
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (this.#listeners.size === 0) return;
|
|
557
675
|
this.#requested = true;
|
|
558
676
|
if (this.#running) return;
|
|
559
677
|
void this.#readLoop();
|
|
560
678
|
};
|
|
679
|
+
reset(): void {
|
|
680
|
+
this.#generation += 1;
|
|
681
|
+
this.#running = false;
|
|
682
|
+
this.#requested = false;
|
|
683
|
+
this.#state = { units: [], pending: [] };
|
|
684
|
+
}
|
|
685
|
+
dispose(): void {
|
|
686
|
+
this.#listeners.clear();
|
|
687
|
+
this.reset();
|
|
688
|
+
}
|
|
561
689
|
onChange(batch: ClientChangeBatch): void {
|
|
562
690
|
if (
|
|
563
691
|
batch.windows.some((change) => change.baseKey === this.baseKey) &&
|
|
@@ -567,11 +695,14 @@ class WindowEntry implements ExternalStoreEntry<WindowState> {
|
|
|
567
695
|
}
|
|
568
696
|
}
|
|
569
697
|
async #readLoop(): Promise<void> {
|
|
698
|
+
const generation = this.#generation;
|
|
570
699
|
this.#running = true;
|
|
571
700
|
try {
|
|
572
701
|
do {
|
|
573
702
|
this.#requested = false;
|
|
574
703
|
const next = await this.store.client.windowState(this.base);
|
|
704
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
705
|
+
return;
|
|
575
706
|
if (
|
|
576
707
|
canonicalValue(next.units) !== canonicalValue(this.#state.units) ||
|
|
577
708
|
canonicalValue(next.pending) !== canonicalValue(this.#state.pending)
|
|
@@ -579,20 +710,22 @@ class WindowEntry implements ExternalStoreEntry<WindowState> {
|
|
|
579
710
|
this.#state = next;
|
|
580
711
|
for (const listener of this.#listeners) listener();
|
|
581
712
|
}
|
|
582
|
-
} while (this.#requested);
|
|
713
|
+
} while (this.#requested && this.#listeners.size > 0);
|
|
583
714
|
} catch {
|
|
584
715
|
// WindowState predates the error-bearing query result. Keep the last
|
|
585
716
|
// coherent snapshot; a later exact window event or refresh retries.
|
|
586
717
|
} finally {
|
|
587
|
-
this.#
|
|
588
|
-
|
|
718
|
+
if (generation === this.#generation) {
|
|
719
|
+
this.#running = false;
|
|
720
|
+
if (this.#requested) this.refresh();
|
|
721
|
+
}
|
|
589
722
|
}
|
|
590
723
|
}
|
|
591
724
|
}
|
|
592
725
|
|
|
593
726
|
export class ReactiveClientStore {
|
|
594
|
-
readonly #queries = new
|
|
595
|
-
readonly #windows = new
|
|
727
|
+
readonly #queries = new ObservationCache();
|
|
728
|
+
readonly #windows = new ObservationCache();
|
|
596
729
|
readonly #windowClaims = new Map<string, WindowClaimGroup>();
|
|
597
730
|
#offChange: (() => void) | undefined;
|
|
598
731
|
#offLeadership: (() => void) | undefined;
|
|
@@ -631,8 +764,8 @@ export class ReactiveClientStore {
|
|
|
631
764
|
async () => {
|
|
632
765
|
try {
|
|
633
766
|
const [found, rejected] = await Promise.all([
|
|
634
|
-
|
|
635
|
-
|
|
767
|
+
client.conflicts(),
|
|
768
|
+
client.rejections(),
|
|
636
769
|
]);
|
|
637
770
|
return {
|
|
638
771
|
conflicts: found,
|
|
@@ -667,9 +800,6 @@ export class ReactiveClientStore {
|
|
|
667
800
|
this.status = status;
|
|
668
801
|
this.conflicts = conflicts;
|
|
669
802
|
this.outcomes = outcomes;
|
|
670
|
-
status.refresh();
|
|
671
|
-
conflicts.refresh();
|
|
672
|
-
outcomes.refresh();
|
|
673
803
|
this.start();
|
|
674
804
|
}
|
|
675
805
|
|
|
@@ -700,8 +830,8 @@ export class ReactiveClientStore {
|
|
|
700
830
|
});
|
|
701
831
|
let entry = this.#queries.get(key) as QueryEntry<Row> | undefined;
|
|
702
832
|
if (entry === undefined) {
|
|
703
|
-
entry = new QueryEntry(this, spec);
|
|
704
|
-
this.#queries.
|
|
833
|
+
entry = new QueryEntry(this, spec, key, this.#queries);
|
|
834
|
+
this.#queries.add(key, entry);
|
|
705
835
|
}
|
|
706
836
|
return entry;
|
|
707
837
|
}
|
|
@@ -739,10 +869,10 @@ export class ReactiveClientStore {
|
|
|
739
869
|
|
|
740
870
|
window(base: WindowBase): ExternalStoreEntry<WindowState> {
|
|
741
871
|
const key = windowBaseKey(base);
|
|
742
|
-
let entry = this.#windows.get(key);
|
|
872
|
+
let entry = this.#windows.get(key) as WindowEntry | undefined;
|
|
743
873
|
if (entry === undefined) {
|
|
744
|
-
entry = new WindowEntry(this, base, key);
|
|
745
|
-
this.#windows.
|
|
874
|
+
entry = new WindowEntry(this, base, key, this.#windows);
|
|
875
|
+
this.#windows.add(key, entry);
|
|
746
876
|
}
|
|
747
877
|
return entry;
|
|
748
878
|
}
|
|
@@ -791,7 +921,8 @@ export class ReactiveClientStore {
|
|
|
791
921
|
}
|
|
792
922
|
|
|
793
923
|
async #flushWindow(group: WindowClaimGroup): Promise<void> {
|
|
794
|
-
|
|
924
|
+
const baseKey = windowBaseKey(group.base);
|
|
925
|
+
if (group.running || this.#windowClaims.get(baseKey) !== group) return;
|
|
795
926
|
group.running = true;
|
|
796
927
|
try {
|
|
797
928
|
while (group.requested) {
|
|
@@ -802,6 +933,7 @@ export class ReactiveClientStore {
|
|
|
802
933
|
const key = canonicalValue(units);
|
|
803
934
|
if (key !== group.appliedKey) {
|
|
804
935
|
await this.client.setWindow(group.base, units);
|
|
936
|
+
if (this.#windowClaims.get(baseKey) !== group) return;
|
|
805
937
|
group.appliedKey = key;
|
|
806
938
|
}
|
|
807
939
|
}
|
|
@@ -810,15 +942,36 @@ export class ReactiveClientStore {
|
|
|
810
942
|
for (const waiter of group.waiters.splice(0)) waiter.reject(error);
|
|
811
943
|
} finally {
|
|
812
944
|
group.running = false;
|
|
813
|
-
if (group.requested
|
|
945
|
+
if (group.requested && this.#windowClaims.get(baseKey) === group)
|
|
946
|
+
this.#scheduleWindow(group);
|
|
947
|
+
else if (
|
|
948
|
+
group.claims.size === 0 &&
|
|
949
|
+
group.waiters.length === 0 &&
|
|
950
|
+
group.appliedKey === canonicalValue([])
|
|
951
|
+
) {
|
|
952
|
+
const key = windowBaseKey(group.base);
|
|
953
|
+
if (this.#windowClaims.get(key) === group)
|
|
954
|
+
this.#windowClaims.delete(key);
|
|
955
|
+
}
|
|
814
956
|
}
|
|
815
957
|
}
|
|
816
958
|
|
|
959
|
+
/** Retained observation counts for diagnostics and resource benchmarks. */
|
|
960
|
+
cacheStats() {
|
|
961
|
+
return {
|
|
962
|
+
queries: this.#queries.size,
|
|
963
|
+
activeQueries: this.#queries.activeSize,
|
|
964
|
+
windows: this.#windows.size,
|
|
965
|
+
activeWindows: this.#windows.activeSize,
|
|
966
|
+
windowClaims: this.#windowClaims.size,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
817
970
|
start(): void {
|
|
818
971
|
if (this.#offChange !== undefined) return;
|
|
819
972
|
this.#offChange = this.client.onChange((batch) => {
|
|
820
|
-
|
|
821
|
-
|
|
973
|
+
this.#queries.onChange(batch);
|
|
974
|
+
this.#windows.onChange(batch);
|
|
822
975
|
if (batch.status !== undefined) {
|
|
823
976
|
(this.status as ValueEntry<StatusStoreSnapshot>).set({
|
|
824
977
|
status: batch.status,
|
|
@@ -838,15 +991,32 @@ export class ReactiveClientStore {
|
|
|
838
991
|
...previous,
|
|
839
992
|
leadership,
|
|
840
993
|
});
|
|
994
|
+
if (previous.isLoading) this.status.refresh();
|
|
841
995
|
});
|
|
996
|
+
this.status.refresh();
|
|
997
|
+
this.conflicts.refresh();
|
|
998
|
+
this.outcomes.refresh();
|
|
842
999
|
}
|
|
843
1000
|
|
|
844
1001
|
dispose(): void {
|
|
1002
|
+
this.#queries.clear();
|
|
1003
|
+
this.#windows.clear();
|
|
1004
|
+
for (const entry of [this.status, this.conflicts, this.outcomes]) {
|
|
1005
|
+
(entry as ValueEntry<unknown>).invalidate();
|
|
1006
|
+
}
|
|
845
1007
|
this.#offChange?.();
|
|
846
1008
|
this.#offChange = undefined;
|
|
847
1009
|
this.#offLeadership?.();
|
|
848
1010
|
this.#offLeadership = undefined;
|
|
849
1011
|
for (const group of this.#windowClaims.values()) {
|
|
1012
|
+
for (const waiter of group.waiters.splice(0)) {
|
|
1013
|
+
waiter.reject(
|
|
1014
|
+
new ClientSyncError(
|
|
1015
|
+
'client.reactive_store_disposed',
|
|
1016
|
+
'reactive store disposed',
|
|
1017
|
+
),
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
850
1020
|
// Releasing a window is best-effort teardown. A resource owner may have
|
|
851
1021
|
// already closed the underlying worker/native handle before React effect
|
|
852
1022
|
// cleanup runs (notably during schema-changing HMR). Do not let that
|
|
@@ -4,9 +4,6 @@ import type {
|
|
|
4
4
|
ClientDiagnosticsListener,
|
|
5
5
|
ClientDiagnosticsSnapshot,
|
|
6
6
|
} from './diagnostics';
|
|
7
|
-
import { realtimeSupervisorObservationSource } from './realtime-supervisor-observation';
|
|
8
|
-
|
|
9
|
-
export { linkRealtimeSupervisorObservation } from './realtime-supervisor-observation';
|
|
10
7
|
|
|
11
8
|
type CancelTimer = () => void;
|
|
12
9
|
|
|
@@ -103,20 +100,14 @@ interface RealtimeSupervisorAttachment {
|
|
|
103
100
|
readonly supervisor: RealtimeSupervisor;
|
|
104
101
|
}
|
|
105
102
|
|
|
106
|
-
function attachment(
|
|
107
|
-
client: object,
|
|
108
|
-
visited: Set<object> = new Set(),
|
|
109
|
-
): RealtimeSupervisorAttachment | undefined {
|
|
110
|
-
if (visited.has(client)) return undefined;
|
|
111
|
-
visited.add(client);
|
|
103
|
+
function attachment(client: object): RealtimeSupervisorAttachment | undefined {
|
|
112
104
|
const candidate = Reflect.get(client, REALTIME_SUPERVISOR_KEY) as
|
|
113
105
|
| Partial<RealtimeSupervisorAttachment>
|
|
114
106
|
| undefined;
|
|
115
107
|
if (candidate?.version === 1 && candidate.supervisor) {
|
|
116
108
|
return candidate as RealtimeSupervisorAttachment;
|
|
117
109
|
}
|
|
118
|
-
|
|
119
|
-
return source === undefined ? undefined : attachment(source, visited);
|
|
110
|
+
return undefined;
|
|
120
111
|
}
|
|
121
112
|
|
|
122
113
|
function scheduleTimer(callback: () => void, delayMs: number): CancelTimer {
|
package/src/worker-entry.ts
CHANGED
|
@@ -151,7 +151,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
151
151
|
if (
|
|
152
152
|
closed ||
|
|
153
153
|
client === undefined ||
|
|
154
|
-
client.securityLifecycle === 'preflight'
|
|
154
|
+
client.securityLifecycle() === 'preflight'
|
|
155
155
|
) {
|
|
156
156
|
return;
|
|
157
157
|
}
|
|
@@ -175,7 +175,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
175
175
|
!autoSync ||
|
|
176
176
|
closed ||
|
|
177
177
|
client === undefined ||
|
|
178
|
-
client.securityLifecycle === 'preflight' ||
|
|
178
|
+
client.securityLifecycle() === 'preflight' ||
|
|
179
179
|
intent.kind === 'none'
|
|
180
180
|
) {
|
|
181
181
|
return;
|
|
@@ -371,12 +371,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
371
371
|
// `start()` may discover persisted subscriptions/outbox work. Its callback
|
|
372
372
|
// fires before this worker publishes the initialized client, so consume
|
|
373
373
|
// the durable state once here as well; coalescing makes this a single task.
|
|
374
|
-
if (started.syncNeeded)
|
|
374
|
+
if (started.statusSnapshot().syncNeeded)
|
|
375
|
+
consumeSyncIntent({ kind: 'interactive' });
|
|
375
376
|
return { clientId: started.clientId };
|
|
376
377
|
}
|
|
377
378
|
|
|
378
379
|
const api: WorkerApi = {
|
|
379
|
-
securityLifecycle: () => requireClient().securityLifecycle,
|
|
380
|
+
securityLifecycle: () => requireClient().securityLifecycle(),
|
|
380
381
|
beginSecurityPreflight: async () => {
|
|
381
382
|
if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
|
|
382
383
|
backgroundTimer = undefined;
|
|
@@ -435,17 +436,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
435
436
|
realtime: snapshot.host.realtime,
|
|
436
437
|
});
|
|
437
438
|
},
|
|
438
|
-
conflicts: () => requireClient().conflicts,
|
|
439
|
-
rejections: () => requireClient().rejections,
|
|
439
|
+
conflicts: () => requireClient().conflicts(),
|
|
440
|
+
rejections: () => requireClient().rejections(),
|
|
440
441
|
commitOutcome: (clientCommitId) =>
|
|
441
442
|
requireClient().commitOutcome(clientCommitId),
|
|
442
443
|
commitOutcomes: (query) => requireClient().commitOutcomes(query),
|
|
443
444
|
resolveCommitOutcome: (input) =>
|
|
444
445
|
requireClient().resolveCommitOutcome(input),
|
|
445
|
-
schemaFloor: () => requireClient().schemaFloor,
|
|
446
|
-
leaseState: () => requireClient().leaseState,
|
|
447
|
-
upgrading: () => requireClient().upgrading,
|
|
448
|
-
syncNeeded: () => requireClient().syncNeeded,
|
|
449
446
|
pendingCommits: () => requireClient().pendingCommits(),
|
|
450
447
|
subscriptions: () => requireClient().subscriptions(),
|
|
451
448
|
subscription: (id) => requireClient().subscription(id),
|
package/src/worker-host.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PromiseMethods } from './client';
|
|
1
2
|
/**
|
|
2
3
|
* Main-thread side of the worker mode and the
|
|
3
4
|
* multi-tab topology.
|
|
@@ -25,13 +26,11 @@ import type { WakeReason } from '@syncular/core';
|
|
|
25
26
|
import type { BlobRef, CachedBlob } from './blob';
|
|
26
27
|
import type {
|
|
27
28
|
ConflictRecord,
|
|
28
|
-
LeaseState,
|
|
29
29
|
MutationInput,
|
|
30
30
|
PresencePeer,
|
|
31
31
|
QueryReadSpec,
|
|
32
32
|
QuerySnapshot,
|
|
33
33
|
RejectionRecord,
|
|
34
|
-
SchemaFloor,
|
|
35
34
|
SecurityLifecycle,
|
|
36
35
|
SubscribeInput,
|
|
37
36
|
SyncClientLimits,
|
|
@@ -244,7 +243,7 @@ interface LeaderCore {
|
|
|
244
243
|
* promise. `role` is `'leader'` (owns the worker) or `'follower'` (proxies to
|
|
245
244
|
* the leader over the channel). Constructed via {@link createSyncClientHandle}.
|
|
246
245
|
*/
|
|
247
|
-
export class SyncClientHandle {
|
|
246
|
+
export class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
248
247
|
/** True only for a leader handle. Kept for the pre-multiTab contract. */
|
|
249
248
|
get isLeader(): boolean {
|
|
250
249
|
return this.#role === 'leader';
|
|
@@ -324,12 +323,12 @@ export class SyncClientHandle {
|
|
|
324
323
|
ref: this,
|
|
325
324
|
clientId: () => this.#clientId,
|
|
326
325
|
role: () => this.#role,
|
|
327
|
-
outbox: async () => (await this.
|
|
326
|
+
outbox: async () => (await this.statusSnapshot()).outbox,
|
|
328
327
|
subscriptions: () => this.subscriptions(),
|
|
329
328
|
conflicts: async () => (await this.conflicts()).length,
|
|
330
329
|
rejections: async () => (await this.rejections()).length,
|
|
331
|
-
syncNeeded: () => this.
|
|
332
|
-
upgrading: () => this.
|
|
330
|
+
syncNeeded: async () => (await this.statusSnapshot()).syncNeeded,
|
|
331
|
+
upgrading: async () => (await this.statusSnapshot()).upgrading,
|
|
333
332
|
onInvalidate: (listener) => this.onInvalidate(listener),
|
|
334
333
|
});
|
|
335
334
|
}
|
|
@@ -593,23 +592,7 @@ export class SyncClientHandle {
|
|
|
593
592
|
return this.#call('resolveCommitOutcome', [input]);
|
|
594
593
|
}
|
|
595
594
|
|
|
596
|
-
schemaFloor(): Promise<SchemaFloor | undefined> {
|
|
597
|
-
return this.#call('schemaFloor', []);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
leaseState(): Promise<LeaseState | undefined> {
|
|
601
|
-
return this.#call('leaseState', []);
|
|
602
|
-
}
|
|
603
|
-
|
|
604
595
|
/** §7.4.5: true while a schema-bump reset + first re-bootstrap runs. */
|
|
605
|
-
upgrading(): Promise<boolean> {
|
|
606
|
-
return this.#call('upgrading', []);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
syncNeeded(): Promise<boolean> {
|
|
610
|
-
return this.#call('syncNeeded', []);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
596
|
pendingCommits(): Promise<OutboxCommit[]> {
|
|
614
597
|
return this.#call('pendingCommits', []);
|
|
615
598
|
}
|