@greendrake/util 0.6.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()
@@ -173,10 +178,13 @@ const { latitude, longitude } = await getUserLocation()
173
178
  - `dClone(data)` — deep clone via a JSON round-trip (JSON-representable data only).
174
179
  - `isEmpty(o, emptyArraysAreNotEmpty?)` — true for `null`, `undefined`, `''`, `NaN`, an empty array (unless the flag is set) and an empty plain object.
175
180
  - `arrayRemove(a, el)` — removes the first occurrence of `el` in place.
181
+ - `arraysEqual(a, b, same = Object.is)` — same length and `same` holds element-wise, in order.
176
182
  - `capitalise(s)` — upper-cases the first character.
177
183
  - `humanSeconds(seconds)` — compact duration, two most significant units: `3661` → `'1h 1m'`.
178
184
  - `humanFileSize(bytes, si = false, dp = 1)` — binary units (`KiB`, `MiB`, …) by default, decimal units (`kB`, `MB`, …) with `si`; `dp` decimal places.
179
185
  - `fileMime(file)` — `File.type`, falling back to an extension map (`heic`, `heif`, `avif`, `webp`) when the OS reports none, then `application/octet-stream`.
186
+ - `Declared<Map, Tag, Value>` (type) — the union a host's declaration map discriminates, one member per key: the key under `Tag`, its type under `Value` (absent for a key declared `never`); `{ [Tag]: string; [Value]?: unknown }` while the map is undeclared. For a package exporting an empty interface for its host to augment.
187
+ - `configuration<T>(owner)` — a package's boot-time configuration: `{ configure, get }`, where `get` throws under `owner`'s name until the host has called `configure`.
180
188
  - `createDeferred<T = void>()` — a `Deferred<T>`: `{ promise, resolve, reject }`.
181
189
 
182
190
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greendrake/util",
3
- "version": "0.6.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>",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "prettier": "@greendrake/dev-config/prettier",
40
40
  "devDependencies": {
41
- "@greendrake/dev-config": "0.6.0",
41
+ "@greendrake/dev-config": "0.7.0",
42
42
  "typescript": "6.0.3"
43
43
  }
44
44
  }
package/src/main.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { Emitter, type EventMap } from './Emitter'
2
- export { randomString, dClone, isEmpty, arrayRemove, capitalise, humanSeconds, humanFileSize, fileMime, createDeferred, type Deferred } from './misc'
3
- export { TimeOutPromise, Debouncer, Delayed } from './timing'
2
+ export { randomString, dClone, isEmpty, arrayRemove, arraysEqual, capitalise, humanSeconds, humanFileSize, fileMime, configuration, createDeferred, type Deferred } from './misc'
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'
7
7
  export { NetworkError, HttpError, safeFetch, fetchJson } from './net'
8
8
  export { RTL_LANGUAGES, isRTLLocale } from './locale'
9
+ export type { Declared } from './types'
package/src/misc.ts CHANGED
@@ -13,6 +13,10 @@ export const arrayRemove = <T>(a: T[], el: T): void => {
13
13
  }
14
14
  }
15
15
 
16
+ // Same length, and `same` holds element-wise in order (the equal lengths make
17
+ // every b[i] an element).
18
+ export const arraysEqual = <T>(a: readonly T[], b: readonly T[], same: (x: T, y: T) => boolean = Object.is): boolean => a.length === b.length && a.every((x, i) => same(x, b[i] as T))
19
+
16
20
  export const capitalise = (s: string): string => (s ? s.charAt(0).toUpperCase() + s.slice(1) : '')
17
21
 
18
22
  // Compact human duration, two most significant units: 3661s → "1h 1m".
@@ -70,6 +74,22 @@ export interface Deferred<T> {
70
74
  reject: (reason?: unknown) => void
71
75
  }
72
76
 
77
+ // A package's boot-time configuration: `configure` for the host to call once,
78
+ // and `get` for the package to read it through — throwing, under `owner`'s
79
+ // name, while the host has not.
80
+ export const configuration = <T>(owner: string): { configure: (value: T) => void; get: () => T } => {
81
+ let current: T | undefined
82
+ return {
83
+ configure: value => {
84
+ current = value
85
+ },
86
+ get: () => {
87
+ if (current === undefined) throw new Error(`${owner}: used before it is configured`)
88
+ return current
89
+ }
90
+ }
91
+ }
92
+
73
93
  export const createDeferred = <T = void>(): Deferred<T> => {
74
94
  let resolve!: (value: T | PromiseLike<T>) => void
75
95
  let reject!: (reason?: unknown) => void
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.
package/src/types.ts ADDED
@@ -0,0 +1,6 @@
1
+ // The union a host's declaration map discriminates, one member per key: the
2
+ // key under `Tag`, and the key's type under `Value` — absent for a key declared
3
+ // `never`. Undeclared (an empty map), any string tags it and its value is
4
+ // unknown. The map is an interface a package exports empty for the host to
5
+ // augment, so the package's own code compiles against the undeclared form.
6
+ export type Declared<Map, Tag extends PropertyKey, Value extends PropertyKey> = [keyof Map] extends [never] ? { [P in Tag]: string } & { [P in Value]?: unknown } : { [K in keyof Map]: { [P in Tag]: K } & ([Map[K]] extends [never] ? unknown : { [P in Value]: Map[K] }) }[keyof Map]