@syncular/client 0.15.47 → 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 +59 -0
- package/dist/bun-database.d.ts +5 -0
- package/dist/bun-database.js +5 -0
- package/dist/client.d.ts +17 -20
- package/dist/client.js +152 -60
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- 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/remote.d.ts +2 -0
- package/dist/remote.js +10 -2
- package/dist/sync-scheduler.d.ts +23 -0
- package/dist/sync-scheduler.js +122 -0
- package/dist/window.d.ts +5 -0
- package/dist/window.js +39 -0
- package/dist/worker-entry.js +8 -19
- 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/bun-database.ts +11 -0
- package/src/client.ts +203 -63
- package/src/index.ts +1 -0
- package/src/outbox.ts +39 -8
- package/src/reactive-store.ts +226 -56
- package/src/realtime-supervisor.ts +2 -11
- package/src/remote.ts +11 -2
- package/src/sync-scheduler.ts +154 -0
- package/src/window.ts +65 -0
- package/src/worker-entry.ts +10 -21
- 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/remote.ts
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
encodeRemoteOperationRequest,
|
|
11
11
|
encodeRemoteOperationRealtimeMessage,
|
|
12
12
|
encodeRow,
|
|
13
|
-
PROTOCOL_WIRE_VERSION,
|
|
14
13
|
type PushOperation,
|
|
15
14
|
type PushOperationResult,
|
|
16
15
|
type PushResultDetailsFrame,
|
|
@@ -42,6 +41,8 @@ export interface SyncRemoteClientConfig {
|
|
|
42
41
|
readonly operations?: RemoteOperationTransport;
|
|
43
42
|
readonly operationRealtime?: RemoteOperationRealtimeConnector;
|
|
44
43
|
readonly encryption?: EncryptionConfig;
|
|
44
|
+
/** Acquired partition log epoch for restore-safe ordinary commits (§2.1). */
|
|
45
|
+
readonly logEpoch?: string;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export interface RemoteCommitInput {
|
|
@@ -203,11 +204,15 @@ export class SyncRemoteClient {
|
|
|
203
204
|
}
|
|
204
205
|
>();
|
|
205
206
|
readonly #encryption: EncryptionConfig | undefined;
|
|
207
|
+
readonly #logEpoch: string | undefined;
|
|
206
208
|
|
|
207
209
|
constructor(config: SyncRemoteClientConfig) {
|
|
208
210
|
if (config.clientId.length === 0) {
|
|
209
211
|
throw invalid('SyncRemoteClient clientId must be non-empty');
|
|
210
212
|
}
|
|
213
|
+
if (config.logEpoch !== undefined && config.logEpoch.length === 0) {
|
|
214
|
+
throw invalid('SyncRemoteClient logEpoch must be non-empty');
|
|
215
|
+
}
|
|
211
216
|
this.#schema =
|
|
212
217
|
config.schema === undefined
|
|
213
218
|
? undefined
|
|
@@ -217,6 +222,7 @@ export class SyncRemoteClient {
|
|
|
217
222
|
this.#operations = config.operations;
|
|
218
223
|
this.#operationRealtime = config.operationRealtime;
|
|
219
224
|
this.#encryption = config.encryption;
|
|
225
|
+
this.#logEpoch = config.logEpoch;
|
|
220
226
|
}
|
|
221
227
|
|
|
222
228
|
async prepareCommit(input: RemoteCommitInput): Promise<PreparedRemoteCommit> {
|
|
@@ -277,13 +283,16 @@ export class SyncRemoteClient {
|
|
|
277
283
|
return {
|
|
278
284
|
requestId: input.requestId,
|
|
279
285
|
bytes: encodeMessage({
|
|
280
|
-
wireVersion:
|
|
286
|
+
wireVersion: this.#logEpoch === undefined ? 1 : 2,
|
|
281
287
|
msgKind: 'request',
|
|
282
288
|
frames: [
|
|
283
289
|
{
|
|
284
290
|
type: 'REQ_HEADER',
|
|
285
291
|
clientId: this.#clientId,
|
|
286
292
|
schemaVersion: schema.version,
|
|
293
|
+
...(this.#logEpoch !== undefined
|
|
294
|
+
? { logEpoch: this.#logEpoch }
|
|
295
|
+
: {}),
|
|
287
296
|
},
|
|
288
297
|
{
|
|
289
298
|
type: 'PUSH_COMMIT',
|