@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.
package/README.md CHANGED
@@ -85,7 +85,14 @@ installRealtimeSupervisor(handle, {
85
85
  It owns one connection attempt, retries initial failure and later socket loss
86
86
  with bounded exponential jitter, suspends while offline/background/protected,
87
87
  runs `syncUntilIdle()` before reporting `connected`, and cancels before
88
- `close()`. `realtimeSupervisorSnapshot()` and
88
+ `close()`.
89
+
90
+ Multi-tab handles share ONE leader socket across every tab, so pass
91
+ `sharedTransport: true` (the templates do): backgrounding this tab then
92
+ leaves the shared socket alone — a sibling tab may still be visible — while
93
+ offline and protection continue to suspend. Each client accepts exactly one
94
+ supervisor for its lifetime; a second `installRealtimeSupervisor` call
95
+ throws. `realtimeSupervisorSnapshot()` and
89
96
  `subscribeRealtimeSupervisor()` expose only `phase`, `attempt`, and the bounded
90
97
  library delay for UI/diagnostics—never raw transport prose or identities.
91
98
 
@@ -350,9 +357,9 @@ visible. Reusing the same id returns `alreadyApplied: true` together with the
350
357
  original `retainedCommits` and `resetSubscriptions` counts. The core stores
351
358
  that counts-only receipt atomically with the reset, so an application crash
352
359
  after the SQLite commit but before its own acknowledgement does not turn the
353
- retry into a misleading zero-impact result. Markers written before Syncular
354
- 0.15.36 cannot reconstruct their historical counts and preserve the former
355
- zero-count replay behavior. A malformed or unreadable persisted receipt fails
360
+ retry into a misleading zero-impact result. Markers persisted in the legacy
361
+ format carry no counts receipt; a retry against one of them keeps returning
362
+ the zero-count result. A malformed or unreadable persisted receipt fails
356
363
  closed with the sanitized `sync.local_corrupt` client-local code and performs
357
364
  no reset. No receipt exposes ids, rows, scopes, or clinical values.
358
365
 
package/dist/client.js CHANGED
@@ -110,6 +110,16 @@ export class SyncClient {
110
110
  * (the fast-bail the delta path reads).
111
111
  */
112
112
  #syncOutstanding = false;
113
+ /**
114
+ * Bumped by every local projection reset (`rebootstrapLocalData`). A sync
115
+ * round captures this epoch together with its subscription state; a
116
+ * mismatch at SUB_END means a reset landed while the round was in flight,
117
+ * so the captured cursors predate the rewind and persisting them would
118
+ * overwrite it and silently skip the fresh bootstrap. The Rust core is
119
+ * synchronous over `&mut self` and gets this fencing for free; the epoch
120
+ * restores the equivalent semantics across the round's await points.
121
+ */
122
+ #localResetEpoch = 0;
113
123
  /** Retry policy belongs to the operation that classified the failure. */
114
124
  #retryDelayMs = 250;
115
125
  #hasBlobs;
@@ -1409,38 +1419,49 @@ export class SyncClient {
1409
1419
  const rejectionCount = this.#rejections.length;
1410
1420
  try {
1411
1421
  return this.#applyBatch((batch) => {
1412
- const initialRowIds = this.#localPurgeRowIds(purge);
1413
1422
  const targetsByTable = this.#localPurgeTargetsByTable(purge);
1414
- const doomed = listOutbox(this.#db)
1415
- .filter((commit) => {
1416
- const images = new Map(listOutboxBeforeImages(this.#db, commit.clientCommitId).map((image) => [image.opIndex, image]));
1417
- return commit.operations.some((operation, opIndex) => {
1418
- const targets = targetsByTable.get(operation.table);
1419
- if (targets === undefined)
1420
- return false;
1421
- if (initialRowIds.get(operation.table)?.has(operation.rowId) ===
1422
- true) {
1423
- return true;
1424
- }
1425
- if (operation.values !== undefined &&
1426
- targets.some((target) => localDataPurgeTargetMatches(target, operation.values ?? {}))) {
1427
- return true;
1428
- }
1429
- const beforeValues = images.get(opIndex)?.values;
1430
- return (beforeValues !== undefined &&
1431
- targets.some((target) => localDataPurgeTargetMatches(target, beforeValues)));
1432
- });
1433
- })
1434
- // Reverse order is essential: each rollback restores its before-image;
1435
- // removing newest-first prevents an older doomed write reappearing.
1436
- .sort((a, b) => b.seq - a.seq);
1437
- for (const commit of doomed) {
1438
- this.#rollbackFailedCommit(commit, batch);
1423
+ // Doomed detection runs to fixpoint, matching the Rust core's rule:
1424
+ // a commit is doomed when any of its ops touches a base-matching
1425
+ // row. Base values only become visible here as rollbacks restore
1426
+ // before-images and rebase later commits' images onto them (a
1427
+ // stacked edit over a moved row initially shows the moved values),
1428
+ // so each pass rolls back what it caught — newest-first, so an
1429
+ // older doomed write never reappears — and re-scans until the
1430
+ // outbox is stable.
1431
+ const doomed = [];
1432
+ let rowIds = this.#localPurgeRowIds(purge);
1433
+ for (;;) {
1434
+ const caught = listOutbox(this.#db)
1435
+ .filter((commit) => {
1436
+ const images = new Map(listOutboxBeforeImages(this.#db, commit.clientCommitId).map((image) => [image.opIndex, image]));
1437
+ return commit.operations.some((operation, opIndex) => {
1438
+ const targets = targetsByTable.get(operation.table);
1439
+ if (targets === undefined)
1440
+ return false;
1441
+ if (rowIds.get(operation.table)?.has(operation.rowId) === true) {
1442
+ return true;
1443
+ }
1444
+ if (operation.values !== undefined &&
1445
+ targets.some((target) => localDataPurgeTargetMatches(target, operation.values ?? {}))) {
1446
+ return true;
1447
+ }
1448
+ const beforeValues = images.get(opIndex)?.values;
1449
+ return (beforeValues !== undefined &&
1450
+ targets.some((target) => localDataPurgeTargetMatches(target, beforeValues)));
1451
+ });
1452
+ })
1453
+ .sort((a, b) => b.seq - a.seq);
1454
+ if (caught.length === 0)
1455
+ break;
1456
+ for (const commit of caught) {
1457
+ this.#rollbackFailedCommit(commit, batch);
1458
+ }
1459
+ doomed.push(...caught);
1460
+ // Rollback may reveal a target row hidden by an optimistic delete
1461
+ // or move; the re-selected set drives both the next scan and the
1462
+ // final row deletion below.
1463
+ rowIds = this.#localPurgeRowIds(purge);
1439
1464
  }
1440
- // Rollback may reveal a target row hidden by an optimistic delete or
1441
- // move, so select the final base/visible set only after doomed commits
1442
- // have been removed.
1443
- const rowIds = this.#localPurgeRowIds(purge);
1444
1465
  let purgedRows = 0;
1445
1466
  for (const [tableName, ids] of rowIds) {
1446
1467
  if (ids.size === 0)
@@ -1551,6 +1572,9 @@ export class SyncClient {
1551
1572
  this.#needsPull = priorNeedsPull;
1552
1573
  throw error;
1553
1574
  }
1575
+ // Fence any in-flight sync round: its captured subscription state now
1576
+ // predates the rewind, so its SUB_END cursors must stay unpersisted.
1577
+ this.#localResetEpoch += 1;
1554
1578
  if (!priorUpgrading)
1555
1579
  this.#config.onUpgrading?.(true);
1556
1580
  this.#config.onSyncNeeded?.('startup');
@@ -1785,6 +1809,9 @@ export class SyncClient {
1785
1809
  // the queue. `pushFrames` and `outbox` stay index-aligned for result
1786
1810
  // mapping.
1787
1811
  const { pushFrames, outbox, deferred } = await this.#encodeOutboxForPush();
1812
+ // Captured together with the subscription state below: the response
1813
+ // apply persists SUB_END cursors only while this epoch is current.
1814
+ const resetEpoch = this.#localResetEpoch;
1788
1815
  const subs = loadSubscriptions(this.#db).filter((sub) => sub.status === 'active');
1789
1816
  const limits = this.#config.limits;
1790
1817
  const frames = [
@@ -1823,7 +1850,7 @@ export class SyncClient {
1823
1850
  if (message.msgKind !== 'response') {
1824
1851
  throw new ClientSyncError('sync.invalid_request', 'transport returned a non-response message');
1825
1852
  }
1826
- const summary = await this.#processResponse(message, outbox, subs, 'pull');
1853
+ const summary = await this.#processResponse(message, outbox, subs, 'pull', resetEpoch);
1827
1854
  // §4.8 E1: the push half may have drained commits that pinned rows of
1828
1855
  // a shrunk window unit — retry any deferred evictions now.
1829
1856
  this.#drainPendingEvictions();
@@ -2148,7 +2175,7 @@ export class SyncClient {
2148
2175
  this.#sendAck(Math.min(...cursors));
2149
2176
  }
2150
2177
  // -- response processing ------------------------------------------------------
2151
- async #processResponse(message, sentCommits, sentSubs, mode) {
2178
+ async #processResponse(message, sentCommits, sentSubs, mode, resetEpoch = this.#localResetEpoch) {
2152
2179
  const summary = emptySummary(sentCommits.length);
2153
2180
  const commitsById = new Map(sentCommits.map((commit) => [commit.clientCommitId, commit]));
2154
2181
  const subsById = new Map((sentSubs ?? loadSubscriptions(this.#db)).map((sub) => [sub.id, sub]));
@@ -2320,7 +2347,12 @@ export class SyncClient {
2320
2347
  case 'SUB_END': {
2321
2348
  if (section !== undefined &&
2322
2349
  !section.skip &&
2323
- section.sub !== undefined) {
2350
+ section.sub !== undefined &&
2351
+ // Reset fence: a rebootstrap that landed mid-round already
2352
+ // rewound this subscription's cursor; the captured SUB_END
2353
+ // state predates the rewind and must stay unpersisted so the
2354
+ // next round runs the fresh bootstrap.
2355
+ this.#localResetEpoch === resetEpoch) {
2324
2356
  const applied = this.#finishSection(section.sub, section.start, frame.nextCursor, frame.bootstrapState, summary);
2325
2357
  if (mode === 'delta' && applied) {
2326
2358
  deltaCursor = Math.max(deltaCursor, frame.nextCursor);
@@ -2367,7 +2399,12 @@ export class SyncClient {
2367
2399
  .map((sub) => sub.id);
2368
2400
  // §7.4.5: the reset is over once the first post-reset pull round leaves
2369
2401
  // no subscription mid-bootstrap — the tables are rebuilt and current.
2370
- if (this.#upgrading && mode === 'pull' && bootstrapping.length === 0) {
2402
+ // The epoch check keeps a round that predates a mid-flight rebootstrap
2403
+ // from declaring that reset finished before its bootstrap ran.
2404
+ if (this.#upgrading &&
2405
+ mode === 'pull' &&
2406
+ bootstrapping.length === 0 &&
2407
+ this.#localResetEpoch === resetEpoch) {
2371
2408
  this.#setUpgrading(false);
2372
2409
  }
2373
2410
  return { ...summary, bootstrapping };
@@ -2376,6 +2413,13 @@ export class SyncClient {
2376
2413
  const commit = commitsById.get(frame.clientCommitId);
2377
2414
  if (commit === undefined)
2378
2415
  return false;
2416
+ // `commitsById` is the round's send-time snapshot. The commit can leave
2417
+ // the outbox before this frame is handled — a local purge doomed it while
2418
+ // the round was in flight, or an earlier duplicate frame in this response
2419
+ // already drained it. Its recorded outcome stands, and skipping keeps the
2420
+ // cached outbox count exact (a drain may only be counted once).
2421
+ if (!this.#outboxCommitExists(frame.clientCommitId))
2422
+ return false;
2379
2423
  if (frame.status === 'applied' || frame.status === 'cached') {
2380
2424
  // §6.3: applied and cached both drain the outbox — cached means
2381
2425
  // "already applied, you may have missed the ack".
@@ -2470,6 +2514,9 @@ export class SyncClient {
2470
2514
  summary.rejected.push(frame.clientCommitId);
2471
2515
  return true;
2472
2516
  }
2517
+ #outboxCommitExists(clientCommitId) {
2518
+ return (this.#db.query('SELECT 1 FROM _syncular_outbox WHERE client_commit_id = ? LIMIT 1', [clientCommitId]).length > 0);
2519
+ }
2473
2520
  #decodeServerRow(tableName, payload) {
2474
2521
  if (tableName === undefined)
2475
2522
  return {};
@@ -43,7 +43,12 @@ export interface EncryptionKeyringConfig {
43
43
  readonly keys: Readonly<Record<string, Uint8Array>>;
44
44
  readonly keyIdColumns?: Readonly<Record<string, string>>;
45
45
  }
46
- /** Convert a portable keyring into the direct-client encryption contract. */
46
+ /**
47
+ * Convert a portable keyring into the direct-client encryption contract.
48
+ * Key lengths are validated here, at install time: every side of the wire
49
+ * enforces {@link KEY_LENGTH}-byte keys, so a wrong-length key fails loud
50
+ * with a clear config error before any sync round runs.
51
+ */
47
52
  export declare function encryptionConfigFromKeyring(keyring: EncryptionKeyringConfig): EncryptionConfig;
48
53
  /**
49
54
  * Encrypt the encrypted columns of a positional row value array in place-safe
@@ -8,11 +8,25 @@
8
8
  * bridge between the positional `RowValue[]` and the `@syncular/core` §5.11
9
9
  * envelope primitives.
10
10
  */
11
- import { DecryptError, decryptValue, encryptValue, } from '@syncular/core';
12
- /** Convert a portable keyring into the direct-client encryption contract. */
11
+ import { DecryptError, decryptValue, encryptValue, KEY_LENGTH, } from '@syncular/core';
12
+ import { ClientSyncError } from './errors.js';
13
+ /**
14
+ * Convert a portable keyring into the direct-client encryption contract.
15
+ * Key lengths are validated here, at install time: every side of the wire
16
+ * enforces {@link KEY_LENGTH}-byte keys, so a wrong-length key fails loud
17
+ * with a clear config error before any sync round runs.
18
+ */
13
19
  export function encryptionConfigFromKeyring(keyring) {
20
+ for (const [keyId, key] of Object.entries(keyring.keys)) {
21
+ if (key.length !== KEY_LENGTH) {
22
+ throw new ClientSyncError('sync.invalid_request', `encryption key ${JSON.stringify(keyId)} must be ${KEY_LENGTH} bytes (AES-256), got ${key.length}`);
23
+ }
24
+ }
14
25
  return {
15
- keyProvider: (keyId) => keyring.keys[keyId],
26
+ // Own-property lookup only: the keyId arrives from the wire envelope, so
27
+ // a prototype-chain name like "constructor" must read as a missing key
28
+ // and take the clean DecryptError path.
29
+ keyProvider: (keyId) => Object.hasOwn(keyring.keys, keyId) ? keyring.keys[keyId] : undefined,
16
30
  ...(keyring.keyIdColumns !== undefined
17
31
  ? { keyIdColumns: keyring.keyIdColumns }
18
32
  : {}),
@@ -691,8 +691,15 @@ export class ReactiveClientStore {
691
691
  // Releasing a window is best-effort teardown. A resource owner may have
692
692
  // already closed the underlying worker/native handle before React effect
693
693
  // cleanup runs (notably during schema-changing HMR). Do not let that
694
- // harmless ordering race escape as an unhandled rejection.
695
- void Promise.resolve(this.client.setWindow(group.base, [])).catch(() => undefined);
694
+ // harmless ordering race escape as an unhandled rejection — and the
695
+ // interface permits a plain void return, so a closed handle may also
696
+ // throw synchronously; swallow that the same way.
697
+ try {
698
+ void Promise.resolve(this.client.setWindow(group.base, [])).catch(() => undefined);
699
+ }
700
+ catch {
701
+ // Same teardown race, surfaced synchronously.
702
+ }
696
703
  }
697
704
  this.#windowClaims.clear();
698
705
  }
@@ -28,8 +28,18 @@ export interface RealtimeSupervisorSnapshot {
28
28
  export interface RealtimeSupervisorOptions {
29
29
  /** Host online/offline evidence. Unknown remains connectable and observable. */
30
30
  readonly connectivity?: RealtimeSupervisorSignal<ClientDiagnosticsConnectivity>;
31
- /** Browser/native foreground evidence. Background always suspends the socket. */
31
+ /** Browser/native foreground evidence. Background suspends a tab-owned
32
+ * socket; with `sharedTransport` it only serves as a resume nudge. */
32
33
  readonly lifecycle?: RealtimeSupervisorSignal<RealtimeSupervisorLifecycleState>;
34
+ /**
35
+ * The client's realtime transport is shared with sibling tabs or webviews
36
+ * (multi-tab handles proxying to one leader socket, native cores behind
37
+ * several views). Backgrounding THIS view then keeps the socket open,
38
+ * because a sibling may still be visible and a follower's disconnect would
39
+ * tear down realtime for everyone. Offline and protection evidence still
40
+ * suspend.
41
+ */
42
+ readonly sharedTransport?: boolean;
33
43
  /** Publish preflight before draining keys so reconnect stops in the same turn. */
34
44
  readonly protection?: RealtimeSupervisorSignal<RealtimeSupervisorProtectionState>;
35
45
  /** Deterministic test/host timer seam. */
@@ -72,7 +82,12 @@ export declare class RealtimeSupervisor {
72
82
  start(): void;
73
83
  stop(): void;
74
84
  }
75
- /** Install one supervisor and make client disposal cancel it before close. */
85
+ /**
86
+ * Install one supervisor and make client disposal cancel it before close.
87
+ * A client accepts exactly one supervisor for its lifetime; installing a
88
+ * second throws, because silently keeping the first would discard the second
89
+ * call's options.
90
+ */
76
91
  export declare function installRealtimeSupervisor<T extends RealtimeSupervisorClient>(client: T, options?: RealtimeSupervisorOptions): T;
77
92
  export declare function realtimeSupervisorSnapshot(client: object): RealtimeSupervisorSnapshot;
78
93
  export declare function subscribeRealtimeSupervisor(client: object, listener: () => void): () => void;
@@ -117,6 +117,7 @@ export class RealtimeSupervisor {
117
117
  #connectivity;
118
118
  #lifecycle;
119
119
  #protection;
120
+ #sharedTransport;
120
121
  #scheduleTimer;
121
122
  #random;
122
123
  #initialDelayMs;
@@ -144,6 +145,7 @@ export class RealtimeSupervisor {
144
145
  this.#connectivity = options.connectivity;
145
146
  this.#lifecycle = options.lifecycle;
146
147
  this.#protection = options.protection;
148
+ this.#sharedTransport = options.sharedTransport === true;
147
149
  this.#scheduleTimer = options.schedule ?? scheduleTimer;
148
150
  this.#random = options.random ?? Math.random;
149
151
  this.#initialDelayMs = boundedDelay(options.initialDelayMs, DEFAULT_INITIAL_DELAY_MS);
@@ -184,6 +186,7 @@ export class RealtimeSupervisor {
184
186
  this.#unsubscribeLifecycle?.();
185
187
  this.#unsubscribeProtection?.();
186
188
  this.#publish({ phase: 'stopped', attempt: 0 });
189
+ this.#listeners.clear();
187
190
  if (disconnect)
188
191
  this.#disconnect();
189
192
  }
@@ -214,8 +217,9 @@ export class RealtimeSupervisor {
214
217
  this.#connectivity?.current() === 'offline') {
215
218
  return 'offline';
216
219
  }
217
- if (this.#lifecycle?.current() === 'background')
220
+ if (!this.#sharedTransport && this.#lifecycle?.current() === 'background') {
218
221
  return 'background';
222
+ }
219
223
  return undefined;
220
224
  }
221
225
  #canConnect() {
@@ -273,43 +277,77 @@ export class RealtimeSupervisor {
273
277
  this.#reconcileHostState();
274
278
  return;
275
279
  }
280
+ // Count an attempt only when this call actually schedules one. Repeated
281
+ // 'disconnected' diagnostics beside a pending retry keep the backoff and
282
+ // the worker-mode reconnect nudge intact.
283
+ if (this.#connected ||
284
+ this.#connecting ||
285
+ this.#cancelRetry !== undefined) {
286
+ return;
287
+ }
276
288
  const delay = this.#retryDelay();
277
289
  this.#attempt = Math.min(32, this.#attempt + 1);
278
290
  this.#schedule(delay);
279
291
  }
292
+ /**
293
+ * A superseded attempt observed a generation bump mid-flight. Whenever a
294
+ * newer generation is mid-connect or holds the transport, that generation
295
+ * owns the shared socket and the stale attempt leaves every field alone.
296
+ * With the supervisor otherwise quiescent (suspended or stopped), the
297
+ * socket this attempt opened is unwanted — release it.
298
+ */
299
+ #releaseSupersededSocket() {
300
+ if (this.#connecting || this.#connected || this.#transportConnected) {
301
+ return;
302
+ }
303
+ this.#disconnect();
304
+ }
280
305
  async #connect() {
281
306
  if (!this.#canConnect() || this.#connected || this.#connecting)
282
307
  return;
308
+ const generation = this.#generation;
283
309
  this.#connecting = true;
284
310
  this.#publish({ phase: 'connecting', attempt: this.#attempt });
285
- const generation = this.#generation;
286
311
  let failed = false;
287
312
  try {
288
313
  await this.#client.connectRealtime();
314
+ if (generation !== this.#generation) {
315
+ this.#releaseSupersededSocket();
316
+ return;
317
+ }
289
318
  this.#transportConnected = true;
290
- if (generation !== this.#generation || !this.#canConnect()) {
319
+ if (!this.#canConnect()) {
320
+ this.#transportConnected = false;
291
321
  this.#disconnect();
292
322
  return;
293
323
  }
294
324
  await this.#catchUpConnectedTransport(generation);
295
325
  }
296
326
  catch {
327
+ if (generation !== this.#generation) {
328
+ this.#releaseSupersededSocket();
329
+ return;
330
+ }
297
331
  failed = true;
298
332
  this.#connected = false;
299
333
  this.#transportConnected = false;
300
334
  this.#disconnect();
301
335
  }
302
336
  finally {
303
- this.#connecting = false;
337
+ // A superseded attempt leaves the successor's in-flight state alone.
338
+ if (generation === this.#generation)
339
+ this.#connecting = false;
304
340
  }
305
341
  if (failed && generation === this.#generation)
306
342
  this.#scheduleRetry();
307
343
  }
308
344
  async #catchUpConnectedTransport(generation) {
309
345
  await this.#client.syncUntilIdle();
310
- if (generation !== this.#generation ||
311
- !this.#canConnect() ||
312
- !this.#transportConnected) {
346
+ if (generation !== this.#generation) {
347
+ this.#releaseSupersededSocket();
348
+ return;
349
+ }
350
+ if (!this.#canConnect() || !this.#transportConnected) {
313
351
  this.#disconnect();
314
352
  return;
315
353
  }
@@ -321,22 +359,28 @@ export class RealtimeSupervisor {
321
359
  if (!this.#canConnect() || this.#connecting || !this.#transportConnected) {
322
360
  return;
323
361
  }
362
+ const generation = this.#generation;
324
363
  this.#connecting = true;
325
364
  this.#clearRetry();
326
365
  this.#publish({ phase: 'connecting', attempt: this.#attempt });
327
- const generation = this.#generation;
328
366
  let failed = false;
329
367
  try {
330
368
  await this.#catchUpConnectedTransport(generation);
331
369
  }
332
370
  catch {
371
+ if (generation !== this.#generation) {
372
+ this.#releaseSupersededSocket();
373
+ return;
374
+ }
333
375
  failed = true;
334
376
  this.#connected = false;
335
377
  this.#transportConnected = false;
336
378
  this.#disconnect();
337
379
  }
338
380
  finally {
339
- this.#connecting = false;
381
+ // A superseded adoption leaves the successor's in-flight state alone.
382
+ if (generation === this.#generation)
383
+ this.#connecting = false;
340
384
  }
341
385
  if (failed && generation === this.#generation)
342
386
  this.#scheduleRetry();
@@ -359,9 +403,14 @@ export class RealtimeSupervisor {
359
403
  this.#disconnect();
360
404
  return;
361
405
  }
362
- if (!this.#realtimeSupported)
363
- return;
364
- this.#realtimeSupported = true;
406
+ if (!this.#realtimeSupported) {
407
+ // A live socket is positive evidence the earlier 'unsupported' report
408
+ // was transient (a mid-restart or mis-detected capability probe), so it
409
+ // recovers the supervisor. Every other report keeps the latch closed.
410
+ if (snapshot.host.realtime !== 'connected')
411
+ return;
412
+ this.#realtimeSupported = true;
413
+ }
365
414
  const block = this.#hostBlock();
366
415
  if (block) {
367
416
  this.#suspend(block);
@@ -369,9 +418,11 @@ export class RealtimeSupervisor {
369
418
  }
370
419
  if (snapshot.host.realtime === 'connected') {
371
420
  this.#transportConnected = true;
372
- if (this.#connecting)
421
+ // Steady state: diagnostics re-announce 'connected' on every sync
422
+ // round and subscription change. An already-adopted transport keeps
423
+ // its phase and skips the redundant catch-up.
424
+ if (this.#connecting || this.#connected)
373
425
  return;
374
- this.#connected = false;
375
426
  void this.#adoptConnectedTransport();
376
427
  return;
377
428
  }
@@ -428,10 +479,17 @@ export class RealtimeSupervisor {
428
479
  void Promise.resolve(this.#client.disconnectRealtime()).catch(() => undefined);
429
480
  }
430
481
  }
431
- /** Install one supervisor and make client disposal cancel it before close. */
482
+ /**
483
+ * Install one supervisor and make client disposal cancel it before close.
484
+ * A client accepts exactly one supervisor for its lifetime; installing a
485
+ * second throws, because silently keeping the first would discard the second
486
+ * call's options.
487
+ */
432
488
  export function installRealtimeSupervisor(client, options) {
433
- if (attachment(client))
434
- return client;
489
+ if (attachment(client)) {
490
+ throw new Error('installRealtimeSupervisor: this client already has a supervisor; ' +
491
+ 'install one supervisor per client lifetime');
492
+ }
435
493
  const supervisor = new RealtimeSupervisor(client, options);
436
494
  Object.defineProperty(client, REALTIME_SUPERVISOR_KEY, {
437
495
  configurable: false,
package/dist/schema.js CHANGED
@@ -213,6 +213,22 @@ function createSyncedTable(db, table) {
213
213
  }
214
214
  }
215
215
  const FTS_SOURCE_ID_COLUMN = '_syncular_source_id';
216
+ /**
217
+ * Create (or migrate) one contentful FTS5 projection for a table plus its
218
+ * synchronizing triggers. Every open drops and recreates the full trigger set
219
+ * (`_bi`, `_ai`, `_ad`, `_au`, and, when the table has a unique index, `_bu`),
220
+ * so existing databases pick up newly added guards. The BEFORE INSERT (`_bi`)
221
+ * and BEFORE UPDATE (`_bu`) guards remove projection rows for entries displaced
222
+ * by `INSERT OR REPLACE` / `UPDATE OR REPLACE` through the primary key or a
223
+ * secondary unique index — cases where SQLite does not reliably fire the AFTER
224
+ * DELETE trigger for the displaced row.
225
+ *
226
+ * Limitation: because a BEFORE trigger runs before SQLite resolves the
227
+ * conflict, an `OR IGNORE` write whose row is dropped still executes these
228
+ * displacement DELETEs, transiently removing the surviving row's projection
229
+ * entry. Syncular's mirror writes never use `OR IGNORE`; see the guard-block
230
+ * comment below for the full rationale.
231
+ */
216
232
  function createFtsProjection(db, table, index) {
217
233
  const existed = db.query("SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?", [index.name]).length > 0;
218
234
  const indexedColumns = index.columns.map(quoteIdent);
@@ -230,18 +246,68 @@ function createFtsProjection(db, table, index) {
230
246
  ...index.columns.map((column) => `new.${quoteIdent(column)}`),
231
247
  ].join(', ');
232
248
  const deleteFor = (value) => `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} = ${value}`;
249
+ const deleteDisplaced = (select) => `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} IN (${select})`;
233
250
  // A clean insert cannot already have a projection row because the source
234
251
  // primary key is unique. Keep clean inserts linear by moving replacement
235
- // cleanup behind an indexed source-table existence check. SQLite does not
236
- // reliably invoke DELETE triggers for the row displaced by REPLACE.
237
- for (const suffix of ['bi', 'ai', 'ad', 'au']) {
252
+ // cleanup behind indexed source-table existence checks. SQLite does not
253
+ // reliably invoke DELETE triggers for rows displaced by REPLACE, and a
254
+ // REPLACE can displace rows through TWO paths: the primary key AND any
255
+ // secondary UNIQUE index (a different-PK row whose unique key matches the
256
+ // incoming row). The BEFORE INSERT guard covers both for `INSERT OR REPLACE`;
257
+ // the mirroring BEFORE UPDATE guard covers `UPDATE OR REPLACE` that pushes a
258
+ // different-PK row out through a unique index (the AFTER UPDATE trigger only
259
+ // knows the updated row's own old/new ids, so it would leave that displaced
260
+ // row's projection entry behind as a ghost hit). Primary-key displacement by
261
+ // `UPDATE OR REPLACE` needs no BEFORE guard: the new pk equals the displaced
262
+ // row's pk, so the AFTER UPDATE delete of the new source id already clears it.
263
+ //
264
+ // Known limitation — `OR IGNORE`: a BEFORE trigger fires before SQLite
265
+ // resolves the conflict, and SQLite exposes no signal for the eventual
266
+ // resolution. So an `INSERT OR IGNORE` / `UPDATE OR IGNORE` whose row is
267
+ // dropped on conflict still runs these displacement DELETEs, removing the
268
+ // SURVIVING row's projection entry (a false negative until that row is next
269
+ // rewritten). Moving cleanup to AFTER triggers would dodge the no-op but
270
+ // reintroduce the REPLACE-doesn't-fire-DELETE gap and a full-projection scan
271
+ // per write, so the guards stay BEFORE. Syncular's own mirror writes use
272
+ // `INSERT … ON CONFLICT (pk) DO UPDATE` (apply.ts), which never takes the
273
+ // IGNORE path; only hand-written `OR IGNORE` against a mirror table is exposed.
274
+ for (const suffix of ['bi', 'ai', 'ad', 'au', 'bu']) {
238
275
  db.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(`${index.name}_${suffix}`)}`);
239
276
  }
240
277
  const replacementExists = `EXISTS (SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = new.${quoteIdent(table.primaryKey)})`;
241
- db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${replacementExists} BEGIN ${deleteFor(newSourceId)}; END`);
278
+ // Per secondary UNIQUE index: the different-PK rows about to be displaced
279
+ // via that unique key. `=` makes NULL unique values match nothing, which is
280
+ // exactly SQLite's unique-index semantics (NULLs never conflict).
281
+ const uniqueIndexes = table.indexes.filter((spec) => spec.unique);
282
+ const displacedByUnique = uniqueIndexes.map((spec) => {
283
+ const match = spec.columns
284
+ .map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
285
+ .join(' AND ');
286
+ return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} != new.${quoteIdent(table.primaryKey)}`;
287
+ });
288
+ const insertGuardCondition = [
289
+ replacementExists,
290
+ ...displacedByUnique.map((select) => `EXISTS (${select})`),
291
+ ].join(' OR ');
292
+ const insertGuardBody = [
293
+ deleteFor(newSourceId),
294
+ ...displacedByUnique.map(deleteDisplaced),
295
+ ].join('; ');
296
+ db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`);
242
297
  db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`);
243
298
  db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ad`)} AFTER DELETE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; END`);
244
299
  db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`);
300
+ // BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
301
+ // different-PK row about to be displaced through a secondary unique index by
302
+ // the new values. Only meaningful when the table has a unique index; with
303
+ // none, no update can displace a foreign row, so the trigger is omitted.
304
+ if (displacedByUnique.length > 0) {
305
+ const updateGuardCondition = displacedByUnique
306
+ .map((select) => `EXISTS (${select})`)
307
+ .join(' OR ');
308
+ const updateGuardBody = displacedByUnique.map(deleteDisplaced).join('; ');
309
+ db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_bu`)} BEFORE UPDATE ON ${quoteIdent(table.name)} WHEN ${updateGuardCondition} BEGIN ${updateGuardBody}; END`);
310
+ }
245
311
  if (!existed) {
246
312
  db.exec(`INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`);
247
313
  }
@@ -391,9 +391,21 @@ export function startSyncWorker(overrides = {}) {
391
391
  database = undefined;
392
392
  },
393
393
  };
394
+ // Local purge/rebootstrap rewrite the same durable state an in-flight
395
+ // sync round captured at send time; running their RPCs on the sync chain
396
+ // orders them against RPC- and auto-driven rounds (the client core's
397
+ // reset fence covers hosts that call the core directly).
398
+ const syncChainMethods = new Set([
399
+ 'purgeLocalData',
400
+ 'rebootstrapLocalData',
401
+ ]);
394
402
  async function dispatch(message) {
395
403
  const method = api[message.method];
396
- return await method.apply(api, message.args);
404
+ const invoke = () => method.apply(api, message.args);
405
+ if (syncChainMethods.has(message.method)) {
406
+ return await serializedSync(async () => invoke());
407
+ }
408
+ return await invoke();
397
409
  }
398
410
  function run(id, fn, transfer) {
399
411
  void (async () => {