@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.
- package/ARCHITECTURE.md +122 -9
- package/README.md +50 -3
- package/dist/types/async.d.ts +39 -0
- package/dist/types/dates/civil.d.ts +24 -5
- package/dist/types/dates/index.d.ts +2 -0
- package/dist/types/dates/parse.d.ts +47 -0
- package/dist/types/dates/ticks.d.ts +59 -0
- package/dist/types/math/float64.d.ts +13 -0
- package/dist/types/random.d.ts +80 -0
- package/dist/types/series/asof.d.ts +88 -0
- package/dist/types/series/bucket.d.ts +206 -0
- package/dist/types/series/downsample.d.ts +53 -0
- package/dist/types/series/index.d.ts +8 -0
- package/dist/types/series/interval-index.d.ts +47 -0
- package/dist/types/series/interval.d.ts +170 -0
- package/dist/types/series/normalize.d.ts +190 -0
- package/dist/types/series/rolling.d.ts +67 -0
- package/dist/types/series/selector.d.ts +29 -0
- package/dist/types/series/zone.d.ts +59 -0
- package/dist/types/stats.d.ts +73 -0
- package/docs/DATES.md +77 -1
- package/docs/GEO.md +2 -2
- package/docs/SERIES.md +342 -0
- package/package.json +21 -1
- package/src/async.js +78 -0
- package/src/dates/civil.js +136 -41
- package/src/dates/index.js +4 -0
- package/src/dates/parse.js +410 -0
- package/src/dates/ticks.js +184 -0
- package/src/math/float64.js +29 -0
- package/src/random.js +125 -0
- package/src/series/asof.js +276 -0
- package/src/series/bucket.js +542 -0
- package/src/series/downsample.js +343 -0
- package/src/series/index.js +86 -0
- package/src/series/interval-index.js +181 -0
- package/src/series/interval.js +374 -0
- package/src/series/normalize.js +330 -0
- package/src/series/rolling.js +229 -0
- package/src/series/selector.js +104 -0
- package/src/series/zone.js +188 -0
- package/src/stats.js +133 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
//#region Half-open interval algebra
|
|
4
|
+
// Set operations over `[start, end)` spans of epoch milliseconds — the
|
|
5
|
+
// vocabulary a roster, a calendar, an availability view and a
|
|
6
|
+
// maintenance window all rebuild by hand today.
|
|
7
|
+
//
|
|
8
|
+
// **Half-open is the whole design.** An interval contains `t` when
|
|
9
|
+
// `start <= t && t < end`, so a day ends exactly where the next one
|
|
10
|
+
// begins and nothing is counted twice at a boundary. Two intervals that
|
|
11
|
+
// touch (`a.end === b.start`) therefore do NOT overlap: back-to-back
|
|
12
|
+
// bookings are not a double booking.
|
|
13
|
+
//
|
|
14
|
+
// Merging is the one place that answer is not enough, and it is why
|
|
15
|
+
// `mergeIntervals` joins touching spans by DEFAULT. Availability asks
|
|
16
|
+
// "is there continuous cover from nine to five", and 09:00-13:00 plus
|
|
17
|
+
// 13:00-17:00 is continuous cover; leaving a zero-width seam between
|
|
18
|
+
// them would report a gap no one can be scheduled into. The other
|
|
19
|
+
// answer is real too — a shift handover is two shifts, not one — so it
|
|
20
|
+
// is spelled `{ adjacent: false }` and nothing has to guess.
|
|
21
|
+
//
|
|
22
|
+
// Every operation refuses an interval that is empty, reversed or not
|
|
23
|
+
// finite. `[t, t)` contains no instant, so it is a defect at the point
|
|
24
|
+
// it was written rather than a value that quietly disappears from a
|
|
25
|
+
// union and quietly consumes a subtraction.
|
|
26
|
+
//
|
|
27
|
+
// A bound is read wherever it is written: every operation takes epoch
|
|
28
|
+
// milliseconds or an RFC 3339 string, and every result is epoch
|
|
29
|
+
// milliseconds. Conversion happens once, at the door.
|
|
30
|
+
//
|
|
31
|
+
// Merge, subtract, gaps and slots return bare `{ start, end }` records:
|
|
32
|
+
// a span welded out of three source rows belongs to none of them, and
|
|
33
|
+
// carrying one of their metadata forward would be a claim the data does
|
|
34
|
+
// not support. Source members survive normalization and the index,
|
|
35
|
+
// where a result IS a row.
|
|
36
|
+
|
|
37
|
+
import { toEpoch, normalizeIntervals } from './normalize.js';
|
|
38
|
+
import { requireSpecMembers } from './selector.js';
|
|
39
|
+
import { parseDuration, durationToMs } from '../dates/duration.js';
|
|
40
|
+
|
|
41
|
+
/** @typedef {import('./normalize.js').Interval} Interval */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One interval as canonical epoch bounds, validated as `[start, end)`.
|
|
45
|
+
* Both bounds go through {@link toEpoch}, so every operation here reads
|
|
46
|
+
* an RFC 3339 string wherever it reads a number, and everything past
|
|
47
|
+
* this point is integer arithmetic.
|
|
48
|
+
* @param {any} interval
|
|
49
|
+
* @param {string} role - what the argument is, for the message
|
|
50
|
+
* @returns {Interval}
|
|
51
|
+
*/
|
|
52
|
+
function requireInterval(interval, role) {
|
|
53
|
+
if (interval === null || typeof interval !== 'object')
|
|
54
|
+
throw new TypeError(`${role} is not an interval`);
|
|
55
|
+
let start;
|
|
56
|
+
let end;
|
|
57
|
+
try {
|
|
58
|
+
start = toEpoch(interval.start);
|
|
59
|
+
end = toEpoch(interval.end);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
throw new TypeError(`${role} has a bound that names no instant: ${
|
|
63
|
+
error instanceof Error ? error.message : String(error)}`);
|
|
64
|
+
}
|
|
65
|
+
if (!(start < end))
|
|
66
|
+
throw new TypeError(`${role} ends at ${end}, at or before its start ${start}`);
|
|
67
|
+
return { start, end };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A positive fixed width in milliseconds, from a number or a fixed ISO
|
|
72
|
+
* 8601 duration string.
|
|
73
|
+
*
|
|
74
|
+
* A calendar duration (`P1M`, `P1Y`) is refused rather than approximated:
|
|
75
|
+
* a month has no width until it is told where it starts, and calling it
|
|
76
|
+
* thirty days is how a schedule drifts.
|
|
77
|
+
*
|
|
78
|
+
* @param {number | string} spec
|
|
79
|
+
* @param {string} role
|
|
80
|
+
* @returns {number} milliseconds
|
|
81
|
+
*/
|
|
82
|
+
function requireWidth(spec, role) {
|
|
83
|
+
let ms = spec;
|
|
84
|
+
if (typeof spec === 'string') {
|
|
85
|
+
const parts = parseDuration(spec);
|
|
86
|
+
if (parts === null)
|
|
87
|
+
throw new TypeError(`${role} '${spec}' is not an ISO 8601 duration`);
|
|
88
|
+
ms = durationToMs(parts);
|
|
89
|
+
if (!Number.isFinite(ms))
|
|
90
|
+
throw new TypeError(`${role} '${spec}' is a calendar duration and has no fixed width`);
|
|
91
|
+
}
|
|
92
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0)
|
|
93
|
+
throw new TypeError(`${role} is a positive number of milliseconds or a fixed ISO 8601 duration`);
|
|
94
|
+
return ms;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Does `interval` contain the instant `at`?
|
|
99
|
+
*
|
|
100
|
+
* Half-open: the start is in, the end is out. An instant on a boundary
|
|
101
|
+
* belongs to exactly one of two touching intervals, which is what makes
|
|
102
|
+
* "which shift is this event in" answerable.
|
|
103
|
+
*
|
|
104
|
+
* @param {Interval} interval
|
|
105
|
+
* @param {number | string} at - epoch milliseconds or RFC 3339
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
108
|
+
* @example
|
|
109
|
+
* containsInstant({ start: 0, end: 10 }, 0); // true
|
|
110
|
+
* containsInstant({ start: 0, end: 10 }, 10); // false
|
|
111
|
+
*/
|
|
112
|
+
export function containsInstant(interval, at) {
|
|
113
|
+
const { start, end } = requireInterval(interval, 'the interval');
|
|
114
|
+
const t = toEpoch(at);
|
|
115
|
+
return t >= start && t < end;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Do two intervals share at least one instant?
|
|
120
|
+
*
|
|
121
|
+
* Touching intervals do not: `[0, 10)` and `[10, 20)` have no instant
|
|
122
|
+
* in common, so consecutive bookings never read as a conflict.
|
|
123
|
+
*
|
|
124
|
+
* @param {Interval} a
|
|
125
|
+
* @param {Interval} b
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
128
|
+
* @example
|
|
129
|
+
* overlapsInterval({ start: 0, end: 10 }, { start: 10, end: 20 }); // false
|
|
130
|
+
* overlapsInterval({ start: 0, end: 10 }, { start: 9, end: 20 }); // true
|
|
131
|
+
*/
|
|
132
|
+
export function overlapsInterval(a, b) {
|
|
133
|
+
const left = requireInterval(a, 'the left interval');
|
|
134
|
+
const right = requireInterval(b, 'the right interval');
|
|
135
|
+
return left.start < right.end && right.start < left.end;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The span two intervals share, or `null` when they share none.
|
|
140
|
+
*
|
|
141
|
+
* `null` rather than an empty interval, because `[t, t)` is not a value
|
|
142
|
+
* this algebra has — the absence is the answer, and it cannot then be
|
|
143
|
+
* fed back in as if it were a span.
|
|
144
|
+
*
|
|
145
|
+
* @param {Interval} a
|
|
146
|
+
* @param {Interval} b
|
|
147
|
+
* @returns {Interval | null} a new record
|
|
148
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
149
|
+
* @example
|
|
150
|
+
* intersectInterval({ start: 0, end: 10 }, { start: 5, end: 20 });
|
|
151
|
+
* // { start: 5, end: 10 }
|
|
152
|
+
*/
|
|
153
|
+
export function intersectInterval(a, b) {
|
|
154
|
+
const left = requireInterval(a, 'the left interval');
|
|
155
|
+
const right = requireInterval(b, 'the right interval');
|
|
156
|
+
const start = left.start > right.start ? left.start : right.start;
|
|
157
|
+
const end = left.end < right.end ? left.end : right.end;
|
|
158
|
+
return start < end ? { start, end } : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `mergeIntervals`' closed specification: whether spans that touch
|
|
163
|
+
* join, which availability normally wants and a handover does not.
|
|
164
|
+
*/
|
|
165
|
+
export const MERGE_MEMBERS = Object.freeze(['adjacent']);
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The union of `intervals`, as the fewest disjoint spans that cover the
|
|
169
|
+
* same instants, ascending.
|
|
170
|
+
*
|
|
171
|
+
* Touching spans are joined by default, because continuous cover is
|
|
172
|
+
* what availability means; `{ adjacent: false }` keeps them apart, which
|
|
173
|
+
* is what a handover between two shifts means. Overlapping spans always
|
|
174
|
+
* join, under both settings.
|
|
175
|
+
*
|
|
176
|
+
* @param {Interval[]} intervals - in any order
|
|
177
|
+
* @param {Object} [options]
|
|
178
|
+
* @param {boolean} [options.adjacent] - join touching spans (default `true`)
|
|
179
|
+
* @returns {Interval[]} new `{ start, end }` records
|
|
180
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
181
|
+
* @example
|
|
182
|
+
* mergeIntervals([{ start: 0, end: 10 }, { start: 10, end: 20 }]);
|
|
183
|
+
* // [{ start: 0, end: 20 }]
|
|
184
|
+
* mergeIntervals([{ start: 0, end: 10 }, { start: 10, end: 20 }],
|
|
185
|
+
* { adjacent: false });
|
|
186
|
+
* // [{ start: 0, end: 10 }, { start: 10, end: 20 }]
|
|
187
|
+
*/
|
|
188
|
+
export function mergeIntervals(intervals, options = {}) {
|
|
189
|
+
requireSpecMembers(options, MERGE_MEMBERS, 'mergeIntervals',
|
|
190
|
+
'a merge spec is an object with an \'adjacent\'');
|
|
191
|
+
const adjacent = options.adjacent ?? true;
|
|
192
|
+
const sorted = normalizeIntervals(intervals);
|
|
193
|
+
/** @type {Interval[]} */
|
|
194
|
+
const out = [];
|
|
195
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
196
|
+
const { start, end } = sorted[i];
|
|
197
|
+
const last = out.length === 0 ? null : out[out.length - 1];
|
|
198
|
+
if (last !== null && (adjacent ? start <= last.end : start < last.end)) {
|
|
199
|
+
if (end > last.end)
|
|
200
|
+
last.end = end;
|
|
201
|
+
}
|
|
202
|
+
else out.push({ start, end });
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The instants in `from` that `remove` does not cover, as disjoint
|
|
209
|
+
* ascending spans.
|
|
210
|
+
*
|
|
211
|
+
* Both sides are merged first, so the result is the set difference and
|
|
212
|
+
* nothing depends on the order the arguments arrived in. A cut through
|
|
213
|
+
* the middle of a span **splits** it into two; a cut that covers a span
|
|
214
|
+
* removes it entirely.
|
|
215
|
+
*
|
|
216
|
+
* @param {Interval[]} from - the spans being reduced
|
|
217
|
+
* @param {Interval[]} remove - the spans taken out of them
|
|
218
|
+
* @returns {Interval[]} new `{ start, end }` records
|
|
219
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
220
|
+
* @example
|
|
221
|
+
* subtractIntervals([{ start: 0, end: 100 }], [{ start: 40, end: 60 }]);
|
|
222
|
+
* // [{ start: 0, end: 40 }, { start: 60, end: 100 }]
|
|
223
|
+
*/
|
|
224
|
+
export function subtractIntervals(from, remove) {
|
|
225
|
+
const base = mergeIntervals(from);
|
|
226
|
+
const cuts = mergeIntervals(remove);
|
|
227
|
+
/** @type {Interval[]} */
|
|
228
|
+
const out = [];
|
|
229
|
+
let j = 0;
|
|
230
|
+
for (let i = 0; i < base.length; i++) {
|
|
231
|
+
const span = base[i];
|
|
232
|
+
let at = span.start;
|
|
233
|
+
// cuts and base are both ascending and disjoint, so a cut that ends
|
|
234
|
+
// at or before this span's start is spent for every later span too
|
|
235
|
+
while (j < cuts.length && cuts[j].end <= at)
|
|
236
|
+
j++;
|
|
237
|
+
for (let k = j; k < cuts.length && cuts[k].start < span.end; k++) {
|
|
238
|
+
if (cuts[k].start > at)
|
|
239
|
+
out.push({ start: at, end: cuts[k].start });
|
|
240
|
+
if (cuts[k].end > at)
|
|
241
|
+
at = cuts[k].end;
|
|
242
|
+
if (at >= span.end)
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
if (at < span.end)
|
|
246
|
+
out.push({ start: at, end: span.end });
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The hull of `intervals` — the one span from the earliest start to the
|
|
253
|
+
* latest end, or `null` when there are none.
|
|
254
|
+
* @param {Interval[]} sorted - ascending on `start`
|
|
255
|
+
* @returns {Interval | null}
|
|
256
|
+
*/
|
|
257
|
+
function hullOf(sorted) {
|
|
258
|
+
if (sorted.length === 0)
|
|
259
|
+
return null;
|
|
260
|
+
let end = sorted[0].end;
|
|
261
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
262
|
+
if (sorted[i].end > end)
|
|
263
|
+
end = sorted[i].end;
|
|
264
|
+
}
|
|
265
|
+
return { start: sorted[0].start, end };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The spans inside `within` that `intervals` leaves uncovered.
|
|
270
|
+
*
|
|
271
|
+
* `within` defaults to the hull of the intervals themselves — the gaps
|
|
272
|
+
* *between* them — because the alternative default would be a clock,
|
|
273
|
+
* and this kernel has none. Passing it explicitly is what reports a
|
|
274
|
+
* missing edge: an empty morning before the first booking is only a gap
|
|
275
|
+
* if the caller says the day starts at nine.
|
|
276
|
+
*
|
|
277
|
+
* @param {Interval[]} intervals - in any order
|
|
278
|
+
* @param {Interval} [within] - the window to look inside
|
|
279
|
+
* @returns {Interval[]} new `{ start, end }` records
|
|
280
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
281
|
+
* @example
|
|
282
|
+
* gapsWithin([{ start: 0, end: 10 }, { start: 30, end: 40 }]);
|
|
283
|
+
* // [{ start: 10, end: 30 }]
|
|
284
|
+
* gapsWithin([{ start: 10, end: 20 }], { start: 0, end: 30 });
|
|
285
|
+
* // [{ start: 0, end: 10 }, { start: 20, end: 30 }]
|
|
286
|
+
*/
|
|
287
|
+
export function gapsWithin(intervals, within) {
|
|
288
|
+
const merged = mergeIntervals(intervals);
|
|
289
|
+
const bounds = within === undefined ? hullOf(merged) : requireInterval(within, 'the window');
|
|
290
|
+
if (bounds === null)
|
|
291
|
+
return [];
|
|
292
|
+
return subtractIntervals([bounds], merged);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* How many milliseconds `intervals` cover, counting an instant once
|
|
297
|
+
* however many spans hold it.
|
|
298
|
+
*
|
|
299
|
+
* Restricted to `within` when given, which is what turns it into a
|
|
300
|
+
* ratio: `coverageOf(shifts, day) / (day.end - day.start)` is the
|
|
301
|
+
* fraction of the day that is staffed.
|
|
302
|
+
*
|
|
303
|
+
* @param {Interval[]} intervals - in any order
|
|
304
|
+
* @param {Interval} [within] - clip to this window first
|
|
305
|
+
* @returns {number} milliseconds
|
|
306
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval
|
|
307
|
+
* @example
|
|
308
|
+
* coverageOf([{ start: 0, end: 10 }, { start: 5, end: 20 }]); // 20
|
|
309
|
+
*/
|
|
310
|
+
export function coverageOf(intervals, within) {
|
|
311
|
+
const merged = mergeIntervals(intervals);
|
|
312
|
+
const bounds = within === undefined ? null : requireInterval(within, 'the window');
|
|
313
|
+
let total = 0;
|
|
314
|
+
for (let i = 0; i < merged.length; i++) {
|
|
315
|
+
const start = bounds === null || merged[i].start > bounds.start ? merged[i].start : bounds.start;
|
|
316
|
+
const end = bounds === null || merged[i].end < bounds.end ? merged[i].end : bounds.end;
|
|
317
|
+
if (end > start)
|
|
318
|
+
total += end - start;
|
|
319
|
+
}
|
|
320
|
+
return total;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* `findSlots`' closed specification: how long a slot is and how far
|
|
325
|
+
* apart two of them start. Enumeration, never a constraint solver.
|
|
326
|
+
*/
|
|
327
|
+
export const SLOTS_MEMBERS = Object.freeze(['duration', 'step']);
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Every place a span of `duration` fits inside `availability`.
|
|
331
|
+
*
|
|
332
|
+
* Availability is merged first — two touching windows are one window,
|
|
333
|
+
* so a meeting may straddle the seam — and each merged window is then
|
|
334
|
+
* walked from its own start in `step` increments (default: back to
|
|
335
|
+
* back), keeping every span that still ends inside the window. A window
|
|
336
|
+
* exactly one duration wide yields exactly one slot.
|
|
337
|
+
*
|
|
338
|
+
* This is enumeration, not scheduling: it answers "where could this
|
|
339
|
+
* go", and choosing among the answers, weighing preferences or
|
|
340
|
+
* assigning people is a solver's job, deliberately not this one.
|
|
341
|
+
*
|
|
342
|
+
* `duration` and `step` are fixed widths — a number of milliseconds or
|
|
343
|
+
* a fixed ISO 8601 duration (`'PT30M'`). A calendar duration is refused
|
|
344
|
+
* rather than approximated, because "every month" needs a calendar and
|
|
345
|
+
* a zone to say where its boundaries fall.
|
|
346
|
+
*
|
|
347
|
+
* @param {Interval[]} availability - in any order
|
|
348
|
+
* @param {Object} spec
|
|
349
|
+
* @param {number | string} spec.duration - how long the span is
|
|
350
|
+
* @param {number | string} [spec.step] - the spacing between starts
|
|
351
|
+
* (default: `duration`)
|
|
352
|
+
* @returns {Interval[]} new `{ start, end }` records, ascending
|
|
353
|
+
* @throws {TypeError} for an empty, reversed or non-finite interval, or
|
|
354
|
+
* a duration/step that is not a positive fixed width
|
|
355
|
+
* @example
|
|
356
|
+
* findSlots([{ start: 0, end: 90 }], { duration: 60, step: 30 });
|
|
357
|
+
* // [{ start: 0, end: 60 }, { start: 30, end: 90 }]
|
|
358
|
+
*/
|
|
359
|
+
export function findSlots(availability, spec) {
|
|
360
|
+
requireSpecMembers(spec, SLOTS_MEMBERS, 'findSlots', 'a slot spec is an object with a duration');
|
|
361
|
+
const duration = requireWidth(spec.duration, 'duration');
|
|
362
|
+
const step = spec.step === undefined ? duration : requireWidth(spec.step, 'step');
|
|
363
|
+
const windows = mergeIntervals(availability);
|
|
364
|
+
/** @type {Interval[]} */
|
|
365
|
+
const out = [];
|
|
366
|
+
for (let i = 0; i < windows.length; i++) {
|
|
367
|
+
const limit = windows[i].end - duration;
|
|
368
|
+
for (let start = windows[i].start; start <= limit; start += step)
|
|
369
|
+
out.push({ start, end: start + duration });
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
//#endregion
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
//#region Canonical instants, samples and intervals
|
|
4
|
+
// The one door every temporal kernel enters through. A consumer's rows
|
|
5
|
+
// arrive as whatever their source spells — an RFC 3339 string out of a
|
|
6
|
+
// document, epoch milliseconds out of a column, a member named `on` or
|
|
7
|
+
// `timestamp` rather than `at` — and everything downstream wants sorted
|
|
8
|
+
// numbers. Converting here, once, is what lets the algebra and the
|
|
9
|
+
// index stay integer arithmetic over a sorted array.
|
|
10
|
+
//
|
|
11
|
+
// Three rules make that conversion safe to build on:
|
|
12
|
+
//
|
|
13
|
+
// **A row is never dropped.** A member that cannot become a finite
|
|
14
|
+
// instant is a TypeError naming the row, not a silently shorter
|
|
15
|
+
// result. Quietly discarding the one malformed sample in ten thousand
|
|
16
|
+
// is how an aggregate becomes wrong without anything looking wrong.
|
|
17
|
+
//
|
|
18
|
+
// **Sorting is stable.** Two samples at the same instant come out in
|
|
19
|
+
// the order they went in, and both participate in an aggregate. A
|
|
20
|
+
// duplicate instant is real data — two readings in the same
|
|
21
|
+
// millisecond — not a key collision to resolve.
|
|
22
|
+
//
|
|
23
|
+
// **A value may be absent, but only explicitly.** `null` is a
|
|
24
|
+
// measured gap and survives; `undefined`, a string or a NaN is a
|
|
25
|
+
// defect and is refused.
|
|
26
|
+
//
|
|
27
|
+
// There is no clock here and no zone here. A full-date reads as UTC
|
|
28
|
+
// midnight because RFC 3339 gives it no offset; a full-time has no
|
|
29
|
+
// instant at all and is refused rather than being invented a day.
|
|
30
|
+
|
|
31
|
+
import { parseRFC3339Parts, epochOfRFC3339Parts } from '../dates/rfc3339.js';
|
|
32
|
+
import { selectorOf, requireRow } from './selector.js';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {Object} Sample
|
|
36
|
+
* @property {number} at - Unix epoch milliseconds
|
|
37
|
+
* @property {number | null} value - the reading, or `null` for a
|
|
38
|
+
* measured gap
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {Object} Interval
|
|
43
|
+
* @property {number} start - inclusive lower bound, epoch milliseconds
|
|
44
|
+
* @property {number} end - exclusive upper bound, epoch milliseconds
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The instant `value` names, in Unix epoch milliseconds.
|
|
49
|
+
*
|
|
50
|
+
* Accepts the two forms a date has in this suite: a finite **number**
|
|
51
|
+
* (already epoch milliseconds, returned unchanged) and a valid **RFC
|
|
52
|
+
* 3339 string**. A full-date reads as UTC midnight, an offset shifts to
|
|
53
|
+
* the instant it names, and `Z` is UTC.
|
|
54
|
+
*
|
|
55
|
+
* Refused, all as `TypeError`: a full-time (`'09:30:00Z'` names no day,
|
|
56
|
+
* and inventing one would be a hidden clock), a date-time without an
|
|
57
|
+
* offset (`'2026-01-01T09:30:00'` names no instant without a zone),
|
|
58
|
+
* `NaN`/`Infinity`, a `Date` object (dates are strings or numbers here,
|
|
59
|
+
* never a wrapper), and anything else.
|
|
60
|
+
*
|
|
61
|
+
* @param {number | string} value
|
|
62
|
+
* @returns {number} epoch milliseconds
|
|
63
|
+
* @throws {TypeError} when `value` names no instant
|
|
64
|
+
* @example
|
|
65
|
+
* toEpoch(0); // 0
|
|
66
|
+
* toEpoch('1970-01-01'); // 0
|
|
67
|
+
* toEpoch('1970-01-01T01:00:00+01:00'); // 0
|
|
68
|
+
*/
|
|
69
|
+
export function toEpoch(value) {
|
|
70
|
+
if (typeof value === 'number') {
|
|
71
|
+
if (!Number.isFinite(value))
|
|
72
|
+
throw new TypeError(`${value} is not an instant`);
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
if (typeof value === 'string') {
|
|
76
|
+
const parts = parseRFC3339Parts(value);
|
|
77
|
+
const ms = parts === null ? NaN : epochOfRFC3339Parts(parts);
|
|
78
|
+
if (!Number.isFinite(ms))
|
|
79
|
+
throw new TypeError(`'${value}' is not an RFC 3339 instant`);
|
|
80
|
+
return ms;
|
|
81
|
+
}
|
|
82
|
+
throw new TypeError('an instant is epoch milliseconds or an RFC 3339 string');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* One instant read out of a row, with the row named when it is not one.
|
|
87
|
+
* "not an RFC 3339 instant" is a puzzle when ten thousand rows were
|
|
88
|
+
* handed in; "row 4172, at: …" is a defect someone can go and look at.
|
|
89
|
+
*
|
|
90
|
+
* @param {any} value - the raw member
|
|
91
|
+
* @param {string} role - the member's name, for the message
|
|
92
|
+
* @param {number} index - the row's position
|
|
93
|
+
* @returns {number} epoch milliseconds
|
|
94
|
+
* @throws {TypeError} when `value` names no instant
|
|
95
|
+
*/
|
|
96
|
+
export function epochAt(value, role, index) {
|
|
97
|
+
try {
|
|
98
|
+
return toEpoch(value);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
throw new TypeError(`row ${index}, ${role}: ${
|
|
102
|
+
error instanceof Error ? error.message : String(error)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Is this array already ascending on `key`? An input that arrives
|
|
108
|
+
* sorted — a column read back in order, a stream appended in time — is
|
|
109
|
+
* the common case, and O(n) to confirm.
|
|
110
|
+
* @param {any[]} rows
|
|
111
|
+
* @param {string} key
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
function isAscending(rows, key) {
|
|
115
|
+
for (let i = 1; i < rows.length; i++) {
|
|
116
|
+
if (rows[i - 1][key] > rows[i][key])
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sorted canonical samples: every row's instant converted once, its
|
|
124
|
+
* value checked, its other members kept, and the whole ascending by
|
|
125
|
+
* instant.
|
|
126
|
+
*
|
|
127
|
+
* The sort is **stable**, so rows sharing an instant come out in input
|
|
128
|
+
* order — and both are present, because a duplicate instant is two
|
|
129
|
+
* readings, not one row written twice.
|
|
130
|
+
*
|
|
131
|
+
* Each result is a shallow copy of its source row with canonical `at`
|
|
132
|
+
* and `value` members written over it, so a source that spelled its
|
|
133
|
+
* instant `on` keeps `on` too and nothing a caller attached is lost.
|
|
134
|
+
*
|
|
135
|
+
* @param {any[]} rows - the source rows
|
|
136
|
+
* @param {Object} [options]
|
|
137
|
+
* @param {string | ((item: any, index: number) => any)} [options.at]
|
|
138
|
+
* where the instant lives (default `'at'`)
|
|
139
|
+
* @param {string | ((item: any, index: number) => any)} [options.value]
|
|
140
|
+
* where the reading lives (default `'value'`)
|
|
141
|
+
* @returns {(Sample & Record<string, any>)[]} a new array of new records
|
|
142
|
+
* @throws {TypeError} for a non-array, a row that is not an object, an
|
|
143
|
+
* instant that names none, or a value that is neither a finite number
|
|
144
|
+
* nor `null`
|
|
145
|
+
* @example
|
|
146
|
+
* normalizeSeries([{ on: '2026-01-01T00:00:01Z', v: 2 },
|
|
147
|
+
* { on: '2026-01-01T00:00:00Z', v: 1 }],
|
|
148
|
+
* { at: 'on', value: 'v' });
|
|
149
|
+
* // [{ on: '…:00Z', v: 1, at: 1767225600000, value: 1 },
|
|
150
|
+
* // { on: '…:01Z', v: 2, at: 1767225601000, value: 2 }]
|
|
151
|
+
*/
|
|
152
|
+
export function normalizeSeries(rows, options = {}) {
|
|
153
|
+
if (!Array.isArray(rows))
|
|
154
|
+
throw new TypeError('a series is an array of rows');
|
|
155
|
+
const readAt = selectorOf(options.at ?? 'at', 'at');
|
|
156
|
+
const readValue = selectorOf(options.value ?? 'value', 'value');
|
|
157
|
+
const out = new Array(rows.length);
|
|
158
|
+
for (let i = 0; i < rows.length; i++) {
|
|
159
|
+
const row = requireRow(rows[i], i);
|
|
160
|
+
const at = epochAt(readAt(row, i), 'at', i);
|
|
161
|
+
const value = readValue(row, i);
|
|
162
|
+
if (value !== null && !(typeof value === 'number' && Number.isFinite(value)))
|
|
163
|
+
throw new TypeError(`row ${i}, value: neither a finite number nor null`);
|
|
164
|
+
out[i] = { ...row, at, value };
|
|
165
|
+
}
|
|
166
|
+
if (!isAscending(out, 'at'))
|
|
167
|
+
out.sort(compareAt);
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** @param {Sample} a @param {Sample} b @returns {number} */
|
|
172
|
+
function compareAt(a, b) {
|
|
173
|
+
return a.at - b.at;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The same canonical samples, without the copy when there is nothing to
|
|
178
|
+
* convert.
|
|
179
|
+
*
|
|
180
|
+
* {@link normalizeSeries} always builds a new array of new records,
|
|
181
|
+
* which is the right answer at the door and the wrong one three kernels
|
|
182
|
+
* later: bucketing, rolling and joining all take a series that a caller
|
|
183
|
+
* usually normalized once already, and re-copying a hundred thousand
|
|
184
|
+
* rows per operation costs more than the operation. So this checks
|
|
185
|
+
* instead of converting — one pass, no allocation — and hands the
|
|
186
|
+
* caller's own array straight back when every row is already
|
|
187
|
+
* `{ at: <finite number>, value: <number | null> }` and ascending.
|
|
188
|
+
*
|
|
189
|
+
* Nothing is trusted: a row that fails the check sends the whole array
|
|
190
|
+
* through {@link normalizeSeries}, which refuses it there with the row
|
|
191
|
+
* named. The fast path is a measurement, not a promise.
|
|
192
|
+
*
|
|
193
|
+
* @param {any[]} rows
|
|
194
|
+
* @param {Object} [options]
|
|
195
|
+
* @param {string | ((item: any, index: number) => any)} [options.at]
|
|
196
|
+
* @param {string | ((item: any, index: number) => any)} [options.value]
|
|
197
|
+
* @returns {(Sample & Record<string, any>)[]} `rows` itself, or a new
|
|
198
|
+
* normalized array
|
|
199
|
+
* @throws {TypeError} exactly where {@link normalizeSeries} does
|
|
200
|
+
*/
|
|
201
|
+
export function canonicalSeries(rows, options = {}) {
|
|
202
|
+
if (Array.isArray(rows) && options.at === undefined && options.value === undefined
|
|
203
|
+
&& isCanonical(rows))
|
|
204
|
+
return rows;
|
|
205
|
+
return normalizeSeries(rows, options);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Is every row already a canonical sample, and the whole ascending?
|
|
210
|
+
* @param {any[]} rows
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
function isCanonical(rows) {
|
|
214
|
+
let previous = -Infinity;
|
|
215
|
+
for (let i = 0; i < rows.length; i++) {
|
|
216
|
+
const row = rows[i];
|
|
217
|
+
if (row === null || typeof row !== 'object')
|
|
218
|
+
return false;
|
|
219
|
+
const at = row.at;
|
|
220
|
+
if (typeof at !== 'number' || !Number.isFinite(at) || at < previous)
|
|
221
|
+
return false;
|
|
222
|
+
const value = row.value;
|
|
223
|
+
if (value !== null && (typeof value !== 'number' || !Number.isFinite(value)))
|
|
224
|
+
return false;
|
|
225
|
+
previous = at;
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Sorted canonical intervals: every bound converted once, the direction
|
|
232
|
+
* checked, other members kept, and the whole ascending by start.
|
|
233
|
+
*
|
|
234
|
+
* Half-open `[start, end)` with `start < end` is the contract the whole
|
|
235
|
+
* algebra rests on, so an empty (`start === end`), reversed or
|
|
236
|
+
* non-finite interval is refused here rather than producing an answer
|
|
237
|
+
* later that no reader could predict. Duplicates survive — two bookings
|
|
238
|
+
* of the same slot are two bookings — and rows sharing a start keep
|
|
239
|
+
* their input order.
|
|
240
|
+
*
|
|
241
|
+
* @param {any[]} rows - the source rows
|
|
242
|
+
* @param {Object} [options]
|
|
243
|
+
* @param {string | ((item: any, index: number) => any)} [options.start]
|
|
244
|
+
* where the lower bound lives (default `'start'`)
|
|
245
|
+
* @param {string | ((item: any, index: number) => any)} [options.end]
|
|
246
|
+
* where the upper bound lives (default `'end'`)
|
|
247
|
+
* @returns {(Interval & Record<string, any>)[]} a new array of new records
|
|
248
|
+
* @throws {TypeError} for a non-array, a row that is not an object, a
|
|
249
|
+
* bound that names no instant, or `end <= start`
|
|
250
|
+
* @example
|
|
251
|
+
* normalizeIntervals([{ start: '2026-01-01', end: '2026-01-02' }]);
|
|
252
|
+
* // [{ start: 1767225600000, end: 1767312000000 }]
|
|
253
|
+
*/
|
|
254
|
+
export function normalizeIntervals(rows, options = {}) {
|
|
255
|
+
if (!Array.isArray(rows))
|
|
256
|
+
throw new TypeError('intervals are an array of rows');
|
|
257
|
+
const readStart = selectorOf(options.start ?? 'start', 'start');
|
|
258
|
+
const readEnd = selectorOf(options.end ?? 'end', 'end');
|
|
259
|
+
const out = new Array(rows.length);
|
|
260
|
+
for (let i = 0; i < rows.length; i++) {
|
|
261
|
+
const row = requireRow(rows[i], i);
|
|
262
|
+
const start = epochAt(readStart(row, i), 'start', i);
|
|
263
|
+
const end = epochAt(readEnd(row, i), 'end', i);
|
|
264
|
+
if (!(start < end))
|
|
265
|
+
throw new TypeError(`row ${i}: an interval ends at ${end}, at or before its start ${start}`);
|
|
266
|
+
out[i] = { ...row, start, end };
|
|
267
|
+
}
|
|
268
|
+
if (!isAscending(out, 'start'))
|
|
269
|
+
out.sort(compareStart);
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** @param {Interval} a @param {Interval} b @returns {number} */
|
|
274
|
+
function compareStart(a, b) {
|
|
275
|
+
return a.start - b.start;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The index of the first row at or after `at` — the lower end of a
|
|
280
|
+
* half-open cut. `rows` must already be ascending on `key`.
|
|
281
|
+
*
|
|
282
|
+
* This is the whole reason a series is normalized once: a range over a
|
|
283
|
+
* sorted array is two binary searches and a slice, not a pass over
|
|
284
|
+
* everything.
|
|
285
|
+
*
|
|
286
|
+
* @param {any[]} rows - ascending on `key`
|
|
287
|
+
* @param {number} at - epoch milliseconds
|
|
288
|
+
* @param {string} [key] - the member holding the instant (default `'at'`)
|
|
289
|
+
* @returns {number} an index in `[0, rows.length]`
|
|
290
|
+
* @example
|
|
291
|
+
* const lo = lowerBoundTime(samples, start);
|
|
292
|
+
* const hi = lowerBoundTime(samples, end);
|
|
293
|
+
* samples.slice(lo, hi); // every sample in [start, end)
|
|
294
|
+
*/
|
|
295
|
+
export function lowerBoundTime(rows, at, key = 'at') {
|
|
296
|
+
let lo = 0;
|
|
297
|
+
let hi = rows.length;
|
|
298
|
+
while (lo < hi) {
|
|
299
|
+
const mid = (lo + hi) >>> 1;
|
|
300
|
+
if (rows[mid][key] < at) lo = mid + 1;
|
|
301
|
+
else hi = mid;
|
|
302
|
+
}
|
|
303
|
+
return lo;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The index of the first row strictly after `at`. `rows` must already
|
|
308
|
+
* be ascending on `key`.
|
|
309
|
+
*
|
|
310
|
+
* The twin of {@link lowerBoundTime}: together they bracket the rows AT
|
|
311
|
+
* an instant (`[lowerBoundTime(rows, t), upperBoundTime(rows, t))`),
|
|
312
|
+
* which is what a duplicate-tolerant as-of has to read.
|
|
313
|
+
*
|
|
314
|
+
* @param {any[]} rows - ascending on `key`
|
|
315
|
+
* @param {number} at - epoch milliseconds
|
|
316
|
+
* @param {string} [key] - the member holding the instant (default `'at'`)
|
|
317
|
+
* @returns {number} an index in `[0, rows.length]`
|
|
318
|
+
*/
|
|
319
|
+
export function upperBoundTime(rows, at, key = 'at') {
|
|
320
|
+
let lo = 0;
|
|
321
|
+
let hi = rows.length;
|
|
322
|
+
while (lo < hi) {
|
|
323
|
+
const mid = (lo + hi) >>> 1;
|
|
324
|
+
if (rows[mid][key] <= at) lo = mid + 1;
|
|
325
|
+
else hi = mid;
|
|
326
|
+
}
|
|
327
|
+
return lo;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
//#endregion
|