@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 CHANGED
@@ -1,6 +1,119 @@
1
1
  # Changelog
2
2
 
3
- ## 3.2.0
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
- private static readonly MAX_COVERING_CELLS;
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
- static optimalPrecisionForRadius(radiusKm: number): number;
376
- static precisionForRadius(radiusKm: number): number;
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
- private static readonly MAX_COVERING_CELLS;
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
- static optimalPrecisionForRadius(radiusKm: number): number;
376
- static precisionForRadius(radiusKm: number): number;
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
- static MAX_COVERING_CELLS = 64;
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
- static optimalPrecisionForRadius(radiusKm) {
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
- if (radiusKm <= 1.5) {
174
- return 6;
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 (radiusKm <= 8) {
177
- return 5;
242
+ if (fallback !== null) {
243
+ return fallback;
178
244
  }
179
- return 4;
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
- const resolvedPrecision = precision ?? this.optimalPrecisionForRadius(radiusKm);
189
- this.assertPrecision(resolvedPrecision);
190
- const center = this.encode(lat, lon, resolvedPrecision);
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 = [center];
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 >= this.MAX_COVERING_CELLS) {
205
- break;
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 = Math.min(Math.max(lon, bounds.lonMin), bounds.lonMax);
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
- static MAX_COVERING_CELLS = 64;
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
- static optimalPrecisionForRadius(radiusKm) {
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
- if (radiusKm <= 1.5) {
151
- return 6;
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 (radiusKm <= 8) {
154
- return 5;
219
+ if (fallback !== null) {
220
+ return fallback;
155
221
  }
156
- return 4;
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
- const resolvedPrecision = precision ?? this.optimalPrecisionForRadius(radiusKm);
166
- this.assertPrecision(resolvedPrecision);
167
- const center = this.encode(lat, lon, resolvedPrecision);
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 = [center];
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 >= this.MAX_COVERING_CELLS) {
182
- break;
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 = Math.min(Math.max(lon, bounds.lonMin), bounds.lonMax);
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": "3.2.0",
4
- "description": "Official Node.js & TypeScript SDK for PulseIndex — hosted search and filtering for large entity sets",
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": [