@pulseindex/sdk 4.0.1 → 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
@@ -496,7 +496,6 @@ var FilterOperation = {
496
496
  SHOULD: 1,
497
497
  MUST_NOT: 2
498
498
  };
499
- var UINT32_MAX = 4294967295;
500
499
  var DEFAULT_ENDPOINT = "localhost:50051";
501
500
  var DEFAULT_TIMEOUT_MS = 5e3;
502
501
  var DEFAULT_POOL_SIZE = 1;
@@ -506,7 +505,8 @@ var DEFAULT_LIMIT = 100;
506
505
  function emptyState() {
507
506
  return {
508
507
  tenantId: "",
509
- locationPrefix: "0",
508
+ exactTotal: false,
509
+ geo: null,
510
510
  limit: DEFAULT_LIMIT,
511
511
  offset: 0,
512
512
  filters: [],
@@ -542,9 +542,17 @@ var QueryBuilder = class _QueryBuilder {
542
542
  state.tenantId = tenantId;
543
543
  });
544
544
  }
545
- 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) {
546
554
  return this.fork((state) => {
547
- state.locationPrefix = String(locationPrefix);
555
+ state.exactTotal = enabled;
548
556
  });
549
557
  }
550
558
  must(attribute) {
@@ -592,7 +600,11 @@ var QueryBuilder = class _QueryBuilder {
592
600
  resolvedPrecision = precision;
593
601
  }
594
602
  const covering = GeoHash.getCoveringHashes(lat, longitude, radius, resolvedPrecision);
603
+ const field = typeof latOrOptions === "object" ? latOrOptions.field : void 0;
595
604
  return this.fork((state) => {
605
+ if (field) {
606
+ state.geo = { field, lat, lon: longitude, radiusKm: radius };
607
+ }
596
608
  const group = state.nextGroup;
597
609
  state.nextGroup += 1;
598
610
  for (const hash of covering) {
@@ -604,6 +616,15 @@ var QueryBuilder = class _QueryBuilder {
604
616
  }
605
617
  });
606
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
+ */
607
628
  range(field, min, max) {
608
629
  if (!field.trim()) {
609
630
  throw new PulseIndexQueryError("Range field must not be empty.");
@@ -651,9 +672,12 @@ var QueryBuilder = class _QueryBuilder {
651
672
  return this.sortBy(field, true);
652
673
  }
653
674
  /**
654
- * Order the page by a numeric field. Rows carrying no value for it sort last
655
- * in both directions; they still count towards `totalMatches`, they simply
656
- * 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.
657
681
  */
658
682
  sortBy(field, descending = false) {
659
683
  if (!field.trim()) {
@@ -663,18 +687,62 @@ var QueryBuilder = class _QueryBuilder {
663
687
  state.sort = { field, descending };
664
688
  });
665
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
+ }
666
731
  toRequest(defaultTenantId = "") {
667
732
  const request = {
668
733
  tenantId: this.state.tenantId || defaultTenantId,
669
- locationPrefix: this.state.locationPrefix,
670
734
  limit: this.state.limit,
671
735
  offset: this.state.offset,
736
+ exactTotal: this.state.exactTotal,
672
737
  filters: this.state.filters.map((filter) => ({ ...filter })),
673
738
  ranges: this.state.ranges.map((range) => ({ ...range }))
674
739
  };
675
740
  if (this.state.sort) {
676
741
  request.sort = { ...this.state.sort };
677
742
  }
743
+ if (this.state.geo) {
744
+ request.geo = { ...this.state.geo };
745
+ }
678
746
  return request;
679
747
  }
680
748
  toArray(defaultTenantId = "") {
@@ -693,8 +761,8 @@ var QueryBuilder = class _QueryBuilder {
693
761
  if (options.tenantId !== void 0) {
694
762
  query = query.tenant(options.tenantId);
695
763
  }
696
- if (options.locationPrefix !== void 0) {
697
- query = query.location(options.locationPrefix);
764
+ if (options.exactTotal !== void 0) {
765
+ query = query.exactTotal(options.exactTotal);
698
766
  }
699
767
  if (options.must !== void 0) {
700
768
  query = query.must(options.must);
@@ -743,12 +811,13 @@ var QueryBuilder = class _QueryBuilder {
743
811
  const next = new _QueryBuilder(this.executor);
744
812
  next.state = {
745
813
  tenantId: this.state.tenantId,
746
- locationPrefix: this.state.locationPrefix,
747
814
  limit: this.state.limit,
748
815
  offset: this.state.offset,
816
+ exactTotal: this.state.exactTotal,
749
817
  filters: this.state.filters.map((filter) => ({ ...filter })),
750
818
  ranges: this.state.ranges.map((range) => ({ ...range })),
751
819
  sort: this.state.sort ? { ...this.state.sort } : null,
820
+ geo: this.state.geo ? { ...this.state.geo } : null,
752
821
  nextGroup: this.state.nextGroup
753
822
  };
754
823
  mutate(next.state);
@@ -1028,9 +1097,8 @@ var SKIP_ATTRIBUTE_KEYS = /* @__PURE__ */ new Set([
1028
1097
  "entityId",
1029
1098
  "entity_id",
1030
1099
  "attributes",
1031
- "price",
1032
- "locationPrefix",
1033
- "location_prefix",
1100
+ "numbers",
1101
+ "points",
1034
1102
  "tenantId",
1035
1103
  "tenant_id",
1036
1104
  "latitude",
@@ -1062,15 +1130,69 @@ function toUint64String(value, field = "entityId") {
1062
1130
  }
1063
1131
  return trimmed.replace(/^0+(?=\d)/, "");
1064
1132
  }
1065
- function toUint32(value, field) {
1066
- if (value === void 0 || value === null || value === "") {
1067
- return 0;
1068
- }
1133
+ function toFieldValue(value, field) {
1069
1134
  const numeric = typeof value === "number" ? value : Number(value);
1070
- if (!Number.isFinite(numeric) || numeric < 0 || numeric > UINT32_MAX) {
1071
- 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 };
1072
1194
  }
1073
- return Math.floor(numeric);
1195
+ return out;
1074
1196
  }
1075
1197
  function asRecord(value) {
1076
1198
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -1176,11 +1298,8 @@ function encodeEntity(entityIdOrInput, attributes = {}, defaults = {}) {
1176
1298
  return {
1177
1299
  entityId: toUint64String(rawId, "entityId"),
1178
1300
  categories: flattenAttributes(merged),
1179
- price: toUint32(merged.price, "price"),
1180
- locationPrefix: toUint64String(
1181
- merged.locationPrefix ?? merged.location_prefix ?? 0,
1182
- "locationPrefix"
1183
- ),
1301
+ numbers: collectNumbers(merged),
1302
+ points: collectPoints(merged),
1184
1303
  tenantId
1185
1304
  };
1186
1305
  }
@@ -1214,10 +1333,11 @@ var PulseIndexClient = class _PulseIndexClient {
1214
1333
  matchedEntityIds: (raw.matchedEntityIds ?? []).map((id) => String(id)),
1215
1334
  totalMatches: Number(raw.totalMatches ?? 0),
1216
1335
  executionTimeUs: Number(raw.executionTimeUs ?? 0),
1217
- // Exact only when nothing made the engine stop early. limit=0 asks for
1218
- // the count and no ids, and is the only shape that counts every match;
1219
- // any page can early-exit as soon as it is full.
1220
- totalIsExact: builder.toArray().limit === 0
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)
1221
1341
  };
1222
1342
  }
1223
1343
  /**
@@ -1229,22 +1349,14 @@ var PulseIndexClient = class _PulseIndexClient {
1229
1349
  * reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
1230
1350
  * that number is wrong by an order of magnitude and looks fine.
1231
1351
  *
1232
- * This sends the count query as well, so it costs two round trips and returns
1233
- * a total you can divide by a page size.
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.
1234
1356
  */
1235
1357
  async searchWithTotal(query) {
1236
- const page = await this.search(query);
1237
- if (page.totalIsExact) {
1238
- return page;
1239
- }
1240
- const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query);
1241
- const counted = await this.search(builder.limit(0));
1242
- return {
1243
- matchedEntityIds: page.matchedEntityIds,
1244
- totalMatches: counted.totalMatches,
1245
- executionTimeUs: page.executionTimeUs + counted.executionTimeUs,
1246
- totalIsExact: true
1247
- };
1358
+ const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query, this);
1359
+ return this.search(builder.exactTotal());
1248
1360
  }
1249
1361
  async index(entityIdOrInput, attributes = {}) {
1250
1362
  const encoded = encodeEntity(entityIdOrInput, attributes, {
@@ -1255,12 +1367,18 @@ var PulseIndexClient = class _PulseIndexClient {
1255
1367
  );
1256
1368
  return { success: Boolean(raw.success) };
1257
1369
  }
1258
- 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 = "") {
1259
1378
  const response = await this.index({
1260
1379
  entityId,
1261
1380
  categories,
1262
- price,
1263
- locationPrefix,
1381
+ numbers,
1264
1382
  tenantId: tenantId || this.connection.tenantId
1265
1383
  });
1266
1384
  return response.success;
@@ -1410,8 +1528,8 @@ var PulseIndex = class extends PulseIndexClient {
1410
1528
  function toIndexRequest(encoded) {
1411
1529
  return {
1412
1530
  entityId: encoded.entityId,
1413
- locationPrefix: encoded.locationPrefix,
1414
- price: encoded.price,
1531
+ numbers: encoded.numbers,
1532
+ points: encoded.points,
1415
1533
  categories: encoded.categories,
1416
1534
  tenantId: encoded.tenantId
1417
1535
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulseindex/sdk",
3
- "version": "4.0.1",
3
+ "version": "5.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/mohammed-alfarra/pulseindex-js.git"
@@ -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
  }