@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/dist/index.mjs CHANGED
@@ -42,11 +42,17 @@ var GeoHash = class {
42
42
  * How much area outside the circle a covering may carry before a finer
43
43
  * precision is worth its cell count.
44
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.
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.
48
54
  */
49
- static ACCEPTABLE_COVER_RATIO = 2;
55
+ static ACCEPTABLE_COVER_RATIO = 3;
50
56
  static NEIGHBORS = {
51
57
  n: ["p0r21436x8zb9dcf5h7kjnmqesgutwvy", "bc01fg45238967deuvhjyznpkmstqrwx"],
52
58
  s: ["14365h7k9dcfesgujnmqp0r2twvyx8zb", "238967debc01fg45kmstqrwxuvhjyznp"],
@@ -490,7 +496,6 @@ var FilterOperation = {
490
496
  SHOULD: 1,
491
497
  MUST_NOT: 2
492
498
  };
493
- var UINT32_MAX = 4294967295;
494
499
  var DEFAULT_ENDPOINT = "localhost:50051";
495
500
  var DEFAULT_TIMEOUT_MS = 5e3;
496
501
  var DEFAULT_POOL_SIZE = 1;
@@ -500,7 +505,8 @@ var DEFAULT_LIMIT = 100;
500
505
  function emptyState() {
501
506
  return {
502
507
  tenantId: "",
503
- locationPrefix: "0",
508
+ exactTotal: false,
509
+ geo: null,
504
510
  limit: DEFAULT_LIMIT,
505
511
  offset: 0,
506
512
  filters: [],
@@ -536,9 +542,17 @@ var QueryBuilder = class _QueryBuilder {
536
542
  state.tenantId = tenantId;
537
543
  });
538
544
  }
539
- location(locationPrefix) {
545
+ /**
546
+ * Count every match instead of stopping as soon as the page is full.
547
+ *
548
+ * A paged search stops early, so the `totalMatches` it carries is only what
549
+ * the engine had counted by then — a lower bound, and one that does not look
550
+ * like one. This makes the count exact in the same request; `totalIsExact`
551
+ * on the response says which you got.
552
+ */
553
+ exactTotal(enabled = true) {
540
554
  return this.fork((state) => {
541
- state.locationPrefix = String(locationPrefix);
555
+ state.exactTotal = enabled;
542
556
  });
543
557
  }
544
558
  must(attribute) {
@@ -586,7 +600,11 @@ var QueryBuilder = class _QueryBuilder {
586
600
  resolvedPrecision = precision;
587
601
  }
588
602
  const covering = GeoHash.getCoveringHashes(lat, longitude, radius, resolvedPrecision);
603
+ const field = typeof latOrOptions === "object" ? latOrOptions.field : void 0;
589
604
  return this.fork((state) => {
605
+ if (field) {
606
+ state.geo = { field, lat, lon: longitude, radiusKm: radius };
607
+ }
590
608
  const group = state.nextGroup;
591
609
  state.nextGroup += 1;
592
610
  for (const hash of covering) {
@@ -598,6 +616,15 @@ var QueryBuilder = class _QueryBuilder {
598
616
  }
599
617
  });
600
618
  }
619
+ /**
620
+ * Filter on a numeric field's inclusive range.
621
+ *
622
+ * `price` is the only number an entity carries. Naming any other field is
623
+ * refused by the engine rather than answered, because a field nothing carries
624
+ * can only match nothing, and an empty page looks exactly like a real one.
625
+ * Model any other number as a category token instead: `must('bedrooms:3')`,
626
+ * or several in one SHOULD group for a range of values.
627
+ */
601
628
  range(field, min, max) {
602
629
  if (!field.trim()) {
603
630
  throw new PulseIndexQueryError("Range field must not be empty.");
@@ -645,9 +672,12 @@ var QueryBuilder = class _QueryBuilder {
645
672
  return this.sortBy(field, true);
646
673
  }
647
674
  /**
648
- * Order the page by a numeric field. Rows carrying no value for it sort last
649
- * in both directions; they still count towards `totalMatches`, they simply
650
- * have nothing to be ordered by.
675
+ * Order the page by a numeric field.
676
+ *
677
+ * Bounded exactly as {@link range} is: `price` is the only field an entity
678
+ * carries, and any other name is refused rather than silently ignored. An
679
+ * order by a field nothing carries used to leave the page in insertion order
680
+ * and report it as sorted.
651
681
  */
652
682
  sortBy(field, descending = false) {
653
683
  if (!field.trim()) {
@@ -657,18 +687,62 @@ var QueryBuilder = class _QueryBuilder {
657
687
  state.sort = { field, descending };
658
688
  });
659
689
  }
690
+ /**
691
+ * Keep only entities within `radiusKm` of the point, measured exactly.
692
+ *
693
+ * This is the circle on its own. {@link withinRadius} with a `field` adds
694
+ * the geohash cells too, which is what stops the engine opening every part
695
+ * of the index to find them.
696
+ */
697
+ within(field, lat, lon, radiusKm) {
698
+ if (!field.trim()) {
699
+ throw new PulseIndexQueryError("A position field name must not be empty.");
700
+ }
701
+ if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(radiusKm)) {
702
+ throw new PulseIndexQueryError("within(field, lat, lon, radiusKm) needs finite numbers.");
703
+ }
704
+ if (radiusKm < 0) {
705
+ throw new PulseIndexQueryError(`A circle cannot have a radius of ${radiusKm}.`);
706
+ }
707
+ return this.fork((state) => {
708
+ state.geo = { field, lat, lon, radiusKm };
709
+ });
710
+ }
711
+ /**
712
+ * Order the page by distance from the point, nearest first.
713
+ *
714
+ * Without a radius this is "the nearest K of whatever else matched"; combine
715
+ * it with {@link within} or {@link withinRadius} to bound the search as well.
716
+ * It used to be impossible: a radius returned everything inside it unordered,
717
+ * so you hydrated every id from your own store before you could sort them.
718
+ */
719
+ nearest(field, lat, lon) {
720
+ if (!field.trim()) {
721
+ throw new PulseIndexQueryError("A position field name must not be empty.");
722
+ }
723
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
724
+ throw new PulseIndexQueryError("nearest(field, lat, lon) needs finite numbers.");
725
+ }
726
+ return this.fork((state) => {
727
+ state.geo = { field, lat, lon, radiusKm: state.geo?.radiusKm ?? 0 };
728
+ state.sort = { field, descending: false, byDistance: true };
729
+ });
730
+ }
660
731
  toRequest(defaultTenantId = "") {
661
732
  const request = {
662
733
  tenantId: this.state.tenantId || defaultTenantId,
663
- locationPrefix: this.state.locationPrefix,
664
734
  limit: this.state.limit,
665
735
  offset: this.state.offset,
736
+ exactTotal: this.state.exactTotal,
666
737
  filters: this.state.filters.map((filter) => ({ ...filter })),
667
738
  ranges: this.state.ranges.map((range) => ({ ...range }))
668
739
  };
669
740
  if (this.state.sort) {
670
741
  request.sort = { ...this.state.sort };
671
742
  }
743
+ if (this.state.geo) {
744
+ request.geo = { ...this.state.geo };
745
+ }
672
746
  return request;
673
747
  }
674
748
  toArray(defaultTenantId = "") {
@@ -687,8 +761,8 @@ var QueryBuilder = class _QueryBuilder {
687
761
  if (options.tenantId !== void 0) {
688
762
  query = query.tenant(options.tenantId);
689
763
  }
690
- if (options.locationPrefix !== void 0) {
691
- query = query.location(options.locationPrefix);
764
+ if (options.exactTotal !== void 0) {
765
+ query = query.exactTotal(options.exactTotal);
692
766
  }
693
767
  if (options.must !== void 0) {
694
768
  query = query.must(options.must);
@@ -737,12 +811,13 @@ var QueryBuilder = class _QueryBuilder {
737
811
  const next = new _QueryBuilder(this.executor);
738
812
  next.state = {
739
813
  tenantId: this.state.tenantId,
740
- locationPrefix: this.state.locationPrefix,
741
814
  limit: this.state.limit,
742
815
  offset: this.state.offset,
816
+ exactTotal: this.state.exactTotal,
743
817
  filters: this.state.filters.map((filter) => ({ ...filter })),
744
818
  ranges: this.state.ranges.map((range) => ({ ...range })),
745
819
  sort: this.state.sort ? { ...this.state.sort } : null,
820
+ geo: this.state.geo ? { ...this.state.geo } : null,
746
821
  nextGroup: this.state.nextGroup
747
822
  };
748
823
  mutate(next.state);
@@ -1022,9 +1097,8 @@ var SKIP_ATTRIBUTE_KEYS = /* @__PURE__ */ new Set([
1022
1097
  "entityId",
1023
1098
  "entity_id",
1024
1099
  "attributes",
1025
- "price",
1026
- "locationPrefix",
1027
- "location_prefix",
1100
+ "numbers",
1101
+ "points",
1028
1102
  "tenantId",
1029
1103
  "tenant_id",
1030
1104
  "latitude",
@@ -1056,15 +1130,69 @@ function toUint64String(value, field = "entityId") {
1056
1130
  }
1057
1131
  return trimmed.replace(/^0+(?=\d)/, "");
1058
1132
  }
1059
- function toUint32(value, field) {
1060
- if (value === void 0 || value === null || value === "") {
1061
- return 0;
1062
- }
1133
+ function toFieldValue(value, field) {
1063
1134
  const numeric = typeof value === "number" ? value : Number(value);
1064
- if (!Number.isFinite(numeric) || numeric < 0 || numeric > UINT32_MAX) {
1065
- throw new PulseIndexQueryError(`${field} must be an integer between 0 and ${UINT32_MAX}.`);
1135
+ if (!Number.isFinite(numeric)) {
1136
+ throw new PulseIndexQueryError(`${field} must be a finite number.`);
1137
+ }
1138
+ if (!Number.isInteger(numeric)) {
1139
+ throw new PulseIndexQueryError(
1140
+ `${field} is ${numeric}, and the engine stores whole numbers. Scale it to an integer and keep the scale on your side \u2014 a price in cents, a rating out of 100.`
1141
+ );
1142
+ }
1143
+ if (!Number.isSafeInteger(numeric)) {
1144
+ throw new PulseIndexQueryError(`${field} is past the range JavaScript can hold exactly.`);
1145
+ }
1146
+ return numeric;
1147
+ }
1148
+ function collectNumbers(merged) {
1149
+ const source = merged.numbers;
1150
+ if (source === void 0 || source === null) {
1151
+ return {};
1152
+ }
1153
+ if (typeof source !== "object" || Array.isArray(source)) {
1154
+ throw new PulseIndexQueryError("numbers must be an object of field name to number.");
1155
+ }
1156
+ const out = {};
1157
+ for (const [name, value] of Object.entries(source)) {
1158
+ if (!name.trim()) {
1159
+ throw new PulseIndexQueryError("A numeric field name must not be empty.");
1160
+ }
1161
+ if (value === void 0 || value === null || value === "") {
1162
+ continue;
1163
+ }
1164
+ out[name] = toFieldValue(value, name);
1165
+ }
1166
+ return out;
1167
+ }
1168
+ function collectPoints(merged) {
1169
+ const source = merged.points;
1170
+ if (source === void 0 || source === null) {
1171
+ return {};
1172
+ }
1173
+ if (typeof source !== "object" || Array.isArray(source)) {
1174
+ throw new PulseIndexQueryError("points must be an object of field name to {lat, lon}.");
1175
+ }
1176
+ const out = {};
1177
+ for (const [name, value] of Object.entries(source)) {
1178
+ if (!name.trim()) {
1179
+ throw new PulseIndexQueryError("A position field name must not be empty.");
1180
+ }
1181
+ const point = asRecord(value);
1182
+ const lat = Number(point.lat ?? point.latitude);
1183
+ const lon = Number(point.lon ?? point.lng ?? point.longitude);
1184
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
1185
+ throw new PulseIndexQueryError(`${name} must be {lat, lon} with finite numbers.`);
1186
+ }
1187
+ if (lat < -90 || lat > 90) {
1188
+ throw new PulseIndexQueryError(`${name}.lat is ${lat}, outside -90..90.`);
1189
+ }
1190
+ if (lon < -180 || lon > 180) {
1191
+ throw new PulseIndexQueryError(`${name}.lon is ${lon}, outside -180..180.`);
1192
+ }
1193
+ out[name] = { lat, lon };
1066
1194
  }
1067
- return Math.floor(numeric);
1195
+ return out;
1068
1196
  }
1069
1197
  function asRecord(value) {
1070
1198
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -1170,11 +1298,8 @@ function encodeEntity(entityIdOrInput, attributes = {}, defaults = {}) {
1170
1298
  return {
1171
1299
  entityId: toUint64String(rawId, "entityId"),
1172
1300
  categories: flattenAttributes(merged),
1173
- price: toUint32(merged.price, "price"),
1174
- locationPrefix: toUint64String(
1175
- merged.locationPrefix ?? merged.location_prefix ?? 0,
1176
- "locationPrefix"
1177
- ),
1301
+ numbers: collectNumbers(merged),
1302
+ points: collectPoints(merged),
1178
1303
  tenantId
1179
1304
  };
1180
1305
  }
@@ -1208,10 +1333,11 @@ var PulseIndexClient = class _PulseIndexClient {
1208
1333
  matchedEntityIds: (raw.matchedEntityIds ?? []).map((id) => String(id)),
1209
1334
  totalMatches: Number(raw.totalMatches ?? 0),
1210
1335
  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
1336
+ // The engine says so now. This used to be inferred from `limit === 0`,
1337
+ // which is a rule this SDK had to keep in step with the engine's own by
1338
+ // hand, and which called a page inexact even when every match fit inside
1339
+ // it and nothing was skipped.
1340
+ totalIsExact: Boolean(raw.totalIsExact)
1215
1341
  };
1216
1342
  }
1217
1343
  /**
@@ -1223,22 +1349,14 @@ var PulseIndexClient = class _PulseIndexClient {
1223
1349
  * reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
1224
1350
  * that number is wrong by an order of magnitude and looks fine.
1225
1351
  *
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.
1352
+ * One request. This used to run the whole query twice once for the page,
1353
+ * once for the count because the wire had no way to ask for both. It does
1354
+ * now, so this is the same round trip with `exactTotal` set, and the total
1355
+ * you get back can be divided by a page size.
1228
1356
  */
1229
1357
  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
1241
- };
1358
+ const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query, this);
1359
+ return this.search(builder.exactTotal());
1242
1360
  }
1243
1361
  async index(entityIdOrInput, attributes = {}) {
1244
1362
  const encoded = encodeEntity(entityIdOrInput, attributes, {
@@ -1249,12 +1367,18 @@ var PulseIndexClient = class _PulseIndexClient {
1249
1367
  );
1250
1368
  return { success: Boolean(raw.success) };
1251
1369
  }
1252
- async indexEntity(entityId, categories = [], price = 0, locationPrefix = 0, tenantId = "") {
1370
+ /**
1371
+ * Index one record.
1372
+ *
1373
+ * `numbers` are yours to name: `{price_cents: 45000, bedrooms: 3}`. This
1374
+ * used to take a single `price` and a `locationPrefix`, which was a schema
1375
+ * this SDK had no business imposing.
1376
+ */
1377
+ async indexEntity(entityId, categories = [], numbers = {}, tenantId = "") {
1253
1378
  const response = await this.index({
1254
1379
  entityId,
1255
1380
  categories,
1256
- price,
1257
- locationPrefix,
1381
+ numbers,
1258
1382
  tenantId: tenantId || this.connection.tenantId
1259
1383
  });
1260
1384
  return response.success;
@@ -1404,8 +1528,8 @@ var PulseIndex = class extends PulseIndexClient {
1404
1528
  function toIndexRequest(encoded) {
1405
1529
  return {
1406
1530
  entityId: encoded.entityId,
1407
- locationPrefix: encoded.locationPrefix,
1408
- price: encoded.price,
1531
+ numbers: encoded.numbers,
1532
+ points: encoded.points,
1409
1533
  categories: encoded.categories,
1410
1534
  tenantId: encoded.tenantId
1411
1535
  };
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "@pulseindex/sdk",
3
- "version": "4.0.0",
4
- "description": "Official Node.js & TypeScript SDK for PulseIndex — hosted search and filtering for large entity sets",
3
+ "version": "5.0.0",
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": [
@@ -62,13 +62,34 @@ message IndexEntityRequest {
62
62
  // Dense, ascending ids index and query most efficiently.
63
63
  uint64 entity_id = 1;
64
64
 
65
- // Optional geohash-style location prefix. Matches the `location_prefix`
66
- // filter on Search, and `location:<prefix>` as an attribute.
67
- // Use 0 to omit location indexing.
68
- uint64 location_prefix = 2;
65
+ reserved 2, 3;
66
+ reserved "location_prefix", "price";
69
67
 
70
- // Numeric price (or similar scalar), filterable through RangePredicate.
71
- uint32 price = 3;
68
+ // Numeric fields under your own names. Any name, any int64, any number of
69
+ // them, each filterable through RangePredicate and orderable through
70
+ // SortSpec. A name means nothing to the engine beyond its hash, so call them
71
+ // whatever your own records call them:
72
+ //
73
+ // {"price_cents": 45000, "bedrooms": 3, "built_at": 1712000000}
74
+ //
75
+ // This replaces a single uint32 named `price` and a uint64 bitfield named
76
+ // `location_prefix` — one number per record, under a name the engine chose,
77
+ // with no negatives and nothing past 4,294,967,295.
78
+ map<string, int64> numbers = 6;
79
+
80
+ // Positions, under your own names, the same way `numbers` are.
81
+ //
82
+ // The engine packs the pair into one number and keeps it in an ordinary
83
+ // column, so a position costs what a number costs. Send degrees and let it
84
+ // pack: a representation split between this SDK and the engine, with nothing
85
+ // comparing the two, is how the geo bugs in 4.0.0 happened - one side
86
+ // computes it differently and every answer is a plausible empty page.
87
+ //
88
+ // points: { "where": { lat: 41.0369, lon: 28.9850 } }
89
+ //
90
+ // A name used here and in `numbers` on the same record is refused: a field
91
+ // is either a number or a position.
92
+ map<string, GeoPoint> points = 7;
72
93
 
73
94
  // Attribute tokens already namespaced by the client, e.g.:
74
95
  // "feature:pool", "furnishing:full", "amenity:parking"
@@ -81,6 +102,12 @@ message IndexEntityRequest {
81
102
  }
82
103
 
83
104
  // IndexEntityResponse acknowledges a successful upsert.
105
+ // GeoPoint is one position in degrees.
106
+ message GeoPoint {
107
+ double lat = 1;
108
+ double lon = 2;
109
+ }
110
+
84
111
  message IndexEntityResponse {
85
112
  // True when the entity was accepted into the index.
86
113
  // Capacity violations return gRPC RESOURCE_EXHAUSTED instead.
@@ -174,14 +201,20 @@ message FilterPredicate {
174
201
 
175
202
  // RangePredicate filters a continuous numeric field to an inclusive range.
176
203
  message RangePredicate {
177
- // Numeric field name. Currently supported: "price".
204
+ // Numeric field name whatever you called it in
205
+ // IndexEntityRequest.numbers.
206
+ //
207
+ // A search naming a field no entity in your tenant carries is refused rather
208
+ // than answered: it can only match nothing, and an empty page looks exactly
209
+ // like a real one.
178
210
  string field = 1;
179
211
 
180
212
  // Inclusive lower bound of the requested range.
181
- uint32 min_val = 2;
213
+ // int64, so a bound may be negative and may go past 4,294,967,295.
214
+ int64 min_val = 2;
182
215
 
183
216
  // Inclusive upper bound of the requested range.
184
- uint32 max_val = 3;
217
+ int64 max_val = 3;
185
218
  }
186
219
 
187
220
  // SortSpec orders a page by a numeric field.
@@ -193,12 +226,47 @@ message RangePredicate {
193
226
  // An ordered search cannot stop early, because the best remaining row may be
194
227
  // anywhere in the tenant, so it costs more than the same filter unordered.
195
228
  // offset + limit is capped at 100,000.
229
+ // GeoPredicate names a position field and a point to measure from.
230
+ //
231
+ // A circle used to reach the engine only as a union of geohash cells this SDK
232
+ // generated, and a union of cells is a superset of the circle: measured at a
233
+ // million entities, a 1 km search returned 2,479 rows where 1,241 were really
234
+ // inside. The cells are still the cheap way to narrow which parts of the index
235
+ // are opened; this is what settles the edge.
236
+ message GeoPredicate {
237
+ // The position field, named as you named it in IndexEntityRequest.points.
238
+ string field = 1;
239
+
240
+ double lat = 2;
241
+ double lon = 3;
242
+
243
+ // Inclusive radius in kilometres. Zero means no radius filter at all: the
244
+ // point is then only an origin to measure from, for SortSpec.by_distance.
245
+ double radius_km = 4;
246
+ }
247
+
196
248
  message SortSpec {
197
- // Numeric field name, the same one a RangePredicate would name.
249
+ // Numeric field name, the same one a RangePredicate would name, and bounded
250
+ // the same way: a name no entity carries is refused, not silently ignored.
251
+ // An order by one used to leave the page in insertion order and call it
252
+ // sorted.
198
253
  string field = 1;
199
254
 
200
- // Largest first when true; smallest first otherwise.
255
+ // Largest first when true; smallest first otherwise. With by_distance,
256
+ // false is nearest-first.
201
257
  bool descending = 2;
258
+
259
+ // Order by distance from GeoPredicate's point rather than by a field value.
260
+ //
261
+ // `field` is ignored and the geo predicate must be present. This is what
262
+ // makes "the nearest fifty" a question you can ask: a radius used to return
263
+ // everything inside it, unordered, so you had to hydrate every id from your
264
+ // own store before you could sort them.
265
+ //
266
+ // Ordering is to the centimetre, which is the precision a stored position
267
+ // has. Rows closer together than that tie, and a tie breaks on the entity id
268
+ // so the same query returns the same page.
269
+ bool by_distance = 3;
202
270
  }
203
271
 
204
272
  // ---------------------------------------------------------------------------
@@ -207,9 +275,8 @@ message SortSpec {
207
275
 
208
276
  // SearchQueryRequest executes a filtered search inside one tenant.
209
277
  message SearchQueryRequest {
210
- // Optional location constraint (0 = no location filter).
211
- // When non-zero, only entities indexed with that location_prefix match.
212
- uint64 location_prefix = 1;
278
+ reserved 1;
279
+ reserved "location_prefix";
213
280
 
214
281
  // Attribute predicates (MUST / SHOULD / MUST_NOT). Order does not affect the
215
282
  // result.
@@ -231,6 +298,19 @@ message SearchQueryRequest {
231
298
 
232
299
  // Optional ordering. Absent returns matches in entity-id order.
233
300
  SortSpec sort = 7;
301
+
302
+ // Count every match instead of stopping as soon as the page is full.
303
+ //
304
+ // A page query stops early, so the count it carries is only what the chunks
305
+ // it opened added up to. That is usually what a page wants, and it is much
306
+ // cheaper. Set this when you need the exact total alongside the page: one
307
+ // request instead of two, and total_is_exact comes back true.
308
+ //
309
+ // limit == 0 already implies it: a count-only request has no page to fill.
310
+ bool exact_total = 8;
311
+
312
+ // Optional circle, and the position field to measure against.
313
+ GeoPredicate geo = 9;
234
314
  }
235
315
 
236
316
  // SearchQueryResponse returns matched ids and timing metadata.
@@ -239,9 +319,21 @@ message SearchQueryResponse {
239
319
  // Caller hydrates full records from the primary data store.
240
320
  repeated uint64 matched_entity_ids = 1;
241
321
 
242
- // Number of matches. Exact when `limit` is 0; may be approximate otherwise.
322
+ // How many entities matched. Exact when total_is_exact; otherwise a lower
323
+ // bound - read that field before showing this number to anyone.
243
324
  uint32 total_matches = 2;
244
325
 
245
326
  // Server-side execution time in microseconds (excludes network RTT).
246
327
  uint64 execution_time_us = 3;
328
+
329
+ // Whether total_matches is the whole answer.
330
+ //
331
+ // False means the scan stopped once the page was full, so the count is only
332
+ // as far as it got. It does not look partial: on 500,000 records that all
333
+ // matched, a `limit 20` query reported 65,536. Send exact_total to get a
334
+ // true count in the same request.
335
+ //
336
+ // True whenever the scan finished, which includes a page query whose matches
337
+ // all fit inside it: nothing was skipped, so nothing is missing.
338
+ bool total_is_exact = 4;
247
339
  }