@syncular/client 0.15.40 → 0.15.43

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.40",
3
+ "version": "0.15.43",
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.40"
92
+ "@syncular/core": "0.15.43"
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.40",
103
+ "@syncular/server": "0.15.43",
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,
package/src/schema.ts CHANGED
@@ -342,6 +342,22 @@ function createSyncedTable(
342
342
 
343
343
  const FTS_SOURCE_ID_COLUMN = '_syncular_source_id';
344
344
 
345
+ /**
346
+ * Create (or migrate) one contentful FTS5 projection for a table plus its
347
+ * synchronizing triggers. Every open drops and recreates the full trigger set
348
+ * (`_bi`, `_ai`, `_ad`, `_au`, and, when the table has a unique index, `_bu`),
349
+ * so existing databases pick up newly added guards. The BEFORE INSERT (`_bi`)
350
+ * and BEFORE UPDATE (`_bu`) guards remove projection rows for entries displaced
351
+ * by `INSERT OR REPLACE` / `UPDATE OR REPLACE` through the primary key or a
352
+ * secondary unique index — cases where SQLite does not reliably fire the AFTER
353
+ * DELETE trigger for the displaced row.
354
+ *
355
+ * Limitation: because a BEFORE trigger runs before SQLite resolves the
356
+ * conflict, an `OR IGNORE` write whose row is dropped still executes these
357
+ * displacement DELETEs, transiently removing the surviving row's projection
358
+ * entry. Syncular's mirror writes never use `OR IGNORE`; see the guard-block
359
+ * comment below for the full rationale.
360
+ */
345
361
  function createFtsProjection(
346
362
  db: ClientDatabase,
347
363
  table: CompiledClientTable,
@@ -371,18 +387,58 @@ function createFtsProjection(
371
387
  ].join(', ');
372
388
  const deleteFor = (value: string) =>
373
389
  `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} = ${value}`;
390
+ const deleteDisplaced = (select: string) =>
391
+ `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} IN (${select})`;
374
392
 
375
393
  // A clean insert cannot already have a projection row because the source
376
394
  // primary key is unique. Keep clean inserts linear by moving replacement
377
- // cleanup behind an indexed source-table existence check. SQLite does not
378
- // reliably invoke DELETE triggers for the row displaced by REPLACE.
379
- for (const suffix of ['bi', 'ai', 'ad', 'au']) {
395
+ // cleanup behind indexed source-table existence checks. SQLite does not
396
+ // reliably invoke DELETE triggers for rows displaced by REPLACE, and a
397
+ // REPLACE can displace rows through TWO paths: the primary key AND any
398
+ // secondary UNIQUE index (a different-PK row whose unique key matches the
399
+ // incoming row). The BEFORE INSERT guard covers both for `INSERT OR REPLACE`;
400
+ // the mirroring BEFORE UPDATE guard covers `UPDATE OR REPLACE` that pushes a
401
+ // different-PK row out through a unique index (the AFTER UPDATE trigger only
402
+ // knows the updated row's own old/new ids, so it would leave that displaced
403
+ // row's projection entry behind as a ghost hit). Primary-key displacement by
404
+ // `UPDATE OR REPLACE` needs no BEFORE guard: the new pk equals the displaced
405
+ // row's pk, so the AFTER UPDATE delete of the new source id already clears it.
406
+ //
407
+ // Known limitation — `OR IGNORE`: a BEFORE trigger fires before SQLite
408
+ // resolves the conflict, and SQLite exposes no signal for the eventual
409
+ // resolution. So an `INSERT OR IGNORE` / `UPDATE OR IGNORE` whose row is
410
+ // dropped on conflict still runs these displacement DELETEs, removing the
411
+ // SURVIVING row's projection entry (a false negative until that row is next
412
+ // rewritten). Moving cleanup to AFTER triggers would dodge the no-op but
413
+ // reintroduce the REPLACE-doesn't-fire-DELETE gap and a full-projection scan
414
+ // per write, so the guards stay BEFORE. Syncular's own mirror writes use
415
+ // `INSERT … ON CONFLICT (pk) DO UPDATE` (apply.ts), which never takes the
416
+ // IGNORE path; only hand-written `OR IGNORE` against a mirror table is exposed.
417
+ for (const suffix of ['bi', 'ai', 'ad', 'au', 'bu']) {
380
418
  db.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(`${index.name}_${suffix}`)}`);
381
419
  }
382
420
  const replacementExists = `EXISTS (SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = new.${quoteIdent(table.primaryKey)})`;
421
+ // Per secondary UNIQUE index: the different-PK rows about to be displaced
422
+ // via that unique key. `=` makes NULL unique values match nothing, which is
423
+ // exactly SQLite's unique-index semantics (NULLs never conflict).
424
+ const uniqueIndexes = table.indexes.filter((spec) => spec.unique);
425
+ const displacedByUnique = uniqueIndexes.map((spec) => {
426
+ const match = spec.columns
427
+ .map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
428
+ .join(' AND ');
429
+ return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} != new.${quoteIdent(table.primaryKey)}`;
430
+ });
431
+ const insertGuardCondition = [
432
+ replacementExists,
433
+ ...displacedByUnique.map((select) => `EXISTS (${select})`),
434
+ ].join(' OR ');
435
+ const insertGuardBody = [
436
+ deleteFor(newSourceId),
437
+ ...displacedByUnique.map(deleteDisplaced),
438
+ ].join('; ');
383
439
 
384
440
  db.exec(
385
- `CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${replacementExists} BEGIN ${deleteFor(newSourceId)}; END`,
441
+ `CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`,
386
442
  );
387
443
  db.exec(
388
444
  `CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`,
@@ -393,6 +449,19 @@ function createFtsProjection(
393
449
  db.exec(
394
450
  `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`,
395
451
  );
452
+ // BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
453
+ // different-PK row about to be displaced through a secondary unique index by
454
+ // the new values. Only meaningful when the table has a unique index; with
455
+ // none, no update can displace a foreign row, so the trigger is omitted.
456
+ if (displacedByUnique.length > 0) {
457
+ const updateGuardCondition = displacedByUnique
458
+ .map((select) => `EXISTS (${select})`)
459
+ .join(' OR ');
460
+ const updateGuardBody = displacedByUnique.map(deleteDisplaced).join('; ');
461
+ db.exec(
462
+ `CREATE TRIGGER ${quoteIdent(`${index.name}_bu`)} BEFORE UPDATE ON ${quoteIdent(table.name)} WHEN ${updateGuardCondition} BEGIN ${updateGuardBody}; END`,
463
+ );
464
+ }
396
465
 
397
466
  if (!existed) {
398
467
  db.exec(
@@ -42,6 +42,7 @@ import {
42
42
  type WorkerErrorShape,
43
43
  type WorkerInitConfig,
44
44
  type WorkerInitResult,
45
+ type WorkerMethod,
45
46
  type WorkerToMainMessage,
46
47
  } from './worker-protocol';
47
48
 
@@ -491,9 +492,22 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
491
492
  },
492
493
  };
493
494
 
495
+ // Local purge/rebootstrap rewrite the same durable state an in-flight
496
+ // sync round captured at send time; running their RPCs on the sync chain
497
+ // orders them against RPC- and auto-driven rounds (the client core's
498
+ // reset fence covers hosts that call the core directly).
499
+ const syncChainMethods: ReadonlySet<WorkerMethod> = new Set<WorkerMethod>([
500
+ 'purgeLocalData',
501
+ 'rebootstrapLocalData',
502
+ ]);
503
+
494
504
  async function dispatch(message: WorkerCallMessage): Promise<unknown> {
495
505
  const method = api[message.method] as (...args: unknown[]) => unknown;
496
- return await method.apply(api, message.args as unknown[]);
506
+ const invoke = () => method.apply(api, message.args as unknown[]);
507
+ if (syncChainMethods.has(message.method)) {
508
+ return await serializedSync(async () => invoke());
509
+ }
510
+ return await invoke();
497
511
  }
498
512
 
499
513
  function run(