@pulseindex/sdk 3.2.0 → 4.0.0

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