@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/CHANGELOG.md +100 -0
- package/dist/index.d.mts +101 -19
- package/dist/index.d.ts +101 -19
- package/dist/index.js +167 -49
- package/dist/index.mjs +167 -49
- package/package.json +1 -1
- package/proto/engine.proto +107 -15
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
678
|
-
*
|
|
679
|
-
*
|
|
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.
|
|
720
|
-
query = query.
|
|
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
|
-
"
|
|
1055
|
-
"
|
|
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
|
|
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)
|
|
1094
|
-
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 };
|
|
1095
1217
|
}
|
|
1096
|
-
return
|
|
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
|
-
|
|
1203
|
-
|
|
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
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
|
|
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
|
-
*
|
|
1256
|
-
*
|
|
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
|
|
1260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1437
|
-
|
|
1554
|
+
numbers: encoded.numbers,
|
|
1555
|
+
points: encoded.points,
|
|
1438
1556
|
categories: encoded.categories,
|
|
1439
1557
|
tenantId: encoded.tenantId
|
|
1440
1558
|
};
|