@pulseindex/sdk 3.2.0 → 4.0.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/CHANGELOG.md +114 -1
- package/README.md +5 -0
- package/dist/index.d.mts +130 -3
- package/dist/index.d.ts +130 -3
- package/dist/index.js +188 -17
- package/dist/index.mjs +188 -17
- package/package.json +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,119 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 4.0.1
|
|
4
|
+
|
|
5
|
+
### The covering threshold, corrected against a real app
|
|
6
|
+
|
|
7
|
+
`withinRadius` is a pre-filter the caller narrows exactly afterwards, so excess
|
|
8
|
+
area is cheaper than predicates. 4.0.0's threshold was too strict: it rejected
|
|
9
|
+
the coarse cell at 5 km, turning a 10-cell covering into 167. Measured through
|
|
10
|
+
the sibling PHP SDK against a real application with 100,000 properties, a 5 km
|
|
11
|
+
radius went from 3,082 µs to 926 µs on wall time — the cost is the request, not
|
|
12
|
+
the search.
|
|
13
|
+
|
|
14
|
+
The coarse cell is now taken up to 3.0× the circle, which still rejects it at
|
|
15
|
+
2 km where it wastes 4.73× to 6.91×. Both SDKs are regenerated against one
|
|
16
|
+
shared vector fixture, so they still agree cell for cell.
|
|
17
|
+
|
|
18
|
+
## 4.0.0
|
|
19
|
+
|
|
20
|
+
**A major, not a minor.** The previous draft of these notes said 3.2.0. Checking
|
|
21
|
+
what actually breaks says otherwise, so the number says otherwise too.
|
|
22
|
+
|
|
23
|
+
### Breaking
|
|
24
|
+
|
|
25
|
+
1. **`withinRadius` returns different results.** It has to: above 8 km it was
|
|
26
|
+
returning **nothing at all**, and below that it over-matched by up to 5.4x.
|
|
27
|
+
Measured against a real engine with 20,000 points:
|
|
28
|
+
|
|
29
|
+
| radius | true | before | after |
|
|
30
|
+
|--------|-----:|-------:|------:|
|
|
31
|
+
| 2 km | 7 | 38 | 9 |
|
|
32
|
+
| 5 km | 36 | 109 | 42 |
|
|
33
|
+
| 15 km | 386 | **0** | 518 |
|
|
34
|
+
| 50 km | 4,282| **0** | 4,800 |
|
|
35
|
+
|
|
36
|
+
2. **`getCoveringHashes()` refuses a precision nothing is indexed at.** Passing
|
|
37
|
+
4 used to return cells that matched no entity; it now throws.
|
|
38
|
+
|
|
39
|
+
3. **A radius too large for the indexed precisions is refused**, naming the
|
|
40
|
+
latitude. Cells narrow toward the poles, so 50 km is available to about 82
|
|
41
|
+
degrees and 15 km to about 89. Previously such a request came back
|
|
42
|
+
silently covering a fraction of its own circle.
|
|
43
|
+
|
|
44
|
+
4. **`SearchResponse` gained a required `totalIsExact`.** Code that *builds* the
|
|
45
|
+
type — a test double, a cache, a mapper — fails to compile until it sets it
|
|
46
|
+
(`TS2741`). Code that only reads search results is unaffected.
|
|
47
|
+
|
|
48
|
+
### Migrating
|
|
49
|
+
|
|
50
|
+
Nothing to change for the common case: index the same way, call `withinRadius`
|
|
51
|
+
the same way, and get results that are actually inside the radius you asked for.
|
|
52
|
+
|
|
53
|
+
If you pinned expectations to the old counts, they will move. If you passed an
|
|
54
|
+
explicit precision, pass one of the indexed precisions or drop the argument. If
|
|
55
|
+
you search above 80 degrees latitude at a large radius, catch the refusal.
|
|
56
|
+
|
|
57
|
+
### Everything else in this release
|
|
58
|
+
|
|
59
|
+
### The total on a paged search is not the number of matches
|
|
60
|
+
|
|
61
|
+
A paged search stops as soon as the page is full — that is what makes it cost
|
|
62
|
+
microseconds — so the total it reports is whatever it had counted when it
|
|
63
|
+
stopped. On a million entities, a query with 166,325 matches reported 10,866
|
|
64
|
+
when asked for a page of 100. Anything printing "page 1 of N" from that number
|
|
65
|
+
is wrong by an order of magnitude and looks entirely fine.
|
|
66
|
+
|
|
67
|
+
The result now says which it is, and there is a call that gets you the real one:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
const page = await client.search(query.limit(20));
|
|
71
|
+
page.totalIsExact; // false — the search early-exited
|
|
72
|
+
|
|
73
|
+
const both = await client.searchWithTotal(query.limit(20));
|
|
74
|
+
both.totalIsExact; // true, at the cost of a second round trip
|
|
75
|
+
both.totalMatches; // the real total
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`limit(0)` still asks for the count alone and is exact by itself; nothing about
|
|
79
|
+
that changed, and `searchWithTotal` skips its second call when you already
|
|
80
|
+
passed it.
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
### `withinRadius` was returning nothing above 8 km
|
|
84
|
+
|
|
85
|
+
`optimalPrecisionForRadius` chose geohash precision 4 for any radius over 8 km,
|
|
86
|
+
and entities are only ever tagged at precisions 5 and 6. A covering at
|
|
87
|
+
precision 4 therefore matched **nothing at all**. Measured against a real
|
|
88
|
+
engine with 20,000 points around Riyadh:
|
|
89
|
+
|
|
90
|
+
| radius | true matches | returned, before | returned, after |
|
|
91
|
+
|--------|-------------:|-----------------:|----------------:|
|
|
92
|
+
| 2 km | 7 | 38 | 9 |
|
|
93
|
+
| 5 km | 36 | 109 | 42 |
|
|
94
|
+
| 15 km | 386 | **0** | 518 |
|
|
95
|
+
| 50 km | 4,282 | **0** | 4,800 |
|
|
96
|
+
|
|
97
|
+
The precision is now always one the index carries, and of those the finest
|
|
98
|
+
whose complete covering fits a cell budget. Small radii also tightened: 2 km
|
|
99
|
+
went from 5.4x the true count to 1.3x.
|
|
100
|
+
|
|
101
|
+
### A covering is no longer truncated in silence
|
|
102
|
+
|
|
103
|
+
The 64-cell limit stopped the search mid-covering and returned what it had, so
|
|
104
|
+
a 50 km circle came back covered 18% and a 1 km circle at fine precision came
|
|
105
|
+
back covered 30% — with no error either time. The limit is now a budget the
|
|
106
|
+
precision is chosen to fit, so the covering always completes. A radius too
|
|
107
|
+
large for any indexed precision is refused by name.
|
|
108
|
+
|
|
109
|
+
### `withinRadius` is a pre-filter, not an exact radius
|
|
110
|
+
|
|
111
|
+
Cells are rectangles and the query is a circle, so the result still contains
|
|
112
|
+
some points outside it — now about 1.1x to 1.8x the circle's area rather than
|
|
113
|
+
up to 6x. The engine stores no coordinates, so only you can filter the
|
|
114
|
+
remainder, from your own data after hydration. This was always true and was
|
|
115
|
+
never written down.
|
|
116
|
+
|
|
4
117
|
|
|
5
118
|
### Delete many entities in one call
|
|
6
119
|
|
package/README.md
CHANGED
|
@@ -71,6 +71,11 @@ const result = await client.search(
|
|
|
71
71
|
.limit(50),
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
+
> **`withinRadius` is a fast pre-filter, not an exact radius.** It expands the
|
|
75
|
+
> circle into geohash cells, which are rectangles, so results include some points
|
|
76
|
+
> outside the radius — about 1.1x to 1.8x the circle's area. The engine stores no
|
|
77
|
+
> coordinates, so filter the remainder from your own data after hydration.
|
|
78
|
+
|
|
74
79
|
const ids = result.matchedEntityIds;
|
|
75
80
|
await client.close();
|
|
76
81
|
```
|
package/dist/index.d.mts
CHANGED
|
@@ -47,6 +47,17 @@ interface SearchResponse {
|
|
|
47
47
|
matchedEntityIds: string[];
|
|
48
48
|
totalMatches: number;
|
|
49
49
|
executionTimeUs: number;
|
|
50
|
+
/**
|
|
51
|
+
* Whether `totalMatches` is the real number of matches or the early-exit
|
|
52
|
+
* count from a paged search. A paged search stops as soon as the page is
|
|
53
|
+
* full, which is what makes it cost microseconds — and leaves the total far
|
|
54
|
+
* below the truth. Measured on a million entities: a query with 166,325
|
|
55
|
+
* matches reported 10,866 when asked for a page of 100.
|
|
56
|
+
*
|
|
57
|
+
* Use {@link PulseIndexClient.searchWithTotal} when you need a number you can
|
|
58
|
+
* divide by a page size.
|
|
59
|
+
*/
|
|
60
|
+
totalIsExact: boolean;
|
|
50
61
|
}
|
|
51
62
|
interface IndexEntityRequest {
|
|
52
63
|
entityId: string;
|
|
@@ -298,6 +309,19 @@ declare class PulseIndexClient implements QueryExecutor {
|
|
|
298
309
|
static query(): QueryBuilder;
|
|
299
310
|
query(): QueryBuilder;
|
|
300
311
|
search(query: QueryBuilder | SearchRequestOptions): Promise<SearchResponse>;
|
|
312
|
+
/**
|
|
313
|
+
* A page of ids together with the real number of matches.
|
|
314
|
+
*
|
|
315
|
+
* A paged search stops as soon as the page is full — that is what makes it
|
|
316
|
+
* cost microseconds — so its total is whatever it had counted when it
|
|
317
|
+
* stopped. Measured on a million entities: a query with 166,325 matches
|
|
318
|
+
* reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
|
|
319
|
+
* that number is wrong by an order of magnitude and looks fine.
|
|
320
|
+
*
|
|
321
|
+
* This sends the count query as well, so it costs two round trips and returns
|
|
322
|
+
* a total you can divide by a page size.
|
|
323
|
+
*/
|
|
324
|
+
searchWithTotal(query: QueryBuilder | SearchRequestOptions): Promise<SearchResponse>;
|
|
301
325
|
index(entityIdOrInput: EntityId | EntityInput, attributes?: EntityAttributes): Promise<IndexEntityResponse>;
|
|
302
326
|
indexEntity(entityId: EntityId, categories?: string[], price?: number, locationPrefix?: EntityId, tenantId?: string): Promise<boolean>;
|
|
303
327
|
batchIndex(entities: Array<EntityInput | BatchEntityInput>): Promise<BatchIndexResponse>;
|
|
@@ -354,7 +378,41 @@ declare class GeoHash {
|
|
|
354
378
|
static readonly INDEX_PRECISIONS: readonly [5, 6];
|
|
355
379
|
private static readonly BASE32;
|
|
356
380
|
private static readonly EARTH_RADIUS_KM;
|
|
357
|
-
|
|
381
|
+
/**
|
|
382
|
+
* Most cells one radius query may expand into, and therefore the most SHOULD
|
|
383
|
+
* predicates it sends.
|
|
384
|
+
*
|
|
385
|
+
* This used to be 64 and it was a truncation limit: the walk stopped mid
|
|
386
|
+
* covering and returned what it had, so a 50 km search covered 18% of its own
|
|
387
|
+
* circle and said nothing. It is now a budget the precision is chosen to fit,
|
|
388
|
+
* so a covering is always complete or the request is refused.
|
|
389
|
+
*
|
|
390
|
+
* The number is set by latitude, not by radius. Cells needed at the coarsest
|
|
391
|
+
* indexed precision, measured: 50 km costs 376 at the equator, 592 at London,
|
|
392
|
+
* 720 at Oslo, 1,044 at Tromso, 2,028 at 80N. A first attempt used 512,
|
|
393
|
+
* chosen at one latitude, and it refused a 50 km search anywhere above 60
|
|
394
|
+
* degrees — Oslo, Stockholm, Helsinki, Saint Petersburg.
|
|
395
|
+
*
|
|
396
|
+
* The cost is bounded: measured against a live engine, a covering costs about
|
|
397
|
+
* 0.57 us per cell, so a query at the full budget spends roughly 1.2 ms in
|
|
398
|
+
* the engine, and stays half of its 4,096-filter ceiling.
|
|
399
|
+
*/
|
|
400
|
+
static readonly COVERING_CELL_BUDGET = 2048;
|
|
401
|
+
/**
|
|
402
|
+
* How much area outside the circle a covering may carry before a finer
|
|
403
|
+
* precision is worth its cell count.
|
|
404
|
+
*
|
|
405
|
+
* `withinRadius` is a pre-filter the caller narrows exactly afterwards, so
|
|
406
|
+
* excess area is cheaper than predicates.
|
|
407
|
+
*
|
|
408
|
+
* 2.0 was the first attempt and it was too strict. It rejected the coarse
|
|
409
|
+
* cell at 5 km, so a covering that had cost 10 cells cost 167, and the demo
|
|
410
|
+
* benchmark went from beating PostgreSQL to losing to it by 2.76x on wall
|
|
411
|
+
* time — the cost is the request, not the search. 3.0 keeps the cheap
|
|
412
|
+
* covering at 5 km (2.76x) and still rejects the coarse cell at 2 km, where
|
|
413
|
+
* it wastes 4.73x to 6.91x depending on latitude.
|
|
414
|
+
*/
|
|
415
|
+
static readonly ACCEPTABLE_COVER_RATIO = 3;
|
|
358
416
|
private static readonly NEIGHBORS;
|
|
359
417
|
private static readonly BORDERS;
|
|
360
418
|
static encode(lat: number, lon: number, precision?: number): string;
|
|
@@ -372,14 +430,83 @@ declare class GeoHash {
|
|
|
372
430
|
static neighbors(hash: string): string[];
|
|
373
431
|
static neighborhood3x3(hash: string): string[];
|
|
374
432
|
static neighborhoodTags(lat: number, lon: number, precision?: number): string[];
|
|
375
|
-
|
|
376
|
-
|
|
433
|
+
/**
|
|
434
|
+
* The precision a radius query should cover at, at this point on the globe.
|
|
435
|
+
*
|
|
436
|
+
* Only ever one of {@link INDEX_PRECISIONS}. That is the correction: this
|
|
437
|
+
* used to return 4 for anything over 8 km, and nothing is indexed at
|
|
438
|
+
* precision 4, so **every radius above 8 km matched nothing at all**.
|
|
439
|
+
* Measured against a real engine with entities tagged by `encodeMultiTags`:
|
|
440
|
+
* 15 km returned 0 of 386, 50 km returned 0 of 4,282 — an empty page, with
|
|
441
|
+
* no error to explain it.
|
|
442
|
+
*
|
|
443
|
+
* Of the indexed precisions it returns the finest whose complete covering
|
|
444
|
+
* fits {@link COVERING_CELL_BUDGET}, because a finer cell wastes less area
|
|
445
|
+
* outside the circle. Measured at Riyadh: 5 km takes 140 cells at precision
|
|
446
|
+
* 6 for 1.21x the circle, against 10 cells at precision 5 for 2.76x; 15 km
|
|
447
|
+
* needs 1,120 at precision 6 and so falls to 47 at precision 5 for 1.44x.
|
|
448
|
+
*
|
|
449
|
+
* Latitude is a parameter because it changes the answer: a cell keeps its
|
|
450
|
+
* width in degrees, so it narrows in kilometres toward the poles and the same
|
|
451
|
+
* radius needs more of them.
|
|
452
|
+
*
|
|
453
|
+
* @throws when no indexed precision can cover the radius within the budget —
|
|
454
|
+
* refused rather than half-covered.
|
|
455
|
+
*/
|
|
456
|
+
static optimalPrecisionForRadius(radiusKm: number, lat?: number, lon?: number): number;
|
|
457
|
+
static precisionForRadius(radiusKm: number, lat?: number, lon?: number): number;
|
|
458
|
+
/**
|
|
459
|
+
* GeoHashes whose cells cover the search circle.
|
|
460
|
+
*
|
|
461
|
+
* The covering is always complete. It used to stop at 64 cells and return
|
|
462
|
+
* what it had, so a caller asking for 50 km got cells covering 18% of that
|
|
463
|
+
* circle — with no error. Now the precision is chosen to fit the budget and
|
|
464
|
+
* the walk always finishes, so the result either covers the circle or the
|
|
465
|
+
* call refuses.
|
|
466
|
+
*
|
|
467
|
+
* Passing `precision` explicitly overrides the choice, and is checked against
|
|
468
|
+
* {@link INDEX_PRECISIONS}: entities carry tags only at those, so any other
|
|
469
|
+
* precision matches nothing at all rather than matching loosely.
|
|
470
|
+
*/
|
|
377
471
|
static getCoveringHashes(lat: number, lon: number, radiusKm: number, precision?: number): string[];
|
|
472
|
+
/**
|
|
473
|
+
* Every cell at `precision` that intersects the circle, breadth-first from
|
|
474
|
+
* the centre and expanding only through cells that intersect.
|
|
475
|
+
*
|
|
476
|
+
* `limit` exists only so the precision chooser can stop early once a
|
|
477
|
+
* precision is known not to fit; a null limit walks to completion, which is
|
|
478
|
+
* what every caller that wants an answer passes.
|
|
479
|
+
*/
|
|
480
|
+
private static walkCovering;
|
|
378
481
|
static tag(geohash: string): string;
|
|
379
482
|
static encodeTag(lat: number, lon: number, precision?: number): string;
|
|
380
483
|
static encodeMultiTags(lat: number, lon: number): string[];
|
|
381
484
|
static haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
|
382
485
|
private static cellIntersectsCircle;
|
|
486
|
+
/**
|
|
487
|
+
* The longitude in [lonMin, lonMax] nearest to `lon`, going the short way
|
|
488
|
+
* round the globe.
|
|
489
|
+
*
|
|
490
|
+
* A plain clamp is wrong at the antimeridian, because -180 and +180 are the
|
|
491
|
+
* same meridian and a numeric comparison does not know it. Measured before
|
|
492
|
+
* this: a query at lon 179.99 against the cell spanning -180 to -179.989
|
|
493
|
+
* clamped to -179.989 and measured 2.334 km, when the true nearest point is
|
|
494
|
+
* -180.0 at 1.112 km. The cell was rejected from a 2 km radius it is well
|
|
495
|
+
* inside, and five of sixteen points on that circle's rim fell outside the
|
|
496
|
+
* covering — silently.
|
|
497
|
+
*
|
|
498
|
+
* Working in deltas normalised to +/-180 removes the discontinuity: the cell
|
|
499
|
+
* either straddles the query meridian, or lies wholly to one side of it and
|
|
500
|
+
* the nearer edge is the answer.
|
|
501
|
+
*/
|
|
502
|
+
private static closestLongitude;
|
|
503
|
+
/** A longitude difference folded into [-180, 180]. */
|
|
504
|
+
private static normalizeLonDelta;
|
|
505
|
+
/**
|
|
506
|
+
* Covered area divided by the circle's, so a precision can be judged on what
|
|
507
|
+
* it wastes rather than only on what it costs.
|
|
508
|
+
*/
|
|
509
|
+
private static coveredRatio;
|
|
383
510
|
private static adjacent;
|
|
384
511
|
private static normalizeHash;
|
|
385
512
|
private static assertLatitude;
|
package/dist/index.d.ts
CHANGED
|
@@ -47,6 +47,17 @@ interface SearchResponse {
|
|
|
47
47
|
matchedEntityIds: string[];
|
|
48
48
|
totalMatches: number;
|
|
49
49
|
executionTimeUs: number;
|
|
50
|
+
/**
|
|
51
|
+
* Whether `totalMatches` is the real number of matches or the early-exit
|
|
52
|
+
* count from a paged search. A paged search stops as soon as the page is
|
|
53
|
+
* full, which is what makes it cost microseconds — and leaves the total far
|
|
54
|
+
* below the truth. Measured on a million entities: a query with 166,325
|
|
55
|
+
* matches reported 10,866 when asked for a page of 100.
|
|
56
|
+
*
|
|
57
|
+
* Use {@link PulseIndexClient.searchWithTotal} when you need a number you can
|
|
58
|
+
* divide by a page size.
|
|
59
|
+
*/
|
|
60
|
+
totalIsExact: boolean;
|
|
50
61
|
}
|
|
51
62
|
interface IndexEntityRequest {
|
|
52
63
|
entityId: string;
|
|
@@ -298,6 +309,19 @@ declare class PulseIndexClient implements QueryExecutor {
|
|
|
298
309
|
static query(): QueryBuilder;
|
|
299
310
|
query(): QueryBuilder;
|
|
300
311
|
search(query: QueryBuilder | SearchRequestOptions): Promise<SearchResponse>;
|
|
312
|
+
/**
|
|
313
|
+
* A page of ids together with the real number of matches.
|
|
314
|
+
*
|
|
315
|
+
* A paged search stops as soon as the page is full — that is what makes it
|
|
316
|
+
* cost microseconds — so its total is whatever it had counted when it
|
|
317
|
+
* stopped. Measured on a million entities: a query with 166,325 matches
|
|
318
|
+
* reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
|
|
319
|
+
* that number is wrong by an order of magnitude and looks fine.
|
|
320
|
+
*
|
|
321
|
+
* This sends the count query as well, so it costs two round trips and returns
|
|
322
|
+
* a total you can divide by a page size.
|
|
323
|
+
*/
|
|
324
|
+
searchWithTotal(query: QueryBuilder | SearchRequestOptions): Promise<SearchResponse>;
|
|
301
325
|
index(entityIdOrInput: EntityId | EntityInput, attributes?: EntityAttributes): Promise<IndexEntityResponse>;
|
|
302
326
|
indexEntity(entityId: EntityId, categories?: string[], price?: number, locationPrefix?: EntityId, tenantId?: string): Promise<boolean>;
|
|
303
327
|
batchIndex(entities: Array<EntityInput | BatchEntityInput>): Promise<BatchIndexResponse>;
|
|
@@ -354,7 +378,41 @@ declare class GeoHash {
|
|
|
354
378
|
static readonly INDEX_PRECISIONS: readonly [5, 6];
|
|
355
379
|
private static readonly BASE32;
|
|
356
380
|
private static readonly EARTH_RADIUS_KM;
|
|
357
|
-
|
|
381
|
+
/**
|
|
382
|
+
* Most cells one radius query may expand into, and therefore the most SHOULD
|
|
383
|
+
* predicates it sends.
|
|
384
|
+
*
|
|
385
|
+
* This used to be 64 and it was a truncation limit: the walk stopped mid
|
|
386
|
+
* covering and returned what it had, so a 50 km search covered 18% of its own
|
|
387
|
+
* circle and said nothing. It is now a budget the precision is chosen to fit,
|
|
388
|
+
* so a covering is always complete or the request is refused.
|
|
389
|
+
*
|
|
390
|
+
* The number is set by latitude, not by radius. Cells needed at the coarsest
|
|
391
|
+
* indexed precision, measured: 50 km costs 376 at the equator, 592 at London,
|
|
392
|
+
* 720 at Oslo, 1,044 at Tromso, 2,028 at 80N. A first attempt used 512,
|
|
393
|
+
* chosen at one latitude, and it refused a 50 km search anywhere above 60
|
|
394
|
+
* degrees — Oslo, Stockholm, Helsinki, Saint Petersburg.
|
|
395
|
+
*
|
|
396
|
+
* The cost is bounded: measured against a live engine, a covering costs about
|
|
397
|
+
* 0.57 us per cell, so a query at the full budget spends roughly 1.2 ms in
|
|
398
|
+
* the engine, and stays half of its 4,096-filter ceiling.
|
|
399
|
+
*/
|
|
400
|
+
static readonly COVERING_CELL_BUDGET = 2048;
|
|
401
|
+
/**
|
|
402
|
+
* How much area outside the circle a covering may carry before a finer
|
|
403
|
+
* precision is worth its cell count.
|
|
404
|
+
*
|
|
405
|
+
* `withinRadius` is a pre-filter the caller narrows exactly afterwards, so
|
|
406
|
+
* excess area is cheaper than predicates.
|
|
407
|
+
*
|
|
408
|
+
* 2.0 was the first attempt and it was too strict. It rejected the coarse
|
|
409
|
+
* cell at 5 km, so a covering that had cost 10 cells cost 167, and the demo
|
|
410
|
+
* benchmark went from beating PostgreSQL to losing to it by 2.76x on wall
|
|
411
|
+
* time — the cost is the request, not the search. 3.0 keeps the cheap
|
|
412
|
+
* covering at 5 km (2.76x) and still rejects the coarse cell at 2 km, where
|
|
413
|
+
* it wastes 4.73x to 6.91x depending on latitude.
|
|
414
|
+
*/
|
|
415
|
+
static readonly ACCEPTABLE_COVER_RATIO = 3;
|
|
358
416
|
private static readonly NEIGHBORS;
|
|
359
417
|
private static readonly BORDERS;
|
|
360
418
|
static encode(lat: number, lon: number, precision?: number): string;
|
|
@@ -372,14 +430,83 @@ declare class GeoHash {
|
|
|
372
430
|
static neighbors(hash: string): string[];
|
|
373
431
|
static neighborhood3x3(hash: string): string[];
|
|
374
432
|
static neighborhoodTags(lat: number, lon: number, precision?: number): string[];
|
|
375
|
-
|
|
376
|
-
|
|
433
|
+
/**
|
|
434
|
+
* The precision a radius query should cover at, at this point on the globe.
|
|
435
|
+
*
|
|
436
|
+
* Only ever one of {@link INDEX_PRECISIONS}. That is the correction: this
|
|
437
|
+
* used to return 4 for anything over 8 km, and nothing is indexed at
|
|
438
|
+
* precision 4, so **every radius above 8 km matched nothing at all**.
|
|
439
|
+
* Measured against a real engine with entities tagged by `encodeMultiTags`:
|
|
440
|
+
* 15 km returned 0 of 386, 50 km returned 0 of 4,282 — an empty page, with
|
|
441
|
+
* no error to explain it.
|
|
442
|
+
*
|
|
443
|
+
* Of the indexed precisions it returns the finest whose complete covering
|
|
444
|
+
* fits {@link COVERING_CELL_BUDGET}, because a finer cell wastes less area
|
|
445
|
+
* outside the circle. Measured at Riyadh: 5 km takes 140 cells at precision
|
|
446
|
+
* 6 for 1.21x the circle, against 10 cells at precision 5 for 2.76x; 15 km
|
|
447
|
+
* needs 1,120 at precision 6 and so falls to 47 at precision 5 for 1.44x.
|
|
448
|
+
*
|
|
449
|
+
* Latitude is a parameter because it changes the answer: a cell keeps its
|
|
450
|
+
* width in degrees, so it narrows in kilometres toward the poles and the same
|
|
451
|
+
* radius needs more of them.
|
|
452
|
+
*
|
|
453
|
+
* @throws when no indexed precision can cover the radius within the budget —
|
|
454
|
+
* refused rather than half-covered.
|
|
455
|
+
*/
|
|
456
|
+
static optimalPrecisionForRadius(radiusKm: number, lat?: number, lon?: number): number;
|
|
457
|
+
static precisionForRadius(radiusKm: number, lat?: number, lon?: number): number;
|
|
458
|
+
/**
|
|
459
|
+
* GeoHashes whose cells cover the search circle.
|
|
460
|
+
*
|
|
461
|
+
* The covering is always complete. It used to stop at 64 cells and return
|
|
462
|
+
* what it had, so a caller asking for 50 km got cells covering 18% of that
|
|
463
|
+
* circle — with no error. Now the precision is chosen to fit the budget and
|
|
464
|
+
* the walk always finishes, so the result either covers the circle or the
|
|
465
|
+
* call refuses.
|
|
466
|
+
*
|
|
467
|
+
* Passing `precision` explicitly overrides the choice, and is checked against
|
|
468
|
+
* {@link INDEX_PRECISIONS}: entities carry tags only at those, so any other
|
|
469
|
+
* precision matches nothing at all rather than matching loosely.
|
|
470
|
+
*/
|
|
377
471
|
static getCoveringHashes(lat: number, lon: number, radiusKm: number, precision?: number): string[];
|
|
472
|
+
/**
|
|
473
|
+
* Every cell at `precision` that intersects the circle, breadth-first from
|
|
474
|
+
* the centre and expanding only through cells that intersect.
|
|
475
|
+
*
|
|
476
|
+
* `limit` exists only so the precision chooser can stop early once a
|
|
477
|
+
* precision is known not to fit; a null limit walks to completion, which is
|
|
478
|
+
* what every caller that wants an answer passes.
|
|
479
|
+
*/
|
|
480
|
+
private static walkCovering;
|
|
378
481
|
static tag(geohash: string): string;
|
|
379
482
|
static encodeTag(lat: number, lon: number, precision?: number): string;
|
|
380
483
|
static encodeMultiTags(lat: number, lon: number): string[];
|
|
381
484
|
static haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
|
382
485
|
private static cellIntersectsCircle;
|
|
486
|
+
/**
|
|
487
|
+
* The longitude in [lonMin, lonMax] nearest to `lon`, going the short way
|
|
488
|
+
* round the globe.
|
|
489
|
+
*
|
|
490
|
+
* A plain clamp is wrong at the antimeridian, because -180 and +180 are the
|
|
491
|
+
* same meridian and a numeric comparison does not know it. Measured before
|
|
492
|
+
* this: a query at lon 179.99 against the cell spanning -180 to -179.989
|
|
493
|
+
* clamped to -179.989 and measured 2.334 km, when the true nearest point is
|
|
494
|
+
* -180.0 at 1.112 km. The cell was rejected from a 2 km radius it is well
|
|
495
|
+
* inside, and five of sixteen points on that circle's rim fell outside the
|
|
496
|
+
* covering — silently.
|
|
497
|
+
*
|
|
498
|
+
* Working in deltas normalised to +/-180 removes the discontinuity: the cell
|
|
499
|
+
* either straddles the query meridian, or lies wholly to one side of it and
|
|
500
|
+
* the nearer edge is the answer.
|
|
501
|
+
*/
|
|
502
|
+
private static closestLongitude;
|
|
503
|
+
/** A longitude difference folded into [-180, 180]. */
|
|
504
|
+
private static normalizeLonDelta;
|
|
505
|
+
/**
|
|
506
|
+
* Covered area divided by the circle's, so a precision can be judged on what
|
|
507
|
+
* it wastes rather than only on what it costs.
|
|
508
|
+
*/
|
|
509
|
+
private static coveredRatio;
|
|
383
510
|
private static adjacent;
|
|
384
511
|
private static normalizeHash;
|
|
385
512
|
private static assertLatitude;
|
package/dist/index.js
CHANGED
|
@@ -41,7 +41,41 @@ var GeoHash = class {
|
|
|
41
41
|
static INDEX_PRECISIONS = [5, 6];
|
|
42
42
|
static BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
|
|
43
43
|
static EARTH_RADIUS_KM = 6371;
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Most cells one radius query may expand into, and therefore the most SHOULD
|
|
46
|
+
* predicates it sends.
|
|
47
|
+
*
|
|
48
|
+
* This used to be 64 and it was a truncation limit: the walk stopped mid
|
|
49
|
+
* covering and returned what it had, so a 50 km search covered 18% of its own
|
|
50
|
+
* circle and said nothing. It is now a budget the precision is chosen to fit,
|
|
51
|
+
* so a covering is always complete or the request is refused.
|
|
52
|
+
*
|
|
53
|
+
* The number is set by latitude, not by radius. Cells needed at the coarsest
|
|
54
|
+
* indexed precision, measured: 50 km costs 376 at the equator, 592 at London,
|
|
55
|
+
* 720 at Oslo, 1,044 at Tromso, 2,028 at 80N. A first attempt used 512,
|
|
56
|
+
* chosen at one latitude, and it refused a 50 km search anywhere above 60
|
|
57
|
+
* degrees — Oslo, Stockholm, Helsinki, Saint Petersburg.
|
|
58
|
+
*
|
|
59
|
+
* The cost is bounded: measured against a live engine, a covering costs about
|
|
60
|
+
* 0.57 us per cell, so a query at the full budget spends roughly 1.2 ms in
|
|
61
|
+
* the engine, and stays half of its 4,096-filter ceiling.
|
|
62
|
+
*/
|
|
63
|
+
static COVERING_CELL_BUDGET = 2048;
|
|
64
|
+
/**
|
|
65
|
+
* How much area outside the circle a covering may carry before a finer
|
|
66
|
+
* precision is worth its cell count.
|
|
67
|
+
*
|
|
68
|
+
* `withinRadius` is a pre-filter the caller narrows exactly afterwards, so
|
|
69
|
+
* excess area is cheaper than predicates.
|
|
70
|
+
*
|
|
71
|
+
* 2.0 was the first attempt and it was too strict. It rejected the coarse
|
|
72
|
+
* cell at 5 km, so a covering that had cost 10 cells cost 167, and the demo
|
|
73
|
+
* benchmark went from beating PostgreSQL to losing to it by 2.76x on wall
|
|
74
|
+
* time — the cost is the request, not the search. 3.0 keeps the cheap
|
|
75
|
+
* covering at 5 km (2.76x) and still rejects the coarse cell at 2 km, where
|
|
76
|
+
* it wastes 4.73x to 6.91x depending on latitude.
|
|
77
|
+
*/
|
|
78
|
+
static ACCEPTABLE_COVER_RATIO = 3;
|
|
45
79
|
static NEIGHBORS = {
|
|
46
80
|
n: ["p0r21436x8zb9dcf5h7kjnmqesgutwvy", "bc01fg45238967deuvhjyznpkmstqrwx"],
|
|
47
81
|
s: ["14365h7k9dcfesgujnmqp0r2twvyx8zb", "238967debc01fg45kmstqrwxuvhjyznp"],
|
|
@@ -166,31 +200,98 @@ var GeoHash = class {
|
|
|
166
200
|
static neighborhoodTags(lat, lon, precision = 6) {
|
|
167
201
|
return this.neighborhood3x3(this.encode(lat, lon, precision)).map((cell) => this.tag(cell));
|
|
168
202
|
}
|
|
169
|
-
|
|
203
|
+
/**
|
|
204
|
+
* The precision a radius query should cover at, at this point on the globe.
|
|
205
|
+
*
|
|
206
|
+
* Only ever one of {@link INDEX_PRECISIONS}. That is the correction: this
|
|
207
|
+
* used to return 4 for anything over 8 km, and nothing is indexed at
|
|
208
|
+
* precision 4, so **every radius above 8 km matched nothing at all**.
|
|
209
|
+
* Measured against a real engine with entities tagged by `encodeMultiTags`:
|
|
210
|
+
* 15 km returned 0 of 386, 50 km returned 0 of 4,282 — an empty page, with
|
|
211
|
+
* no error to explain it.
|
|
212
|
+
*
|
|
213
|
+
* Of the indexed precisions it returns the finest whose complete covering
|
|
214
|
+
* fits {@link COVERING_CELL_BUDGET}, because a finer cell wastes less area
|
|
215
|
+
* outside the circle. Measured at Riyadh: 5 km takes 140 cells at precision
|
|
216
|
+
* 6 for 1.21x the circle, against 10 cells at precision 5 for 2.76x; 15 km
|
|
217
|
+
* needs 1,120 at precision 6 and so falls to 47 at precision 5 for 1.44x.
|
|
218
|
+
*
|
|
219
|
+
* Latitude is a parameter because it changes the answer: a cell keeps its
|
|
220
|
+
* width in degrees, so it narrows in kilometres toward the poles and the same
|
|
221
|
+
* radius needs more of them.
|
|
222
|
+
*
|
|
223
|
+
* @throws when no indexed precision can cover the radius within the budget —
|
|
224
|
+
* refused rather than half-covered.
|
|
225
|
+
*/
|
|
226
|
+
static optimalPrecisionForRadius(radiusKm, lat = 0, lon = 0) {
|
|
170
227
|
if (radiusKm < 0) {
|
|
171
228
|
throw new Error("Radius must be non-negative.");
|
|
172
229
|
}
|
|
173
|
-
|
|
174
|
-
|
|
230
|
+
const budget = this.COVERING_CELL_BUDGET;
|
|
231
|
+
let fallback = null;
|
|
232
|
+
for (const precision of [...this.INDEX_PRECISIONS].sort((a, b) => a - b)) {
|
|
233
|
+
const cells = this.walkCovering(lat, lon, radiusKm, precision, budget + 1);
|
|
234
|
+
if (cells.length > budget) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (radiusKm > 0 && this.coveredRatio(cells, radiusKm) <= this.ACCEPTABLE_COVER_RATIO) {
|
|
238
|
+
return precision;
|
|
239
|
+
}
|
|
240
|
+
fallback = precision;
|
|
175
241
|
}
|
|
176
|
-
if (
|
|
177
|
-
return
|
|
242
|
+
if (fallback !== null) {
|
|
243
|
+
return fallback;
|
|
178
244
|
}
|
|
179
|
-
|
|
245
|
+
throw new Error(
|
|
246
|
+
`A ${radiusKm} km radius at latitude ${lat.toFixed(1)} needs more than ${budget} geohash cells at every indexed precision (${this.INDEX_PRECISIONS.join(", ")}). Geohash cells narrow toward the poles, so the same radius costs more cells the further from the equator it is asked. Use a smaller radius, move the search nearer the equator, or index a coarser precision.`
|
|
247
|
+
);
|
|
180
248
|
}
|
|
181
|
-
static precisionForRadius(radiusKm) {
|
|
182
|
-
return this.optimalPrecisionForRadius(radiusKm);
|
|
249
|
+
static precisionForRadius(radiusKm, lat = 0, lon = 0) {
|
|
250
|
+
return this.optimalPrecisionForRadius(radiusKm, lat, lon);
|
|
183
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* GeoHashes whose cells cover the search circle.
|
|
254
|
+
*
|
|
255
|
+
* The covering is always complete. It used to stop at 64 cells and return
|
|
256
|
+
* what it had, so a caller asking for 50 km got cells covering 18% of that
|
|
257
|
+
* circle — with no error. Now the precision is chosen to fit the budget and
|
|
258
|
+
* the walk always finishes, so the result either covers the circle or the
|
|
259
|
+
* call refuses.
|
|
260
|
+
*
|
|
261
|
+
* Passing `precision` explicitly overrides the choice, and is checked against
|
|
262
|
+
* {@link INDEX_PRECISIONS}: entities carry tags only at those, so any other
|
|
263
|
+
* precision matches nothing at all rather than matching loosely.
|
|
264
|
+
*/
|
|
184
265
|
static getCoveringHashes(lat, lon, radiusKm, precision) {
|
|
185
266
|
if (radiusKm < 0) {
|
|
186
267
|
throw new Error("Radius must be non-negative.");
|
|
187
268
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
269
|
+
let resolved;
|
|
270
|
+
if (precision === void 0) {
|
|
271
|
+
resolved = this.optimalPrecisionForRadius(radiusKm, lat, lon);
|
|
272
|
+
} else {
|
|
273
|
+
this.assertPrecision(precision);
|
|
274
|
+
if (!this.INDEX_PRECISIONS.includes(precision)) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Precision ${precision} is not indexed, so a covering at it matches nothing. Indexed precisions: ${this.INDEX_PRECISIONS.join(", ")}.`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
resolved = precision;
|
|
280
|
+
}
|
|
281
|
+
return this.walkCovering(lat, lon, radiusKm, resolved, null);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Every cell at `precision` that intersects the circle, breadth-first from
|
|
285
|
+
* the centre and expanding only through cells that intersect.
|
|
286
|
+
*
|
|
287
|
+
* `limit` exists only so the precision chooser can stop early once a
|
|
288
|
+
* precision is known not to fit; a null limit walks to completion, which is
|
|
289
|
+
* what every caller that wants an answer passes.
|
|
290
|
+
*/
|
|
291
|
+
static walkCovering(lat, lon, radiusKm, precision, limit) {
|
|
191
292
|
const covering = [];
|
|
192
293
|
const visited = /* @__PURE__ */ new Set();
|
|
193
|
-
const queue = [
|
|
294
|
+
const queue = [this.encode(lat, lon, precision)];
|
|
194
295
|
while (queue.length > 0) {
|
|
195
296
|
const current = queue.shift();
|
|
196
297
|
if (current === void 0 || visited.has(current)) {
|
|
@@ -201,8 +302,8 @@ var GeoHash = class {
|
|
|
201
302
|
continue;
|
|
202
303
|
}
|
|
203
304
|
covering.push(current);
|
|
204
|
-
if (covering.length >=
|
|
205
|
-
|
|
305
|
+
if (limit !== null && covering.length >= limit) {
|
|
306
|
+
return covering;
|
|
206
307
|
}
|
|
207
308
|
for (const neighbor of this.neighbors(current)) {
|
|
208
309
|
if (!visited.has(neighbor)) {
|
|
@@ -231,9 +332,49 @@ var GeoHash = class {
|
|
|
231
332
|
static cellIntersectsCircle(hash, lat, lon, radiusKm) {
|
|
232
333
|
const bounds = this.decodeBounds(hash);
|
|
233
334
|
const closestLat = Math.min(Math.max(lat, bounds.latMin), bounds.latMax);
|
|
234
|
-
const closestLon =
|
|
335
|
+
const closestLon = this.closestLongitude(lon, bounds.lonMin, bounds.lonMax);
|
|
235
336
|
return this.haversineKm(lat, lon, closestLat, closestLon) <= radiusKm;
|
|
236
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* The longitude in [lonMin, lonMax] nearest to `lon`, going the short way
|
|
340
|
+
* round the globe.
|
|
341
|
+
*
|
|
342
|
+
* A plain clamp is wrong at the antimeridian, because -180 and +180 are the
|
|
343
|
+
* same meridian and a numeric comparison does not know it. Measured before
|
|
344
|
+
* this: a query at lon 179.99 against the cell spanning -180 to -179.989
|
|
345
|
+
* clamped to -179.989 and measured 2.334 km, when the true nearest point is
|
|
346
|
+
* -180.0 at 1.112 km. The cell was rejected from a 2 km radius it is well
|
|
347
|
+
* inside, and five of sixteen points on that circle's rim fell outside the
|
|
348
|
+
* covering — silently.
|
|
349
|
+
*
|
|
350
|
+
* Working in deltas normalised to +/-180 removes the discontinuity: the cell
|
|
351
|
+
* either straddles the query meridian, or lies wholly to one side of it and
|
|
352
|
+
* the nearer edge is the answer.
|
|
353
|
+
*/
|
|
354
|
+
static closestLongitude(lon, lonMin, lonMax) {
|
|
355
|
+
const toMin = this.normalizeLonDelta(lonMin - lon);
|
|
356
|
+
const toMax = this.normalizeLonDelta(lonMax - lon);
|
|
357
|
+
if (toMin <= 0 && toMax >= 0) {
|
|
358
|
+
return lon;
|
|
359
|
+
}
|
|
360
|
+
return Math.abs(toMin) <= Math.abs(toMax) ? lonMin : lonMax;
|
|
361
|
+
}
|
|
362
|
+
/** A longitude difference folded into [-180, 180]. */
|
|
363
|
+
static normalizeLonDelta(delta) {
|
|
364
|
+
return ((delta + 180) % 360 + 360) % 360 - 180;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Covered area divided by the circle's, so a precision can be judged on what
|
|
368
|
+
* it wastes rather than only on what it costs.
|
|
369
|
+
*/
|
|
370
|
+
static coveredRatio(cells, radiusKm) {
|
|
371
|
+
const rad = (d) => d * Math.PI / 180;
|
|
372
|
+
const covered = cells.reduce((sum, hash) => {
|
|
373
|
+
const b = this.decodeBounds(hash);
|
|
374
|
+
return sum + this.EARTH_RADIUS_KM * rad(b.latMax - b.latMin) * this.EARTH_RADIUS_KM * Math.cos(rad((b.latMax + b.latMin) / 2)) * rad(b.lonMax - b.lonMin);
|
|
375
|
+
}, 0);
|
|
376
|
+
return covered / (Math.PI * radiusKm ** 2);
|
|
377
|
+
}
|
|
237
378
|
static adjacent(hash, direction) {
|
|
238
379
|
if (hash.length === 0) {
|
|
239
380
|
throw new Error("GeoHash must not be empty.");
|
|
@@ -1095,7 +1236,37 @@ var PulseIndexClient = class _PulseIndexClient {
|
|
|
1095
1236
|
return {
|
|
1096
1237
|
matchedEntityIds: (raw.matchedEntityIds ?? []).map((id) => String(id)),
|
|
1097
1238
|
totalMatches: Number(raw.totalMatches ?? 0),
|
|
1098
|
-
executionTimeUs: Number(raw.executionTimeUs ?? 0)
|
|
1239
|
+
executionTimeUs: Number(raw.executionTimeUs ?? 0),
|
|
1240
|
+
// Exact only when nothing made the engine stop early. limit=0 asks for
|
|
1241
|
+
// the count and no ids, and is the only shape that counts every match;
|
|
1242
|
+
// any page can early-exit as soon as it is full.
|
|
1243
|
+
totalIsExact: builder.toArray().limit === 0
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* A page of ids together with the real number of matches.
|
|
1248
|
+
*
|
|
1249
|
+
* A paged search stops as soon as the page is full — that is what makes it
|
|
1250
|
+
* cost microseconds — so its total is whatever it had counted when it
|
|
1251
|
+
* stopped. Measured on a million entities: a query with 166,325 matches
|
|
1252
|
+
* reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
|
|
1253
|
+
* that number is wrong by an order of magnitude and looks fine.
|
|
1254
|
+
*
|
|
1255
|
+
* This sends the count query as well, so it costs two round trips and returns
|
|
1256
|
+
* a total you can divide by a page size.
|
|
1257
|
+
*/
|
|
1258
|
+
async searchWithTotal(query) {
|
|
1259
|
+
const page = await this.search(query);
|
|
1260
|
+
if (page.totalIsExact) {
|
|
1261
|
+
return page;
|
|
1262
|
+
}
|
|
1263
|
+
const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query);
|
|
1264
|
+
const counted = await this.search(builder.limit(0));
|
|
1265
|
+
return {
|
|
1266
|
+
matchedEntityIds: page.matchedEntityIds,
|
|
1267
|
+
totalMatches: counted.totalMatches,
|
|
1268
|
+
executionTimeUs: page.executionTimeUs + counted.executionTimeUs,
|
|
1269
|
+
totalIsExact: true
|
|
1099
1270
|
};
|
|
1100
1271
|
}
|
|
1101
1272
|
async index(entityIdOrInput, attributes = {}) {
|
package/dist/index.mjs
CHANGED
|
@@ -18,7 +18,41 @@ var GeoHash = class {
|
|
|
18
18
|
static INDEX_PRECISIONS = [5, 6];
|
|
19
19
|
static BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
|
|
20
20
|
static EARTH_RADIUS_KM = 6371;
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Most cells one radius query may expand into, and therefore the most SHOULD
|
|
23
|
+
* predicates it sends.
|
|
24
|
+
*
|
|
25
|
+
* This used to be 64 and it was a truncation limit: the walk stopped mid
|
|
26
|
+
* covering and returned what it had, so a 50 km search covered 18% of its own
|
|
27
|
+
* circle and said nothing. It is now a budget the precision is chosen to fit,
|
|
28
|
+
* so a covering is always complete or the request is refused.
|
|
29
|
+
*
|
|
30
|
+
* The number is set by latitude, not by radius. Cells needed at the coarsest
|
|
31
|
+
* indexed precision, measured: 50 km costs 376 at the equator, 592 at London,
|
|
32
|
+
* 720 at Oslo, 1,044 at Tromso, 2,028 at 80N. A first attempt used 512,
|
|
33
|
+
* chosen at one latitude, and it refused a 50 km search anywhere above 60
|
|
34
|
+
* degrees — Oslo, Stockholm, Helsinki, Saint Petersburg.
|
|
35
|
+
*
|
|
36
|
+
* The cost is bounded: measured against a live engine, a covering costs about
|
|
37
|
+
* 0.57 us per cell, so a query at the full budget spends roughly 1.2 ms in
|
|
38
|
+
* the engine, and stays half of its 4,096-filter ceiling.
|
|
39
|
+
*/
|
|
40
|
+
static COVERING_CELL_BUDGET = 2048;
|
|
41
|
+
/**
|
|
42
|
+
* How much area outside the circle a covering may carry before a finer
|
|
43
|
+
* precision is worth its cell count.
|
|
44
|
+
*
|
|
45
|
+
* `withinRadius` is a pre-filter the caller narrows exactly afterwards, so
|
|
46
|
+
* excess area is cheaper than predicates.
|
|
47
|
+
*
|
|
48
|
+
* 2.0 was the first attempt and it was too strict. It rejected the coarse
|
|
49
|
+
* cell at 5 km, so a covering that had cost 10 cells cost 167, and the demo
|
|
50
|
+
* benchmark went from beating PostgreSQL to losing to it by 2.76x on wall
|
|
51
|
+
* time — the cost is the request, not the search. 3.0 keeps the cheap
|
|
52
|
+
* covering at 5 km (2.76x) and still rejects the coarse cell at 2 km, where
|
|
53
|
+
* it wastes 4.73x to 6.91x depending on latitude.
|
|
54
|
+
*/
|
|
55
|
+
static ACCEPTABLE_COVER_RATIO = 3;
|
|
22
56
|
static NEIGHBORS = {
|
|
23
57
|
n: ["p0r21436x8zb9dcf5h7kjnmqesgutwvy", "bc01fg45238967deuvhjyznpkmstqrwx"],
|
|
24
58
|
s: ["14365h7k9dcfesgujnmqp0r2twvyx8zb", "238967debc01fg45kmstqrwxuvhjyznp"],
|
|
@@ -143,31 +177,98 @@ var GeoHash = class {
|
|
|
143
177
|
static neighborhoodTags(lat, lon, precision = 6) {
|
|
144
178
|
return this.neighborhood3x3(this.encode(lat, lon, precision)).map((cell) => this.tag(cell));
|
|
145
179
|
}
|
|
146
|
-
|
|
180
|
+
/**
|
|
181
|
+
* The precision a radius query should cover at, at this point on the globe.
|
|
182
|
+
*
|
|
183
|
+
* Only ever one of {@link INDEX_PRECISIONS}. That is the correction: this
|
|
184
|
+
* used to return 4 for anything over 8 km, and nothing is indexed at
|
|
185
|
+
* precision 4, so **every radius above 8 km matched nothing at all**.
|
|
186
|
+
* Measured against a real engine with entities tagged by `encodeMultiTags`:
|
|
187
|
+
* 15 km returned 0 of 386, 50 km returned 0 of 4,282 — an empty page, with
|
|
188
|
+
* no error to explain it.
|
|
189
|
+
*
|
|
190
|
+
* Of the indexed precisions it returns the finest whose complete covering
|
|
191
|
+
* fits {@link COVERING_CELL_BUDGET}, because a finer cell wastes less area
|
|
192
|
+
* outside the circle. Measured at Riyadh: 5 km takes 140 cells at precision
|
|
193
|
+
* 6 for 1.21x the circle, against 10 cells at precision 5 for 2.76x; 15 km
|
|
194
|
+
* needs 1,120 at precision 6 and so falls to 47 at precision 5 for 1.44x.
|
|
195
|
+
*
|
|
196
|
+
* Latitude is a parameter because it changes the answer: a cell keeps its
|
|
197
|
+
* width in degrees, so it narrows in kilometres toward the poles and the same
|
|
198
|
+
* radius needs more of them.
|
|
199
|
+
*
|
|
200
|
+
* @throws when no indexed precision can cover the radius within the budget —
|
|
201
|
+
* refused rather than half-covered.
|
|
202
|
+
*/
|
|
203
|
+
static optimalPrecisionForRadius(radiusKm, lat = 0, lon = 0) {
|
|
147
204
|
if (radiusKm < 0) {
|
|
148
205
|
throw new Error("Radius must be non-negative.");
|
|
149
206
|
}
|
|
150
|
-
|
|
151
|
-
|
|
207
|
+
const budget = this.COVERING_CELL_BUDGET;
|
|
208
|
+
let fallback = null;
|
|
209
|
+
for (const precision of [...this.INDEX_PRECISIONS].sort((a, b) => a - b)) {
|
|
210
|
+
const cells = this.walkCovering(lat, lon, radiusKm, precision, budget + 1);
|
|
211
|
+
if (cells.length > budget) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (radiusKm > 0 && this.coveredRatio(cells, radiusKm) <= this.ACCEPTABLE_COVER_RATIO) {
|
|
215
|
+
return precision;
|
|
216
|
+
}
|
|
217
|
+
fallback = precision;
|
|
152
218
|
}
|
|
153
|
-
if (
|
|
154
|
-
return
|
|
219
|
+
if (fallback !== null) {
|
|
220
|
+
return fallback;
|
|
155
221
|
}
|
|
156
|
-
|
|
222
|
+
throw new Error(
|
|
223
|
+
`A ${radiusKm} km radius at latitude ${lat.toFixed(1)} needs more than ${budget} geohash cells at every indexed precision (${this.INDEX_PRECISIONS.join(", ")}). Geohash cells narrow toward the poles, so the same radius costs more cells the further from the equator it is asked. Use a smaller radius, move the search nearer the equator, or index a coarser precision.`
|
|
224
|
+
);
|
|
157
225
|
}
|
|
158
|
-
static precisionForRadius(radiusKm) {
|
|
159
|
-
return this.optimalPrecisionForRadius(radiusKm);
|
|
226
|
+
static precisionForRadius(radiusKm, lat = 0, lon = 0) {
|
|
227
|
+
return this.optimalPrecisionForRadius(radiusKm, lat, lon);
|
|
160
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* GeoHashes whose cells cover the search circle.
|
|
231
|
+
*
|
|
232
|
+
* The covering is always complete. It used to stop at 64 cells and return
|
|
233
|
+
* what it had, so a caller asking for 50 km got cells covering 18% of that
|
|
234
|
+
* circle — with no error. Now the precision is chosen to fit the budget and
|
|
235
|
+
* the walk always finishes, so the result either covers the circle or the
|
|
236
|
+
* call refuses.
|
|
237
|
+
*
|
|
238
|
+
* Passing `precision` explicitly overrides the choice, and is checked against
|
|
239
|
+
* {@link INDEX_PRECISIONS}: entities carry tags only at those, so any other
|
|
240
|
+
* precision matches nothing at all rather than matching loosely.
|
|
241
|
+
*/
|
|
161
242
|
static getCoveringHashes(lat, lon, radiusKm, precision) {
|
|
162
243
|
if (radiusKm < 0) {
|
|
163
244
|
throw new Error("Radius must be non-negative.");
|
|
164
245
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
246
|
+
let resolved;
|
|
247
|
+
if (precision === void 0) {
|
|
248
|
+
resolved = this.optimalPrecisionForRadius(radiusKm, lat, lon);
|
|
249
|
+
} else {
|
|
250
|
+
this.assertPrecision(precision);
|
|
251
|
+
if (!this.INDEX_PRECISIONS.includes(precision)) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Precision ${precision} is not indexed, so a covering at it matches nothing. Indexed precisions: ${this.INDEX_PRECISIONS.join(", ")}.`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
resolved = precision;
|
|
257
|
+
}
|
|
258
|
+
return this.walkCovering(lat, lon, radiusKm, resolved, null);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Every cell at `precision` that intersects the circle, breadth-first from
|
|
262
|
+
* the centre and expanding only through cells that intersect.
|
|
263
|
+
*
|
|
264
|
+
* `limit` exists only so the precision chooser can stop early once a
|
|
265
|
+
* precision is known not to fit; a null limit walks to completion, which is
|
|
266
|
+
* what every caller that wants an answer passes.
|
|
267
|
+
*/
|
|
268
|
+
static walkCovering(lat, lon, radiusKm, precision, limit) {
|
|
168
269
|
const covering = [];
|
|
169
270
|
const visited = /* @__PURE__ */ new Set();
|
|
170
|
-
const queue = [
|
|
271
|
+
const queue = [this.encode(lat, lon, precision)];
|
|
171
272
|
while (queue.length > 0) {
|
|
172
273
|
const current = queue.shift();
|
|
173
274
|
if (current === void 0 || visited.has(current)) {
|
|
@@ -178,8 +279,8 @@ var GeoHash = class {
|
|
|
178
279
|
continue;
|
|
179
280
|
}
|
|
180
281
|
covering.push(current);
|
|
181
|
-
if (covering.length >=
|
|
182
|
-
|
|
282
|
+
if (limit !== null && covering.length >= limit) {
|
|
283
|
+
return covering;
|
|
183
284
|
}
|
|
184
285
|
for (const neighbor of this.neighbors(current)) {
|
|
185
286
|
if (!visited.has(neighbor)) {
|
|
@@ -208,9 +309,49 @@ var GeoHash = class {
|
|
|
208
309
|
static cellIntersectsCircle(hash, lat, lon, radiusKm) {
|
|
209
310
|
const bounds = this.decodeBounds(hash);
|
|
210
311
|
const closestLat = Math.min(Math.max(lat, bounds.latMin), bounds.latMax);
|
|
211
|
-
const closestLon =
|
|
312
|
+
const closestLon = this.closestLongitude(lon, bounds.lonMin, bounds.lonMax);
|
|
212
313
|
return this.haversineKm(lat, lon, closestLat, closestLon) <= radiusKm;
|
|
213
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* The longitude in [lonMin, lonMax] nearest to `lon`, going the short way
|
|
317
|
+
* round the globe.
|
|
318
|
+
*
|
|
319
|
+
* A plain clamp is wrong at the antimeridian, because -180 and +180 are the
|
|
320
|
+
* same meridian and a numeric comparison does not know it. Measured before
|
|
321
|
+
* this: a query at lon 179.99 against the cell spanning -180 to -179.989
|
|
322
|
+
* clamped to -179.989 and measured 2.334 km, when the true nearest point is
|
|
323
|
+
* -180.0 at 1.112 km. The cell was rejected from a 2 km radius it is well
|
|
324
|
+
* inside, and five of sixteen points on that circle's rim fell outside the
|
|
325
|
+
* covering — silently.
|
|
326
|
+
*
|
|
327
|
+
* Working in deltas normalised to +/-180 removes the discontinuity: the cell
|
|
328
|
+
* either straddles the query meridian, or lies wholly to one side of it and
|
|
329
|
+
* the nearer edge is the answer.
|
|
330
|
+
*/
|
|
331
|
+
static closestLongitude(lon, lonMin, lonMax) {
|
|
332
|
+
const toMin = this.normalizeLonDelta(lonMin - lon);
|
|
333
|
+
const toMax = this.normalizeLonDelta(lonMax - lon);
|
|
334
|
+
if (toMin <= 0 && toMax >= 0) {
|
|
335
|
+
return lon;
|
|
336
|
+
}
|
|
337
|
+
return Math.abs(toMin) <= Math.abs(toMax) ? lonMin : lonMax;
|
|
338
|
+
}
|
|
339
|
+
/** A longitude difference folded into [-180, 180]. */
|
|
340
|
+
static normalizeLonDelta(delta) {
|
|
341
|
+
return ((delta + 180) % 360 + 360) % 360 - 180;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Covered area divided by the circle's, so a precision can be judged on what
|
|
345
|
+
* it wastes rather than only on what it costs.
|
|
346
|
+
*/
|
|
347
|
+
static coveredRatio(cells, radiusKm) {
|
|
348
|
+
const rad = (d) => d * Math.PI / 180;
|
|
349
|
+
const covered = cells.reduce((sum, hash) => {
|
|
350
|
+
const b = this.decodeBounds(hash);
|
|
351
|
+
return sum + this.EARTH_RADIUS_KM * rad(b.latMax - b.latMin) * this.EARTH_RADIUS_KM * Math.cos(rad((b.latMax + b.latMin) / 2)) * rad(b.lonMax - b.lonMin);
|
|
352
|
+
}, 0);
|
|
353
|
+
return covered / (Math.PI * radiusKm ** 2);
|
|
354
|
+
}
|
|
214
355
|
static adjacent(hash, direction) {
|
|
215
356
|
if (hash.length === 0) {
|
|
216
357
|
throw new Error("GeoHash must not be empty.");
|
|
@@ -1072,7 +1213,37 @@ var PulseIndexClient = class _PulseIndexClient {
|
|
|
1072
1213
|
return {
|
|
1073
1214
|
matchedEntityIds: (raw.matchedEntityIds ?? []).map((id) => String(id)),
|
|
1074
1215
|
totalMatches: Number(raw.totalMatches ?? 0),
|
|
1075
|
-
executionTimeUs: Number(raw.executionTimeUs ?? 0)
|
|
1216
|
+
executionTimeUs: Number(raw.executionTimeUs ?? 0),
|
|
1217
|
+
// Exact only when nothing made the engine stop early. limit=0 asks for
|
|
1218
|
+
// the count and no ids, and is the only shape that counts every match;
|
|
1219
|
+
// any page can early-exit as soon as it is full.
|
|
1220
|
+
totalIsExact: builder.toArray().limit === 0
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* A page of ids together with the real number of matches.
|
|
1225
|
+
*
|
|
1226
|
+
* A paged search stops as soon as the page is full — that is what makes it
|
|
1227
|
+
* cost microseconds — so its total is whatever it had counted when it
|
|
1228
|
+
* stopped. Measured on a million entities: a query with 166,325 matches
|
|
1229
|
+
* reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
|
|
1230
|
+
* that number is wrong by an order of magnitude and looks fine.
|
|
1231
|
+
*
|
|
1232
|
+
* This sends the count query as well, so it costs two round trips and returns
|
|
1233
|
+
* a total you can divide by a page size.
|
|
1234
|
+
*/
|
|
1235
|
+
async searchWithTotal(query) {
|
|
1236
|
+
const page = await this.search(query);
|
|
1237
|
+
if (page.totalIsExact) {
|
|
1238
|
+
return page;
|
|
1239
|
+
}
|
|
1240
|
+
const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query);
|
|
1241
|
+
const counted = await this.search(builder.limit(0));
|
|
1242
|
+
return {
|
|
1243
|
+
matchedEntityIds: page.matchedEntityIds,
|
|
1244
|
+
totalMatches: counted.totalMatches,
|
|
1245
|
+
executionTimeUs: page.executionTimeUs + counted.executionTimeUs,
|
|
1246
|
+
totalIsExact: true
|
|
1076
1247
|
};
|
|
1077
1248
|
}
|
|
1078
1249
|
async index(entityIdOrInput, attributes = {}) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pulseindex/sdk",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
3
|
+
"version": "4.0.1",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/mohammed-alfarra/pulseindex-js.git"
|
|
7
|
+
},
|
|
8
|
+
"description": "Official Node.js & TypeScript SDK for PulseIndex \u2014 hosted search and filtering for large entity sets",
|
|
5
9
|
"license": "MIT",
|
|
6
10
|
"author": "PulseIndex",
|
|
7
11
|
"keywords": [
|