Skip to content

Commit bf91b75

Browse files
committed
fix(module-state): surface pull rejection errors and register one-shot pull for teardown
- pull.then() previously discarded the rejection reason when it was the only signal a pull failed - finish() now accepts an optional error and emits an onStateSync.error event for it. - The active one-shot pull's cancel and its replication-event subscription are now registered via _addTeardown() so disposing the storage mid-pull cancels them instead of leaving them running past the storage's lifetime. - Also applies biome's formatting suggestions across this file (line wrapping for multi-arg calls and long conditions).
1 parent 1dfa6e5 commit bf91b75

1 file changed

Lines changed: 60 additions & 22 deletions

File tree

packages/modules/state/src/storage/PouchDbSyncStorage.ts

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -186,14 +186,19 @@ export class PouchDbSyncStorage extends PouchDbStorage {
186186
* @template T - State value type.
187187
* @returns The live push replication handle.
188188
*/
189-
protected _startLivePush<T extends AllowedValue = AllowedValue>(): PouchDB.Replication.Replication<{
189+
protected _startLivePush<
190+
T extends AllowedValue = AllowedValue,
191+
>(): PouchDB.Replication.Replication<{
190192
value: T;
191193
}> {
192-
const push = this._db.replicate.to<{ value: T }>(this.#remoteDb as PouchDB.Database<{ value: T }>, {
193-
...this.#syncOptions,
194-
live: true,
195-
retry: this.#syncOptions.retry ?? true,
196-
});
194+
const push = this._db.replicate.to<{ value: T }>(
195+
this.#remoteDb as PouchDB.Database<{ value: T }>,
196+
{
197+
...this.#syncOptions,
198+
live: true,
199+
retry: this.#syncOptions.retry ?? true,
200+
},
201+
);
197202

198203
const subscription = observePouchDbReplicate<T>(push, 'push', (doc) => ({
199204
_id: doc._id,
@@ -228,19 +233,24 @@ export class PouchDbSyncStorage extends PouchDbStorage {
228233
return Promise.resolve();
229234
}
230235
this.#pullInFlight = true;
231-
this._emitEvent(new StateSyncEvent.Poll({ detail: { trigger, skipped: false } }) as StateEventType);
236+
this._emitEvent(
237+
new StateSyncEvent.Poll({ detail: { trigger, skipped: false } }) as StateEventType,
238+
);
232239

233240
let pull: PouchDB.Replication.Replication<{ value: T }>;
234241
try {
235-
pull = this._db.replicate.from<{ value: T }>(this.#remoteDb as PouchDB.Database<{ value: T }>, {
236-
...this.#syncOptions,
237-
live: false,
238-
retry: false,
239-
// Guarantees 'complete'/'error' fires even against a backend that never answers a
240-
// one-shot request - otherwise a single hung poll would wedge #pullInFlight forever,
241-
// silently turning every later timer/focus trigger into a no-op skip.
242-
timeout: this.#syncOptions.timeout ?? 30000,
243-
});
242+
pull = this._db.replicate.from<{ value: T }>(
243+
this.#remoteDb as PouchDB.Database<{ value: T }>,
244+
{
245+
...this.#syncOptions,
246+
live: false,
247+
retry: false,
248+
// Guarantees 'complete'/'error' fires even against a backend that never answers a
249+
// one-shot request - otherwise a single hung poll would wedge #pullInFlight forever,
250+
// silently turning every later timer/focus trigger into a no-op skip.
251+
timeout: this.#syncOptions.timeout ?? 30000,
252+
},
253+
);
244254
} catch (error) {
245255
console.error('[state] failed to start one-shot pull replication', error);
246256
// A synchronous throw here (bad remote config, custom fetch misuse, etc.) would
@@ -259,31 +269,55 @@ export class PouchDbSyncStorage extends PouchDbStorage {
259269
value: doc.value,
260270
})).subscribe({ next: (event) => this._emitEvent(event as StateEventType) });
261271

272+
// Registered so disposing storage while this pull is mid-flight cancels its request,
273+
// listeners, and watchdog instead of leaving them running past the storage's own lifetime.
274+
// Deregistered in `finish()` below - `_pullOnce` runs repeatedly for the life of the
275+
// storage, so leaving these registered past each pull's own completion would leak one
276+
// teardown entry per poll.
277+
const removePullTeardown = this._addTeardown(() => pull.cancel());
278+
const removeSubscriptionTeardown = this._addTeardown(subscription);
279+
262280
return new Promise((resolve) => {
263281
let settled = false;
264-
const finish = () => {
282+
const finish = (error?: unknown) => {
265283
// 'complete'/'error' can both fire in some PouchDB versions, and the watchdog/promise
266284
// fallbacks below can race with either - only unblock once, whichever gets here first.
267285
if (settled) return;
268286
settled = true;
269287
clearTimeout(watchdog);
270288
subscription.unsubscribe();
289+
removePullTeardown();
290+
removeSubscriptionTeardown();
271291
this.#pullInFlight = false;
292+
// Only the rejection branch passes an error - 'complete'/'error' already emitted
293+
// their own onStateSync.error via observePouchDbReplicate, and the watchdog's forced
294+
// cancel isn't itself an error worth surfacing again.
295+
if (error !== undefined) {
296+
this._emitEvent(
297+
new StateSyncEvent.Error({ detail: { type: 'error', error } }) as StateEventType,
298+
);
299+
}
272300
resolve();
273301
};
274-
pull.on('complete', finish);
275-
pull.on('error', finish);
302+
pull.on('complete', () => finish());
303+
pull.on('error', () => finish());
276304

277305
// `Replication` is also thenable (it resolves/rejects the same way `db.sync()`'s
278306
// `.then()` does) - a fallback for when the 'complete'/'error' *events* themselves
279307
// don't fire, which has been observed to happen even though the underlying requests succeed.
280-
pull.then(finish, finish);
308+
// A rejection reaching here (rather than the 'error' event above) would otherwise surface
309+
// as a silent no-op - report it as an onStateSync.error instead of discarding the reason.
310+
pull.then(
311+
() => finish(),
312+
(error) => finish(error),
313+
);
281314

282315
// PouchDB's own `timeout` option only bounds the underlying `_changes` request, not the
283316
// full replication (checkpoint read/write, `_revs_diff`, `_bulk_get`) - observed in practice
284317
// to never emit 'complete'/'error' at all in some cases, wedging #pullInFlight forever.
285318
// This watchdog guarantees forward progress regardless of where PouchDB got stuck.
286-
const watchdogMs = (typeof this.#syncOptions.timeout === 'number' ? this.#syncOptions.timeout : 30000) + 5000;
319+
const watchdogMs =
320+
(typeof this.#syncOptions.timeout === 'number' ? this.#syncOptions.timeout : 30000) + 5000;
287321
const watchdog = setTimeout(() => {
288322
pull.cancel();
289323
finish();
@@ -310,7 +344,11 @@ export class PouchDbSyncStorage extends PouchDbStorage {
310344
const timer = setInterval(() => {
311345
// A backgrounded tab has no user waiting on fresh data - skip the tick rather than
312346
// hold a connection open for it, and let the visibilitychange catch-up handle it instead.
313-
if (!pauseWhenHidden || typeof document === 'undefined' || document.visibilityState === 'visible') {
347+
if (
348+
!pauseWhenHidden ||
349+
typeof document === 'undefined' ||
350+
document.visibilityState === 'visible'
351+
) {
314352
run('interval');
315353
}
316354
}, intervalMs);

0 commit comments

Comments
 (0)