@syncular/client 0.15.43 → 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.43",
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.43"
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.43",
103
+ "@syncular/server": "0.15.44",
104
104
  "@types/better-sqlite3": "^7.6.13",
105
105
  "better-sqlite3": "^12.11.1"
106
106
  }
@@ -334,6 +334,17 @@ export class SyncClientHandle {
334
334
  });
335
335
  }
336
336
 
337
+ /**
338
+ * @internal — whether `close()` has run.
339
+ *
340
+ * A follower keeps a blocking `acquire` outstanding for the whole time it is a
341
+ * follower, so its promotion can fire long after the application has discarded
342
+ * the handle. The promotion path consults this before opening anything.
343
+ */
344
+ get __isClosed(): boolean {
345
+ return this.#closed;
346
+ }
347
+
337
348
  /** @internal — swap this handle from follower to leader (promotion). */
338
349
  __becomeLeader(core: LeaderCore): void {
339
350
  this.#follower?.close();
@@ -1020,6 +1031,14 @@ async function bootFollower(
1020
1031
  await lease.release();
1021
1032
  return;
1022
1033
  }
1034
+ if (handle.__isClosed) {
1035
+ // The application discarded this handle while it was queued for
1036
+ // leadership. Promoting now would open a database nobody is holding and
1037
+ // then keep the lock forever, so no other tab could ever take over.
1038
+ // Release instead, and let the next waiter have it.
1039
+ await lease.release();
1040
+ return;
1041
+ }
1023
1042
  // The follower saw the departing leader's epoch; the new leader must
1024
1043
  // strictly exceed it so stale replies/events are discarded everywhere.
1025
1044
  const nextEpoch = follower.maxEpochSeen + 1;
@@ -1058,6 +1077,13 @@ async function bootFollower(
1058
1077
  }
1059
1078
  : {}),
1060
1079
  });
1080
+ if (handle.__isClosed) {
1081
+ // Closed while the worker was starting. `close()` already ran and found
1082
+ // no core to shut down, so this one would leak: tear it down here rather
1083
+ // than hand it to a discarded handle. `close(true)` releases the lease.
1084
+ await core.close(true);
1085
+ return;
1086
+ }
1061
1087
  handle.__becomeLeader(core);
1062
1088
  } catch {
1063
1089
  // Promotion failed to spawn a worker — release so the next tab tries.