@pulseindex/sdk 4.0.0 → 5.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,5 +1,120 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.0.0
4
+
5
+ Needs a PulseIndex engine at v2.0.0 or later. The wire contract is a
6
+ breaking change: an SDK at 4.x cannot talk to a v2 engine, and this cannot
7
+ talk to a v1 one.
8
+
9
+ ### A radius that means what it says, and "the nearest K"
10
+
11
+ A circle used to be a union of geohash cells, and a union of cells is a superset
12
+ of the circle. Measured against a million entities, a 1 km search returned
13
+ **2,479** rows where **1,241** were really inside — and nothing in the answer
14
+ said which. You hydrated all 2,479 from your own store and measured them again.
15
+
16
+ Send a position with the record and the engine settles the edge itself:
17
+
18
+ points: { where: { lat: 41.0369, lon: 28.9850 } }
19
+
20
+ Then `withinRadius({ ..., field: 'where' })` narrows on the cells and measures
21
+ the true distance, or `within('where', lat, lon, km)` measures without them.
22
+ Degrees go on the wire and the engine packs them: a representation split between
23
+ this package and the engine, with nothing comparing the two, is how the geo
24
+ defects in 4.0.0 happened.
25
+
26
+ `nearest('where', lat, lon)` orders by distance, nearest first. That was not a
27
+ question you could ask before at any price. It needs no radius guessed to make
28
+ it quick, and combines with every other filter, so "the closest ten available
29
+ drivers with room" is one request.
30
+
31
+ Ordering is to the centimetre, which is the precision a stored position has.
32
+
33
+
34
+ ### The result count now says whether it is the whole count
35
+
36
+ A paged search stops as soon as the page is full, so the `totalMatches` it
37
+ carried was only what the engine had reached by then. Nothing said so, and it
38
+ does not look partial: measured on 500,000 records that all matched, a page of
39
+ 20 reported **65,536**; a harder query reported **48,105** against a true total
40
+ of **333,895**. Any interface printing "N results" from that was wrong by
41
+ several times over and looked fine.
42
+
43
+ `totalIsExact` now comes from the engine instead of being guessed from
44
+ `limit === 0`, and `searchWithTotal()` is **one request instead of two** — the
45
+ wire can ask for a page and a true count together. A page whose matches all fit
46
+ inside it is reported exact, which the old guess got wrong.
47
+
48
+
49
+ ### A record carries whatever numbers you name, and no field the engine chose
50
+
51
+ An entity used to be forced through one `uint32` called `price` and one `uint64`
52
+ bitfield called `locationPrefix`. That is a schema this SDK had no business
53
+ imposing: one number per record, under a name we picked, with no negatives, and
54
+ nothing above 4,294,967,295. A rating, a capacity, a timestamp, an elevation and
55
+ a balance were all the same field or no field at all.
56
+
57
+ Both are gone. A record now carries `numbers`, under your own names:
58
+
59
+ client.index('1001', {
60
+ categories: ['feature:pool'],
61
+ numbers: { price_cents: 45000, bedrooms: 3, built_at: 1712000000 },
62
+ })
63
+
64
+ Any name, any 64-bit integer, any number of them, and every one is filterable
65
+ through `range()` and orderable through `sortBy()`. A name means nothing to the
66
+ engine beyond its hash.
67
+
68
+ ### Three things this fixes that were losing data quietly
69
+
70
+ **A fraction was floored without a word.** `4.3` was sent as `4`, `0.5` as `0`,
71
+ `199.99` as `199` — measured, not inferred. The engine's column is a 64-bit
72
+ integer, so a fraction is refused now, with the scaling it needs named. Keep the
73
+ scale on your side: a price in cents, a rating out of 100.
74
+
75
+ **A field of your own called `price` disappeared.** Sixteen key names were
76
+ reserved out of your attributes, and anything under one was dropped: measured,
77
+ `{price: 250, rating: 4.3, lat: 41, kind: 'villa'}` came out as
78
+ `["rating:4.3", "kind:villa"]` — `price` and `lat` gone, no error. Numbers go
79
+ through `numbers` now, so nothing in your own object is swallowed.
80
+
81
+ **A range or an order on a field nothing carries was answered, not refused.**
82
+ It excluded every entity, or left the page in insertion order and reported it as
83
+ sorted. At ten million records `bedrooms 3..6` returned 0 while the tag
84
+ `bedrooms:3` returned 1,666,667. The engine refuses it by name now — but only
85
+ when the tenant holds entities and none of them carries that field, because an
86
+ empty tenant has nothing to be wrong about.
87
+
88
+ ### Migrating
89
+
90
+ - `index(id, {price: N})` → `index(id, {numbers: {price: N}})`. A top-level
91
+ `price` is no longer special; it becomes the tag `price:N` like any other
92
+ scalar attribute.
93
+ - `indexEntity(id, categories, price, locationPrefix, tenantId)` →
94
+ `indexEntity(id, categories, numbers, tenantId)`.
95
+ - `.location(prefix)` on the query builder is gone. Nothing ever sent it: both
96
+ SDKs passed 0 on every request.
97
+ - A range bound may now be negative or past 4,294,967,295.
98
+
99
+ This needs an engine built from the same commit. The proto is a breaking change:
100
+ field numbers 2 and 3 on `IndexEntityRequest`, and 1 on `SearchQueryRequest`, are
101
+ reserved rather than reused.
102
+
103
+ ## 4.0.1
104
+
105
+ ### The covering threshold, corrected against a real app
106
+
107
+ `withinRadius` is a pre-filter the caller narrows exactly afterwards, so excess
108
+ area is cheaper than predicates. 4.0.0's threshold was too strict: it rejected
109
+ the coarse cell at 5 km, turning a 10-cell covering into 167. Measured through
110
+ the sibling PHP SDK against a real application with 100,000 properties, a 5 km
111
+ radius went from 3,082 µs to 926 µs on wall time — the cost is the request, not
112
+ the search.
113
+
114
+ The coarse cell is now taken up to 3.0× the circle, which still rejects it at
115
+ 2 km where it wastes 4.73× to 6.91×. Both SDKs are regenerated against one
116
+ shared vector fixture, so they still agree cell for cell.
117
+
3
118
  ## 4.0.0
