@jarenjs/core 0.46.5 → 0.56.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.
Files changed (42) hide show
  1. package/ARCHITECTURE.md +122 -9
  2. package/README.md +50 -3
  3. package/dist/types/async.d.ts +39 -0
  4. package/dist/types/dates/civil.d.ts +24 -5
  5. package/dist/types/dates/index.d.ts +2 -0
  6. package/dist/types/dates/parse.d.ts +47 -0
  7. package/dist/types/dates/ticks.d.ts +59 -0
  8. package/dist/types/math/float64.d.ts +13 -0
  9. package/dist/types/random.d.ts +80 -0
  10. package/dist/types/series/asof.d.ts +88 -0
  11. package/dist/types/series/bucket.d.ts +206 -0
  12. package/dist/types/series/downsample.d.ts +53 -0
  13. package/dist/types/series/index.d.ts +8 -0
  14. package/dist/types/series/interval-index.d.ts +47 -0
  15. package/dist/types/series/interval.d.ts +170 -0
  16. package/dist/types/series/normalize.d.ts +190 -0
  17. package/dist/types/series/rolling.d.ts +67 -0
  18. package/dist/types/series/selector.d.ts +29 -0
  19. package/dist/types/series/zone.d.ts +59 -0
  20. package/dist/types/stats.d.ts +73 -0
  21. package/docs/DATES.md +77 -1
  22. package/docs/GEO.md +2 -2
  23. package/docs/SERIES.md +342 -0
  24. package/package.json +21 -1
  25. package/src/async.js +78 -0
  26. package/src/dates/civil.js +136 -41
  27. package/src/dates/index.js +4 -0
  28. package/src/dates/parse.js +410 -0
  29. package/src/dates/ticks.js +184 -0
  30. package/src/math/float64.js +29 -0
  31. package/src/random.js +125 -0
  32. package/src/series/asof.js +276 -0
  33. package/src/series/bucket.js +542 -0
  34. package/src/series/downsample.js +343 -0
  35. package/src/series/index.js +86 -0
  36. package/src/series/interval-index.js +181 -0
  37. package/src/series/interval.js +374 -0
  38. package/src/series/normalize.js +330 -0
  39. package/src/series/rolling.js +229 -0
  40. package/src/series/selector.js +104 -0
  41. package/src/series/zone.js +188 -0
  42. package/src/stats.js +133 -0
