@jarenjs/json 0.46.4 → 0.49.2

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,415 @@
1
+ //@ts-check
2
+
3
+ //#region series spec compilation (section 8.16)
4
+ // The literal half of the five temporal operators. Everything a
5
+ // `$resample`, `$rolling`, `$asof` or `$time-bucket` needs to know
6
+ // before it sees a single row — the width, the aggregate, the fill
7
+ // policy, the wall clock, where the instant lives in a row — is read
8
+ // here, ONCE, at query compile time.
9
+ //
10
+ // That is the whole design. A spec is a **literal**, never an
11
+ // expression: it is captured verbatim by the 'raw' argument kind
12
+ // (normalize.js `makeRaw`), so nothing inside it is evaluated, nothing
13
+ // inside it can vary per row, and a misspelled member is a broken
14
+ // document rather than a surprise on the ten-thousandth sample. The
15
+ // alternative — a spec computed from the data — would make the kernel
16
+ // re-compile a boundary ladder per call and would put a typo beyond the
17
+ // reach of every gate a document has.
18
+ //
19
+ // Three rules run through it:
20
+ //
21
+ // **Closed.** Each spec names its members exactly. An unknown one is
22
+ // `JQ0003` with the near-miss named, because `minPeriod` for
23
+ // `minPeriods` silently ignored is the bug that takes an afternoon.
24
+ //
25
+ // **Compile what is authored, refuse what is not.** A bad duration, a
26
+ // bad aggregate, a bad path, a zone with no provider: all `JQ0003`
27
+ // against the member that carried them. Only what the DATA decides —
28
+ // a row that is not a sample, an instant that names none — is
29
+ // `JQ2001` at runtime.
30
+ //
31
+ // **No second loop.** These helpers shape arguments for
32
+ // `@jarenjs/core/series` and read its answers back. The bucketing,
33
+ // the window, the join and the fill exist once, in the kernel; this
34
+ // module is the door they are reached through from a document.
35
+
36
+ import { parseJSONPath, JSONPathSyntaxError } from '../path.js';
37
+ import { isSingularSegments, compileSingularGetter, NOTHING } from '../segments.js';
38
+ import {
39
+ resolveClock,
40
+ CLOCK_MEMBERS as KERNEL_CLOCK_MEMBERS,
41
+ RESAMPLE_MEMBERS as KERNEL_RESAMPLE_MEMBERS,
42
+ ROLLING_MEMBERS as KERNEL_ROLLING_MEMBERS,
43
+ } from '@jarenjs/core/series';
44
+ import { JsonQueryCompileError, JsonQueryRuntimeError } from './errors.js';
45
+ import { EMPTY, Seq, describeItem } from './runtime.js';
46
+
47
+ const hasOwn = Object.hasOwn;
48
+
49
+ /**
50
+ * The one member of a kernel specification a DOCUMENT cannot carry.
51
+ * `provider` is a pair of functions and JSON has no such value, so a
52
+ * named zone reaches the kernel through `options.zoneProvider` at
53
+ * compile time instead. Everything else these operators admit is
54
+ * whatever `@jarenjs/core/series` admits, read from the kernel rather
55
+ * than restated here — one list, one place, and a member added to a
56
+ * kernel reaches the language in the same change.
57
+ */
58
+ const NOT_IN_A_DOCUMENT = Object.freeze(['provider']);
59
+
60
+ /** @param {readonly string[]} members @returns {readonly string[]} */
61
+ const spellable = (members) =>
62
+ Object.freeze(members.filter((name) => !NOT_IN_A_DOCUMENT.includes(name)));
63
+
64
+ /**
65
+ * The calendar context every clock-reading spec carries, and the one
66
+ * `$time-bucket` takes as its own literal. `zone` is an IANA name — only
67
+ * `'UTC'` resolves without a provider (D7: this suite bundles no tzdb) —
68
+ * `offset` is minutes east of UTC, and `disambiguation` says what a
69
+ * local time that happens twice, or never, resolves to.
70
+ */
71
+ export const CLOCK_MEMBERS = spellable(KERNEL_CLOCK_MEMBERS);
72
+
73
+ /** `$resample`: the D5 bucket contract, plus where a row keeps its members. */
74
+ export const RESAMPLE_MEMBERS = spellable(KERNEL_RESAMPLE_MEMBERS);
75
+
76
+ /** `$rolling`: a window measured in time, and how much of one counts. */
77
+ export const ROLLING_MEMBERS = spellable(KERNEL_ROLLING_MEMBERS);
78
+
79
+ /**
80
+ * `$asof`: which way to look, how far, and what makes two rows
81
+ * comparable. The one list NOT read from the kernel: `asOfJoin` takes
82
+ * nested `left`/`right` selector records, and a spec that stays one
83
+ * flat literal is what makes "compiled once" true — so the document
84
+ * spells `by`, `leftAt` and `rightAt`, and `compileAsOfSpec` below is
85
+ * the single place that translation happens.
86
+ */
87
+ export const ASOF_MEMBERS = Object.freeze([
88
+ 'direction', 'tolerance', 'by', 'leftAt', 'rightAt',
89
+ ]);
90
+
91
+ /** What a local time that happens twice, or never, may resolve to. */
92
+ const DISAMBIGUATIONS = Object.freeze(['reject', 'earlier', 'later']);
93
+
94
+ /** The spec members that are singular paths into a row rather than values. */
95
+ const SELECTOR_MEMBERS = Object.freeze(['at', 'value', 'by', 'leftAt', 'rightAt']);
96
+
97
+ /** @param {string} code @param {string} message @param {string} docPath @param {unknown} [cause] */
98
+ function compileError(code, message, docPath, cause) {
99
+ return new JsonQueryCompileError(code, message, docPath,
100
+ cause === undefined ? undefined : { cause });
101
+ }
102
+
103
+ /**
104
+ * The nearest admitted member to a misspelling, by a cheap edit-distance
105
+ * proxy: same first letter and a length within one, or a case-folded
106
+ * match. Enough to catch `minPeriod`, `Every` and `agregate`, and it
107
+ * never guesses when nothing is close.
108
+ * @param {string} name
109
+ * @param {readonly string[]} allowed
110
+ * @returns {string} `''`, or ` (did you mean 'x'?)`
111
+ */
112
+ function nearMiss(name, allowed) {
113
+ const lower = name.toLowerCase();
114
+ for (const candidate of allowed) {
115
+ const other = candidate.toLowerCase();
116
+ if (other === lower
117
+ || (other.startsWith(lower) && other.length - lower.length <= 2)
118
+ || (lower.startsWith(other) && lower.length - other.length <= 2))
119
+ return ` (did you mean '${candidate}'?)`;
120
+ }
121
+ return '';
122
+ }
123
+
124
+ /**
125
+ * One captured spec literal, confirmed to be an object naming only
126
+ * admitted members.
127
+ * @param {any} value - the raw node's frozen value
128
+ * @param {readonly string[]} allowed - the closed member list
129
+ * @param {string} operator - the operator's name, for the message
130
+ * @param {string} docPath - the spec argument's pointer
131
+ * @returns {any} the same object
132
+ * @throws {JsonQueryCompileError} JQ0003
133
+ */
134
+ export function requireSpec(value, allowed, operator, docPath) {
135
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
136
+ throw compileError('JQ0003',
137
+ `'${operator}' takes a literal spec object, got ${describeItem(value)}`, docPath);
138
+ }
139
+ for (const name of Object.keys(value)) {
140
+ if (!allowed.includes(name)) {
141
+ throw compileError('JQ0003',
142
+ `'${operator}' has no spec member '${name}'${nearMiss(name, allowed)}; it admits ${
143
+ allowed.map((m) => `'${m}'`).join(', ')}`, docPath);
144
+ }
145
+ }
146
+ return value;
147
+ }
148
+
149
+ /**
150
+ * A spec member that must be one of a closed set of names.
151
+ * @param {any} value
152
+ * @param {readonly string[]} allowed
153
+ * @param {string} member
154
+ * @param {string} docPath
155
+ * @returns {string}
156
+ */
157
+ export function requireEnum(value, allowed, member, docPath) {
158
+ if (typeof value !== 'string' || !allowed.includes(value)) {
159
+ throw compileError('JQ0003',
160
+ `'${member}' is ${allowed.map((a) => `'${a}'`).join(', ')}, got ${
161
+ typeof value === 'string' ? JSON.stringify(value) : describeItem(value)}`,
162
+ docPath);
163
+ }
164
+ return value;
165
+ }
166
+
167
+ /**
168
+ * A spec member that must be a finite number.
169
+ * @param {any} value
170
+ * @param {string} member
171
+ * @param {string} docPath
172
+ * @returns {number}
173
+ */
174
+ export function requireNumber(value, member, docPath) {
175
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
176
+ throw compileError('JQ0003',
177
+ `'${member}' is a finite number, got ${describeItem(value)}`, docPath);
178
+ }
179
+ return value;
180
+ }
181
+
182
+ /**
183
+ * A spec member naming an instant: epoch milliseconds or an RFC 3339
184
+ * string. The kernel converts it; this only refuses what is not one of
185
+ * the two spellings, so the message names the member rather than a row.
186
+ * @param {any} value
187
+ * @param {string} member
188
+ * @param {string} docPath
189
+ * @returns {number | string}
190
+ */
191
+ export function requireInstant(value, member, docPath) {
192
+ if (typeof value === 'number' && Number.isFinite(value))
193
+ return value;
194
+ if (typeof value === 'string')
195
+ return value;
196
+ throw compileError('JQ0003',
197
+ `'${member}' is epoch milliseconds or an RFC 3339 string, got ${describeItem(value)}`, docPath);
198
+ }
199
+
200
+ /**
201
+ * A spec member naming a span: a finite count of milliseconds or an ISO
202
+ * 8601 duration string. The kernel decides whether the duration is one
203
+ * it can walk; this refuses the shapes that are not spans at all.
204
+ * @param {any} value
205
+ * @param {string} member
206
+ * @param {string} docPath
207
+ * @returns {number | string}
208
+ */
209
+ export function requireSpan(value, member, docPath) {
210
+ if (typeof value === 'number' && Number.isFinite(value))
211
+ return value;
212
+ if (typeof value === 'string')
213
+ return value;
214
+ throw compileError('JQ0003',
215
+ `'${member}' is a duration string or a count of milliseconds, got ${describeItem(value)}`,
216
+ docPath);
217
+ }
218
+
219
+ /**
220
+ * A **row selector**: the query language's singular-path spelling, with
221
+ * `$` reading as the ROW rather than as the document — `'$.on'`,
222
+ * `'$[\'recorded at\']'`, `'$.meta.at'`. Compiled once into a direct
223
+ * property walk (`compileSingularGetter`), so nothing is parsed per row.
224
+ *
225
+ * A bare member name is the common case, and it is handed to the kernel
226
+ * as a name rather than as a closure: `canonicalSeries`'s no-copy fast
227
+ * path is only available to a series already spelled `at`/`value`, and
228
+ * a closure would take it away from every caller who did not need one.
229
+ *
230
+ * @param {any} value - the raw spec member
231
+ * @param {string} member - its name, for the message
232
+ * @param {string} docPath - the spec argument's pointer
233
+ * @returns {string | ((item: any) => any)} a member name, or a reader
234
+ * @throws {JsonQueryCompileError} JQ0003 for a non-singular or invalid path
235
+ */
236
+ export function compileSelector(value, member, docPath) {
237
+ if (typeof value !== 'string') {
238
+ throw compileError('JQ0003',
239
+ `'${member}' is a singular path into the row, got ${describeItem(value)}`, docPath);
240
+ }
241
+ let ast;
242
+ try {
243
+ ast = parseJSONPath(value);
244
+ }
245
+ catch (e) {
246
+ throw compileError('JQ0003',
247
+ `'${member}': ${e instanceof JSONPathSyntaxError ? e.message : 'is not a path'}`,
248
+ docPath, e);
249
+ }
250
+ const segments = ast.segments;
251
+ if (segments.length === 0) {
252
+ throw compileError('JQ0003',
253
+ `'${member}' selects the whole row rather than a member of it`, docPath);
254
+ }
255
+ if (!isSingularSegments(segments)) {
256
+ throw compileError('JQ0003',
257
+ `'${member}' is a singular path — one name or index per segment, no wildcard,`
258
+ + ' descendant or filter', docPath);
259
+ }
260
+ if (segments.length === 1 && segments[0].selectors[0].kind === 'name')
261
+ return segments[0].selectors[0].name;
262
+ const walk = compileSingularGetter(segments, true);
263
+ return (item) => {
264
+ const found = walk(item, item);
265
+ return found === NOTHING ? undefined : found;
266
+ };
267
+ }
268
+
269
+ /**
270
+ * The wall clock a spec's calendar boundaries fall on, resolved once.
271
+ *
272
+ * UTC and a fixed offset need nothing. A named zone needs the tzdb this
273
+ * suite deliberately does not bundle (D7), and a JSON document cannot
274
+ * carry one — so the provider arrives through the compilation's
275
+ * `zoneProvider` option, and a named zone without one is a refusal
276
+ * naming the seam rather than a silent fall back to UTC that is right
277
+ * for eight months of the year.
278
+ *
279
+ * @param {any} spec - the captured spec (or calendar context)
280
+ * @param {any} provider - `options.zoneProvider`, or null
281
+ * @param {string} docPath
282
+ * @returns {{ zone?: string, offset?: number, provider?: any, disambiguation?: string }}
283
+ * the clock options the kernel takes
284
+ * @throws {JsonQueryCompileError} JQ0003
285
+ */
286
+ export function compileClock(spec, provider, docPath) {
287
+ /** @type {any} */
288
+ const clock = {};
289
+ if (hasOwn(spec, 'zone')) {
290
+ if (typeof spec.zone !== 'string') {
291
+ throw compileError('JQ0003',
292
+ `'zone' is an IANA zone name, got ${describeItem(spec.zone)}`, docPath);
293
+ }
294
+ clock.zone = spec.zone;
295
+ if (spec.zone !== 'UTC') {
296
+ if (provider === null) {
297
+ throw compileError('JQ0003',
298
+ `the zone '${spec.zone}' needs a time-zone provider: this suite bundles no tzdb, so`
299
+ + ' a named zone is compiled with options.zoneProvider (toParts / toEpoch).'
300
+ + " 'UTC' and a numeric 'offset' need none", docPath);
301
+ }
302
+ clock.provider = provider;
303
+ }
304
+ }
305
+ if (hasOwn(spec, 'offset'))
306
+ clock.offset = requireNumber(spec.offset, 'offset', docPath);
307
+ if (hasOwn(spec, 'disambiguation'))
308
+ clock.disambiguation = requireEnum(spec.disambiguation, DISAMBIGUATIONS, 'disambiguation', docPath);
309
+ // the kernel owns the remaining refusals (a zone and an offset at
310
+ // once); raising them here keeps them compile-time
311
+ try {
312
+ resolveClock(clock);
313
+ }
314
+ catch (e) {
315
+ throw compileError('JQ0003',
316
+ `the calendar context: ${e instanceof Error ? e.message : 'is invalid'}`, docPath, e);
317
+ }
318
+ return clock;
319
+ }
320
+
321
+ /** The seven aggregates D5 fixes, for both `$resample` and `$rolling`. */
322
+ export const AGGREGATES = Object.freeze([
323
+ 'sum', 'mean', 'min', 'max', 'first', 'last', 'count']);
324
+
325
+ /** The five fill policies D5 fixes. */
326
+ export const FILLS = Object.freeze(['omit', 'null', 'zero', 'locf', 'linear']);
327
+
328
+ /** Which way an as-of join looks for its match. */
329
+ export const DIRECTIONS = Object.freeze(['backward', 'forward', 'nearest']);
330
+
331
+ /**
332
+ * Copy the members a spec authored into the shape the kernel takes,
333
+ * compiling each through its own rule. Members absent from the document
334
+ * stay absent, so the kernel's own defaults are the only defaults.
335
+ * @param {any} spec - the captured literal
336
+ * @param {any} provider - `options.zoneProvider`, or null
337
+ * @param {string} docPath
338
+ * @param {Record<string, (value: any, member: string, docPath: string) => any>} rules
339
+ * @returns {any} the kernel spec
340
+ */
341
+ export function buildKernelSpec(spec, provider, docPath, rules) {
342
+ /** @type {any} */
343
+ const out = compileClock(spec, provider, docPath);
344
+ for (const member of Object.keys(rules)) {
345
+ if (!hasOwn(spec, member))
346
+ continue;
347
+ const value = SELECTOR_MEMBERS.includes(member)
348
+ ? compileSelector(spec[member], member, docPath)
349
+ : rules[member](spec[member], member, docPath);
350
+ out[member] = value;
351
+ }
352
+ return out;
353
+ }
354
+
355
+ /**
356
+ * The rows a series operand carries.
357
+ *
358
+ * Both spellings a document actually has work, and neither is ambiguous
359
+ * because a sample is an object: a path that fans out (`$.rows[*]`)
360
+ * arrives as a sequence of rows, and a path that does not (`$.rows`)
361
+ * arrives as the one array item that holds them. The empty sequence is
362
+ * an empty series — no rows is data, not an error.
363
+ *
364
+ * @param {any} v - the evaluated operand
365
+ * @param {string} docPath
366
+ * @returns {any[]}
367
+ * @throws {JsonQueryRuntimeError} JQ2001 when it is not rows at all
368
+ */
369
+ export function seriesArg(v, docPath) {
370
+ if (v === EMPTY)
371
+ return [];
372
+ if (v instanceof Seq)
373
+ return v.items;
374
+ if (Array.isArray(v))
375
+ return v;
376
+ if (v !== null && typeof v === 'object')
377
+ return [v];
378
+ throw new JsonQueryRuntimeError('JQ2001',
379
+ `expected a series (records with an instant and a reading), got ${describeItem(v)}`, docPath);
380
+ }
381
+
382
+ /**
383
+ * One half-open interval operand: a record with a `start` and an `end`.
384
+ * The kernel decides whether the bounds name instants and whether the
385
+ * span is a span; this only refuses what is not a record.
386
+ * @param {any} v
387
+ * @param {string} docPath
388
+ * @returns {any}
389
+ * @throws {JsonQueryRuntimeError} JQ2001
390
+ */
391
+ export function intervalArg(v, docPath) {
392
+ if (v === null || typeof v !== 'object' || Array.isArray(v) || v instanceof Seq) {
393
+ throw new JsonQueryRuntimeError('JQ2001',
394
+ `expected an interval record { start, end }, got ${describeItem(v)}`, docPath);
395
+ }
396
+ return v;
397
+ }
398
+
399
+ /**
400
+ * The kernel's refusals, in this language's vocabulary. Everything the
401
+ * temporal kernel throws is a `TypeError` about the DATA it was handed —
402
+ * a row that is not a sample, an instant that names none, a local time
403
+ * that never happened — which is exactly what `JQ2001` is for. A host
404
+ * failure that is not a `TypeError` is not laundered.
405
+ * @param {unknown} e
406
+ * @param {string} docPath
407
+ * @returns {JsonQueryRuntimeError}
408
+ */
409
+ export function seriesRefusal(e, docPath) {
410
+ if (!(e instanceof TypeError))
411
+ throw e;
412
+ return new JsonQueryRuntimeError('JQ2001', e.message, docPath, { cause: e });
413
+ }
414
+
415
+ //#endregion