4
119
 
5
120
  **A major, not a minor.** The previous draft of these notes said 3.2.0. Checking
package/dist/index.d.mts CHANGED
@@ -27,19 +27,38 @@ interface SortSpec {
27
27
  field: string;
28
28
  /** Largest first when true; smallest first otherwise. */
29
29
  descending: boolean;
30
+ /**
31
+ * Order by distance from the query's geo predicate instead of by a field
32
+ * value. `field` is ignored.
33
+ *
34
+ * Ordering is to the centimetre, which is the precision a stored position
35
+ * has. Rows closer together than that tie, and a tie breaks on the entity id
36
+ * so the same query returns the same page.
37
+ */
38
+ byDistance?: boolean;
30
39
  }
31
40
  interface RangePredicate {
32
41
  field: string;
33
42
  minVal: number;
34
43
  maxVal: number;
35
44
  }
45
+ /** A circle, and the position field to measure it against. */
46
+ interface GeoPredicate {
47
+ field: string;
48
+ lat: number;
49
+ lon: number;
50
+ /** Inclusive. 0 means no radius bound, only an origin to measure from. */
51
+ radiusKm: number;
52
+ }
36
53
  interface SearchQueryRequest {
37
- locationPrefix: string;
38
54
  filters: FilterPredicate[];
55
+ geo?: GeoPredicate;
39
56
  ranges: RangePredicate[];
40
57
  limit: number;
41
58
  offset: number;
42
59
  tenantId: string;
60
+ /** Count every match rather than stopping when the page is full. */
61
+ exactTotal: boolean;
43
62
  /** Absent returns matches in entity-id order. */
44
63
  sort?: SortSpec;
45
64
  }
@@ -61,8 +80,8 @@ interface SearchResponse {
61
80
  }
62
81
  interface IndexEntityRequest {
63
82
  entityId: string;
64
- locationPrefix: string;
65
- price: number;
83
+ numbers: Record<string, number>;
84
+ points: Record<string, GeoPoint>;
66
85
  categories: string[];
67
86
  tenantId: string;
68
87
  }
