@greendrake/util 0.7.0 → 0.8.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/README.md CHANGED
@@ -35,9 +35,14 @@ off()
35
35
  - `TimeOutPromise(ms)` — `promise` resolves after `ms`; `isResolved`; `cancel()` clears the timer and rejects the promise (with `undefined`) while it is pending. `TimeOutPromise.wait(ms)` is the one-shot form.
36
36
  - `Debouncer(ms)` — `fire(ms?)` waits the delay (the argument overrides the constructed default for that call only) and resolves with a `proceed` predicate that is true only if no later `fire()` or `cancel()` superseded this one. `cancel()` invalidates every in-flight `fire()`.
37
37
  - `Delayed` — named-timeout registry: `go(name, fn, ms)` schedules `fn`, replacing a pending timeout of the same name; `cancel(name)`; `cancelAll()`. Instance-scoped, so equal names in unrelated instances cannot collide.
38
+ - `coalesced(run)` — returns `pull()`: one run at a time, and at most one more queued behind it however many times it is pulled meanwhile, for work whose every request asks the same question (read this again, rebuild that). Each pull settles with the outcome of the run that covers it — the one it started, or the one queued behind a run already going, never the run in progress, which may have begun before whatever prompted the pull. A run that throws synchronously rejects like any other; the slot is freed and handed to the queued run before the finished run's pulls settle. The outcome is the puller's to handle.
38
39
 
39
40
  ```ts
40
- import { Debouncer, Delayed, TimeOutPromise } from '@greendrake/util'
41
+ import { Debouncer, Delayed, TimeOutPromise, coalesced } from '@greendrake/util'
42
+
43
+ const refresh = coalesced(() => loadView())
44
+ source.on('changed', () => void refresh().catch(report))
45
+ const current = await refresh() // never a view read before this call
41
46
 
42
47
  const debouncer = new Debouncer(300)
43
48
  const proceed = await debouncer.fire()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greendrake/util",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Framework-free, Node-safe utilities: typed event emitter, timing, retry, staleness, comparators, fetch helpers, misc",
5
5
  "license": "MIT",
6
6
  "author": "Eugene Greendrake <eugene@greendrake.nz>",
package/src/main.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { Emitter, type EventMap } from './Emitter'
2
2
  export { randomString, dClone, isEmpty, arrayRemove, arraysEqual, capitalise, humanSeconds, humanFileSize, fileMime, configuration, createDeferred, type Deferred } from './misc'
3
- export { TimeOutPromise, Debouncer, Delayed } from './timing'
3
+ export { TimeOutPromise, Debouncer, Delayed, coalesced } from './timing'
4
4
  export { Retrier, RetryStopped, type RetrierOptions, type RetrierEvents } from './Retrier'
5
5
  export { StalenessDetector, type StalenessEvents } from './StalenessDetector'
6
6
  export { composeComparators, byProp, byPreset, type Comparator } from './compare'
package/src/timing.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { createDeferred, type Deferred } from './misc'
2
+
1
3
  export class TimeOutPromise {
2
4
  #timeout: ReturnType<typeof setTimeout>
3
5
  #reject: (reason?: unknown) => void
@@ -59,6 +61,60 @@ export class Debouncer {
59
61
  }
60
62
  }
61
63
 
64
+ // One run at a time, and at most one more behind it however many times it is
65
+ // pulled meanwhile — for work whose every request asks the same question (read
66
+ // this again, rebuild that), so the answer to the last request is the answer to
67
+ // all of them. A pull settles with the outcome of the run that covers it: the
68
+ // one it started, or the one queued behind a run already going. Never the run
69
+ // in progress, which may have begun before whatever prompted the pull.
70
+ // const refresh = coalesced(() => load())
71
+ // store.on('changed', () => void refresh().catch(report))
72
+ // The outcome is the puller's: a pull nobody handles rejects unhandled like any
73
+ // other promise. Whatever the outcome, the slot is freed.
74
+ export const coalesced = <T>(run: () => T | Promise<T>): (() => Promise<T>) => {
75
+ let running = false
76
+ // The run queued behind the current one, as every pull covered by it holds it.
77
+ let queued: Deferred<T> | null = null
78
+
79
+ // The slot is passed on before the finished run's pulls are settled, so
80
+ // whoever they wake finds the queued run already under way rather than a
81
+ // slot about to change hands.
82
+ const finish = (): void => {
83
+ running = false
84
+ const next = queued
85
+ queued = null
86
+ if (next) start(next)
87
+ }
88
+
89
+ const start = (covering: Deferred<T>): void => {
90
+ running = true
91
+ // `new Promise(resolve => resolve(run()))` rather than
92
+ // Promise.resolve(run()): it puts the call inside the promise, so a run
93
+ // that throws synchronously is a rejection like any other instead of
94
+ // escaping with the slot still held, which would wedge every later pull.
95
+ void new Promise<T>(resolve => resolve(run())).then(
96
+ value => {
97
+ finish()
98
+ covering.resolve(value)
99
+ },
100
+ (error: unknown) => {
101
+ finish()
102
+ covering.reject(error)
103
+ }
104
+ )
105
+ }
106
+
107
+ return () => {
108
+ if (running) {
109
+ queued ??= createDeferred<T>()
110
+ return queued.promise
111
+ }
112
+ const covering = createDeferred<T>()
113
+ start(covering)
114
+ return covering.promise
115
+ }
116
+ }
117
+
62
118
  // Named-timeout registry: scheduling under an existing name replaces the
63
119
  // pending timeout. Instance-scoped — each consumer creates its own registry,
64
120
  // so equal names in unrelated consumers cannot collide.