package/docs/SERIES.md ADDED
@@ -0,0 +1,342 @@
1
+ # `@jarenjs/core/series`
2
+
3
+ The temporal kernel: one meaning for an instant, one meaning for an
4
+ interval, and the set algebra over them. A roster, a calendar, an event
5
+ log, an availability view and a telemetry graph each rebuild the same
6
+ loops today — "does this overlap", "where is the free time", "what is
7
+ covered", "which events are live right now" — and this is the module
8
+ that answers them once.
9
+
10
+ Two constraints shape it, the same two the calendar kernel is built on.
11
+ **There is no type**: an instant is epoch milliseconds or an RFC 3339
12
+ string, a sample is `{ at, value }`, an interval is `{ start, end }` —
13
+ all of them JSON already, so they survive a patch, a schema, a pointer,
14
+ a stored document and a wire reply unchanged. And **there is no `now`**:
15
+ every bound is data, and an operation that needs a window and was given
16
+ none derives it from its own input, never from the clock. Import the
17
+ barrel (`@jarenjs/core/series`) or a single module.
18
+
19
+ **Every specification is closed.** `resampleSeries`, `rollingSeries`,
20
+ `asOfJoin`, `downsampleSeries`, `findSlots` and `mergeIntervals` each
21
+ publish the members they admit — `RESAMPLE_MEMBERS`, `ROLLING_MEMBERS`,
22
+ `ASOF_MEMBERS`, `DOWNSAMPLE_MEMBERS`, `SLOTS_MEMBERS`, `MERGE_MEMBERS`
23
+ — and anything else is a `TypeError` naming the near miss. `minPeriod`
24
+ for `minPeriods` accepted and ignored is a window with no minimum and a
25
+ plausible number; `timezone` for `zone` is the quiet fall back to UTC
26
+ that the clock's own refusal exists to prevent. The query language
27
+ reads these same lists (§8.16), minus `provider`, which is a pair of
28
+ functions and therefore not something a document can carry.
29
+
30
+ ## Normalization — `normalize.js`
31
+
32
+ `toEpoch(value)` is the one door: a finite number passes through, a
33
+ valid RFC 3339 string becomes the instant it names. A full-date reads as
34
+ UTC midnight. Three things are `TypeError` rather than a guess — a
35
+ full-time (`'09:30:00Z'` names no day), an offset-less date-time
36
+ (`'2026-01-01T09:30:00'` names no instant without a zone, and there is
37
+ no implicit machine zone here), and a `Date` object.
38
+
39
+ `normalizeSeries(rows, {at, value}?)` and
40
+ `normalizeIntervals(rows, {start, end}?)` convert a whole collection
41
+ once and sort it. Both take a **selector** per member — a property name
42
+ or a function — so rows spelled `on`, `recorded_at` or `from`/`to` are
43
+ read where they are rather than rewritten first. Each result is a
44
+ shallow copy of its source row with the canonical members written over
45
+ it, so nothing a caller attached is lost.
46
+
47
+ Three rules make the result safe to build on:
48
+
49
+ - **A row is never dropped.** A member that cannot become a finite
50
+ instant is a refusal naming the row (`row 4172, at: …`), not a
51
+ silently shorter answer.
52
+ - **The sort is stable.** Rows sharing an instant come out in input
53
+ order, and *all* of them are present: a duplicate instant is two
54
+ readings in the same millisecond, not a key collision.
55
+ - **A value may be absent, but only explicitly.** `null` is a measured
56
+ gap and survives; `undefined`, a string or a `NaN` is a defect.
57
+
58
+ `lowerBoundTime(rows, at, key?)` and `upperBoundTime(rows, at, key?)`
59
+ are the binary cuts a normalized array is then read through — together
60
+ they bracket the rows *at* an instant, which is what a duplicate-tolerant
61
+ as-of has to read.
62
+
63
+ ## Interval algebra — `interval.js`
64
+
65
+ Every interval is **half-open**, `[start, end)`: it holds its start and
66
+ not its end. So a day ends exactly where the next begins, nothing is
67
+ counted twice at a boundary, and two intervals that touch do **not**
68
+ overlap — back-to-back bookings are not a double booking. An interval
69
+ that is empty (`[t, t)`), reversed or not finite is refused at the point
70
+ it was written.
71
+
72
+ | Function | Answers |
73
+ |---|---|
74
+ | `containsInstant(interval, at)` | is this instant inside — `start <= at < end` |
75
+ | `overlapsInterval(a, b)` | do they share an instant (touching does not) |
76
+ | `intersectInterval(a, b)` | the span they share, or `null` |
77
+ | `mergeIntervals(list, {adjacent}?)` | the union, as the fewest disjoint spans |
78
+ | `subtractIntervals(from, remove)` | the set difference; a cut through the middle splits |
79
+ | `gapsWithin(list, window?)` | the uncovered spans |
80
+ | `coverageOf(list, window?)` | milliseconds covered, an instant counted once |
81
+ | `findSlots(availability, spec)` | where a fixed-width span fits |
82
+
83
+ Merging is the one place "touching does not overlap" is not the answer
84
+ a caller wants, which is why `mergeIntervals` **joins touching spans by
85
+ default**: availability asks whether there is continuous cover, and
86
+ 09:00–13:00 plus 13:00–17:00 is continuous cover. The other reading is
87
+ real too — a handover is two shifts, not one — and it is spelled
88
+ `{ adjacent: false }`, so nothing has to guess. Overlapping spans join
89
+ under both settings.
90
+
91
+ `gapsWithin` and `coverageOf` take an optional window. Without one they
92
+ work inside the hull of the intervals themselves, because the only other
93
+ default would be a clock. Passing the window explicitly is what reports a
94
+ missing *edge*: an empty morning is only a gap once the caller says the
95
+ day starts at nine. `coverageOf` returns milliseconds, so the ratio is
96
+ the caller's own division:
97
+ `coverageOf(shifts, day) / (day.end - day.start)`.
98
+
99
+ `findSlots(availability, { duration, step? })` merges availability, then
100
+ walks each window from its own start in `step` increments (default: back
101
+ to back), keeping every span that still ends inside. `duration` and
102
+ `step` are fixed widths — milliseconds, or a fixed ISO 8601 duration
103
+ (`'PT30M'`); a calendar duration (`P1M`) is refused rather than called
104
+ thirty days. It is enumeration, not scheduling: choosing among the
105
+ answers, weighing preferences and assigning people is a solver's job,
106
+ deliberately not this one.
107
+
108
+ Merge, subtract, gaps and slots return bare `{ start, end }` records. A
109
+ span welded out of three source rows belongs to none of them, and
110
+ carrying one of their identities forward would be a claim the data does
111
+ not support.
112
+
113
+ ## The index — `interval-index.js`
114
+
115
+ `createIntervalIndex(items, selectors?)` builds once and answers many:
116
+ `index.at(instant)` for the spans holding an instant,
117
+ `index.overlapping(start, end)` for the spans sharing one with a window.
118
+ Results are the caller's **own rows**, ascending by start and — for rows
119
+ sharing a start — in the order they arrived, in a fresh array each time.
120
+
121
+ Sorting by start is not enough, and the reason is the whole design. A
122
+ binary search finds where a query falls among the starts, but a span that
123
+ began a year earlier and has not ended yet sits far to the *left* of that
124
+ neighbourhood and still overlaps — a conference week among hourly
125
+ meetings is exactly that span, and an index that merely cuts around the
126
+ query loses it while looking plausible. So the index carries a second
127
+ array: the prefix maximum end, non-decreasing by construction and
128
+ therefore binary-searchable too. The first position where it passes the
129
+ query's start is the first position where anything can still be live.
130
+
131
+ A query is two binary cuts and a walk between them — O(log n + k) — with
132
+ no pass over the array and no per-query sort. It is **static**: the
133
+ bounds are copied into flat typed arrays at build time, so a query reads
134
+ no source object at all, and a row mutated afterwards cannot change what
135
+ the index answers.
136
+
137
+ ```javascript
138
+ import { createIntervalIndex, mergeIntervals, gapsWithin, findSlots } from '@jarenjs/core/series';
139
+
140
+ const shifts = [
141
+ { from: '2026-03-02T09:00:00Z', to: '2026-03-02T13:00:00Z', who: 'ada' },
142
+ { from: '2026-03-02T13:00:00Z', to: '2026-03-02T17:00:00Z', who: 'grace' },
143
+ ];
144
+ const index = createIntervalIndex(shifts, { start: 'from', end: 'to' });
145
+ index.at('2026-03-02T13:00:00Z'); // [grace] — half-open: the handover belongs to one shift
146
+
147
+ const cover = shifts.map((s) => ({ start: s.from, end: s.to }));
148
+ mergeIntervals(cover); // one span, 09:00–17:00: touching is continuous cover
149
+ gapsWithin(cover, { start: '2026-03-02T08:00:00Z', end: '2026-03-02T18:00:00Z' });
150
+ // the hour before and the hour after
151
+ findSlots(cover, { duration: 'PT30M' }); // sixteen half-hour slots, one straddling the handover
152
+ ```
153
+
154
+ ## The clock — `zone.js`
155
+
156
+ Where a calendar boundary falls depends on a wall clock, and a wall
157
+ clock that is not UTC is data this suite refuses to bundle: a tzdb is
158
+ megabytes that go stale on a government's timetable, a Temporal
159
+ polyfill is a runtime dependency, and reading the host's zone is the
160
+ hidden clock this kernel exists without. So `resolveClock(options)` is a
161
+ **seam**:
162
+
163
+ | options | the clock |
164
+ |---|---|
165
+ | *(nothing)* | UTC. Nothing to configure, and no local time is ever ambiguous |
166
+ | `{ offset: -300 }` | minutes east of UTC, constant. Exact integer arithmetic |
167
+ | `{ zone, provider }` | the caller's tzdb, in whatever form they already have one |
168
+
169
+ A provider answers two questions, and the second is the hard one:
170
+ `toParts(epoch, zone)` is the wall clock at an instant, and
171
+ `toEpoch(parts, zone, disambiguation)` is the instant at a wall clock —
172
+ hard because a local time is not a function of the clock. On a
173
+ spring-forward day 02:30 never happens; on a fall-back day it happens
174
+ twice. `disambiguation` is `'reject'` (the default: an error, not an
175
+ hour nobody notices), `'earlier'` or `'later'`. Asking for a named zone
176
+ with no provider is a refusal, never a quiet fall back to UTC — which is
177
+ right for Amsterdam for none of the year and *looks* right for eight
178
+ months of it.
179
+
180
+ ## Buckets, resampling and fill — `bucket.js`
181
+
182
+ Two questions that are always asked together and are not the same
183
+ question. **Bucketing** is "which span does this instant fall in", and
184
+ it is arithmetic. **Filling** is "what does a span with no readings
185
+ say", and it is a policy. An average over an empty hour is not zero, and
186
+ it is not yesterday's average, and it is not nothing.
187
+
188
+ `compileBuckets(spec, options?)` validates a ladder once —
189
+ `compileBuckets('PT15M')`, or `{ every, origin }` — and returns
190
+ `floor`, `startOf`, `indexOf` and the shape it resolved to. Boundaries
191
+ come in two flavours, and the difference is physical:
192
+
193
+ - **fixed** — `PT15M`, `PT1H`, `P1D`, or a number of milliseconds. The
194
+ boundary is `origin + k × width`, integer arithmetic all the way down,
195
+ and it *floors*, so an instant before 1970 lands in its own bucket
196
+ rather than the one after it.
197
+ - **calendar** — `P1M`, `P1Y`, and a whole number of days *on a named
198
+ zone*. A month has no width, so the boundary is walked by the calendar
199
+ kernel from the anchor; on a named zone a day that the clock changed
200
+ on is 23 or 25 hours long, and a ladder multiplying by 86,400,000
201
+ would drift off local midnight for the rest of the year.
202
+
203
+ A width that mixes the two families (`P1MT1H`) is refused: a month and
204
+ an hour share no boundary. The default `origin` is local
205
+ `1970-01-01T00:00:00` **on the clock**, so a daily bucket in `+02:00`
206
+ falls on local midnight rather than on UTC's.
207
+
208
+ `resampleSeries(rows, spec)` returns ascending `{ at, value, count }`
209
+ labelled at each bucket's **start**. `count` is the number of source
210
+ rows — duplicates and measured gaps included — so it is the honest
211
+ denominator of what was *seen*. All six value aggregates (`sum`, `mean`,
212
+ `min`, `max`, `first`, `last`) skip `null` readings, so `value` is
213
+ `null` exactly when there was nothing to measure and `count` says
214
+ whether that was because nobody reported or because everybody reported a
215
+ gap. `aggregate: 'count'` returns that row count as the value.
216
+
217
+ With no `start`/`end` the window is the data's own — the bucket holding
218
+ the first sample through the bucket holding the last — because the only
219
+ other default would be a clock. Pass them when an empty *edge* matters.
220
+
221
+ The five fill policies decide what an **empty** bucket says, and nothing
222
+ else; a bucket that held rows and no numbers reports `null`, because
223
+ that is a measurement:
224
+
225
+ | fill | an empty bucket |
226
+ |---|---|
227
+ | `omit` | is not emitted — the default: a gap is not a row |
228
+ | `null` | is emitted as `null` |
229
+ | `zero` | is emitted as `0` |
230
+ | `locf` | repeats the last value before it |
231
+ | `linear` | is interpolated between its two neighbours |
232
+
233
+ Neither `locf` nor `linear` invents a value at the leading edge, and
234
+ `linear` needs a value on **both** sides. To seed one, widen the window
235
+ until the earlier reading falls inside it: the seed is then a bucket
236
+ with data, which is the only anchor either policy will extrapolate from.
237
+
238
+ ## Rolling windows — `rolling.js`
239
+
240
+ `rollingSeries(rows, spec)` aggregates over a *duration* rather than a
241
+ count of rows, which is the whole point: sixty rows of a sensor
242
+ reporting every second is a minute, and sixty rows of a sensor that
243
+ dropped half its readings is two minutes. One of those is a
244
+ specification.
245
+
246
+ The window is `(at − width, at]` — exactly `width` wide, holding the
247
+ current instant and not the one a full width behind it. Two samples at
248
+ one instant share that window and therefore share an answer: a span of
249
+ time is a function of the instant it ends at, not of which simultaneous
250
+ reading arrived first. `minPeriods` is how many source rows the window
251
+ must hold before a value is reported at all.
252
+
253
+ The complexity is per aggregate and is structural rather than hopeful:
254
+ `sum`/`mean`/`count` carry a running total, `min`/`max` use a monotone
255
+ deque, and `first`/`last` are two pointers that only move forward. A
256
+ carried total is not a fresh sum in the last bits when the values are
257
+ not exactly representable — that is what carrying one costs, and the
258
+ suite's corpora use exact binary fractions so the difference is zero and
259
+ equality is the check.
260
+
261
+ ## As-of joins — `asof.js`
262
+
263
+ `asOfJoin(left, right, spec?)` answers "what was the price when this
264
+ trade printed" for two series that share a timeline and nothing else.
265
+ One record per left row, in the left series' order, unmatched included
266
+ as `{ left, right: null, distance: null }` — a join that quietly returns
267
+ fewer rows than it was given is how a report loses the events nothing
268
+ explained.
269
+
270
+ | direction | the right row chosen |
271
+ |---|---|
272
+ | `backward` | the last one at or before the left instant (default) |
273
+ | `forward` | the last one at or after it |
274
+ | `nearest` | whichever is closer; a tie chooses `backward` |
275
+
276
+ At an equal instant the **last** right-side row wins in every direction,
277
+ because duplicates are two readings and "as of" means the later one. A
278
+ `nearest` tie chooses backward because a value already observed is
279
+ evidence and one that has not been is a forecast. `tolerance` is the
280
+ furthest a match may be; beyond it there is no match, not a distant one.
281
+ `key` joins within groups — the right side is partitioned **once**, and
282
+ no left row ever filters it.
283
+
284
+ ## Downsampling — `downsample.js`
285
+
286
+ A hundred thousand points on a line eight hundred pixels wide is a
287
+ hundred and twenty five points per pixel. `downsampleSeries(rows, spec)`
288
+ supplies `lttb` (largest-triangle-three-buckets: keeps the shape a
289
+ reader recognizes) and `minmax` (keeps the envelope exactly), and
290
+ reports `{ points, sourceCount, renderedCount, method }` so a consumer
291
+ can always say how much of the data it is looking at.
292
+
293
+ Three rules stop either from lying. **A gap is never bridged** — the
294
+ series is cut at every run of `null`s, each run keeps a marker, and each
295
+ segment is sampled on its own budget. **The ends stay** — and a series
296
+ ending in gaps keeps its last instant as that run's marker, so the
297
+ rendered domain still reaches the end of the data. **An impossible
298
+ target is refused** — those markers and endpoints are the minimum a
299
+ faithful picture needs, and a prettier lie is worse than a `RangeError`
300
+ naming the number.
301
+
302
+ ```javascript
303
+ import { resampleSeries, rollingSeries, asOfJoin, downsampleSeries } from '@jarenjs/core/series';
304
+
305
+ resampleSeries(readings, { every: 'PT1H', aggregate: 'mean', fill: 'linear' });
306
+ resampleSeries(readings, { every: 'P1M', zone: 'Europe/Amsterdam', provider });
307
+ rollingSeries(readings, { width: 'PT5M', aggregate: 'max', minPeriods: 3 });
308
+ asOfJoin(trades, quotes, { direction: 'nearest', tolerance: 'PT1S', key: 'symbol' });
309
+ downsampleSeries(readings, { target: 2000 }); // → { points, sourceCount, renderedCount, method }
310
+ ```
311
+
312
+ ## What it costs
313
+
314
+ Measured by `benchmark/series.js` over <!--fact:series.corpus-->100,000 samples at 1-second spacing, Node v24.19.0<!--/fact-->,
315
+ which gates every timing on equivalence first: no number below is printed
316
+ unless the kernel answered the identical rows the references did.
317
+
318
+ The kernel is not the ceiling and does not claim to be. A one-pass loop
319
+ written for one question validates nothing, normalizes nothing and
320
+ returns a bare pair. Against those loops the kernel costs <!--fact:series.kernelVsCeiling-->3.4× the one-pass bucket loop and 6.2× the one-pass ring sum<!--/fact-->,
321
+ and against the vocabulary a consumer had instead it is <!--fact:series.kernelVsQuery-->73.6× faster than the generic query bucket and 81.2× faster than the labelled count window<!--/fact-->.
322
+
323
+ <!--fact:series.kernelTable-->
324
+ | operation | median | rows | against | what that is | ratio |
325
+ |---|---:|---:|---:|---|---:|
326
+ | `resampleSeries`, 60 s buckets | 1.5 ms | 1,667 | 0.45 ms | one-pass loop | 3.4× |
327
+ | `resampleSeries`, + linear fill | 1.1 ms | 1,657 | 1 ms | the same buckets, omitting | 1.0× |
328
+ | `rollingSeries`, 60 s window | 7.2 ms | 100,000 | 1.2 ms | one-pass ring sum | 6.2× |
329
+ | `asOfJoin`, one left row per 100 | 1.7 ms | 1,000 | 2.2 ms | one index read per row | 0.8× |
330
+ | `downsampleSeries`, lttb, gap corpus | 1.6 ms | 2,000 | 1.8 ms | the same line with no holes in it | 0.9× |
331
+ <!--/fact-->
332
+
333
+ A row that loses stays in, and the two shapes of the same join are published side by side rather than the flattering one alone. <!--fact:series.asofShape-->The as-of join costs 14.7× a handful of index reads, and beats them by 1.3× once there is one left row per hundred right ones. The reason is the shape rather than the engine: a b-tree pays per probe, and a sorted walk pays for the whole right side whether it was asked one question or a thousand.<!--/fact-->
334
+
335
+ And the seam has a price that this corpus cannot charge it. <!--fact:series.zoneCost-->Walking every boundary through an injected zone provider costs 1.0× the integer ladder over an identical answer — near parity because it is near nothing, since the benchmark corpus spans 28 hours and holds two daily boundaries. What the suite gates instead is that the provider is consulted per boundary rather than per sample.<!--/fact-->
336
+
337
+ ## Not here
338
+
339
+ Named time zones are injected, never bundled; recurrence grammars
340
+ (RRULE, iCalendar) and any kind of scheduling solver are somebody else's
341
+ layer. This module supplies the algebra those are built from, and
342
+ nothing that needs a clock, a locale or a zone database to be correct.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/core",
3
3
  "private": false,
