@syncular/client 0.15.42 → 0.15.44

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.
@@ -157,6 +157,14 @@ export declare class SyncClientHandle {
157
157
  leadershipListeners?: Set<(state: LeadershipState) => void>;
158
158
  leadership?: LeadershipState;
159
159
  });
160
+ /**
161
+ * @internal — whether `close()` has run.
162
+ *
163
+ * A follower keeps a blocking `acquire` outstanding for the whole time it is a
164
+ * follower, so its promotion can fire long after the application has discarded
165
+ * the handle. The promotion path consults this before opening anything.
166
+ */
167
+ get __isClosed(): boolean;
160
168
  /** @internal — swap this handle from follower to leader (promotion). */
161
169
  __becomeLeader(core: LeaderCore): void;
162
170
  /** @internal — apply a follower reachability snapshot in place. */
@@ -113,6 +113,16 @@ export class SyncClientHandle {
113
113
  onInvalidate: (listener) => this.onInvalidate(listener),
114
114
  });
115
115
  }
116
+ /**
117
+ * @internal — whether `close()` has run.
118
+ *
119
+ * A follower keeps a blocking `acquire` outstanding for the whole time it is a
120
+ * follower, so its promotion can fire long after the application has discarded
121
+ * the handle. The promotion path consults this before opening anything.
122
+ */
123
+ get __isClosed() {
124
+ return this.#closed;
125
+ }
116
126
  /** @internal — swap this handle from follower to leader (promotion). */
117
127
  __becomeLeader(core) {
118
128
  this.#follower?.close();
@@ -652,6 +662,14 @@ async function bootFollower(config, lockName, lock, parts) {
652
662
  await lease.release();
653
663
  return;
654
664
  }
665
+ if (handle.__isClosed) {
666
+ // The application discarded this handle while it was queued for
667
+ // leadership. Promoting now would open a database nobody is holding and
668
+ // then keep the lock forever, so no other tab could ever take over.
669
+ // Release instead, and let the next waiter have it.
670
+ await lease.release();
671
+ return;
672
+ }
655
673
  // The follower saw the departing leader's epoch; the new leader must
656
674
  // strictly exceed it so stale replies/events are discarded everywhere.
657
675
  const nextEpoch = follower.maxEpochSeen + 1;
@@ -681,6 +699,13 @@ async function bootFollower(config, lockName, lock, parts) {
681
699
  }
682
700
  : {}),
683
701
  });
702
+ if (handle.__isClosed) {
703
+ // Closed while the worker was starting. `close()` already ran and found
704
+ // no core to shut down, so this one would leak: tear it down here rather
705
+ // than hand it to a discarded handle. `close(true)` releases the lease.
706
+ await core.close(true);
707
+ return;
708
+ }
684
709
  handle.__becomeLeader(core);
685
710
  }
686
711
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.42",
3
+ "version": "0.15.44",
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,7 +89,7 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.15.42"
92
+ "@syncular/core": "0.15.44"
93
93
  },
94
94
  "peerDependencies": {
95
95
  "better-sqlite3": ">=11"
@@ -100,7 +100,7 @@
100
100
  }
101
101
  },
102
102
  "devDependencies": {
103
- "@syncular/server": "0.15.42",
103
+ "@syncular/server": "0.15.44",
104
104
  "@types/better-sqlite3": "^7.6.13",
105
105
  "better-sqlite3": "^12.11.1"
106
106
  }
