@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.
@@ -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 = 'loading' | 'partial' | 'ready' | 'error';
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) this.store.releaseWindowClaims(this.#owner);
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<Row>({
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
- snapshot.rows,
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
- { status: undefined, error: undefined, isLoading: true },
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 { status: undefined, error: errorOf(error), isLoading: false };
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
- ): void {
128
- const staleIds = db
129
- .query('SELECT id, tbl FROM _syncular_subscriptions')
130
- .filter((row) => !tableNames.has(String(row.tbl)))
131
- .map((row) => String(row.id));
132
- for (const id of staleIds) {
133
- db.exec('DELETE FROM _syncular_windows WHERE sub_id = ?', [id]);
134
- db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?', [
135
- id,
136
- ]);
137
- db.exec('DELETE FROM _syncular_subscriptions WHERE id = ?', [id]);
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 {