@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.js CHANGED
@@ -65,11 +65,17 @@ var GeoHash = class {
65
65
  * How much area outside the circle a covering may carry before a finer
66
66
  * precision is worth its cell count.
67
67
  *
68
- * 2.0 is where the measured choices come out right at every radius: it
69
- * rejects the coarse cell at 2 km (6.91x) and 5 km (2.76x) and accepts it at
70
- * 15 km (1.44x), which is also where the cell count turns from 47 into 1,120.
68
+ * `withinRadius` is a pre-filter the caller narrows exactly afterwards, so
69
+ * excess area is cheaper than predicates.
70
+ *
71
+ * 2.0 was the first attempt and it was too strict. It rejected the coarse
72
+ * cell at 5 km, so a covering that had cost 10 cells cost 167, and the demo
73
+ * benchmark went from beating PostgreSQL to losing to it by 2.76x on wall
74
+ * time — the cost is the request, not the search. 3.0 keeps the cheap
75
+ * covering at 5 km (2.76x) and still rejects the coarse cell at 2 km, where
76
+ * it wastes 4.73x to 6.91x depending on latitude.
71
77
  */
72
- static ACCEPTABLE_COVER_RATIO = 2;
78
+ static ACCEPTABLE_COVER_RATIO = 3;
73
79
  static NEIGHBORS = {
74
80
  n: ["p0r21436x8zb9dcf5h7kjnmqesgutwvy", "bc01fg45238967deuvhjyznpkmstqrwx"],
75
81
  s: ["14365h7k9dcfesgujnmqp0r2twvyx8zb", "238967debc01fg45kmstqrwxuvhjyznp"],
@@ -513,7 +519,6 @@ var FilterOperation = {
513
519
  SHOULD: 1,
514
520
  MUST_NOT: 2
515
521
  };
516
- var UINT32_MAX = 4294967295;
517
522
  var DEFAULT_ENDPOINT = "localhost:50051";
518
523
  var DEFAULT_TIMEOUT_MS = 5e3;
519
524
  var DEFAULT_POOL_SIZE = 1;
@@ -523,7 +528,8 @@ var DEFAULT_LIMIT = 100;
523
528
  function emptyState() {
524
529
  return {
525
530
  tenantId: "",
526
- locationPrefix: "0",
531
+ exactTotal: false,
532
+ geo: null,
527
533
  limit: DEFAULT_LIMIT,
528
534
  offset: 0,
529
535
  filters: [],
@@ -559,9 +565,17 @@ var QueryBuilder = class _QueryBuilder {
559
565
  state.tenantId = tenantId;
560
566
  });
561
567
  }
562
- 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) {
563
577
  return this.fork((state) => {
564
- state.locationPrefix = String(locationPrefix);
578
+ state.exactTotal = enabled;
565
579
  });
566
580
  }
567
581
  must(attribute) {
@@ -609,7 +623,11 @@ var QueryBuilder = class _QueryBuilder {
609
623
  resolvedPrecision = precision;
610
624
  }
611
625
  const covering = GeoHash.getCoveringHashes(lat, longitude, radius, resolvedPrecision);
626
+ const field = typeof latOrOptions === "object" ? latOrOptions.field : void 0;
612
627
  return this.fork((state) => {
628
+ if (field) {
629
+ state.geo = { field, lat, lon: longitude, radiusKm: radius };
630
+ }
613
631
  const group = state.nextGroup;
614
632
  state.nextGroup += 1;
615
633
  for (const hash of covering) {
@@ -621,6 +639,15 @@ var QueryBuilder = class _QueryBuilder {
621
639
  }
622
640
  });
623
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
+ */
624
651
  range(field, min, max) {
625
652
  if (!field.trim()) {
626
653
  throw new PulseIndexQueryError("Range field must not be empty.");
@@ -668,9 +695,12 @@ var QueryBuilder = class _QueryBuilder {
668
695
  return this.sortBy(field, true);
669
696
  }
670
697
  /**
671
- * Order the page by a numeric field. Rows carrying no value for it sort last
672
- * in both directions; they still count towards `totalMatches`, they simply
673
- * 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.
674
704
  */
675
705
  sortBy(field, descending = false) {
676
706
  if (!field.trim()) {
@@ -680,18 +710,62 @@ var QueryBuilder = class _QueryBuilder {
680
710
  state.sort = { field, descending };
681
711
  });
682
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
+ }
683
754
  toRequest(defaultTenantId = "") {
684
755
  const request = {
685
756
  tenantId: this.state.tenantId || defaultTenantId,
686
- locationPrefix: this.state.locationPrefix,
687
757
  limit: this.state.limit,
688
758
  offset: this.state.offset,
759
+ exactTotal: this.state.exactTotal,
689
760
  filters: this.state.filters.map((filter) => ({ ...filter })),
690
761
  ranges: this.state.ranges.map((range) => ({ ...range }))
691
762
  };
692
763
  if (this.state.sort) {
693
764
  request.sort = { ...this.state.sort };
694
765
  }
766
+ if (this.state.geo) {
767
+ request.geo = { ...this.state.geo };
768
+ }
695
769
  return request;
696
770
  }
697
771
  toArray(defaultTenantId = "") {
@@ -710,8 +784,8 @@ var QueryBuilder = class _QueryBuilder {
710
784
  if (options.tenantId !== void 0) {
711
785
  query = query.tenant(options.tenantId);
712
786
  }
713
- if (options.locationPrefix !== void 0) {
714
- query = query.location(options.locationPrefix);
787
+ if (options.exactTotal !== void 0) {
788
+ query = query.exactTotal(options.exactTotal);
715
789
  }
716
790
  if (options.must !== void 0) {
717
791
  query = query.must(options.must);
@@ -760,12 +834,13 @@ var QueryBuilder = class _QueryBuilder {
760
834
  const next = new _QueryBuilder(this.executor);
761
835
  next.state = {
762
836
  tenantId: this.state.tenantId,
763
- locationPrefix: this.state.locationPrefix,
764
837
  limit: this.state.limit,
765
838
  offset: this.state.offset,
839
+ exactTotal: this.state.exactTotal,
766
840
  filters: this.state.filters.map((filter) => ({ ...filter })),
767
841
  ranges: this.state.ranges.map((range) => ({ ...range })),
768
842
  sort: this.state.sort ? { ...this.state.sort } : null,
843
+ geo: this.state.geo ? { ...this.state.geo } : null,
769
844
  nextGroup: this.state.nextGroup
770
845
  };
771
846
  mutate(next.state);
@@ -1045,9 +1120,8 @@ var SKIP_ATTRIBUTE_KEYS = /* @__PURE__ */ new Set([
1045
1120
  "entityId",
1046
1121
  "entity_id",
1047
1122
  "attributes",
1048
- "price",
1049
- "locationPrefix",
1050
- "location_prefix",
1123
+ "numbers",
1124
+ "points",
1051
1125
  "tenantId",
1052
1126
  "tenant_id",
1053
1127
  "latitude",
@@ -1079,15 +1153,69 @@ function toUint64String(value, field = "entityId") {
1079
1153
  }
1080
1154
  return trimmed.replace(/^0+(?=\d)/, "");
1081
1155
  }
1082
- function toUint32(value, field) {
1083
- if (value === void 0 || value === null || value === "") {
1084
- return 0;
1085
- }
1156
+ function toFieldValue(value, field) {
1086
1157
  const numeric = typeof value === "number" ? value : Number(value);
1087
- if (!Number.isFinite(numeric) || numeric < 0 || numeric > UINT32_MAX) {
1088
- 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 };
1089
1217
  }
1090
- return Math.floor(numeric);
1218
+ return out;
1091
1219
  }
1092
1220
  function asRecord(value) {
1093
1221
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -1193,11 +1321,8 @@ function encodeEntity(entityIdOrInput, attributes = {}, defaults = {}) {
1193
1321
  return {
1194
1322
  entityId: toUint64String(rawId, "entityId"),
1195
1323
  categories: flattenAttributes(merged),
1196
- price: toUint32(merged.price, "price"),
1197
- locationPrefix: toUint64String(
1198
- merged.locationPrefix ?? merged.location_prefix ?? 0,
1199
- "locationPrefix"
1200
- ),
1324
+ numbers: collectNumbers(merged),
1325
+ points: collectPoints(merged),
1201
1326
  tenantId
1202
1327
  };
1203
1328
  }
@@ -1231,10 +1356,11 @@ var PulseIndexClient = class _PulseIndexClient {
1231
1356
  matchedEntityIds: (raw.matchedEntityIds ?? []).map((id) => String(id)),
1232
1357
  totalMatches: Number(raw.totalMatches ?? 0),
1233
1358
  executionTimeUs: Number(raw.executionTimeUs ?? 0),
1234
- // Exact only when nothing made the engine stop early. limit=0 asks for
1235
- // the count and no ids, and is the only shape that counts every match;
1236
- // any page can early-exit as soon as it is full.
1237
- totalIsExact: builder.toArray().limit === 0
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)
1238
1364
  };
1239
1365
  }
1240
1366
  /**
@@ -1246,22 +1372,14 @@ var PulseIndexClient = class _PulseIndexClient {
1246
1372
  * reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
1247
1373
  * that number is wrong by an order of magnitude and looks fine.
1248
1374
  *
1249
- * This sends the count query as well, so it costs two round trips and returns
1250
- * a total you can divide by a page size.
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.
1251
1379
  */
1252
1380
  async searchWithTotal(query) {
1253
- const page = await this.search(query);
1254
- if (page.totalIsExact) {
1255
- return page;
1256
- }
1257
- const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query);
1258
- const counted = await this.search(builder.limit(0));
1259
- return {
1260
- matchedEntityIds: page.matchedEntityIds,
1261
- totalMatches: counted.totalMatches,
1262
- executionTimeUs: page.executionTimeUs + counted.executionTimeUs,
1263
- totalIsExact: true
1264
- };
1381
+ const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query, this);
1382
+ return this.search(builder.exactTotal());
1265
1383
  }
1266
1384
  async index(entityIdOrInput, attributes = {}) {
1267
1385
  const encoded = encodeEntity(entityIdOrInput, attributes, {
@@ -1272,12 +1390,18 @@ var PulseIndexClient = class _PulseIndexClient {
1272
1390
  );
1273
1391
  return { success: Boolean(raw.success) };
1274
1392
  }
1275
- 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 = "") {
1276
1401
  const response = await this.index({
1277
1402
  entityId,
1278
1403
  categories,
1279
- price,
1280
- locationPrefix,
1404
+ numbers,
1281
1405
  tenantId: tenantId || this.connection.tenantId
1282
1406
  });
1283
1407
  return response.success;
@@ -1427,8 +1551,8 @@ var PulseIndex = class extends PulseIndexClient {
1427
1551
  function toIndexRequest(encoded) {
1428
1552
  return {
1429
1553
  entityId: encoded.entityId,
1430
- locationPrefix: encoded.locationPrefix,
1431
- price: encoded.price,
1554
+ numbers: encoded.numbers,
1555
+ points: encoded.points,
1432
1556
  categories: encoded.categories,
1433
1557
  tenantId: encoded.tenantId
1434
1558
  };