package/src/client.ts CHANGED
@@ -558,6 +558,16 @@ export class SyncClient {
558
558
  * (the fast-bail the delta path reads).
559
559
  */
560
560
  #syncOutstanding = false;
561
+ /**
562
+ * Bumped by every local projection reset (`rebootstrapLocalData`). A sync
563
+ * round captures this epoch together with its subscription state; a
564
+ * mismatch at SUB_END means a reset landed while the round was in flight,
565
+ * so the captured cursors predate the rewind and persisting them would
566
+ * overwrite it and silently skip the fresh bootstrap. The Rust core is
567
+ * synchronous over `&mut self` and gets this fencing for free; the epoch
568
+ * restores the equivalent semantics across the round's await points.
569
+ */
570
+ #localResetEpoch = 0;
561
571
  /** Retry policy belongs to the operation that classified the failure. */
562
572
  #retryDelayMs = 250;
563
573
  readonly #hasBlobs: boolean;
@@ -2124,53 +2134,62 @@ export class SyncClient {
2124
2134
  const rejectionCount = this.#rejections.length;
2125
2135
  try {
2126
2136
  return this.#applyBatch((batch) => {
2127
- const initialRowIds = this.#localPurgeRowIds(purge);
2128
2137
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
2129
- const doomed = listOutbox(this.#db)
2130
- .filter((commit) => {
2131
- const images = new Map(
2132
- listOutboxBeforeImages(this.#db, commit.clientCommitId).map(
2133
- (image) => [image.opIndex, image],
2134
- ),
2135
- );
2136
- return commit.operations.some((operation, opIndex) => {
2137
- const targets = targetsByTable.get(operation.table);
2138
- if (targets === undefined) return false;
2139
- if (
2140
- initialRowIds.get(operation.table)?.has(operation.rowId) ===
2141
- true
2142
- ) {
2143
- return true;
2144
- }
2145
- if (
2146
- operation.values !== undefined &&
2147
- targets.some((target) =>
2148
- localDataPurgeTargetMatches(target, operation.values ?? {}),
2149
- )
2150
- ) {
2151
- return true;
2152
- }
2153
- const beforeValues = images.get(opIndex)?.values;
2154
- return (
2155
- beforeValues !== undefined &&
2156
- targets.some((target) =>
2157
- localDataPurgeTargetMatches(target, beforeValues),
2158
- )
2138
+ // Doomed detection runs to fixpoint, matching the Rust core's rule:
2139
+ // a commit is doomed when any of its ops touches a base-matching
2140
+ // row. Base values only become visible here as rollbacks restore
2141
+ // before-images and rebase later commits' images onto them (a
2142
+ // stacked edit over a moved row initially shows the moved values),
2143
+ // so each pass rolls back what it caught — newest-first, so an
2144
+ // older doomed write never reappears — and re-scans until the
2145
+ // outbox is stable.
2146
+ const doomed: OutboxCommit[] = [];
2147
+ let rowIds = this.#localPurgeRowIds(purge);
2148
+ for (;;) {
2149
+ const caught = listOutbox(this.#db)
2150
+ .filter((commit) => {
2151
+ const images = new Map(
2152
+ listOutboxBeforeImages(this.#db, commit.clientCommitId).map(
2153
+ (image) => [image.opIndex, image],
2154
+ ),
2159
2155
  );
2160
- });
2161
- })
2162
- // Reverse order is essential: each rollback restores its before-image;
2163
- // removing newest-first prevents an older doomed write reappearing.
2164
- .sort((a, b) => b.seq - a.seq);
2165
-
2166
- for (const commit of doomed) {
2167
- this.#rollbackFailedCommit(commit, batch);
2156
+ return commit.operations.some((operation, opIndex) => {
2157
+ const targets = targetsByTable.get(operation.table);
2158
+ if (targets === undefined) return false;
2159
+ if (
2160
+ rowIds.get(operation.table)?.has(operation.rowId) === true
2161
+ ) {
2162
+ return true;
2163
+ }
2164
+ if (
2165
+ operation.values !== undefined &&
2166
+ targets.some((target) =>
2167
+ localDataPurgeTargetMatches(target, operation.values ?? {}),
2168
+ )
2169
+ ) {
2170
+ return true;
2171
+ }
2172
+ const beforeValues = images.get(opIndex)?.values;
2173
+ return (
2174
+ beforeValues !== undefined &&
2175
+ targets.some((target) =>
2176
+ localDataPurgeTargetMatches(target, beforeValues),
2177
+ )
2178
+ );
2179
+ });
2180
+ })
2181
+ .sort((a, b) => b.seq - a.seq);
2182
+ if (caught.length === 0) break;
2183
+ for (const commit of caught) {
2184
+ this.#rollbackFailedCommit(commit, batch);
2185
+ }
2186
+ doomed.push(...caught);
2187
+ // Rollback may reveal a target row hidden by an optimistic delete
2188
+ // or move; the re-selected set drives both the next scan and the
2189
+ // final row deletion below.
2190
+ rowIds = this.#localPurgeRowIds(purge);
2168
2191
  }
2169
2192
 
2170
- // Rollback may reveal a target row hidden by an optimistic delete or
2171
- // move, so select the final base/visible set only after doomed commits
2172
- // have been removed.
2173
- const rowIds = this.#localPurgeRowIds(purge);
2174
2193
  let purgedRows = 0;
2175
2194
  for (const [tableName, ids] of rowIds) {
2176
2195
  if (ids.size === 0) continue;
@@ -2296,6 +2315,9 @@ export class SyncClient {
2296
2315
  this.#needsPull = priorNeedsPull;
2297
2316
  throw error;
2298
2317
  }
2318
+ // Fence any in-flight sync round: its captured subscription state now
2319
+ // predates the rewind, so its SUB_END cursors must stay unpersisted.
2320
+ this.#localResetEpoch += 1;
2299
2321
 
2300
2322
  if (!priorUpgrading) this.#config.onUpgrading?.(true);
2301
2323
  this.#config.onSyncNeeded?.('startup');
@@ -2575,6 +2597,9 @@ export class SyncClient {
2575
2597
  // mapping.
2576
2598
  const { pushFrames, outbox, deferred } =
2577
2599
  await this.#encodeOutboxForPush();
2600
+ // Captured together with the subscription state below: the response
2601
+ // apply persists SUB_END cursors only while this epoch is current.
2602
+ const resetEpoch = this.#localResetEpoch;
2578
2603
  const subs = loadSubscriptions(this.#db).filter(
2579
2604
  (sub) => sub.status === 'active',
2580
2605
  );
@@ -2625,6 +2650,7 @@ export class SyncClient {
2625
2650
  outbox,
2626
2651
  subs,
2627
2652
  'pull',
2653
+ resetEpoch,
2628
2654
  );
2629
2655
  // §4.8 E1: the push half may have drained commits that pinned rows of
2630
2656
  // a shrunk window unit — retry any deferred evictions now.
@@ -2998,6 +3024,7 @@ export class SyncClient {
2998
3024
  sentCommits: readonly OutboxCommit[],
2999
3025
  sentSubs: readonly SubscriptionRecord[] | undefined,
3000
3026
  mode: 'pull' | 'delta',
3027
+ resetEpoch: number = this.#localResetEpoch,
3001
3028
  ): Promise<SyncSummary> {
3002
3029
  const summary = emptySummary(sentCommits.length);
3003
3030
  const commitsById = new Map(
@@ -3258,7 +3285,12 @@ export class SyncClient {
3258
3285
  if (
3259
3286
  section !== undefined &&
3260
3287
  !section.skip &&
3261
- section.sub !== undefined
3288
+ section.sub !== undefined &&
3289
+ // Reset fence: a rebootstrap that landed mid-round already
3290
+ // rewound this subscription's cursor; the captured SUB_END
3291
+ // state predates the rewind and must stay unpersisted so the
3292
+ // next round runs the fresh bootstrap.
3293
+ this.#localResetEpoch === resetEpoch
3262
3294
  ) {
3263
3295
  const applied = this.#finishSection(
3264
3296
  section.sub,
@@ -3315,7 +3347,14 @@ export class SyncClient {
3315
3347
  .map((sub) => sub.id);
3316
3348
  // §7.4.5: the reset is over once the first post-reset pull round leaves
3317
3349
  // no subscription mid-bootstrap — the tables are rebuilt and current.
3318
- if (this.#upgrading && mode === 'pull' && bootstrapping.length === 0) {
3350
+ // The epoch check keeps a round that predates a mid-flight rebootstrap
3351
+ // from declaring that reset finished before its bootstrap ran.
3352
+ if (
3353
+ this.#upgrading &&
3354
+ mode === 'pull' &&
3355
+ bootstrapping.length === 0 &&
3356
+ this.#localResetEpoch === resetEpoch
3357
+ ) {
3319
3358
  this.#setUpgrading(false);
3320
3359
  }
3321
3360
  return { ...summary, bootstrapping };
@@ -3331,6 +3370,12 @@ export class SyncClient {
3331
3370
  ): boolean {
3332
3371
  const commit = commitsById.get(frame.clientCommitId);
3333
3372
  if (commit === undefined) return false;
3373
+ // `commitsById` is the round's send-time snapshot. The commit can leave
3374
+ // the outbox before this frame is handled — a local purge doomed it while
3375
+ // the round was in flight, or an earlier duplicate frame in this response
3376
+ // already drained it. Its recorded outcome stands, and skipping keeps the
3377
+ // cached outbox count exact (a drain may only be counted once).
3378
+ if (!this.#outboxCommitExists(frame.clientCommitId)) return false;
3334
3379
  if (frame.status === 'applied' || frame.status === 'cached') {
3335
3380
  // §6.3: applied and cached both drain the outbox — cached means
3336
3381
  // "already applied, you may have missed the ack".
@@ -3427,6 +3472,15 @@ export class SyncClient {
3427
3472
  return true;
3428
3473
  }
3429
3474
 
3475
+ #outboxCommitExists(clientCommitId: string): boolean {
3476
+ return (
3477
+ this.#db.query(
3478
+ 'SELECT 1 FROM _syncular_outbox WHERE client_commit_id = ? LIMIT 1',
3479
+ [clientCommitId],
3480
+ ).length > 0
3481
+ );
3482
+ }
3483
+
3430
3484
  #decodeServerRow(
3431
3485
  tableName: string | undefined,
3432
3486
  payload: Uint8Array,
package/src/encryption.ts CHANGED
@@ -13,11 +13,13 @@ import {
13
13
  DecryptError,
14
14
  decryptValue,
15
15
  encryptValue,
16
+ KEY_LENGTH,
16
17
  type NonceSource,
17
18
  type PlainValue,
18
19
  type RowColumn,
19
20
  type RowValue,
20
21
  } from '@syncular/core';
22
+ import { ClientSyncError } from './errors';
21
23
  import type { CompiledClientTable } from './schema';
22
24
 
23
25
  /**
@@ -59,12 +61,29 @@ export interface EncryptionKeyringConfig {
59
61
  readonly keyIdColumns?: Readonly<Record<string, string>>;
60
62
  }
61
63
 
62
- /** Convert a portable keyring into the direct-client encryption contract. */
64
+ /**
65
+ * Convert a portable keyring into the direct-client encryption contract.
66
+ * Key lengths are validated here, at install time: every side of the wire
67
+ * enforces {@link KEY_LENGTH}-byte keys, so a wrong-length key fails loud
68
+ * with a clear config error before any sync round runs.
69
+ */
63
70
  export function encryptionConfigFromKeyring(
64
71
  keyring: EncryptionKeyringConfig,
65
72
  ): EncryptionConfig {
73
+ for (const [keyId, key] of Object.entries(keyring.keys)) {
74
+ if (key.length !== KEY_LENGTH) {
75
+ throw new ClientSyncError(
76
+ 'sync.invalid_request',
77
+ `encryption key ${JSON.stringify(keyId)} must be ${KEY_LENGTH} bytes (AES-256), got ${key.length}`,
78
+ );
79
+ }
80
+ }
66
81
  return {
67
- keyProvider: (keyId) => keyring.keys[keyId],
82
+ // Own-property lookup only: the keyId arrives from the wire envelope, so
83
+ // a prototype-chain name like "constructor" must read as a missing key
84
+ // and take the clean DecryptError path.
85
+ keyProvider: (keyId) =>
86
+ Object.hasOwn(keyring.keys, keyId) ? keyring.keys[keyId] : undefined,
68
87
  ...(keyring.keyIdColumns !== undefined
69
88
  ? { keyIdColumns: keyring.keyIdColumns }
70
89
  : {}),
@@ -850,10 +850,16 @@ export class ReactiveClientStore {
850
850
  // Releasing a window is best-effort teardown. A resource owner may have
851
851
  // already closed the underlying worker/native handle before React effect
852
852
  // cleanup runs (notably during schema-changing HMR). Do not let that
853
- // harmless ordering race escape as an unhandled rejection.
854
- void Promise.resolve(this.client.setWindow(group.base, [])).catch(
855
- () => undefined,
856
- );
853
+ // harmless ordering race escape as an unhandled rejection — and the
854
+ // interface permits a plain void return, so a closed handle may also
855
+ // throw synchronously; swallow that the same way.
856
+ try {
857
+ void Promise.resolve(this.client.setWindow(group.base, [])).catch(
858
+ () => undefined,
859
+ );
860
+ } catch {
861
+ // Same teardown race, surfaced synchronously.
862
+ }
857
863
  }
858
864
  this.#windowClaims.clear();
859
865
  }
@@ -53,8 +53,18 @@ export interface RealtimeSupervisorSnapshot {
53
53
  export interface RealtimeSupervisorOptions {
54
54
  /** Host online/offline evidence. Unknown remains connectable and observable. */
55
55
  readonly connectivity?: RealtimeSupervisorSignal<ClientDiagnosticsConnectivity>;
56
- /** Browser/native foreground evidence. Background always suspends the socket. */
56
+ /** Browser/native foreground evidence. Background suspends a tab-owned
57
+ * socket; with `sharedTransport` it only serves as a resume nudge. */
57
58
  readonly lifecycle?: RealtimeSupervisorSignal<RealtimeSupervisorLifecycleState>;
59
+ /**
60
+ * The client's realtime transport is shared with sibling tabs or webviews
61
+ * (multi-tab handles proxying to one leader socket, native cores behind
62
+ * several views). Backgrounding THIS view then keeps the socket open,
63
+ * because a sibling may still be visible and a follower's disconnect would
64
+ * tear down realtime for everyone. Offline and protection evidence still
65
+ * suspend.
66
+ */
67
+ readonly sharedTransport?: boolean;
58
68
  /** Publish preflight before draining keys so reconnect stops in the same turn. */
59
69
  readonly protection?: RealtimeSupervisorSignal<RealtimeSupervisorProtectionState>;
60
70
  /** Deterministic test/host timer seam. */
@@ -215,6 +225,7 @@ export class RealtimeSupervisor {
215
225
  readonly #connectivity?: RealtimeSupervisorOptions['connectivity'];
216
226
  readonly #lifecycle?: RealtimeSupervisorOptions['lifecycle'];
217
227
  readonly #protection?: RealtimeSupervisorOptions['protection'];
228
+ readonly #sharedTransport: boolean;
218
229
  readonly #scheduleTimer: NonNullable<RealtimeSupervisorOptions['schedule']>;
219
230
  readonly #random: () => number;
220
231
  readonly #initialDelayMs: number;
@@ -247,6 +258,7 @@ export class RealtimeSupervisor {
247
258
  this.#connectivity = options.connectivity;
248
259
  this.#lifecycle = options.lifecycle;
249
260
  this.#protection = options.protection;
261
+ this.#sharedTransport = options.sharedTransport === true;
250
262
  this.#scheduleTimer = options.schedule ?? scheduleTimer;
251
263
  this.#random = options.random ?? Math.random;
252
264
  this.#initialDelayMs = boundedDelay(
@@ -304,6 +316,7 @@ export class RealtimeSupervisor {
304
316
  this.#unsubscribeLifecycle?.();
305
317
  this.#unsubscribeProtection?.();
306
318
  this.#publish({ phase: 'stopped', attempt: 0 });
319
+ this.#listeners.clear();
307
320
  if (disconnect) this.#disconnect();
308
321
  }
309
322
 
@@ -336,7 +349,9 @@ export class RealtimeSupervisor {
336
349
  ) {
337
350
  return 'offline';
338
351
  }
339
- if (this.#lifecycle?.current() === 'background') return 'background';
352
+ if (!this.#sharedTransport && this.#lifecycle?.current() === 'background') {
353
+ return 'background';
354
+ }
340
355
  return undefined;
341
356
  }
342
357
 
@@ -410,43 +425,77 @@ export class RealtimeSupervisor {
410
425
  this.#reconcileHostState();
411
426
  return;
412
427
  }
428
+ // Count an attempt only when this call actually schedules one. Repeated
429
+ // 'disconnected' diagnostics beside a pending retry keep the backoff and
430
+ // the worker-mode reconnect nudge intact.
431
+ if (
432
+ this.#connected ||
433
+ this.#connecting ||
434
+ this.#cancelRetry !== undefined
435
+ ) {
436
+ return;
437
+ }
413
438
  const delay = this.#retryDelay();
414
439
  this.#attempt = Math.min(32, this.#attempt + 1);
415
440
  this.#schedule(delay);
416
441
  }
417
442
 
443
+ /**
444
+ * A superseded attempt observed a generation bump mid-flight. Whenever a
445
+ * newer generation is mid-connect or holds the transport, that generation
446
+ * owns the shared socket and the stale attempt leaves every field alone.
447
+ * With the supervisor otherwise quiescent (suspended or stopped), the
448
+ * socket this attempt opened is unwanted — release it.
449
+ */
450
+ #releaseSupersededSocket(): void {
451
+ if (this.#connecting || this.#connected || this.#transportConnected) {
452
+ return;
453
+ }
454
+ this.#disconnect();
455
+ }
456
+
418
457
  async #connect(): Promise<void> {
419
458
  if (!this.#canConnect() || this.#connected || this.#connecting) return;
459
+ const generation = this.#generation;
420
460
  this.#connecting = true;
421
461
  this.#publish({ phase: 'connecting', attempt: this.#attempt });
422
- const generation = this.#generation;
423
462
  let failed = false;
424
463
  try {
425
464
  await this.#client.connectRealtime();
465
+ if (generation !== this.#generation) {
466
+ this.#releaseSupersededSocket();
467
+ return;
468
+ }
426
469
  this.#transportConnected = true;
427
- if (generation !== this.#generation || !this.#canConnect()) {
470
+ if (!this.#canConnect()) {
471
+ this.#transportConnected = false;
428
472
  this.#disconnect();
429
473
  return;
430
474
  }
431
475
  await this.#catchUpConnectedTransport(generation);
432
476
  } catch {
477
+ if (generation !== this.#generation) {
478
+ this.#releaseSupersededSocket();
479
+ return;
480
+ }
433
481
  failed = true;
434
482
  this.#connected = false;
435
483
  this.#transportConnected = false;
436
484
  this.#disconnect();
437
485
  } finally {
438
- this.#connecting = false;
486
+ // A superseded attempt leaves the successor's in-flight state alone.
487
+ if (generation === this.#generation) this.#connecting = false;
439
488
  }
440
489
  if (failed && generation === this.#generation) this.#scheduleRetry();
441
490
  }
442
491
 
443
492
  async #catchUpConnectedTransport(generation: number): Promise<void> {
444
493
  await this.#client.syncUntilIdle();
445
- if (
446
- generation !== this.#generation ||
447
- !this.#canConnect() ||
448
- !this.#transportConnected
449
- ) {
494
+ if (generation !== this.#generation) {
495
+ this.#releaseSupersededSocket();
496
+ return;
497
+ }
498
+ if (!this.#canConnect() || !this.#transportConnected) {
450
499
  this.#disconnect();
451
500
  return;
452
501
  }
@@ -459,20 +508,25 @@ export class RealtimeSupervisor {
459
508
  if (!this.#canConnect() || this.#connecting || !this.#transportConnected) {
460
509
  return;
461
510
  }
511
+ const generation = this.#generation;
462
512
  this.#connecting = true;
463
513
  this.#clearRetry();
464
514
  this.#publish({ phase: 'connecting', attempt: this.#attempt });
465
- const generation = this.#generation;
466
515
  let failed = false;
467
516
  try {
468
517
  await this.#catchUpConnectedTransport(generation);
469
518
  } catch {
519
+ if (generation !== this.#generation) {
520
+ this.#releaseSupersededSocket();
521
+ return;
522
+ }
470
523
  failed = true;
471
524
  this.#connected = false;
472
525
  this.#transportConnected = false;
473
526
  this.#disconnect();
474
527
  } finally {
475
- this.#connecting = false;
528
+ // A superseded adoption leaves the successor's in-flight state alone.
529
+ if (generation === this.#generation) this.#connecting = false;
476
530
  }
477
531
  if (failed && generation === this.#generation) this.#scheduleRetry();
478
532
  }
@@ -497,8 +551,13 @@ export class RealtimeSupervisor {
497
551
  if (disconnect) this.#disconnect();
498
552
  return;
499
553
  }
500
- if (!this.#realtimeSupported) return;
501
- this.#realtimeSupported = true;
554
+ if (!this.#realtimeSupported) {
555
+ // A live socket is positive evidence the earlier 'unsupported' report
556
+ // was transient (a mid-restart or mis-detected capability probe), so it
557
+ // recovers the supervisor. Every other report keeps the latch closed.
558
+ if (snapshot.host.realtime !== 'connected') return;
559
+ this.#realtimeSupported = true;
560
+ }
502
561
  const block = this.#hostBlock();
503
562
  if (block) {
504
563
  this.#suspend(block);
@@ -506,8 +565,10 @@ export class RealtimeSupervisor {
506
565
  }
507
566
  if (snapshot.host.realtime === 'connected') {
508
567
  this.#transportConnected = true;
509
- if (this.#connecting) return;
510
- this.#connected = false;
568
+ // Steady state: diagnostics re-announce 'connected' on every sync
569
+ // round and subscription change. An already-adopted transport keeps
570
+ // its phase and skips the redundant catch-up.
571
+ if (this.#connecting || this.#connected) return;
511
572
  void this.#adoptConnectedTransport();
512
573
  return;
513
574
  }
@@ -566,12 +627,22 @@ export class RealtimeSupervisor {
566
627
  }
567
628
  }
568
629
 
569
- /** Install one supervisor and make client disposal cancel it before close. */
630
+ /**
631
+ * Install one supervisor and make client disposal cancel it before close.
632
+ * A client accepts exactly one supervisor for its lifetime; installing a
633
+ * second throws, because silently keeping the first would discard the second
634
+ * call's options.
635
+ */
570
636
  export function installRealtimeSupervisor<T extends RealtimeSupervisorClient>(
571
637
  client: T,
572
638
  options?: RealtimeSupervisorOptions,
573
639
  ): T {
574
- if (attachment(client)) return client;
640
+ if (attachment(client)) {
641
+ throw new Error(
642
+ 'installRealtimeSupervisor: this client already has a supervisor; ' +
643
+ 'install one supervisor per client lifetime',
644
+ );
645
+ }
575
646
  const supervisor = new RealtimeSupervisor(client, options);
576
647
  Object.defineProperty(client, REALTIME_SUPERVISOR_KEY, {
577
648
  configurable: false,