@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 +115 -0
- package/dist/index.d.mts +111 -23
- package/dist/index.d.ts +111 -23
- package/dist/index.js +177 -53
- package/dist/index.mjs +177 -53
- package/package.json +6 -2
- package/proto/engine.proto +107 -15
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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
672
|
-
*
|
|
673
|
-
*
|
|
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.
|
|
714
|
-
query = query.
|
|
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
|
-
"
|
|
1049
|
-
"
|
|
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
|
|
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)
|
|
1088
|
-
throw new PulseIndexQueryError(`${field} must be
|
|
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
|
|
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
|
-
|
|
1197
|
-
|
|
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
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
|
|
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
|
-
*
|
|
1250
|
-
*
|
|
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
|
|
1254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1431
|
-
|
|
1554
|
+
numbers: encoded.numbers,
|
|
1555
|
+
points: encoded.points,
|
|
1432
1556
|
categories: encoded.categories,
|
|
1433
1557
|
tenantId: encoded.tenantId
|
|
1434
1558
|
};
|