@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.js CHANGED
@@ -519,7 +519,6 @@ var FilterOperation = {
519
519
  SHOULD: 1,
520
520
  MUST_NOT: 2
521
521
  };
522
- var UINT32_MAX = 4294967295;
523
522
  var DEFAULT_ENDPOINT = "localhost:50051";
524
523
  var DEFAULT_TIMEOUT_MS = 5e3;
525
524
  var DEFAULT_POOL_SIZE = 1;
@@ -529,7 +528,8 @@ var DEFAULT_LIMIT = 100;
529
528
  function emptyState() {
530
529
  return {
531
530
  tenantId: "",
532
- locationPrefix: "0",
531
+ exactTotal: false,
532
+ geo: null,
533
533
  limit: DEFAULT_LIMIT,
534
534
  offset: 0,
535
535
  filters: [],
@@ -565,9 +565,17 @@ var QueryBuilder = class _QueryBuilder {
565
565
  state.tenantId = tenantId;
566
566
  });
567
567
  }
568
- location(locationPrefix) {
568
+ /**
569
+ * Count every match instead of stopping as soon as the page is full.
570
+ *
571
+ * A paged search stops early, so the `totalMatches` it carries is only what
572
+ * the engine had counted by then — a lower bound, and one that does not look
573
+ * like one. This makes the count exact in the same request; `totalIsExact`
574
+ * on the response says which you got.
575
+ */
576
+ exactTotal(enabled = true) {
569
577
  return this.fork((state) => {
570
- state.locationPrefix = String(locationPrefix);
578
+ state.exactTotal = enabled;
571
579
  });
572
580
  }
573
581
  must(attribute) {
@@ -615,7 +623,11 @@ var QueryBuilder = class _QueryBuilder {
615
623
  resolvedPrecision = precision;
616
624
  }
617
625
  const covering = GeoHash.getCoveringHashes(lat, longitude, radius, resolvedPrecision);
626
+ const field = typeof latOrOptions === "object" ? latOrOptions.field : void 0;
618
627
  return this.fork((state) => {
628
+ if (field) {
629
+ state.geo = { field, lat, lon: longitude, radiusKm: radius };
630
+ }
619
631
  const group = state.nextGroup;
620
632
  state.nextGroup += 1;
621
633
  for (const hash of covering) {
@@ -627,6 +639,15 @@ var QueryBuilder = class _QueryBuilder {
627
639
  }
628
640
  });
629
641
  }
642
+ /**
643
+ * Filter on a numeric field's inclusive range.
644
+ *
645
+ * `price` is the only number an entity carries. Naming any other field is
646
+ * refused by the engine rather than answered, because a field nothing carries
647
+ * can only match nothing, and an empty page looks exactly like a real one.
648
+ * Model any other number as a category token instead: `must('bedrooms:3')`,
649
+ * or several in one SHOULD group for a range of values.
650
+ */
630
651
  range(field, min, max) {
631
652
  if (!field.trim()) {
632
653
  throw new PulseIndexQueryError("Range field must not be empty.");
@@ -674,9 +695,12 @@ var QueryBuilder = class _QueryBuilder {
674
695
  return this.sortBy(field, true);
675
696
  }
676
697
  /**
677
- * Order the page by a numeric field. Rows carrying no value for it sort last
678
- * in both directions; they still count towards `totalMatches`, they simply
679
- * have nothing to be ordered by.
698
+ * Order the page by a numeric field.
699
+ *
700
+ * Bounded exactly as {@link range} is: `price` is the only field an entity
701
+ * carries, and any other name is refused rather than silently ignored. An
702
+ * order by a field nothing carries used to leave the page in insertion order
703
+ * and report it as sorted.
680
704
  */
681
705
  sortBy(field, descending = false) {
682
706
  if (!field.trim()) {
@@ -686,18 +710,62 @@ var QueryBuilder = class _QueryBuilder {
686
710
  state.sort = { field, descending };
687
711
  });
688
712
  }
713
+ /**
714
+ * Keep only entities within `radiusKm` of the point, measured exactly.
715
+ *
716
+ * This is the circle on its own. {@link withinRadius} with a `field` adds
717
+ * the geohash cells too, which is what stops the engine opening every part
718
+ * of the index to find them.
719
+ */
720
+ within(field, lat, lon, radiusKm) {
721
+ if (!field.trim()) {
722
+ throw new PulseIndexQueryError("A position field name must not be empty.");
723
+ }
724
+ if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(radiusKm)) {
725
+ throw new PulseIndexQueryError("within(field, lat, lon, radiusKm) needs finite numbers.");
726
+ }
727
+ if (radiusKm < 0) {
728
+ throw new PulseIndexQueryError(`A circle cannot have a radius of ${radiusKm}.`);
729
+ }
730
+ return this.fork((state) => {
731
+ state.geo = { field, lat, lon, radiusKm };
732
+ });
733
+ }
734
+ /**
735
+ * Order the page by distance from the point, nearest first.
736
+ *
737
+ * Without a radius this is "the nearest K of whatever else matched"; combine
738
+ * it with {@link within} or {@link withinRadius} to bound the search as well.
739
+ * It used to be impossible: a radius returned everything inside it unordered,
740
+ * so you hydrated every id from your own store before you could sort them.
741
+ */
742
+ nearest(field, lat, lon) {
743
+ if (!field.trim()) {
744
+ throw new PulseIndexQueryError("A position field name must not be empty.");
745
+ }
746
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
747
+ throw new PulseIndexQueryError("nearest(field, lat, lon) needs finite numbers.");
748
+ }
749
+ return this.fork((state) => {
750
+ state.geo = { field, lat, lon, radiusKm: state.geo?.radiusKm ?? 0 };
751
+ state.sort = { field, descending: false, byDistance: true };
752
+ });
753
+ }
689
754
  toRequest(defaultTenantId = "") {
690
755
  const request = {
691
756
  tenantId: this.state.tenantId || defaultTenantId,
692
- locationPrefix: this.state.locationPrefix,
693
757
  limit: this.state.limit,
694
758
  offset: this.state.offset,
759
+ exactTotal: this.state.exactTotal,
695
760
  filters: this.state.filters.map((filter) => ({ ...filter })),
696
761
  ranges: this.state.ranges.map((range) => ({ ...range }))
697
762
  };
698
763
  if (this.state.sort) {
699
764
  request.sort = { ...this.state.sort };
700
765
  }
766
+ if (this.state.geo) {
767
+ request.geo = { ...this.state.geo };
768
+ }
701
769
  return request;
702
770
  }
703
771
  toArray(defaultTenantId = "") {
@@ -716,8 +784,8 @@ var QueryBuilder = class _QueryBuilder {
716
784
  if (options.tenantId !== void 0) {
717
785
  query = query.tenant(options.tenantId);
718
786
  }
719
- if (options.locationPrefix !== void 0) {
720
- query = query.location(options.locationPrefix);
787
+ if (options.exactTotal !== void 0) {
788
+ query = query.exactTotal(options.exactTotal);
721
789
  }
722
790
  if (options.must !== void 0) {
723
791
  query = query.must(options.must);
@@ -766,12 +834,13 @@ var QueryBuilder = class _QueryBuilder {
766
834
  const next = new _QueryBuilder(this.executor);
767
835
  next.state = {
768
836
  tenantId: this.state.tenantId,
769
- locationPrefix: this.state.locationPrefix,
770
837
  limit: this.state.limit,
771
838
  offset: this.state.offset,
839
+ exactTotal: this.state.exactTotal,
772
840
  filters: this.state.filters.map((filter) => ({ ...filter })),
773
841
  ranges: this.state.ranges.map((range) => ({ ...range })),
774
842
  sort: this.state.sort ? { ...this.state.sort } : null,
843
+ geo: this.state.geo ? { ...this.state.geo } : null,
775
844
  nextGroup: this.state.nextGroup
776
845
  };
777
846
  mutate(next.state);
@@ -1051,9 +1120,8 @@ var SKIP_ATTRIBUTE_KEYS = /* @__PURE__ */ new Set([
1051
1120
  "entityId",
1052
1121
  "entity_id",
1053
1122
  "attributes",
1054
- "price",
1055
- "locationPrefix",
1056
- "location_prefix",
1123
+ "numbers",
1124
+ "points",
1057
1125
  "tenantId",
1058
1126
  "tenant_id",
1059
1127
  "latitude",
@@ -1085,15 +1153,69 @@ function toUint64String(value, field = "entityId") {
1085
1153
  }
1086
1154
  return trimmed.replace(/^0+(?=\d)/, "");
1087
1155
  }
1088
- function toUint32(value, field) {
1089
- if (value === void 0 || value === null || value === "") {
1090
- return 0;
1091
- }
1156
+ function toFieldValue(value, field) {
1092
1157
  const numeric = typeof value === "number" ? value : Number(value);
1093
- if (!Number.isFinite(numeric) || numeric < 0 || numeric > UINT32_MAX) {
1094
- throw new PulseIndexQueryError(`${field} must be an integer between 0 and ${UINT32_MAX}.`);
1158
+ if (!Number.isFinite(numeric)) {
1159
+ throw new PulseIndexQueryError(`${field} must be a finite number.`);
1160
+ }
1161
+ if (!Number.isInteger(numeric)) {
1162
+ throw new PulseIndexQueryError(
1163
+ `${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.`
1164
+ );
1165
+ }
1166
+ if (!Number.isSafeInteger(numeric)) {
1167
+ throw new PulseIndexQueryError(`${field} is past the range JavaScript can hold exactly.`);
1168
+ }
1169
+ return numeric;
1170
+ }
1171
+ function collectNumbers(merged) {
1172
+ const source = merged.numbers;
1173
+ if (source === void 0 || source === null) {
1174
+ return {};
1175
+ }
1176
+ if (typeof source !== "object" || Array.isArray(source)) {
1177
+ throw new PulseIndexQueryError("numbers must be an object of field name to number.");
1178
+ }
1179
+ const out = {};
1180
+ for (const [name, value] of Object.entries(source)) {
1181
+ if (!name.trim()) {
1182
+ throw new PulseIndexQueryError("A numeric field name must not be empty.");
1183
+ }
1184
+ if (value === void 0 || value === null || value === "") {
1185
+ continue;
1186
+ }
1187
+ out[name] = toFieldValue(value, name);
1188
+ }
1189
+ return out;
1190
+ }
1191
+ function collectPoints(merged) {
1192
+ const source = merged.points;
1193
+ if (source === void 0 || source === null) {
1194
+ return {};
1195
+ }
1196
+ if (typeof source !== "object" || Array.isArray(source)) {
1197
+ throw new PulseIndexQueryError("points must be an object of field name to {lat, lon}.");
1198
+ }
1199
+ const out = {};
1200
+ for (const [name, value] of Object.entries(source)) {
1201
+ if (!name.trim()) {
1202
+ throw new PulseIndexQueryError("A position field name must not be empty.");
1203
+ }
1204
+ const point = asRecord(value);
1205
+ const lat = Number(point.lat ?? point.latitude);
1206
+ const lon = Number(point.lon ?? point.lng ?? point.longitude);
1207
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
1208
+ throw new PulseIndexQueryError(`${name} must be {lat, lon} with finite numbers.`);
1209
+ }
1210
+ if (lat < -90 || lat > 90) {
1211
+ throw new PulseIndexQueryError(`${name}.lat is ${lat}, outside -90..90.`);
1212
+ }
1213
+ if (lon < -180 || lon > 180) {
1214
+ throw new PulseIndexQueryError(`${name}.lon is ${lon}, outside -180..180.`);
1215
+ }
1216
+ out[name] = { lat, lon };
1095
1217
  }
1096
- return Math.floor(numeric);
1218
+ return out;
1097
1219
  }
1098
1220
  function asRecord(value) {
1099
1221
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -1199,11 +1321,8 @@ function encodeEntity(entityIdOrInput, attributes = {}, defaults = {}) {
1199
1321
  return {
1200
1322
  entityId: toUint64String(rawId, "entityId"),
1201
1323
  categories: flattenAttributes(merged),
1202
- price: toUint32(merged.price, "price"),
1203
- locationPrefix: toUint64String(
1204
- merged.locationPrefix ?? merged.location_prefix ?? 0,
1205
- "locationPrefix"
1206
- ),
1324
+ numbers: collectNumbers(merged),
1325
+ points: collectPoints(merged),
1207
1326
  tenantId
1208
1327
  };
1209
1328
  }
@@ -1237,10 +1356,11 @@ var PulseIndexClient = class _PulseIndexClient {
1237
1356
  matchedEntityIds: (raw.matchedEntityIds ?? []).map((id) => String(id)),
1238
1357
  totalMatches: Number(raw.totalMatches ?? 0),
1239
1358
  executionTimeUs: Number(raw.executionTimeUs ?? 0),
1240
- // Exact only when nothing made the engine stop early. limit=0 asks for
1241
- // the count and no ids, and is the only shape that counts every match;
1242
- // any page can early-exit as soon as it is full.
1243
- totalIsExact: builder.toArray().limit === 0
1359
+ // The engine says so now. This used to be inferred from `limit === 0`,
1360
+ // which is a rule this SDK had to keep in step with the engine's own by
1361
+ // hand, and which called a page inexact even when every match fit inside
1362
+ // it and nothing was skipped.
1363
+ totalIsExact: Boolean(raw.totalIsExact)
1244
1364
  };
1245
1365
  }
1246
1366
  /**
@@ -1252,22 +1372,14 @@ var PulseIndexClient = class _PulseIndexClient {
1252
1372
  * reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
1253
1373
  * that number is wrong by an order of magnitude and looks fine.
1254
1374
  *
1255
- * This sends the count query as well, so it costs two round trips and returns
1256
- * a total you can divide by a page size.
1375
+ * One request. This used to run the whole query twice once for the page,
1376
+ * once for the count because the wire had no way to ask for both. It does
1377
+ * now, so this is the same round trip with `exactTotal` set, and the total
1378
+ * you get back can be divided by a page size.
1257
1379
  */
1258
1380
  async searchWithTotal(query) {
1259
- const page = await this.search(query);
1260
- if (page.totalIsExact) {
1261
- return page;
1262
- }
1263
- const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query);
1264
- const counted = await this.search(builder.limit(0));
1265
- return {
1266
- matchedEntityIds: page.matchedEntityIds,
1267
- totalMatches: counted.totalMatches,
1268
- executionTimeUs: page.executionTimeUs + counted.executionTimeUs,
1269
- totalIsExact: true
1270
- };
1381
+ const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query, this);
1382
+ return this.search(builder.exactTotal());
1271
1383
  }
1272
1384
  async index(entityIdOrInput, attributes = {}) {
1273
1385
  const encoded = encodeEntity(entityIdOrInput, attributes, {
@@ -1278,12 +1390,18 @@ var PulseIndexClient = class _PulseIndexClient {
1278
1390
  );
1279
1391
  return { success: Boolean(raw.success) };
1280
1392
  }
1281
- async indexEntity(entityId, categories = [], price = 0, locationPrefix = 0, tenantId = "") {
1393
+ /**
1394
+ * Index one record.
1395
+ *
1396
+ * `numbers` are yours to name: `{price_cents: 45000, bedrooms: 3}`. This
1397
+ * used to take a single `price` and a `locationPrefix`, which was a schema
1398
+ * this SDK had no business imposing.
1399
+ */
1400
+ async indexEntity(entityId, categories = [], numbers = {}, tenantId = "") {
1282
1401
  const response = await this.index({
1283
1402
  entityId,
1284
1403
  categories,
1285
- price,
1286
- locationPrefix,
1404
+ numbers,
1287
1405
  tenantId: tenantId || this.connection.tenantId
1288
1406
  });
1289
1407
  return response.success;
@@ -1433,8 +1551,8 @@ var PulseIndex = class extends PulseIndexClient {
1433
1551
  function toIndexRequest(encoded) {
1434
1552
  return {
1435
1553
  entityId: encoded.entityId,
1436
- locationPrefix: encoded.locationPrefix,
1437
- price: encoded.price,
1554
+ numbers: encoded.numbers,
1555
+ points: encoded.points,
1438
1556
  categories: encoded.categories,
1439
1557
  tenantId: encoded.tenantId
1440
1558
  };