@@ -88,10 +107,21 @@ interface RadiusOptions {
88
107
  lon?: number;
89
108
  radiusKm: number;
90
109
  precision?: number;
110
+ /**
111
+ * The position field to measure against, as named in `points` when indexing.
112
+ *
113
+ * Given one, the engine narrows on the geohash cells and then measures the
114
+ * true distance, so the answer holds only what is really inside the circle.
115
+ * Without it the cells are the whole answer, and a union of cells is a
116
+ * superset: measured at a million entities, a 1 km search returned 2,479
117
+ * rows where 1,241 were inside.
118
+ */
119
+ field?: string;
91
120
  }
92
121
  interface SearchRequestOptions {
93
122
  tenantId?: string;
94
- locationPrefix?: EntityId;
123
+ /** Count every match rather than stopping when the page is full. */
124
+ exactTotal?: boolean;
95
125
  must?: string | string[];
96
126
  should?: string | string[];
97
127
  mustNot?: string | string[];
@@ -118,9 +148,7 @@ interface SearchRequestOptions {
118
148
  interface EntityAttributes {
119
149
  categories?: unknown;
120
150
  tags?: unknown;
121
- price?: unknown;
122
- locationPrefix?: unknown;
123
- location_prefix?: unknown;
151
+ numbers?: unknown;
124
152
  tenantId?: unknown;
125
153
  tenant_id?: unknown;
126
154
  latitude?: unknown;
@@ -137,9 +165,7 @@ interface EntityInput {
137
165
  attributes?: EntityAttributes;
138
166
  categories?: unknown;
139
167
  tags?: unknown;
140
- price?: unknown;
141
- locationPrefix?: unknown;
142
- location_prefix?: unknown;
168
+ numbers?: unknown;
143
169
  tenantId?: unknown;
144
170
  tenant_id?: unknown;
145
171
  latitude?: unknown;
@@ -156,11 +182,21 @@ interface BatchEntityInput {
156
182
  attributes?: EntityAttributes;
157
183
  [key: string]: unknown;
158
184
  }
185
+ /** One position in degrees. The engine packs it; this SDK does not. */
186
+ interface GeoPoint {
187
+ lat: number;
188
+ lon: number;
189
+ }
159
190
  interface EncodedEntity {
160
191
  entityId: string;
161
192
  categories: string[];
162
- price: number;
163
- locationPrefix: string;
193
+ /** Positions under your own names. */
194
+ points: Record<string, GeoPoint>;
195
+ /**
196
+ * Numeric fields under your own names. Any name, any integer, any number of
197
+ * them. This replaced a single `price` field the engine named for you.
198
+ */
199
+ numbers: Record<string, number>;
164
200
  tenantId: string;
165
201
  }
166
202
  interface PulseIndexClientConfig {
@@ -198,7 +234,15 @@ declare class QueryBuilder {
198
234
  private state;
199
235
  constructor(executor?: QueryExecutor | null);
200
236
  tenant(tenantId: string): QueryBuilder;
201
- location(locationPrefix: string | number | bigint): QueryBuilder;
237
+ /**
238
+ * Count every match instead of stopping as soon as the page is full.
239
+ *
240
+ * A paged search stops early, so the `totalMatches` it carries is only what
241
+ * the engine had counted by then — a lower bound, and one that does not look
242
+ * like one. This makes the count exact in the same request; `totalIsExact`
243
+ * on the response says which you got.
244
+ */
245
+ exactTotal(enabled?: boolean): QueryBuilder;
202
246
  must(attribute: string | string[]): QueryBuilder;
203
247
  /**
204
248
  * At least one of these has to match.
@@ -216,6 +260,15 @@ declare class QueryBuilder {
216
260
  inGeoHash(geohash: string): QueryBuilder;
217
261
  withinRadius(lat: number, lon: number, radiusKm: number, precision?: number): QueryBuilder;
218
262
  withinRadius(options: RadiusOptions): QueryBuilder;
263
+ /**
264
+ * Filter on a numeric field's inclusive range.
265
+ *
266
+ * `price` is the only number an entity carries. Naming any other field is
267
+ * refused by the engine rather than answered, because a field nothing carries
268
+ * can only match nothing, and an empty page looks exactly like a real one.
269
+ * Model any other number as a category token instead: `must('bedrooms:3')`,
270
+ * or several in one SHOULD group for a range of values.
271
+ */
219
272
  range(field: string, min: number, max: number): QueryBuilder;
220
273
  /**
221
274
  * How many ids to return. Zero asks the engine for the number of matches
@@ -234,11 +287,31 @@ declare class QueryBuilder {
234
287
  /** Order the page by a numeric field, largest first. */
235
288
  sortDesc(field: string): QueryBuilder;
236
289
  /**
237
- * Order the page by a numeric field. Rows carrying no value for it sort last
238
- * in both directions; they still count towards `totalMatches`, they simply
239
- * have nothing to be ordered by.
290
+ * Order the page by a numeric field.
291
+ *
292
+ * Bounded exactly as {@link range} is: `price` is the only field an entity
293
+ * carries, and any other name is refused rather than silently ignored. An
294
+ * order by a field nothing carries used to leave the page in insertion order
295
+ * and report it as sorted.
240
296
  */
241
297
  sortBy(field: string, descending?: boolean): QueryBuilder;
298
+ /**
299
+ * Keep only entities within `radiusKm` of the point, measured exactly.
300
+ *
301
+ * This is the circle on its own. {@link withinRadius} with a `field` adds
302
+ * the geohash cells too, which is what stops the engine opening every part
303
+ * of the index to find them.
304
+ */
305
+ within(field: string, lat: number, lon: number, radiusKm: number): QueryBuilder;
306
+ /**
307
+ * Order the page by distance from the point, nearest first.
308
+ *
309
+ * Without a radius this is "the nearest K of whatever else matched"; combine
310
+ * it with {@link within} or {@link withinRadius} to bound the search as well.
311
+ * It used to be impossible: a radius returned everything inside it unordered,
312
+ * so you hydrated every id from your own store before you could sort them.
313
+ */
314
+ nearest(field: string, lat: number, lon: number): QueryBuilder;
242
315
  toRequest(defaultTenantId?: string): SearchQueryRequest;
243
316
  toArray(defaultTenantId?: string): SearchQueryRequest;
244
317
  execute(): Promise<SearchResponse>;
@@ -318,12 +391,21 @@ declare class PulseIndexClient implements QueryExecutor {
318
391
  * reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
319
392
  * that number is wrong by an order of magnitude and looks fine.
320
393
  *
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.
394
+ * One request. This used to run the whole query twice once for the page,
395
+ * once for the count because the wire had no way to ask for both. It does
396
+ * now, so this is the same round trip with `exactTotal` set, and the total
397
+ * you get back can be divided by a page size.
323
398
  */
324
399
  searchWithTotal(query: QueryBuilder | SearchRequestOptions): Promise<SearchResponse>;
325
400
  index(entityIdOrInput: EntityId | EntityInput, attributes?: EntityAttributes): Promise<IndexEntityResponse>;
326
- indexEntity(entityId: EntityId, categories?: string[], price?: number, locationPrefix?: EntityId, tenantId?: string): Promise<boolean>;
401
+ /**
402
+ * Index one record.
403
+ *
404
+ * `numbers` are yours to name: `{price_cents: 45000, bedrooms: 3}`. This
405
+ * used to take a single `price` and a `locationPrefix`, which was a schema
406
+ * this SDK had no business imposing.
407
+ */
408
+ indexEntity(entityId: EntityId, categories?: string[], numbers?: Record<string, number>, tenantId?: string): Promise<boolean>;
327
409
  batchIndex(entities: Array<EntityInput | BatchEntityInput>): Promise<BatchIndexResponse>;
328
410
  delete(entityId: EntityId, tenantId?: string): Promise<DeleteResponse>;
329
411
  deleteEntity(entityId: EntityId, tenantId?: string): Promise<boolean>;
@@ -402,11 +484,17 @@ declare class GeoHash {
402
484
  * How much area outside the circle a covering may carry before a finer
403
485
  * precision is worth its cell count.
404
486
  *
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.
487
+ * `withinRadius` is a pre-filter the caller narrows exactly afterwards, so
488
+ * excess area is cheaper than predicates.
489
+ *
490
+ * 2.0 was the first attempt and it was too strict. It rejected the coarse
491
+ * cell at 5 km, so a covering that had cost 10 cells cost 167, and the demo
492
+ * benchmark went from beating PostgreSQL to losing to it by 2.76x on wall
493
+ * time — the cost is the request, not the search. 3.0 keeps the cheap
494
+ * covering at 5 km (2.76x) and still rejects the coarse cell at 2 km, where
495
+ * it wastes 4.73x to 6.91x depending on latitude.
408
496
  */
409
- static readonly ACCEPTABLE_COVER_RATIO = 2;
497
+ static readonly ACCEPTABLE_COVER_RATIO = 3;
410
498
  private static readonly NEIGHBORS;
411
499
  private static readonly BORDERS;
412
500
  static encode(lat: number, lon: number, precision?: number): string;
package/dist/index.d.ts CHANGED
@@ -27,19 +27,38 @@ interface SortSpec {
27
27
  field: string;
28
28
  /** Largest first when true; smallest first otherwise. */
29
29
  descending: boolean;
30
+ /**
31
+ * Order by distance from the query's geo predicate instead of by a field
32
+ * value. `field` is ignored.
33
+ *
34
+ * Ordering is to the centimetre, which is the precision a stored position
35
+ * has. Rows closer together than that tie, and a tie breaks on the entity id
36
+ * so the same query returns the same page.
37
+ */
38
+ byDistance?: boolean;
30
39
  }
31
40
  interface RangePredicate {
32
41
  field: string;
33
42
  minVal: number;
34
43
  maxVal: number;
35
44
  }
45
+ /** A circle, and the position field to measure it against. */
46
+ interface GeoPredicate {
47
+ field: string;
48
+ lat: number;
49
+ lon: number;
50
+ /** Inclusive. 0 means no radius bound, only an origin to measure from. */
51
+ radiusKm: number;
52
+ }
36
53
  interface SearchQueryRequest {
37
- locationPrefix: string;
38
54
  filters: FilterPredicate[];
55
+ geo?: GeoPredicate;
39
56
  ranges: RangePredicate[];
40
57
  limit: number;
41
58
  offset: number;
42
59
  tenantId: string;
60
+ /** Count every match rather than stopping when the page is full. */
61
+ exactTotal: boolean;
43
62
  /** Absent returns matches in entity-id order. */
44
63
  sort?: SortSpec;
45
64
  }
@@ -61,8 +80,8 @@ interface SearchResponse {
61
80
  }
62
81
  interface IndexEntityRequest {
63
82
  entityId: string;
64
- locationPrefix: string;
65
- price: number;
83
+ numbers: Record<string, number>;
84
+ points: Record<string, GeoPoint>;
66
85
  categories: string[];
67
86
  tenantId: string;
68
87
  }
@@ -88,10 +107,21 @@ interface RadiusOptions {
88
107
  lon?: number;
89
108
  radiusKm: number;
90
109
  precision?: number;
110
+ /**
111
+ * The position field to measure against, as named in `points` when indexing.
112
+ *
113
+ * Given one, the engine narrows on the geohash cells and then measures the
114
+ * true distance, so the answer holds only what is really inside the circle.
115
+ * Without it the cells are the whole answer, and a union of cells is a
116
+ * superset: measured at a million entities, a 1 km search returned 2,479
117
+ * rows where 1,241 were inside.
118
+ */
119
+ field?: string;
91
120
  }
92
121
  interface SearchRequestOptions {
93
122
  tenantId?: string;
94
- locationPrefix?: EntityId;
123
+ /** Count every match rather than stopping when the page is full. */
124
+ exactTotal?: boolean;
95
125
  must?: string | string[];
96
126
  should?: string | string[];
97
127
  mustNot?: string | string[];
@@ -118,9 +148,7 @@ interface SearchRequestOptions {
118
148
  interface EntityAttributes {
119
149
  categories?: unknown;
120
150
  tags?: unknown;
121
- price?: unknown;
122
- locationPrefix?: unknown;
123
- location_prefix?: unknown;
151
+ numbers?: unknown;
124
152
  tenantId?: unknown;
125
153
  tenant_id?: unknown;
126
154
  latitude?: unknown;
@@ -137,9 +165,7 @@ interface EntityInput {
137
165
  attributes?: EntityAttributes;
138
166
  categories?: unknown;
139
167
  tags?: unknown;
140
- price?: unknown;
141
- locationPrefix?: unknown;
142
- location_prefix?: unknown;
168
+ numbers?: unknown;
143
169
  tenantId?: unknown;
144
170
  tenant_id?: unknown;
145
171
  latitude?: unknown;
@@ -156,11 +182,21 @@ interface BatchEntityInput {
156
182
  attributes?: EntityAttributes;
157
183
  [key: string]: unknown;
158
184
  }
185
+ /** One position in degrees. The engine packs it; this SDK does not. */
186
+ interface GeoPoint {
187
+ lat: number;
188
+ lon: number;
189
+ }
159
190
  interface EncodedEntity {
160
191
  entityId: string;
161
192
  categories: string[];
162
- price: number;
163
- locationPrefix: string;
193
+ /** Positions under your own names. */
194
+ points: Record<string, GeoPoint>;
195
+ /**
196
+ * Numeric fields under your own names. Any name, any integer, any number of
197
+ * them. This replaced a single `price` field the engine named for you.
198
+ */
199
+ numbers: Record<string, number>;
164
200
  tenantId: string;
165
201
  }
166
202
  interface PulseIndexClientConfig {
@@ -198,7 +234,15 @@ declare class QueryBuilder {
198
234
  private state;
199
235
  constructor(executor?: QueryExecutor | null);
200
236
  tenant(tenantId: string): QueryBuilder;
201
- location(locationPrefix: string | number | bigint): QueryBuilder;
237
+ /**
238
+ * Count every match instead of stopping as soon as the page is full.
239
+ *
240
+ * A paged search stops early, so the `totalMatches` it carries is only what
241
+ * the engine had counted by then — a lower bound, and one that does not look
242
+ * like one. This makes the count exact in the same request; `totalIsExact`
243
+ * on the response says which you got.
244
+ */
245
+ exactTotal(enabled?: boolean): QueryBuilder;
202
246
  must(attribute: string | string[]): QueryBuilder;
203
247
  /**
204
248
  * At least one of these has to match.
@@ -216,6 +260,15 @@ declare class QueryBuilder {
216
260
  inGeoHash(geohash: string): QueryBuilder;
217
261
  withinRadius(lat: number, lon: number, radiusKm: number, precision?: number): QueryBuilder;
218
262
  withinRadius(options: RadiusOptions): QueryBuilder;
263
+ /**
264
+ * Filter on a numeric field's inclusive range.
265
+ *
266
+ * `price` is the only number an entity carries. Naming any other field is
267
+ * refused by the engine rather than answered, because a field nothing carries
268
+ * can only match nothing, and an empty page looks exactly like a real one.
269
+ * Model any other number as a category token instead: `must('bedrooms:3')`,
270
+ * or several in one SHOULD group for a range of values.
271
+ */
219
272
  range(field: string, min: number, max: number): QueryBuilder;
220
273
  /**
221
274
  * How many ids to return. Zero asks the engine for the number of matches
@@ -234,11 +287,31 @@ declare class QueryBuilder {
234
287
  /** Order the page by a numeric field, largest first. */
235
288
  sortDesc(field: string): QueryBuilder;
236
289
  /**
237
- * Order the page by a numeric field. Rows carrying no value for it sort last
238
- * in both directions; they still count towards `totalMatches`, they simply
239
- * have nothing to be ordered by.
290
+ * Order the page by a numeric field.
291
+ *
292
+ * Bounded exactly as {@link range} is: `price` is the only field an entity
293
+ * carries, and any other name is refused rather than silently ignored. An
294
+ * order by a field nothing carries used to leave the page in insertion order
295
+ * and report it as sorted.
240
296
  */
241
297
  sortBy(field: string, descending?: boolean): QueryBuilder;
298
+ /**
299
+ * Keep only entities within `radiusKm` of the point, measured exactly.
300
+ *
301
+ * This is the circle on its own. {@link withinRadius} with a `field` adds
302
+ * the geohash cells too, which is what stops the engine opening every part
303
+ * of the index to find them.
304
+ */
305
+ within(field: string, lat: number, lon: number, radiusKm: number): QueryBuilder;
306
+ /**
307
+ * Order the page by distance from the point, nearest first.
308
+ *
309
+ * Without a radius this is "the nearest K of whatever else matched"; combine
310
+ * it with {@link within} or {@link withinRadius} to bound the search as well.
311
+ * It used to be impossible: a radius returned everything inside it unordered,
312
+ * so you hydrated every id from your own store before you could sort them.
313
+ */
314
+ nearest(field: string, lat: number, lon: number): QueryBuilder;
242
315
  toRequest(defaultTenantId?: string): SearchQueryRequest;
243
316
  toArray(defaultTenantId?: string): SearchQueryRequest;
244
317
  execute(): Promise<SearchResponse>;
@@ -318,12 +391,21 @@ declare class PulseIndexClient implements QueryExecutor {
318
391
  * reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
319
392
  * that number is wrong by an order of magnitude and looks fine.
320
393
  *
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.
394
+ * One request. This used to run the whole query twice once for the page,
395
+ * once for the count because the wire had no way to ask for both. It does
396
+ * now, so this is the same round trip with `exactTotal` set, and the total
397
+ * you get back can be divided by a page size.
323
398
  */
324
399
  searchWithTotal(query: QueryBuilder | SearchRequestOptions): Promise<SearchResponse>;
325
400
  index(entityIdOrInput: EntityId | EntityInput, attributes?: EntityAttributes): Promise<IndexEntityResponse>;
326
- indexEntity(entityId: EntityId, categories?: string[], price?: number, locationPrefix?: EntityId, tenantId?: string): Promise<boolean>;
401
+ /**
402
+ * Index one record.
403
+ *
404
+ * `numbers` are yours to name: `{price_cents: 45000, bedrooms: 3}`. This
405
+ * used to take a single `price` and a `locationPrefix`, which was a schema
406
+ * this SDK had no business imposing.
407
+ */
408
+ indexEntity(entityId: EntityId, categories?: string[], numbers?: Record<string, number>, tenantId?: string): Promise<boolean>;
327
409
  batchIndex(entities: Array<EntityInput | BatchEntityInput>): Promise<BatchIndexResponse>;
328
410
  delete(entityId: EntityId, tenantId?: string): Promise<DeleteResponse>;
329
411
  deleteEntity(entityId: EntityId, tenantId?: string): Promise<boolean>;
@@ -402,11 +484,17 @@ declare class GeoHash {
402
484
  * How much area outside the circle a covering may carry before a finer
403
485
  * precision is worth its cell count.
404
486
  *
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.
487
+ * `withinRadius` is a pre-filter the caller narrows exactly afterwards, so
488
+ * excess area is cheaper than predicates.
489
+ *
490
+ * 2.0 was the first attempt and it was too strict. It rejected the coarse
491
+ * cell at 5 km, so a covering that had cost 10 cells cost 167, and the demo
492
+ * benchmark went from beating PostgreSQL to losing to it by 2.76x on wall
493
+ * time — the cost is the request, not the search. 3.0 keeps the cheap
494
+ * covering at 5 km (2.76x) and still rejects the coarse cell at 2 km, where
495
+ * it wastes 4.73x to 6.91x depending on latitude.
408
496
  */
409
- static readonly ACCEPTABLE_COVER_RATIO = 2;
497
+ static readonly ACCEPTABLE_COVER_RATIO = 3;
410
498
  private static readonly NEIGHBORS;
411
499
  private static readonly BORDERS;
412
500
  static encode(lat: number, lon: number, precision?: number): string;