@jarenjs/core 0.49.2 → 0.66.1

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.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @file Descriptive statistics over a sample of numbers: the mean, the
3
+ * sample variance and its root, the median, and a quantile that will
4
+ * not answer until told which quantile it is being asked for.
5
+ *
6
+ * Before this file the suite computed these in two places with two
7
+ * quantile rules — linear interpolation in the query layer's statistics
8
+ * pack, nearest rank in the benchmark harness — and each was the right
9
+ * rule for its consumer: an interpolated percentile is what an analyst
10
+ * expects of `$percentile`, while a benchmark row of eleven readings
11
+ * should not publish a latency nobody measured. Both rules stay; the
12
+ * definitions move here so that there is one of each, and `quantile`
13
+ * makes the caller name its method, because "the 95th percentile" of a
14
+ * small sample is a different number under every one of the seven
15
+ * common definitions and a default would decide silently.
16
+ *
17
+ * Every function answers `undefined` for a sample it cannot summarize —
18
+ * an empty one, or fewer than two values for a variance — rather than
19
+ * `NaN` or `0`: a number that was not measured must not format as one.
20
+ * The caller's array is never reordered; a quantile sorts a copy.
21
+ * Values are numbers by contract and are not checked one by one.
22
+ */
23
+ /**
24
+ * The arithmetic mean.
25
+ * @param {readonly number[]} values
26
+ * @returns {number | undefined} `undefined` for an empty sample
27
+ */
28
+ export declare function mean(values: readonly number[]): number | undefined;
29
+ /**
30
+ * The SAMPLE variance, with Bessel's correction (`n − 1`): the sample is
31
+ * taken as drawn from a population it did not enumerate, which is what a
32
+ * benchmark's rounds and a query's rows both are.
33
+ * @param {readonly number[]} values
34
+ * @returns {number | undefined} `undefined` for fewer than two values
35
+ */
36
+ export declare function variance(values: readonly number[]): number | undefined;
37
+ /**
38
+ * The sample standard deviation — the root of {@link variance}.
39
+ * @param {readonly number[]} values
40
+ * @returns {number | undefined} `undefined` where the variance is
41
+ */
42
+ export declare function stddev(values: readonly number[]): number | undefined;
43
+ /**
44
+ * The median: the middle value, or the mean of the two middle values
45
+ * when the sample has an even count.
46
+ *
47
+ * This is NOT `quantile(values, 0.5, …)` under either method, and on an
48
+ * even count the three disagree: for `[1, 2, 3, 4]` the median is `2.5`,
49
+ * the nearest-rank p50 is `2`, and the linear p50 is `2.5` only because
50
+ * that sample happens to be evenly spaced. A consumer publishing a "p50"
51
+ * beside a "p95" wants `quantile` with its method named; a consumer
52
+ * asking for the median wants this.
53
+ * @param {readonly number[]} values
54
+ * @returns {number | undefined} `undefined` for an empty sample
55
+ */
56
+ export declare function median(values: readonly number[]): number | undefined;
57
+ export type QuantileMethod = 'nearest-rank' | 'linear';
58
+ /**
59
+ * The `p`-quantile of a sample, `p` on `[0, 1]`, under a NAMED method.
60
+ *
61
+ * The method is required, not defaulted: on a small sample the common
62
+ * definitions disagree by whole values, and a caller who did not say
63
+ * which one it wanted has published a number it cannot explain.
64
+ * @param {readonly number[]} values
65
+ * @param {number} p - the probability, `0` (the minimum) to `1` (the maximum)
66
+ * @param {{ method: QuantileMethod }} options
67
+ * @returns {number | undefined} `undefined` for an empty sample
68
+ * @throws {TypeError} when `method` is absent or not one of {@link QuantileMethod}
69
+ * @throws {RangeError} when `p` is not a number in `[0, 1]`
70
+ */
71
+ export declare function quantile(values: readonly number[], p: number, options: {
72
+ method: QuantileMethod;
73
+ }): number | undefined;
@@ -54,6 +54,19 @@ export declare function getStringLength(str: string, useGrapheme?: boolean): num
54
54
  * @returns {number} Number of occurrences in [start, end)