4
- "version": "0.46.5",
4
+ "version": "0.56.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -40,6 +40,10 @@
40
40
  "types": "./dist/types/array.d.ts",
41
41
  "default": "./src/array.js"
42
42
  },
43
+ "./async": {
44
+ "types": "./dist/types/async.d.ts",
45
+ "default": "./src/async.js"
46
+ },
43
47
  "./bigint": {
44
48
  "types": "./dist/types/bigint.d.ts",
45
49
  "default": "./src/bigint.js"
@@ -104,6 +108,10 @@
104
108
  "types": "./dist/types/object.d.ts",
105
109
  "default": "./src/object.js"
106
110
  },
111
+ "./random": {
112
+ "types": "./dist/types/random.d.ts",
113
+ "default": "./src/random.js"
114
+ },
107
115
  "./scan": {
108
116
  "types": "./dist/types/scan.d.ts",
109
117
  "default": "./src/scan.js"
@@ -112,6 +120,18 @@
112
120
  "types": "./dist/types/schema.d.ts",
113
121
  "default": "./src/schema.js"
114
122
  },
123
+ "./series": {
124
+ "types": "./dist/types/series/index.d.ts",
125
+ "default": "./src/series/index.js"
126
+ },
127
+ "./series/*": {
128
+ "types": "./dist/types/series/*.d.ts",
129
+ "default": "./src/series/*.js"
130
+ },
131
+ "./stats": {
132
+ "types": "./dist/types/stats.d.ts",
133
+ "default": "./src/stats.js"
134
+ },
115
135
  "./string": {
116
136
  "types": "./dist/types/string.d.ts",
117
137
  "default": "./src/string.js"
package/src/async.js ADDED
@@ -0,0 +1,78 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The bounded ordered asynchronous map: run a worker over a list
4
+ * with never more than `limit` calls in flight, and answer the results
5
+ * in the list's order. Before this file the same twelve lines lived in
6
+ * the AI package's program runner and in the benchmark harness, and a
7
+ * downstream consumer had written them a third time; a pool that exists
8
+ * once is one whose edge behavior can be pinned once.
9
+ *
10
+ * The contract, in full:
11
+ *
12
+ * - results are in INPUT order, whatever order the workers finish in;
13
+ * - never more than `limit` workers are in flight; `limit` must be a
14
+ * number of at least 1 (`Infinity` is allowed and means unbounded) —
15
+ * anything else is a `TypeError`, never a silent clamp, because a
16
+ * limit of 0 is a bug in the caller and "sequential" is spelled 1;
17
+ * - a worker rejection stops dispatch: no item starts after it, the
18
+ * workers already in flight are awaited, and only then does the map
19
+ * reject with that first rejection. A worker that throws
20
+ * synchronously is a rejection;
21
+ * - an abort does the same, rejecting with the signal's reason; a signal
22
+ * that is already aborted rejects before any worker runs;
23
+ * - so when the returned promise settles, NO worker is still running —
24
+ * the caller can close whatever the workers were using.
25
+ *
26
+ * The map does not retry, rate-limit, delay per origin or know anything
27
+ * about what the worker does; those are the caller's policies around it.
28
+ */
29
+
30
+ /**
31
+ * @template T, R
32
+ * @param {readonly T[]} items
33
+ * @param {number} limit - workers in flight at once; a number >= 1, `Infinity` for unbounded
34
+ * @param {(item: T, index: number) => Promise<R> | R} worker
35
+ * @param {{ signal?: AbortSignal }} [options]
36
+ * @returns {Promise<R[]>} the results, in input order
37
+ * @throws {TypeError} (as a rejection) when `limit` is not a number >= 1
38
+ */
39
+ export async function mapConcurrent(items, limit, worker, options = {}) {
40
+ if (typeof limit !== 'number' || !(limit >= 1))
41
+ throw new TypeError(`mapConcurrent needs a limit of at least 1, got ${String(limit)}`);
42
+ const { signal } = options;
43
+ if (signal?.aborted) throw signal.reason;
44
+ const count = items.length;
45
+ /** @type {R[]} */
46
+ const results = new Array(count);
47
+ if (count === 0) return results;
48
+
49
+ let next = 0;
50
+ // the first failure or abort, kept as a one-element list: the lanes
51
+ // stop dispatching the moment it is set and drain what they hold
52
+ /** @type {unknown[]} */
53
+ const stop = [];
54
+ /** @type {(() => void) | undefined} */
55
+ let onAbort;
56
+ if (signal !== undefined) {
57
+ onAbort = () => { if (stop.length === 0) stop.push(signal.reason); };
58
+ signal.addEventListener('abort', onAbort, { once: true });
59
+ }
60
+
61
+ const lane = async () => {
62
+ while (stop.length === 0) {
63
+ const index = next++;
64
+ if (index >= count) return;
65
+ try {
66
+ results[index] = await worker(items[index], index);
67
+ }
68
+ catch (error) {
69
+ if (stop.length === 0) stop.push(error);
70
+ }
71
+ }
72
+ };
73
+ const lanes = Array.from({ length: Math.min(limit, count) }, lane);
74
+ await Promise.all(lanes);
75
+ if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort);
76
+ if (stop.length > 0) throw stop[0];
77
+ return results;
78
+ }