@jarenjs/json 0.34.2 → 0.43.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.
package/ARCHITECTURE.md CHANGED
@@ -163,7 +163,7 @@ Grouping, `$distinct` and the `$orderby` machinery need a total, deterministic e
163
163
 
164
164
  ### The operator registry
165
165
 
166
- All 93 §8 operators live in one table in `operators.js` — the query-language analogue of `path.js`'s `FUNCTIONS` table (the count is cross-checked against QUERY-FORMAT §8 by a test, so it cannot go stale again):
166
+ All 98 §8 operators live in one table in `operators.js` — the query-language analogue of `path.js`'s `FUNCTIONS` table. A test derives the count from the registry and asserts it against QUERY-FORMAT §8 *and* against every committed document that states a number — this file, the README twice, `site.md` and `@jarenjs/linq`'s architecture — so an operator cannot be added without moving all of them in the same change:
167
167
 
168
168
  ```javascript
169
169
  '$substring': {
package/README.md CHANGED
@@ -386,9 +386,9 @@ queryJson({
386
386
  }, [1, 2, 3, 4, 5]); // [2, 3, 4] — a 3-point moving average
387
387
  ```
388
388
 
389
- The operator library (93 operators: comparisons, IEEE-double arithmetic, logic, strings with I-Regexp `$match`/`$search`/`$replace`, aggregates, sequence tools like `$distinct`/`$subsequence`/`$range`, type predicates and casts, `$coalesce`, the RFC 3339 date family, and the spatial family) is cataloged in [QUERY-FORMAT.md §8](./docs/QUERY-FORMAT.md#8-operators).
389
+ The operator library (98 operators: comparisons, IEEE-double arithmetic, logic, strings with I-Regexp `$match`/`$search`/`$replace`, aggregates, sequence tools like `$distinct`/`$subsequence`/`$range`, type predicates and casts, `$coalesce`, the RFC 3339 date family, and the spatial family) is cataloged in [QUERY-FORMAT.md §8](./docs/QUERY-FORMAT.md#8-operators).
390
390
 
391
- **Extending the vocabulary (host opt-in).** The 93 are closed, but a host can add more the way `@jarenjs/validate` gains formats from `@jarenjs/formats`: `createJsltRegistry().use(mathPack).use(financePack)` composes packs of pure `@jarenjs/core` functions into a compiler, so `{ "$sqrt": "$.variance" }` and `{ "$npv": ["$.rate", "$.cashflows[*]"] }` work in a stylesheet, a bare query, and `@jarenjs/linq` — while a document compiled *without* the registry still rejects them. Aggregators fold a `seq` operand to an array before the pure call. See [JSLT-FORMAT.md §13](./docs/JSLT-FORMAT.md#13-registered-operators-host-opt-in-non-normative).
391
+ **Extending the vocabulary (host opt-in).** The 98 are closed, but a host can add more the way `@jarenjs/validate` gains formats from `@jarenjs/formats`: `createJsltRegistry().use(mathPack).use(financePack)` composes packs of pure `@jarenjs/core` functions into a compiler, so `{ "$sqrt": "$.variance" }` and `{ "$npv": ["$.rate", "$.cashflows[*]"] }` work in a stylesheet, a bare query, and `@jarenjs/linq` — while a document compiled *without* the registry still rejects them. Aggregators fold a `seq` operand to an array before the pure call. See [JSLT-FORMAT.md §13](./docs/JSLT-FORMAT.md#13-registered-operators-host-opt-in-non-normative).
392
392
 
393
393
  **Dates are RFC 3339 strings** ([§8.13](./docs/QUERY-FORMAT.md#813-dates-and-times)): `$is-date`/`$is-time`/`$is-datetime`/`$is-duration` test the lexical forms, `$year`…`$seconds` and `$offset` read components *lexically, in the value's own offset* (so "group by month" means what you expect), `$week`/`$week-year`/`$quarter`/`$weekday` add the derived calendar fields, and `$epoch`/`$datetime` convert to and from epoch milliseconds — the one place a value is shifted to UTC, and therefore the way to compare instants across offsets. There is deliberately no `current-dateTime`: a compiled query is cached by document identity and saved as a rule, so it must answer the same for the same input forever.
394
394
 
@@ -417,7 +417,30 @@ queryJson({
417
417
  }, data); // a spatial filter and a spatial sort, in the language's own clauses
418
418
  ```
419
419
 
420
- A geohash needs no operator of its own to be useful: it is a string, so proximity is `$starts-with` on a prefix and spatial bucketing is `$groupby` over `$substring`. `$geohash` only produces it.
420
+ **Geography goes in and out as the text the world already speaks.** `$geo-parse` reads Well-Known Text — what PostGIS, SpatiaLite, GEOS, JTS and every `ST_AsText` emit — and `$geo-text` writes it back, so a WKT column becomes measurable in one expression and a result leaves as something a spatial database reads. `$geohash-bounds` turns a cell string back into the `Polygon` it covers, and `$geo-simplify` reduces a value **for storage and transport**: the same structure and properties with fewer positions, rings still closed, so it can be kept or sent as-is.
421
+
422
+ Getting a CSV of coordinates in needs **no code and no operator** — it is a stylesheet. `parseCsv(text, { headers: true, typed: true })` then one rule:
423
+
424
+ ```js
425
+ compileJsltStylesheet([{
426
+ match: '$',
427
+ body: {
428
+ type: 'FeatureCollection',
429
+ features: [{
430
+ $for: { r: '$[*]' },
431
+ $return: {
432
+ type: 'Feature',
433
+ geometry: { type: 'Point', coordinates: ['$r.lon', '$r.lat'] },
434
+ properties: { name: '$r.name', pop: '$r.pop' },
435
+ },
436
+ }],
437
+ },
438
+ }]);
439
+ ```
440
+
441
+ The one trap: `coordinates` and `features` take the **array constructor** (the brackets, §3.4), never `$seq` — a two-item *sequence* in member position is `JQ2001`, because a sequence is not an array.
442
+
443
+ A geohash is a string, so **bucketing and tiling** need no operator: `$groupby` over `$substring` groups by cell and an index over the hash is a spatial index. **Proximity is different**, and a prefix test is not it — two points ten metres apart can differ in the *first* character of their cell, so a single prefix misses a neighbour at every cell boundary. `$geohash-neighbours` gives the nine-cell probe a correct proximity query needs; narrow it with `$distance` when an exact radius matters. The whole recipe, end to end, is in [HOWTO](../../docs/HOWTO.md#getting-geographic-data-in-and-out).
421
444
 
422
445
  ### External parameters
423
446
 
@@ -35,3 +35,19 @@ export declare class JsonCanonicalizeError extends TypeError {
35
35
  * canonicalizeJson(1e21); // '1e+21'
36
36
  */
37
37
  export declare function canonicalizeJson(value: any): string;
38
+ /**
39
+ * The lowercase hex SHA-256 over the RFC 8785 canonical UTF-8 bytes of a
40
+ * JSON value — the content identity two independent processes agree on
41
+ * (a document revision, an idempotency request hash), which is why it is
42
+ * SHA-256 over the canonical text and never a 32-bit fingerprint that
43
+ * collides. Asynchronous because it rides the platform's
44
+ * `globalThis.crypto.subtle` (Node ≥ 20, Bun, browsers, workers); the
45
+ * canonicalization itself is synchronous and its refusals
46
+ * (`JsonCanonicalizeError`) surface as the rejection.
47
+ *
48
+ * @param {any} value - The JSON value to identify
49
+ * @returns {Promise<string>} 64 lowercase hex characters
50
+ * @example
51
+ * await canonicalSha256({ b: 1, a: 2 }) === await canonicalSha256({ a: 2, b: 1 }); // true
52
+ */
53
+ export declare function canonicalSha256(value: any): Promise<string>;
@@ -495,22 +495,22 @@ export declare const OPERATORS: Readonly<{
495
495
  };
496
496
  $bbox: {
497
497
  params: string;
498
- result: typeof resultEmptyPropagates;
498
+ result: typeof RESULT_OPT;
499
499
  compile: (gets: any, args: any) => (f: any) => any;
500
500
  };
501
501
  $area: {
502
502
  params: string;
503
- result: typeof resultEmptyPropagates;
503
+ result: typeof RESULT_OPT;
504
504
  compile: (gets: any, args: any) => (f: any) => any;
505
505
  };
506
506
  $length: {
507
507
  params: string;
508
- result: typeof resultEmptyPropagates;
508
+ result: typeof RESULT_OPT;
509
509
  compile: (gets: any, args: any) => (f: any) => any;
510
510
  };
511
511
  $centroid: {
512
512
  params: string;
513
- result: typeof resultEmptyPropagates;
513
+ result: typeof RESULT_OPT;
514
514
  compile: (gets: any, args: any) => (f: any) => any;
515
515
  };
516
516
  $distance: {
@@ -542,9 +542,37 @@ export declare const OPERATORS: Readonly<{
542
542
  kinds: readonly string[];
543
543
  min: 1;
544
544
  }>;
545
- result: typeof resultEmptyPropagates;
545
+ result: typeof RESULT_OPT;
546
546
  compile: (gets: any, args: any) => (f: any) => string | typeof EMPTY;
547
547
  };
548
+ '$geo-parse': {
549
+ params: string;
550
+ result: typeof RESULT_OPT;
551
+ compile: (gets: any, args: any) => (f: any) => any;
552
+ };
553
+ '$geo-text': {
554
+ params: string;
555
+ result: typeof RESULT_OPT;
556
+ compile: (gets: any, args: any) => (f: any) => any;
557
+ };
558
+ '$geohash-bounds': {
559
+ params: string;
560
+ result: typeof RESULT_OPT;
561
+ compile: (gets: any, args: any) => (f: any) => any;
562
+ };
563
+ '$geohash-neighbours': {
564
+ params: string;
565
+ result: typeof RESULT_MANY;
566
+ compile: (gets: any, args: any) => (f: any) => any;
567
+ };
568
+ '$geo-simplify': {
569
+ params: Readonly<{
570
+ kinds: readonly string[];
571
+ min: 2;
572
+ }>;
573
+ result: typeof resultEmptyPropagates;
574
+ compile: (gets: any, args: any) => (f: any) => any;
575
+ };
548
576
  '$date-add': {
549
577
  params: Readonly<{
550
578
  kinds: readonly string[];
@@ -211,7 +211,7 @@ evaluation, use `$const` (§3.5.1); to bind one without iteration, use `$let`
211
211
 
212
212
  #### 3.5.1 `$const` — quote
213
213
 
214
- ```json
214
+ ```jsonc
215
215
  { "$const": v }
216
216
  ```
217
217
 
@@ -227,7 +227,7 @@ constructs `{ "template": { "$for": "kept verbatim", "price": null }, "label": "
227
227
 
228
228
  #### 3.5.2 `$map` — general map constructor
229
229
 
230
- ```json
230
+ ```jsonc
231
231
  { "$map": [[keyExpr, valueExpr], ...] }
232
232
  ```
233
233
 
@@ -270,7 +270,7 @@ A query document is either:
270
270
  bare scalar; or
271
271
  2. the **version envelope** phrase:
272
272
 
273
- ```json
273
+ ```jsonc
274
274
  { "$query": "0.1", "$expr": <expression> }
275
275
  ```
276
276
 
@@ -409,7 +409,7 @@ clause (§6.9), in which case it evaluates to the final accumulator and
409
409
 
410
410
  ### 6.2 `$for` — iteration bindings
411
411
 
412
- ```json
412
+ ```jsonc
413
413
  "$for": { name: source, ... }
414
414
  ```
415
415
 
@@ -438,7 +438,7 @@ may reference variables bound earlier in the same `$for` object.
438
438
  **Extended binding form** — a source written as an object with an `$in`
439
439
  member iterates `$in` like a plain source, with options:
440
440
 
441
- ```json
441
+ ```jsonc
442
442
  { "$in": expr, "$at": "posName" }
443
443
  ```
444
444
 
@@ -456,7 +456,7 @@ the `$window` family — are §6.10.
456
456
 
457
457
  ### 6.3 `$let` — sequence bindings
458
458
 
459
- ```json
459
+ ```jsonc
460
460
  "$let": { name: expr, ... }
461
461
  ```
462
462
 
@@ -474,7 +474,7 @@ ordinary shadowing and is allowed.
474
474
 
475
475
  ### 6.4 `$where` — tuple filter
476
476
 
477
- ```json
477
+ ```jsonc
478
478
  "$where": expr
479
479
  ```
480
480
 
@@ -484,7 +484,7 @@ true. Cross-variable predicates (joins) belong here, not in path filters
484
484
 
485
485
  ### 6.5 `$groupby` — grouping
486
486
 
487
- ```json
487
+ ```jsonc
488
488
  "$groupby": { name: keyExpr, ... }
489
489
  ```
490
490
 
@@ -522,7 +522,7 @@ that genre's books.
522
522
 
523
523
  ### 6.6 `$orderby` — ordering
524
524
 
525
- ```json
525
+ ```jsonc
526
526
  "$orderby": keySpec
527
527
  "$orderby": [keySpec, ...]
528
528
  ```
@@ -530,7 +530,7 @@ that genre's books.
530
530
  A *keySpec* is either an expression (shorthand for ascending, empty-least) or
531
531
  the explicit form
532
532
 
533
- ```json
533
+ ```jsonc
534
534
  { "$key": expr, "$dir": "asc" | "desc", "$empty": "least" | "greatest",
535
535
  "$collation": "name" }
536
536
  ```
@@ -573,7 +573,7 @@ saved rule declares the collations it needs.
573
573
 
574
574
  ### 6.7 `$count` — tuple numbering
575
575
 
576
- ```json
576
+ ```jsonc
577
577
  "$count": "name"
578
578
  ```
579
579
 
@@ -589,7 +589,7 @@ one key.
589
589
 
590
590
  ### 6.8 `$as` — schema assertions on bindings
591
591
 
592
- ```json
592
+ ```jsonc
593
593
  "$as": { name: schema, ... }
594
594
  ```
595
595
 
@@ -625,7 +625,7 @@ instead.
625
625
 
626
626
  ### 6.9 `$fold` — the accumulator clause
627
627
 
628
- ```json
628
+ ```jsonc
629
629
  "$fold": { name: initExpr }
630
630
  ```
631
631
 
@@ -682,7 +682,7 @@ Two further options of the extended `$for` binding form (§6.2).
682
682
  would yield no tuple at all, the clause emits exactly **one** tuple with the
683
683
  variable bound to the **empty sequence**, so the enclosing tuple survives.
684
684
 
685
- ```json
685
+ ```jsonc
686
686
  { "$in": expr, "$allowing-empty": true }
687
687
  ```
688
688
 
@@ -711,7 +711,7 @@ because an empty member value omits the member (§3.4).
711
711
  **Windows** iterate consecutive *runs* of the item stream instead of single
712
712
  items:
713
713
 
714
- ```json
714
+ ```jsonc
715
715
  { "$in": expr, "$window": "tumbling" | "sliding", "$size": n, "$step": m, "$at": "w" }
716
716
  ```
717
717
 
@@ -745,7 +745,7 @@ two if the stream does not divide evenly.
745
745
 
746
746
  ## 7. Quantifier phrases
747
747
 
748
- ```json
748
+ ```jsonc
749
749
  { "$some": { name: expr, ... }, "$satisfies": expr }
750
750
  { "$every": { name: expr, ... }, "$satisfies": expr }
751
751
  ```
@@ -1339,10 +1339,83 @@ intersection is overlay work and is deliberately absent.
1339
1339
  | `$within` | `[a, b]` → is `a`'s representative position inside `b`'s surface? Only a polygon has an inside, so a line or point as `b` is `false` |
1340
1340
  | `$bbox-intersects` | `[a, b]` → do the two bounding boxes overlap? Touching edges count |
1341
1341
  | `$geohash` | `[value]` or `[value, precision]` → the base-32 cell string; precision is 1-12, default 9 |
1342
+ | `$geo-parse` | a Well-Known Text string → the geometry it denotes; text that is not well-formed WKT → empty. A non-string operand is `JQ2001` |
1343
+ | `$geo-text` | any value → its Well-Known Text string; a value with no WKT spelling → empty |
1344
+ | `$geohash-bounds` | a cell string → the `Polygon` covering that cell; a string outside the base-32 alphabet → empty |
1345
+ | `$geohash-neighbours` | a cell string → the cell and its neighbours as a sequence of up to nine strings, in reading order (north-west first, the cell itself in the middle); cells past a pole do not exist and are absent |
1346
+ | `$geo-simplify` | `[value, tolerance]` → the same value with vertices dropped. The tolerance is in **degrees**, not metres |
1347
+
1348
+ **Conversion is how geography gets in and out.** Well-Known Text is what
1349
+ PostGIS, SpatiaLite, GEOS, JTS and every `ST_AsText` emit, so `$geo-parse` and
1350
+ `$geo-text` are the doors: a WKT column becomes a value the rest of §8.14
1351
+ measures, and any value goes back out as the text the database reads.
1352
+ `$geo-parse` answers a **geometry**, never a `Feature` — WKT carries no
1353
+ properties, and inventing an empty `properties` member would be a value the
1354
+ source never had.
1355
+
1356
+ `$geo-simplify` is reduction **for storage and transport**: a 50 MB
1357
+ `FeatureCollection` a caller wants to keep, send or store smaller comes back as
1358
+ valid GeoJSON with the same structure and fewer positions. A renderer's
1359
+ simplification is a different job and is not this one — a chart simplifies into
1360
+ its own drawing space and hands back a picture, not a document. A ring stays
1361
+ closed and a line keeps both endpoints, so a value that was valid before is
1362
+ valid after. The tolerance is a planar vertex-dropping threshold in the
1363
+ coordinate's own units, which for GeoJSON is degrees; calling it metres would
1364
+ be exactly the planar-for-geodesic confusion the rule above exists to prevent.
1365
+ A negative or non-numeric tolerance is `JQ2001`.
1366
+
1367
+ **A non-finite coordinate has no measurement.** `NaN` and `Infinity` are not
1368
+ JSON numbers, but a value that reached the engine through a computation can
1369
+ carry one, and every underlying formula launders it into something plausible —
1370
+ a great-circle distance from `NaN` comes back as the antipodal distance, and a
1371
+ `NaN` area compares false against zero and reports `0`. So a measurement over a
1372
+ value carrying a non-finite coordinate answers **empty** (`$bbox`, `$area`,
1373
+ `$length`, `$centroid`, `$distance`, `$geohash`), and a predicate answers
1374
+ **false** (`$within`, `$bbox-intersects`) — each the same answer it already
1375
+ gives for a missing operand. A value with no positions at all is a different
1376
+ thing and still measures: `$area` of an empty `FeatureCollection` is `0`.
1377
+
1378
+ Because of that rule, **every measurement and every conversion in this section
1379
+ is optional-valued**: `$bbox`, `$area`, `$length`, `$centroid`, `$distance`,
1380
+ `$geohash`, `$geo-parse`, `$geo-text` and `$geohash-bounds` can answer the
1381
+ empty sequence for an operand that is present — a non-finite coordinate, text
1382
+ that is not well-formed WKT, a cell outside the base-32 alphabet. Only the two
1383
+ predicates answer exactly one item, because `false` is their missing-operand
1384
+ value. This matters wherever such an expression sits in a position that expects
1385
+ one item: `[{"$bbox": expr}]` builds an empty array rather than a one-item one,
1386
+ and `{"m": {"$bbox": expr}}` omits the member entirely.
1387
+
1388
+ **Boxes do not cross the antimeridian.** RFC 7946 §3.1.9 tells producers to cut
1389
+ geometries at ±180° rather than let them span it, and this format follows that
1390
+ rather than re-joining what a producer split: `$bbox` of an uncut geometry whose
1391
+ positions sit either side of the line returns a box spanning the globe the
1392
+ *wrong* way — `[-179, 0, 179, 0]` for two points four degrees apart. Cut the
1393
+ geometry at the antimeridian, as the RFC asks, and every box, containment test
1394
+ and index probe is right. There is no flag for this: a box that silently meant
1395
+ "the short way round" for some values and "the long way" for others is worse
1396
+ than one rule.
1397
+
1398
+ Note what needs **no** operator — and one that does. A geohash is a string, so
1399
+ **bucketing and tiling** need nothing new: `$groupby` over `$substring` groups
1400
+ by cell, ordering by the hash orders by locality, and an index over it is a
1401
+ spatial index. **Proximity is different, and `$starts-with` on a prefix is not
1402
+ it.** Two points ten metres apart can differ in the *first* character of their
1403
+ cell, so a single-prefix test misses a neighbour at every cell boundary — which
1404
+ is to say, everywhere a customer actually looks. A correct proximity probe
1405
+ tests the neighbourhood: `$geohash-neighbours` of the query point's cell, then
1406
+ a membership test against those cells, then the exact `$distance` on what
1407
+ survives.
1408
+
1409
+ ```json
1410
+ { "$let": { "cells": { "$geohash-neighbours": { "$geohash": ["$.here", 6] } } },
1411
+ "$return": { "$for": { "c": "$.places[*]" },
1412
+ "$where": { "$exists": { "$index-of": ["$cells", { "$geohash": ["$c.at", 6] }] } },
1413
+ "$return": "$c.name" } }
1414
+ ```
1342
1415
 
1343
- Note what needs **no** operator. A geohash is a string, so proximity is
1344
- `$starts-with` on a prefix and spatial bucketing is `$groupby` over
1345
- `$substring` — the existing vocabulary already indexes, groups and orders them.
1416
+ the nine-cell probe, in the language's own clauses. Narrow it with
1417
+ `$distance` when an exact radius matters; the cells are the cheap filter, not
1418
+ the answer.
1346
1419
 
1347
1420
  ```json
1348
1421
  { "$for": { "c": "$.cities[*]" },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/json",
3
3
  "private": false,
4
- "version": "0.34.2",
4
+ "version": "0.43.1",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -90,6 +90,6 @@
90
90
  "prepack": "npm run build:types"
91
91
  },
92
92
  "dependencies": {
93
- "@jarenjs/core": "^0.34.2"
93
+ "@jarenjs/core": "^0.43.1"
94
94
  }
95
95
  }
package/src/canonical.js CHANGED
@@ -167,4 +167,33 @@ export function canonicalizeJson(value) {
167
167
  return serializeValue(value, '', new Set());
168
168
  }
169
169
 
170
+ const HEX = '0123456789abcdef';
171
+
172
+ /**
173
+ * The lowercase hex SHA-256 over the RFC 8785 canonical UTF-8 bytes of a
174
+ * JSON value — the content identity two independent processes agree on
175
+ * (a document revision, an idempotency request hash), which is why it is
176
+ * SHA-256 over the canonical text and never a 32-bit fingerprint that
177
+ * collides. Asynchronous because it rides the platform's
178
+ * `globalThis.crypto.subtle` (Node ≥ 20, Bun, browsers, workers); the
179
+ * canonicalization itself is synchronous and its refusals
180
+ * (`JsonCanonicalizeError`) surface as the rejection.
181
+ *
182
+ * @param {any} value - The JSON value to identify
183
+ * @returns {Promise<string>} 64 lowercase hex characters
184
+ * @example
185
+ * await canonicalSha256({ b: 1, a: 2 }) === await canonicalSha256({ a: 2, b: 1 }); // true
186
+ */
187
+ export async function canonicalSha256(value) {
188
+ const text = canonicalizeJson(value);
189
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
190
+ const bytes = new Uint8Array(digest);
191
+ let out = '';
192
+ for (let i = 0; i < bytes.length; i++) {
193
+ const b = bytes[i];
194
+ out += HEX[b >> 4] + HEX[b & 15];
195
+ }
196
+ return out;
197
+ }
198
+
170
199
  //#endregion
@@ -402,6 +402,21 @@ const OPERATOR_ALIASES = {
402
402
  // conditionals ($coalesce is a real operator; the guesses are here)
403
403
  $case: "$if", $cond: "$if", $switch: "$if", $ternary: "$if",
404
404
  $ifnull: "$default", $ifempty: "$default", $nvl: "$default",
405
+ // spatial: the PostGIS/Turf spellings, and the American plural
406
+ $wkt: "$geo-parse to read one, $geo-text to write one",
407
+ "$parse-wkt": "$geo-parse", "$from-wkt": "$geo-parse", $st_geomfromtext: "$geo-parse",
408
+ "$to-wkt": "$geo-text", $st_astext: "$geo-text", "$geo-stringify": "$geo-text",
409
+ "$geohash-decode": "$geohash-bounds", "$geohash-bbox": "$geohash-bounds",
410
+ "$geohash-neighbors": "$geohash-neighbours", "$geohash-adjacent": "$geohash-neighbours",
411
+ $simplify: "$geo-simplify", "$douglas-peucker": "$geo-simplify",
412
+ $intersects: "$bbox-intersects (boxes only — real overlay is deliberately absent)",
413
+ $buffer: null, $union: null, $difference: null,
414
+ // the one absence that needs its reason, not a pointer: a projected
415
+ // position is the same [x, y] array as a geographic one, so an
416
+ // operator making one could not stop it reaching $distance
417
+ $project: "the renderer — the language cannot make a projected coordinate at all, so a measurement can never land on one; measurement here is geodesic",
418
+ "$geo-project": "the renderer, as for '$project'",
419
+ $srid: null, $transform: null,
405
420
  };
406
421
 
407
422
  // JQ0002 for an unknown $-key, with a "did you mean" suggestion. A
@@ -60,12 +60,18 @@ import {
60
60
  isPosition,
61
61
  bboxOf,
62
62
  bboxIntersects,
63
+ bboxPolygon,
63
64
  geometryArea,
64
65
  geometryLength,
65
66
  centroidOf,
66
67
  containsPosition,
67
68
  geoDistance,
68
69
  geohashEncode,
70
+ geohashBounds,
71
+ geohashNeighbours,
72
+ wktToGeoJson,
73
+ geoJsonToWkt,
74
+ simplifyGeometry,
69
75
  } from '@jarenjs/core/geo';
70
76
  import { JsonQueryCompileError, JsonQueryRuntimeError } from './errors.js';
71
77
  import {
@@ -748,7 +754,17 @@ function dateTruncEntry(truncate) {
748
754
  // What is deliberately absent is real geometry-to-geometry intersection.
749
755
  // `$bbox-intersects` says exactly what it tests, because an operator
750
756
  // named `$intersects` that only compared bounding boxes would be a lie
751
- // the first time two L-shapes shared a box and nothing else.
757
+ // the first time two L-shapes shared a box and nothing else. So is any
758
+ // operator producing a PROJECTED coordinate: a projected position is
759
+ // the same `[x, y]` array as a geographic one, so a language that could
760
+ // make one could not stop it reaching `$distance`. Leaving it out makes
761
+ // "never measure on a projected coordinate" structural instead of
762
+ // advisory; `projectMercator` stays a kernel export for renderers.
763
+ //
764
+ // The conversion members carry geography in and out of the forms the
765
+ // rest of the world uses — Well-Known Text, geohash cells — and reduce
766
+ // a value for storage. Each is a call into the kernel and nothing else:
767
+ // the grammar, the cell arithmetic and Douglas-Peucker each exist once.
752
768
 
753
769
  // A spatial operand, rejected uniformly: the empty sequence propagates
754
770
  // at the call site, so this only ever sees a real item.
@@ -761,11 +777,53 @@ function geoArg(v, docPath) {
761
777
  return v;
762
778
  }
763
779
 
764
- // a unary spatial measurement: empty propagates, anything else is JQ2001
765
- function geoUnaryEntry(measure, resultCard = resultEmptyPropagates) {
780
+ // A cell string operand the geohash family and `$geo-parse` take text
781
+ // where the rest of the family takes a value.
782
+ function geoTextArg(v, what, docPath) {
783
+ if (typeof v !== 'string') {
784
+ throw runtimeError('JQ2001',
785
+ `expected ${what}, got ${describeItem(v)}`, docPath);
786
+ }
787
+ return v;
788
+ }
789
+
790
+ // The family's non-finite rule, in one place. A coordinate that is not a
791
+ // finite number has no measurement, and every kernel function that
792
+ // touches one launders it into something plausible: `haversineDistance`
793
+ // answers the antipodal distance, a NaN area compares false against zero
794
+ // and reports 0. A representative-position operator checks the one
795
+ // position it uses; an aggregate measurement has to know that EVERY
796
+ // position is finite, and `centroidOf` is the kernel walk that already
797
+ // answers it — NaN propagates through the mean, and a null mean is "no
798
+ // positions at all", which still measures (as zero) rather than being
799
+ // refused. No second finiteness walk exists.
800
+ function finitePosition(at) {
801
+ return at !== null && Number.isFinite(at[0]) && Number.isFinite(at[1]) ? at : null;
802
+ }
803
+
804
+ function geoFinite(value) {
805
+ const at = centroidOf(value);
806
+ return at === null || finitePosition(at) !== null;
807
+ }
808
+
809
+ // the representative position §8.14 measures a value by: a bare position
810
+ // is itself, anything else is its centroid
811
+ function representative(value) {
812
+ return finitePosition(isPosition(value) ? value : centroidOf(value));
813
+ }
814
+
815
+ // A unary spatial measurement: empty propagates, anything else is
816
+ // JQ2001 — and the answer is OPTIONAL rather than exactly-one, because
817
+ // `null` from the kernel IS the empty sequence and every one of these
818
+ // can answer it for an operand that is present (a non-finite
819
+ // coordinate, text that is not well-formed, a cell outside the
820
+ // alphabet). Declaring exactly-one would let the internal empty marker
821
+ // escape into an array or object constructor, which is neither an item
822
+ // nor a JSON value.
823
+ function geoUnaryEntry(measure, check = geoArg) {
766
824
  return {
767
825
  params: UNARY,
768
- result: resultCard,
826
+ result: RESULT_OPT,
769
827
  compile: (gets, args) => {
770
828
  const get = gets[0];
771
829
  const docPath = args[0].docPath;
@@ -773,7 +831,7 @@ function geoUnaryEntry(measure, resultCard = resultEmptyPropagates) {
773
831
  const v = get(f);
774
832
  if (v === EMPTY)
775
833
  return EMPTY;
776
- const out = measure(geoArg(v, docPath));
834
+ const out = measure(check(v, docPath));
777
835
  return out === null ? EMPTY : out;
778
836
  };
779
837
  },
@@ -1516,9 +1574,9 @@ export const OPERATORS = Object.freeze({
1516
1574
  //#region section 8.14 - spatial
1517
1575
 
1518
1576
  '$bbox': geoUnaryEntry(bboxOf),
1519
- '$area': geoUnaryEntry(geometryArea, RESULT_ONE),
1520
- '$length': geoUnaryEntry(geometryLength, RESULT_ONE),
1521
- '$centroid': geoUnaryEntry(centroidOf),
1577
+ '$area': geoUnaryEntry((v) => (geoFinite(v) ? geometryArea(v) : null)),
1578
+ '$length': geoUnaryEntry((v) => (geoFinite(v) ? geometryLength(v) : null)),
1579
+ '$centroid': geoUnaryEntry((v) => finitePosition(centroidOf(v))),
1522
1580
 
1523
1581
  '$distance': { // metres between two values' representative positions
1524
1582
  params: ARGS_2,
@@ -1533,7 +1591,11 @@ export const OPERATORS = Object.freeze({
1533
1591
  const b = bGet(f);
1534
1592
  if (a === EMPTY || b === EMPTY)
1535
1593
  return EMPTY;
1536
- const out = geoDistance(geoArg(a, aPath), geoArg(b, bPath));
1594
+ const pa = representative(geoArg(a, aPath));
1595
+ const pb = representative(geoArg(b, bPath));
1596
+ if (pa === null || pb === null)
1597
+ return EMPTY;
1598
+ const out = geoDistance(pa, pb);
1537
1599
  return out === null ? EMPTY : out;
1538
1600
  };
1539
1601
  },
@@ -1552,13 +1614,16 @@ export const OPERATORS = Object.freeze({
1552
1614
  const area = areaGet(f);
1553
1615
  if (point === EMPTY || area === EMPTY)
1554
1616
  return false; // nothing is inside nothing
1555
- const p = geoArg(point, pointPath);
1556
1617
  // a bare position is itself; anything else is represented by its
1557
1618
  // centroid, the same rule $distance uses
1558
- const at = isPosition(p) ? p : centroidOf(p);
1559
- if (at === null)
1619
+ const at = representative(geoArg(point, pointPath));
1620
+ const surface = geoArg(area, areaPath);
1621
+ // a predicate answers its missing-operand value, not empty: the
1622
+ // surface has to be bounded too, or a NaN vertex makes an
1623
+ // even-odd crossing count report containment that is not there
1624
+ if (at === null || !geoFinite(surface))
1560
1625
  return false;
1561
- return containsPosition(geoArg(area, areaPath), at[0], at[1]);
1626
+ return containsPosition(surface, at[0], at[1]);
1562
1627
  };
1563
1628
  },
1564
1629
  },
@@ -1585,7 +1650,9 @@ export const OPERATORS = Object.freeze({
1585
1650
 
1586
1651
  '$geohash': { // a position as a base-32 cell string
1587
1652
  params: ARGS_1_2,
1588
- result: resultEmptyPropagates,
1653
+ // OPTIONAL for the same reason the unary measurements are: a value
1654
+ // with no bounded position answers the empty sequence
1655
+ result: RESULT_OPT,
1589
1656
  compile: (gets, args) => {
1590
1657
  const get = gets[0];
1591
1658
  const docPath = args[0].docPath;
@@ -1595,8 +1662,7 @@ export const OPERATORS = Object.freeze({
1595
1662
  const v = get(f);
1596
1663
  if (v === EMPTY)
1597
1664
  return EMPTY;
1598
- const value = geoArg(v, docPath);
1599
- const at = isPosition(value) ? value : centroidOf(value);
1665
+ const at = representative(geoArg(v, docPath));
1600
1666
  if (at === null)
1601
1667
  return EMPTY;
1602
1668
  let precision = 9;
@@ -1613,6 +1679,73 @@ export const OPERATORS = Object.freeze({
1613
1679
  },
1614
1680
  },
1615
1681
 
1682
+ // -- conversion: geography in and out of the forms the world uses --
1683
+
1684
+ // Well-Known Text is what PostGIS, SpatiaLite, GEOS, JTS and every
1685
+ // `ST_AsText` emit, so without these a document can only carry such a
1686
+ // string through untouched. Text that is not well-formed WKT is empty
1687
+ // rather than an error, like every other "nothing to answer" here.
1688
+ '$geo-parse': geoUnaryEntry(wktToGeoJson,
1689
+ (v, docPath) => geoTextArg(v, 'a Well-Known Text string', docPath)),
1690
+
1691
+ // A value with no WKT spelling — a non-finite coordinate — is empty,
1692
+ // never written approximately.
1693
+ '$geo-text': geoUnaryEntry(geoJsonToWkt),
1694
+
1695
+ '$geohash-bounds': geoUnaryEntry(
1696
+ (hash) => bboxPolygon(geohashBounds(hash)),
1697
+ (v, docPath) => geoTextArg(v, 'a geohash cell string', docPath)),
1698
+
1699
+ // The neighbourhood, not the cell: two points metres apart can sit in
1700
+ // different cells, so a proximity probe tests the nine cells and a
1701
+ // single prefix is bucketing. The cells come in reading order — north
1702
+ // -west first, the cell itself in the middle; cells past a pole do
1703
+ // not exist and are absent, so the sequence can be shorter.
1704
+ '$geohash-neighbours': {
1705
+ params: UNARY,
1706
+ result: RESULT_MANY,
1707
+ compile: (gets, args) => {
1708
+ const get = gets[0];
1709
+ const docPath = args[0].docPath;
1710
+ return (f) => {
1711
+ const v = get(f);
1712
+ if (v === EMPTY)
1713
+ return EMPTY;
1714
+ return seqOf(geohashNeighbours(geoTextArg(v, 'a geohash cell string', docPath)));
1715
+ };
1716
+ },
1717
+ },
1718
+
1719
+ // Reduction for STORAGE and TRANSPORT: the same value with vertices
1720
+ // dropped, still valid GeoJSON a caller can keep or send. The chart
1721
+ // layer simplifies too, but only into its own drawing space, so
1722
+ // nothing there hands a document back.
1723
+ '$geo-simplify': {
1724
+ params: ARGS_2,
1725
+ result: resultEmptyPropagates,
1726
+ compile: (gets, args) => {
1727
+ const valueGet = gets[0];
1728
+ const valuePath = args[0].docPath;
1729
+ const toleranceGet = gets[1];
1730
+ const tolerancePath = args[1].docPath;
1731
+ return (f) => {
1732
+ const v = valueGet(f);
1733
+ const tolerance = toleranceGet(f);
1734
+ if (v === EMPTY || tolerance === EMPTY)
1735
+ return EMPTY;
1736
+ // degrees, not metres: a planar vertex-dropping threshold. A
1737
+ // degree of longitude is not a fixed distance, so naming it a
1738
+ // distance is the confusion this family exists to prevent.
1739
+ if (typeof tolerance !== 'number' || !Number.isFinite(tolerance) || tolerance < 0) {
1740
+ throw runtimeError('JQ2001',
1741
+ `a simplification tolerance is a non-negative number of degrees, got ${describeItem(tolerance)}`,
1742
+ tolerancePath);
1743
+ }
1744
+ return simplifyGeometry(geoArg(v, valuePath), tolerance);
1745
+ };
1746
+ },
1747
+ },
1748
+
1616
1749
  //#endregion
1617
1750
 
1618
1751
  //#region section 8.13 - dates and times, continued