55
55
  */
56
56
  export declare function countCharCode(str: string, code: number, start?: number, end?: number): number;
57
+ /**
58
+ * The UTF-8 byte length of a slice of a string — the one measure every
59
+ * byte bound in the suite counts (a body limit, a page, a change
60
+ * record, a parser's record or token limit) — computed without encoding
61
+ * a copy: one byte below U+0080, two below U+0800, four for a surrogate
62
+ * pair (one code point), three otherwise; a lone surrogate counts three,
63
+ * as its replacement would.
64
+ * @param {string} str
65
+ * @param {number} [start] - Inclusive start offset (defaults to 0)
66
+ * @param {number} [end] - Exclusive end offset (defaults to full length)
67
+ * @returns {number} The UTF-8 bytes of `str[start, end)`
68
+ */
69
+ export declare function utf8ByteLength(str: string, start?: number, end?: number): number;
57
70
  /**
58
71
  * Count the Unicode code points of a string (surrogate-pair aware;
59
72
  * a lone surrogate counts as one code point).
package/docs/DATES.md CHANGED
@@ -21,7 +21,9 @@ Parsing: `parseRFC3339Parts(str)` → a flat **parts** record
21
21
  `{year, month, day, hours, minutes, seconds, offset}`. Absent fields are
22
22
  `-1` sentinels; `offset` is minutes east of UTC, `0` for `Z` and `null`
23
23
  when the string carried none. The inverse is `formatRFC3339Parts(parts)`
24
- (round-trips the offset). `epochOfRFC3339Parts(parts)` epoch ms: a
24
+ (round-trips the offset and up to six fractional-second digits, dropping
25
+ trailing zeros). The `SSS` formatting token writes the first three fraction
26
+ digits. `epochOfRFC3339Parts(parts)` → epoch ms: a
25
27
  date-only reads as UTC midnight, an offset shifts to its instant, and a
26
28
  time-only has **no** instant — it returns `NaN` rather than inventing a
27
29
  day. `getDateTypeOf*` variants produce a `Date` for callers that want
package/docs/GEO.md CHANGED
@@ -22,10 +22,10 @@ the *wrong sign* on near-collinear input, which makes containment
22
22
  contradict itself. `orient2dFast` is the naive form, exported for
23
23
  callers that provably do not care. Every winding and containment answer
24
24
  in this module rests on this sign; its cost is the deliberate
25
- point-in-polygon loss on the benchmark page (<!--bm:geo.pip2000-->0.5×<!--/bm--> against
25
+ point-in-polygon loss on the benchmark page (<!--fact:geo.pip2000-->0.5×<!--/fact--> against
26
26
  Turf at 2000 vertices, kept on purpose). Every loss the kernel carries is
27
27
  published in [ARCHITECTURE](../ARCHITECTURE.md), derived from the committed
28
- measurement: <!--bm:geo.losses-->three rows lose to a rival: point in polygon (2000-vertex) at 0.5× (turf), bounding box (2000-vertex) at 0.9× (turf), index build (100k boxes) at 0.8× (flatbush)<!--/bm-->.
28
+ measurement: <!--fact:geo.losses-->three rows lose to a rival: point in polygon (2000-vertex) at 0.5× (turf), bounding box (2000-vertex) at 0.9× (turf), index build (100k boxes) at 0.8× (flatbush)<!--/fact-->.
29
29
 
30
30
  ## Distance — `distance.js`
31
31
 
package/docs/SERIES.md CHANGED
@@ -177,6 +177,20 @@ with no provider is a refusal, never a quiet fall back to UTC — which is
177
177
  right for Amsterdam for none of the year and *looks* right for eight
178
178
  months of it.
179
179
 
180
+ The seam is crossed, not closed. `@jarenjs/locales/intl-zones` ships
181
+ `createIntlZoneProvider()`, a provider over the host's own ICU data —
182
+ the one copy of the tzdb that is already installed and already
183
+ maintained — that a caller passes as `provider`. It is opt-in and on
184
+ its own subpath, because ICU output moves between hosts and may not be
185
+ anyone's default; it never reads the host's own zone, because every
186
+ call names its zone and there is no default that asks the process
187
+ where it is; and it answers a gap and a fold exactly, proved by a
188
+ transition corpus rather than by examples. Nothing about the seam
189
+ moves: this kernel still bundles no zone data and reaches for no
190
+ `Intl`, a named zone with no provider is still a refusal, and a host
191
+ with a tzdb of its own still passes that instead. The provider's own
192
+ document is [`@jarenjs/locales`' README](../../locales/README.md).
193
+
180
194
  ## Buckets, resampling and fill — `bucket.js`
181
195
 
182
196
  Two questions that are always asked together and are not the same
@@ -311,32 +325,33 @@ downsampleSeries(readings, { target: 2000 }); // → { points, sourceCount, re
311
325
 
312
326
  ## What it costs
313
327
 
314
- Measured by `benchmark/series.js` over <!--bm:series.corpus-->100,000 samples at 1-second spacing, Node v24.19.0<!--/bm-->,
328
+ Measured by `benchmark/series.js` over <!--fact:series.corpus-->100,000 samples at 1-second spacing, Node v24.19.0<!--/fact-->,
315
329
  which gates every timing on equivalence first: no number below is printed
316
330
  unless the kernel answered the identical rows the references did.
317
331
 
318
332
  The kernel is not the ceiling and does not claim to be. A one-pass loop
319
333
  written for one question validates nothing, normalizes nothing and
320
- returns a bare pair. Against those loops the kernel costs <!--bm:series.kernelVsCeiling-->3.4× the one-pass bucket loop and 6.2× the one-pass ring sum<!--/bm-->,
321
- and against the vocabulary a consumer had instead it is <!--bm:series.kernelVsQuery-->73.6× faster than the generic query bucket and 81.2× faster than the labelled count window<!--/bm-->.
334
+ returns a bare pair. Against those loops the kernel costs <!--fact:series.kernelVsCeiling-->3.3× the one-pass bucket loop and 6.0× the one-pass ring sum<!--/fact-->,
335
+ and against the vocabulary a consumer had instead it is <!--fact:series.kernelVsQuery-->76.7× faster than the generic query bucket and 78.5× faster than the labelled count window<!--/fact-->.
322
336
 
323
- <!--bm:series.kernelTable-->
337
+ <!--fact:series.kernelTable-->
324
338
  | operation | median | rows | against | what that is | ratio |
325
339
  |---|---:|---:|---:|---|---:|
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
- <!--/bm-->
340
+ | `resampleSeries`, 60 s buckets | 1.6 ms | 1,667 | 0.47 ms | one-pass loop | 3.3× |
341
+ | `resampleSeries`, + linear fill | 1.1 ms | 1,657 | 1 ms | the same buckets, omitting | 1.1× |
342
+ | `rollingSeries`, 60 s window | 7.6 ms | 100,000 | 1.3 ms | one-pass ring sum | 6.0× |
343
+ | `asOfJoin`, one left row per 100 | 1.7 ms | 1,000 | 1.7 ms | one index read per row | 1.0× |
344
+ | `downsampleSeries`, lttb, gap corpus | 1.7 ms | 2,000 | 1.9 ms | the same line with no holes in it | 0.9× |
345
+ <!--/fact-->
332
346
 
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. <!--bm: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.<!--/bm-->
347
+ 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 19.7× a handful of index reads, and narrows to 1.0× the same join once there is one left row per hundred right ones — still the statement's win, published as one. 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
348
 
335
- And the seam has a price that this corpus cannot charge it. <!--bm: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.<!--/bm-->
349
+ And the seam has a price that this corpus cannot charge it. <!--fact:series.zoneCost-->Walking every boundary through the shipped Intl zone provider costs 1.1× the integer ladder over an identical answer — a handful of ICU reads against the whole ladder, since the benchmark corpus spans 28 hours and holds two daily boundaries. What the suite gates is that the provider is consulted per boundary rather than per sample.<!--/fact-->
336
350
 
337
351
  ## Not here
338
352
 
339
- Named time zones are injected, never bundled; recurrence grammars
353
+ Named time zones are injected, never bundled the provider over host
354
+ ICU lives opt-in in `@jarenjs/locales`, not here; recurrence grammars
340
355
  (RRULE, iCalendar) and any kind of scheduling solver are somebody else's
341
356
  layer. This module supplies the algebra those are built from, and
342
357
  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.49.2",
4
+ "version": "0.66.1",
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,14 @@
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
+ },
115
+ "./runtime": {
116
+ "types": "./dist/types/runtime.d.ts",
117
+ "default": "./src/runtime.js"
118
+ },
107
119
  "./scan": {
108
120
  "types": "./dist/types/scan.d.ts",
109
121
  "default": "./src/scan.js"
@@ -120,6 +132,10 @@
120
132
  "types": "./dist/types/series/*.d.ts",
121
133
  "default": "./src/series/*.js"
122
134
  },
135
+ "./stats": {
136
+ "types": "./dist/types/stats.d.ts",
137
+ "default": "./src/stats.js"
138
+ },
123
139
  "./string": {
124
140
  "types": "./dist/types/string.d.ts",
125
141
  "default": "./src/string.js"
package/src/async.js ADDED
@@ -0,0 +1,285 @@
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
+ import { isThenable } from './function.js';
31
+
32
+ /**
33
+ * @template T, R
34
+ * @param {readonly T[]} items
35
+ * @param {number} limit - workers in flight at once; a number >= 1, `Infinity` for unbounded
36
+ * @param {(item: T, index: number) => Promise<R> | R} worker
37
+ * @param {{ signal?: AbortSignal }} [options]
38
+ * @returns {Promise<R[]>} the results, in input order
39
+ * @throws {TypeError} (as a rejection) when `limit` is not a number >= 1
40
+ */
41
+ export async function mapConcurrent(items, limit, worker, options = {}) {
42
+ if (typeof limit !== 'number' || !(limit >= 1))
43
+ throw new TypeError(`mapConcurrent needs a limit of at least 1, got ${String(limit)}`);
44
+ const { signal } = options;
45
+ if (signal?.aborted) throw signal.reason;
46
+ const count = items.length;
47
+ /** @type {R[]} */
48
+ const results = new Array(count);
49
+ if (count === 0) return results;
50
+
51
+ let next = 0;
52
+ // the first failure or abort, kept as a one-element list: the lanes
53
+ // stop dispatching the moment it is set and drain what they hold
54
+ /** @type {unknown[]} */
55
+ const stop = [];
56
+ /** @type {(() => void) | undefined} */
57
+ let onAbort;
58
+ if (signal !== undefined) {
59
+ onAbort = () => { if (stop.length === 0) stop.push(signal.reason); };
60
+ signal.addEventListener('abort', onAbort, { once: true });
61
+ }
62
+
63
+ const lane = async () => {
64
+ while (stop.length === 0) {
65
+ const index = next++;
66
+ if (index >= count) return;
67
+ try {
68
+ results[index] = await worker(items[index], index);
69
+ }
70
+ catch (error) {
71
+ if (stop.length === 0) stop.push(error);
72
+ }
73
+ }
74
+ };
75
+ const lanes = Array.from({ length: Math.min(limit, count) }, lane);
76
+ await Promise.all(lanes);
77
+ if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort);
78
+ if (stop.length > 0) throw stop[0];
79
+ return results;
80
+ }
81
+
82
+ /**
83
+ * The underlying sink an awaited sink serializes: `write` answers a
84
+ * value (the chunk is written) or a promise (the chunk is written when
85
+ * it settles — a socket waiting for `drain`, a stream waiting for the
86
+ * consumer's next pull); `end` and `abort` are optional and may answer
87
+ * either way too.
88
+ * @template T
89
+ * @typedef {Object} SinkLike
90
+ * @property {(chunk: T) => unknown} write
91
+ * @property {() => unknown} [end]
92
+ * @property {(reason: unknown) => unknown} [abort]
93
+ */
94
+
95
+ /**
96
+ * The serialized, awaited view of a sink.
97
+ * @template T
98
+ * @typedef {Object} AwaitedSink
99
+ * @property {(chunk: T) => Promise<void> | undefined} write - queue one
100
+ * chunk behind every earlier write; `undefined` when the underlying
101
+ * write answered synchronously with nothing pending (the fast path),
102
+ * else a promise that settles when the underlying write did
103
+ * @property {() => Promise<void>} end - queue the underlying `end` once
104
+ * after every write; repeated calls answer the same promise
105
+ * @property {(reason?: unknown) => Promise<void>} abort - stop accepting
106
+ * writes, reject the writes still queued, and call the underlying
107
+ * `abort` once, immediately; repeated calls answer the same promise
108
+ * @property {() => boolean} failed - whether a write rejected or an abort
109
+ * happened; nothing reaches the underlying sink after that
110
+ * @property {() => boolean} closed - whether `end` or `abort` was called
111
+ */
112
+
113
+ /**
114
+ * Serialize a sink: every write waits for the previous one, `end` waits
115
+ * for every write, and one failure stops everything after it. This is
116
+ * the one place the suite decides what "the next chunk" means for a
117
+ * sink that may answer a promise — a Node response whose `write()` said
118
+ * `false` (wait for `drain`), a Web `ReadableStream` bridge that waits
119
+ * for the consumer's pull, a stream runner whose carrier hooks may be
120
+ * asynchronous — so backpressure is a property of the sink, never of
121
+ * the code writing into it.
122
+ *
123
+ * The contract, in full:
124
+ *
125
+ * - writes reach the underlying sink in call order and never overlap:
126
+ * the next underlying write begins only after the previous one
127
+ * settled;
128
+ * - a synchronous sink stays on a no-extra-promise fast path: while
129
+ * nothing is pending and the underlying write answers a non-thenable,
130
+ * `write` answers `undefined`; the first thenable answer opens the
131
+ * queue, and an idle queue returns to the fast path;
132
+ * - `end()` runs the underlying `end` once, after every write queued
133
+ * before it; `abort(reason)` runs the underlying `abort` once, at once
134
+ * (an abort is urgent — the pending underlying write is not waited
135
+ * for), and every write still queued rejects with the reason;
136
+ * - a write that throws or rejects (including a throwing `then` getter
137
+ * on its answer) fails the sink: its own promise
138
+ * rejects, every write queued behind it rejects with the same reason
139
+ * without reaching the underlying sink, later writes reject at once,
140
+ * and `end()` rejects too — the stream did not end cleanly;
141
+ * - `end` and `abort` are mutually terminal: the first one decides, the
142
+ * other one (and every repeat) answers the first one's promise; a
143
+ * write after either rejects;
144
+ * - `write` never throws — a refusal is a rejected promise, so a caller
145
+ * handles one shape.
146
+ *
147
+ * @template T
148
+ * @param {SinkLike<T>} sink
149
+ * @returns {AwaitedSink<T>}
150
+ * @throws {TypeError} when `sink` has no `write` function
151
+ */
152
+ export function createAwaitedSink(sink) {
153
+ if (sink === null || typeof sink !== 'object' || typeof sink.write !== 'function') {
154
+ throw new TypeError('createAwaitedSink needs a sink with a write function');
155
+ }
156
+ /** whether an underlying operation is in flight (it answered a thenable) */
157
+ let running = false;
158
+ /**
159
+ * The operations waiting for the one in flight, in call order.
160
+ * @type {{ op: () => unknown, resolve: () => void, reject: (reason: unknown) => void }[]}
161
+ */
162
+ const queued = [];
163
+ let failed = false;
164
+ /** @type {unknown} */
165
+ let failure;
166
+ let closed = false;
167
+ /** @type {Promise<void> | null} the first end()/abort() settlement */
168
+ let terminal = null;
169
+
170
+ /** @param {unknown} reason */
171
+ const fail = (reason) => {
172
+ if (failed) return;
173
+ failed = true;
174
+ failure = reason;
175
+ };
176
+
177
+ /** Run what is queued, one at a time, until one answers a thenable. */
178
+ const next = () => {
179
+ while (!running && queued.length > 0) {
180
+ const item = /** @type {NonNullable<typeof queued[0]>} */ (queued.shift());
181
+ if (failed) {
182
+ item.reject(failure);
183
+ continue;
184
+ }
185
+ let answer;
186
+ try {
187
+ answer = item.op();
188
+ if (!isThenable(answer)) {
189
+ item.resolve();
190
+ continue;
191
+ }
192
+ }
193
+ catch (err) {
194
+ fail(err);
195
+ item.reject(err);
196
+ continue;
197
+ }
198
+ running = true;
199
+ Promise.resolve(answer).then(() => {
200
+ running = false;
201
+ item.resolve();
202
+ next();
203
+ }, (err) => {
204
+ running = false;
205
+ fail(err);
206
+ item.reject(err);
207
+ next();
208
+ });
209
+ }
210
+ };
211
+
212
+ /**
213
+ * Run one operation behind the queue.
214
+ * @param {() => unknown} op
215
+ * @returns {Promise<void> | undefined}
216
+ */
217
+ const run = (op) => {
218
+ if (running || queued.length > 0) {
219
+ return new Promise((resolve, reject) => { queued.push({ op, resolve, reject }); });
220
+ }
221
+ let answer;
222
+ try {
223
+ answer = op();
224
+ if (!isThenable(answer)) return undefined;
225
+ }
226
+ catch (err) {
227
+ fail(err);
228
+ return Promise.reject(err);
229
+ }
230
+ running = true;
231
+ return new Promise((resolve, reject) => {
232
+ Promise.resolve(answer).then(() => {
233
+ running = false;
234
+ resolve();
235
+ next();
236
+ }, (err) => {
237
+ running = false;
238
+ fail(err);
239
+ reject(err);
240
+ next();
241
+ });
242
+ });
243
+ };
244
+
245
+ return {
246
+ write(chunk) {
247
+ if (failed) return Promise.reject(failure);
248
+ if (closed) return Promise.reject(new Error('createAwaitedSink: write after end'));
249
+ return run(() => sink.write(chunk));
250
+ },
251
+ end() {
252
+ if (terminal !== null) return terminal;
253
+ closed = true;
254
+ if (failed) {
255
+ terminal = Promise.reject(failure);
256
+ return terminal;
257
+ }
258
+ const answer = run(() => (typeof sink.end === 'function' ? sink.end() : undefined));
259
+ terminal = answer === undefined ? Promise.resolve() : answer;
260
+ return terminal;
261
+ },
262
+ abort(reason) {
263
+ if (terminal !== null) return terminal;
264
+ closed = true;
265
+ const why = reason === undefined ? new Error('createAwaitedSink: aborted') : reason;
266
+ fail(why);
267
+ // what is still queued rejects now — an abort does not wait for
268
+ // the operation in flight, whose late settlement is then ignored
269
+ const waiting = queued.splice(0, queued.length);
270
+ for (let i = 0; i < waiting.length; i++) waiting[i].reject(why);
271
+ let answer;
272
+ try {
273
+ answer = typeof sink.abort === 'function' ? sink.abort(why) : undefined;
274
+ }
275
+ catch (err) {
276
+ terminal = Promise.reject(err);
277
+ return terminal;
278
+ }
279
+ terminal = Promise.resolve(answer).then(() => undefined);
280
+ return terminal;
281
+ },
282
+ failed: () => failed,
283
+ closed: () => closed,
284
+ };
285
+ }
@@ -40,6 +40,13 @@ import {
40
40
  const D2 = (n) => (n < 10 ? '0' + n : '' + n);
41
41
  const D3 = (n) => (n < 10 ? '00' + n : n < 100 ? '0' + n : '' + n);
42
42
 
43
+ // The parser retains up to six fractional digits. Round the whole seconds
44
+ // before extracting them, so subtraction does not expose binary noise.
45
+ /** @param {number} seconds */
46
+ function fractionDigits(seconds) {
47
+ return seconds.toFixed(6).slice(-6);
48
+ }
49
+
43
50
  function pad(n, width) {
44
51
  const s = '' + n;
45
52
  return s.length >= width ? s : '0'.repeat(width - s.length) + s;
@@ -86,8 +93,8 @@ const TOKENS = {
86
93
  m: (p) => '' + p.minutes,
87
94
  ss: (p) => D2(Math.trunc(p.seconds)),
88
95
  s: (p) => '' + Math.trunc(p.seconds),
89
- SSS: (p) => D3(Math.round((p.seconds - Math.trunc(p.seconds)) * 1000)),
90
- S: (p) => '' + Math.trunc((p.seconds - Math.trunc(p.seconds)) * 10),
96
+ SSS: (p) => fractionDigits(p.seconds).slice(0, 3),
97
+ S: (p) => fractionDigits(p.seconds).slice(0, 1),
91
98
  a: (p, n) => n.meridiem[p.hours < 12 ? 0 : 1],
92
99
  XXX: (p) => offsetText(p.offset, true, true),
93
100
  XX: (p) => offsetText(p.offset, false, true),
@@ -203,7 +210,8 @@ const FORMAT_TIME = compileDateFormat('HH:mm:ssXXX');
203
210
  * `full-time`, and the offset is the record's own rather than UTC.
204
211
  *
205
212
  * The round trip preserves the *value*, not necessarily the spelling: a
206
- * fractional second is emitted only when non-zero and without trailing
213
+ * fractional second keeps the parser's six-digit precision and is emitted
214
+ * only when non-zero and without trailing
207
215
  * zeros, so `…:05.250Z` comes back `…:05.25Z`. The parts record holds
208
216
  * the fraction as a number, so the original digit count is not
209
217
  * recoverable — a consumer that must reproduce the input byte for byte
@@ -222,9 +230,11 @@ export function formatRFC3339Parts(parts) {
222
230
  if (fraction === 0)
223
231
  return base;
224
232
  // splice the fraction in after the seconds, before the offset
225
- const digits = fraction.toFixed(3).slice(1).replace(/0+$/, '');
233
+ const digits = fractionDigits(parts.seconds).replace(/0+$/, '');
234
+ if (digits.length === 0)
235
+ return base;
226
236
  const cut = base.length - (FORMAT_OFFSET_LEN(parts));
227
- return base.slice(0, cut) + digits + base.slice(cut);
237
+ return base.slice(0, cut) + '.' + digits + base.slice(cut);
228
238
  }
229
239
 
230
240
  // how many characters the rendered offset takes, so the fraction can be
@@ -5,7 +5,7 @@ import {
5
5
  isObjectOfClass,
6
6
  } from '../index.js';
7
7
  // the calendar rules live in civil.js, which owns the day tables
8
- import { isLeapYear, daysInMonth } from './civil.js';
8
+ import { isLeapYear, daysInMonth, daysFromCivil } from './civil.js';
9
9
 
10
10
  export { isLeapYear };
11
11
 
@@ -365,18 +365,16 @@ export function epochOfRFC3339Parts(parts) {
365
365
  return NaN;
366
366
  const seconds = parts.seconds < 0 ? 0 : parts.seconds;
367
367
  const whole = Math.floor(seconds);
368
+ // Civil day numbers preserve year zero's leap day and carries across
369
+ // year 99; Date.UTC's 1900-based interpretation of 0-99 cannot.
368
370
  const ms = Date.UTC(
369
- parts.year, parts.month - 1, parts.day,
371
+ 1970, 0, 1 + daysFromCivil(parts.year, parts.month, parts.day),
370
372
  parts.hours < 0 ? 0 : parts.hours,
371
373
  parts.minutes < 0 ? 0 : parts.minutes,
372
374
  whole, Math.round((seconds - whole) * 1000));
373
375
  if (ms !== ms)
374
376
  return NaN;
375
- // Date.UTC maps years 0-99 into the 1900s; restore the real year
376
- const utc = new Date(ms);
377
- if (parts.year >= 0 && parts.year < 100)
378
- utc.setUTCFullYear(parts.year);
379
- return utc.getTime() - (parts.offset === null ? 0 : parts.offset) * 60000;
377
+ return ms - (parts.offset === null ? 0 : parts.offset) * 60000;
380
378
  }
381
379
  //#endregion
382
380