pending-task-kit 0.1.0 → 0.2.0

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/dist/index.js CHANGED
@@ -1,8 +1,37 @@
1
1
  // src/store.ts
2
2
  import { create } from "zustand";
3
3
  import { createJSONStorage, persist } from "zustand/middleware";
4
+
5
+ // src/safe-storage.ts
6
+ function safeGetItem(key) {
7
+ try {
8
+ if (typeof localStorage === "undefined") return null;
9
+ return localStorage.getItem(key);
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ function safeSetItem(key, value) {
15
+ try {
16
+ if (typeof localStorage === "undefined") return false;
17
+ localStorage.setItem(key, value);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+ function safeRemoveItem(key) {
24
+ try {
25
+ if (typeof localStorage === "undefined") return;
26
+ localStorage.removeItem(key);
27
+ } catch {
28
+ }
29
+ }
30
+
31
+ // src/store.ts
4
32
  var DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
5
33
  var DEFAULT_STORAGE_KEY = "pending-tasks";
34
+ var DEFAULT_TASK_LIST_WARN_THRESHOLD = 200;
6
35
  function isPendingTaskShape(value) {
7
36
  if (!value || typeof value !== "object") return false;
8
37
  const t = value;
@@ -20,13 +49,25 @@ function parseTasksFromStorageValue(value) {
20
49
  }
21
50
  }
22
51
  function readPersistedTasks(storageKey) {
23
- if (typeof localStorage === "undefined") return [];
24
- return parseTasksFromStorageValue(localStorage.getItem(storageKey));
52
+ return parseTasksFromStorageValue(safeGetItem(storageKey));
25
53
  }
26
54
  function createPendingTaskStore(options = {}) {
27
55
  const storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
56
+ const taskListWarnThreshold = options.taskListWarnThreshold === void 0 || Number.isNaN(options.taskListWarnThreshold) ? DEFAULT_TASK_LIST_WARN_THRESHOLD : options.taskListWarnThreshold;
28
57
  const readPersisted = () => readPersistedTasks(storageKey);
58
+ let hasWarnedAboutTaskListSize = false;
59
+ const warnIfTaskListTooLarge = (length) => {
60
+ if (hasWarnedAboutTaskListSize || length <= taskListWarnThreshold) return;
61
+ hasWarnedAboutTaskListSize = true;
62
+ try {
63
+ console.warn(
64
+ `pending-task-kit: tracking ${length} tasks for storageKey "${storageKey}", past the soft warning threshold of ${taskListWarnThreshold}. The whole list is persisted as a single localStorage entry on every change \u2014 a very large list risks the ~5MB per-origin quota and slows down every write. Consider pruning stale tasks more aggressively (see pruneTasksBy), or pass a higher taskListWarnThreshold if this app genuinely needs to track this many.`
65
+ );
66
+ } catch {
67
+ }
68
+ };
29
69
  const writeTasks = (tasks) => {
70
+ warnIfTaskListTooLarge(tasks.length);
30
71
  try {
31
72
  useStore.setState({ tasks });
32
73
  useStore.hasUnpersistedWrites = false;
@@ -60,7 +101,15 @@ function createPendingTaskStore(options = {}) {
60
101
  {
61
102
  name: storageKey,
62
103
  storage: createJSONStorage(() => localStorage),
63
- partialize: (state) => ({ tasks: state.tasks })
104
+ partialize: (state) => ({ tasks: state.tasks }),
105
+ // The one path that bypasses `writeTasks` (and so its size-warning check) entirely:
106
+ // zustand's own initial rehydrate-from-storage on store creation calls its internal
107
+ // `setState` directly, not through `writeTasks`. Without this, an app that starts up
108
+ // with an already-oversized persisted list would only ever get warned on its *next*
109
+ // mutation, not on load — the case this app most needs the warning for.
110
+ onRehydrateStorage: () => (state) => {
111
+ if (state) warnIfTaskListTooLarge(state.tasks.length);
112
+ }
64
113
  }
65
114
  )
66
115
  );
@@ -88,32 +137,6 @@ function createPendingTaskRegistryBinding(store, registry) {
88
137
  return { addTask, addTaskIfMissing };
89
138
  }
90
139
 
91
- // src/safe-storage.ts
92
- function safeGetItem(key) {
93
- try {
94
- if (typeof localStorage === "undefined") return null;
95
- return localStorage.getItem(key);
96
- } catch {
97
- return null;
98
- }
99
- }
100
- function safeSetItem(key, value) {
101
- try {
102
- if (typeof localStorage === "undefined") return false;
103
- localStorage.setItem(key, value);
104
- return true;
105
- } catch {
106
- return false;
107
- }
108
- }
109
- function safeRemoveItem(key) {
110
- try {
111
- if (typeof localStorage === "undefined") return;
112
- localStorage.removeItem(key);
113
- } catch {
114
- }
115
- }
116
-
117
140
  // src/poll-lease.ts
118
141
  function readLease(storageKey) {
119
142
  const raw = safeGetItem(storageKey);
@@ -219,6 +242,10 @@ var PendingTaskPoller = class {
219
242
  this.isChecking = false;
220
243
  this.pendingForce = false;
221
244
  this.stopped = false;
245
+ /** This tab's own most recently reported leadership status, for `onLeaderChange` — tracked
246
+ * here (rather than derived fresh each time from `fence`) purely so that callback fires only
247
+ * on an actual flip, not once per tick it happens to still hold/still lack leadership. */
248
+ this.isLeaderTab = false;
222
249
  /** Task ids that already got their one `finalCheckOnExpiry` attempt, so a repeatedly-failing
223
250
  * final check doesn't get retried every tick. Reset on process restart — worst case that
224
251
  * costs one extra check, never an infinite retry loop.
@@ -248,7 +275,9 @@ var PendingTaskPoller = class {
248
275
  onResult: options.onResult,
249
276
  onCheckError: options.onCheckError,
250
277
  claimResultOnce: options.claimResultOnce,
251
- acceptRelayedResult: options.acceptRelayedResult
278
+ acceptRelayedResult: options.acceptRelayedResult,
279
+ onLeaderChange: options.onLeaderChange,
280
+ onTick: options.onTick
252
281
  };
253
282
  this.ownerId = generatePollOwnerId();
254
283
  this.pollLease = createPollLeaseClaimer(this.options.pollLeaseKey, this.options.pollLeaseTtlMs);
@@ -291,6 +320,7 @@ var PendingTaskPoller = class {
291
320
  stop() {
292
321
  this.stopped = true;
293
322
  this.pendingForce = false;
323
+ this.inFlightAbortController?.abort();
294
324
  if (this.intervalId !== void 0) {
295
325
  clearInterval(this.intervalId);
296
326
  this.intervalId = void 0;
@@ -301,6 +331,7 @@ var PendingTaskPoller = class {
301
331
  }
302
332
  if (this.options.crossTabPollLeaderElection) {
303
333
  void this.releaseLeadership().catch(() => void 0);
334
+ this.setLeaderStatus(false);
304
335
  }
305
336
  }
306
337
  /** Re-check every tracked task right now, bypassing each task's poll interval (e.g. on tab focus). */
@@ -310,6 +341,25 @@ var PendingTaskPoller = class {
310
341
  claimLeadership() {
311
342
  return withTabLock(this.options.pollLeaseKey, () => this.pollLease.claim(this.ownerId));
312
343
  }
344
+ /** Updates `isLeaderTab` and fires `onLeaderChange`, but only on an actual flip — see that
345
+ * option's doc comment, including the "only ever called when `crossTabPollLeaderElection` is
346
+ * on" part, which this enforces itself rather than relying on every call site to remember to
347
+ * guard it (a call site that forgot would otherwise be a real, undetected bug — the whole
348
+ * reason this guard lives here instead of at each of this method's several call sites). Safe
349
+ * to call redundantly (e.g. after every successful claim/reconfirm in a tick, not just the
350
+ * first) since a no-op call is just an equality check. */
351
+ setLeaderStatus(isLeader) {
352
+ if (!this.options.crossTabPollLeaderElection) return;
353
+ if (this.isLeaderTab === isLeader) return;
354
+ this.isLeaderTab = isLeader;
355
+ try {
356
+ this.options.onLeaderChange?.(isLeader);
357
+ } catch (error) {
358
+ queueMicrotask(() => {
359
+ throw error;
360
+ });
361
+ }
362
+ }
313
363
  /**
314
364
  * Re-confirms that poll leadership is still this tab's — and still the *same continuous
315
365
  * tenure* as when `fence` was captured, not just "is nobody else currently holding it" (a
@@ -327,11 +377,11 @@ var PendingTaskPoller = class {
327
377
  * changed hands in between. See `PollLeaseClaimResult`.
328
378
  */
329
379
  async reconfirmLeadership(task, expired, fence) {
330
- if (!this.options.crossTabPollLeaderElection) return fence;
331
380
  if (this.stopped) {
332
381
  if (expired) this.finalCheckAttempted.delete(task.id);
333
382
  return false;
334
383
  }
384
+ if (!this.options.crossTabPollLeaderElection) return fence;
335
385
  let result;
336
386
  try {
337
387
  result = await this.claimLeadership();
@@ -465,8 +515,8 @@ var PendingTaskPoller = class {
465
515
  if (tasks.length === 0) return;
466
516
  this.isChecking = true;
467
517
  const batch = /* @__PURE__ */ new Map();
518
+ const now = Date.now();
468
519
  try {
469
- const now = Date.now();
470
520
  let fence;
471
521
  let leadershipLost = false;
472
522
  for (const task of tasks) {
@@ -479,7 +529,19 @@ var PendingTaskPoller = class {
479
529
  }
480
530
  continue;
481
531
  }
482
- const interval = handler.pollIntervalMs ?? this.options.defaultPollIntervalMs;
532
+ const failureCount = task.failureCount ?? 0;
533
+ let backoffMs;
534
+ if (failureCount > 0) {
535
+ try {
536
+ backoffMs = handler.retryBackoffMs?.(failureCount);
537
+ } catch (retryBackoffMsError) {
538
+ queueMicrotask(() => {
539
+ throw retryBackoffMsError;
540
+ });
541
+ backoffMs = void 0;
542
+ }
543
+ }
544
+ const interval = backoffMs !== void 0 && Number.isFinite(backoffMs) && backoffMs > 0 ? backoffMs : handler.pollIntervalMs ?? this.options.defaultPollIntervalMs;
483
545
  const lastChecked = task.lastCheckedAt ?? task.startedAt;
484
546
  const due = force || now - lastChecked >= interval;
485
547
  const finalAttemptDone = this.finalCheckAttempted.has(task.id);
@@ -505,25 +567,31 @@ var PendingTaskPoller = class {
505
567
  if (expired) this.finalCheckAttempted.delete(task.id);
506
568
  throw error;
507
569
  }
508
- if (!claimed.leader) {
570
+ if (this.stopped || !claimed.leader) {
509
571
  leadershipLost = true;
572
+ this.setLeaderStatus(false);
510
573
  if (expired) this.finalCheckAttempted.delete(task.id);
511
574
  continue;
512
575
  }
513
576
  fence = claimed.fence;
577
+ this.setLeaderStatus(true);
514
578
  }
515
579
  }
516
580
  let result;
581
+ const abortController = new AbortController();
582
+ this.inFlightAbortController = abortController;
517
583
  try {
518
- result = await handler.check(task);
584
+ result = await handler.check(task, abortController.signal);
519
585
  } catch (error) {
520
586
  const reconfirmedOnError = await this.reconfirmLeadership(task, expired, fence);
521
587
  if (reconfirmedOnError === false) {
522
588
  leadershipLost = true;
523
589
  fence = void 0;
590
+ this.setLeaderStatus(false);
524
591
  continue;
525
592
  }
526
593
  fence = reconfirmedOnError;
594
+ this.setLeaderStatus(true);
527
595
  let intercepted;
528
596
  try {
529
597
  intercepted = this.options.onCheckError?.(error, task);
@@ -543,21 +611,25 @@ var PendingTaskPoller = class {
543
611
  continue;
544
612
  }
545
613
  const latest = this.getLatestTask(task);
546
- const failureCount = (latest.failureCount ?? 0) + 1;
547
- if (failureCount >= this.options.maxFailureCount) {
614
+ const failureCount2 = (latest.failureCount ?? 0) + 1;
615
+ if (failureCount2 >= this.options.maxFailureCount) {
548
616
  await this.finalize(task, { status: "error", data: describeError(error) }, handler, batch);
549
617
  } else {
550
- batch.set(task.id, { lastCheckedAt: now, failureCount });
618
+ batch.set(task.id, { lastCheckedAt: now, failureCount: failureCount2 });
551
619
  }
552
620
  continue;
621
+ } finally {
622
+ if (this.inFlightAbortController === abortController) this.inFlightAbortController = void 0;
553
623
  }
554
624
  const reconfirmedOnSuccess = await this.reconfirmLeadership(task, expired, fence);
555
625
  if (reconfirmedOnSuccess === false) {
556
626
  leadershipLost = true;
557
627
  fence = void 0;
628
+ this.setLeaderStatus(false);
558
629
  continue;
559
630
  }
560
631
  fence = reconfirmedOnSuccess;
632
+ this.setLeaderStatus(true);
561
633
  if (result.status === "pending") {
562
634
  const latest = this.getLatestTask(task);
563
635
  batch.set(task.id, {
@@ -573,8 +645,23 @@ var PendingTaskPoller = class {
573
645
  await this.finalize(task, { status: result.status, data: result.data }, handler, batch);
574
646
  }
575
647
  } finally {
576
- this.flushBatch(batch);
577
648
  this.isChecking = false;
649
+ try {
650
+ this.flushBatch(batch);
651
+ } catch (error) {
652
+ queueMicrotask(() => {
653
+ throw error;
654
+ });
655
+ }
656
+ if (this.options.onTick) {
657
+ try {
658
+ this.options.onTick({ durationMs: Date.now() - now, taskCount: tasks.length });
659
+ } catch (error) {
660
+ queueMicrotask(() => {
661
+ throw error;
662
+ });
663
+ }
664
+ }
578
665
  if (this.pendingForce && !this.stopped) {
579
666
  this.pendingForce = false;
580
667
  this.runTickSafely(true);
@@ -643,6 +730,7 @@ export {
643
730
  DEFAULT_POLL_TICK_MS,
644
731
  DEFAULT_RESULT_EVENT,
645
732
  DEFAULT_STORAGE_KEY,
733
+ DEFAULT_TASK_LIST_WARN_THRESHOLD,
646
734
  DEFAULT_TTL_MS,
647
735
  PendingTaskPoller,
648
736
  clearResultRelay,