@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.mjs
CHANGED
|
@@ -42,11 +42,17 @@ var GeoHash = class {
|
|
|
42
42
|
* How much area outside the circle a covering may carry before a finer
|
|
43
43
|
* precision is worth its cell count.
|
|
44
44
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
* `withinRadius` is a pre-filter the caller narrows exactly afterwards, so
|
|
46
|
+
* excess area is cheaper than predicates.
|
|
47
|
+
*
|
|
48
|
+
* 2.0 was the first attempt and it was too strict. It rejected the coarse
|
|
49
|
+
* cell at 5 km, so a covering that had cost 10 cells cost 167, and the demo
|
|
50
|
+
* benchmark went from beating PostgreSQL to losing to it by 2.76x on wall
|
|
51
|
+
* time — the cost is the request, not the search. 3.0 keeps the cheap
|
|
52
|
+
* covering at 5 km (2.76x) and still rejects the coarse cell at 2 km, where
|
|
53
|
+
* it wastes 4.73x to 6.91x depending on latitude.
|
|
48
54
|
*/
|
|
49
|
-
static ACCEPTABLE_COVER_RATIO =
|
|
55
|
+
static ACCEPTABLE_COVER_RATIO = 3;
|
|
50
56
|
static NEIGHBORS = {
|
|
51
57
|
n: ["p0r21436x8zb9dcf5h7kjnmqesgutwvy", "bc01fg45238967deuvhjyznpkmstqrwx"],
|
|
52
58
|
s: ["14365h7k9dcfesgujnmqp0r2twvyx8zb", "238967debc01fg45kmstqrwxuvhjyznp"],
|
|
@@ -490,7 +496,6 @@ var FilterOperation = {
|
|
|
490
496
|
SHOULD: 1,
|
|
491
497
|
MUST_NOT: 2
|
|
492
498
|
};
|
|
493
|
-
var UINT32_MAX = 4294967295;
|
|
494
499
|
var DEFAULT_ENDPOINT = "localhost:50051";
|
|
495
500
|
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
496
501
|
var DEFAULT_POOL_SIZE = 1;
|
|
@@ -500,7 +505,8 @@ var DEFAULT_LIMIT = 100;
|
|
|
500
505
|
function emptyState() {
|
|
501
506
|
return {
|
|
502
507
|
tenantId: "",
|
|
503
|
-
|
|
508
|
+
exactTotal: false,
|
|
509
|
+
geo: null,
|
|
504
510
|
limit: DEFAULT_LIMIT,
|
|
505
511
|
offset: 0,
|
|
506
512
|
filters: [],
|
|
@@ -536,9 +542,17 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
536
542
|
state.tenantId = tenantId;
|
|
537
543
|
});
|
|
538
544
|
}
|
|
539
|
-
|
|
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) {
|
|
540
554
|
return this.fork((state) => {
|
|
541
|
-
state.
|
|
555
|
+
state.exactTotal = enabled;
|
|
542
556
|
});
|
|
543
557
|
}
|
|
544
558
|
must(attribute) {
|
|
@@ -586,7 +600,11 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
586
600
|
resolvedPrecision = precision;
|
|
587
601
|
}
|
|
588
602
|
const covering = GeoHash.getCoveringHashes(lat, longitude, radius, resolvedPrecision);
|
|
603
|
+
const field = typeof latOrOptions === "object" ? latOrOptions.field : void 0;
|
|
589
604
|
return this.fork((state) => {
|
|
605
|
+
if (field) {
|
|
606
|
+
state.geo = { field, lat, lon: longitude, radiusKm: radius };
|
|
607
|
+
}
|
|
590
608
|
const group = state.nextGroup;
|
|
591
609
|
state.nextGroup += 1;
|
|
592
610
|
for (const hash of covering) {
|
|
@@ -598,6 +616,15 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
598
616
|
}
|
|
599
617
|
});
|
|
600
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
|
+
*/
|
|
601
628
|
range(field, min, max) {
|
|
602
629
|
if (!field.trim()) {
|
|
603
630
|
throw new PulseIndexQueryError("Range field must not be empty.");
|
|
@@ -645,9 +672,12 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
645
672
|
return this.sortBy(field, true);
|
|
646
673
|
}
|
|
647
674
|
/**
|
|
648
|
-
* Order the page by a numeric field.
|
|
649
|
-
*
|
|
650
|
-
*
|
|
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.
|
|
651
681
|
*/
|
|
652
682
|
sortBy(field, descending = false) {
|
|
653
683
|
if (!field.trim()) {
|
|
@@ -657,18 +687,62 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
657
687
|
state.sort = { field, descending };
|
|
658
688
|
});
|
|
659
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
|
+
}
|
|
660
731
|
toRequest(defaultTenantId = "") {
|
|
661
732
|
const request = {
|
|
662
733
|
tenantId: this.state.tenantId || defaultTenantId,
|
|
663
|
-
locationPrefix: this.state.locationPrefix,
|
|
664
734
|
limit: this.state.limit,
|
|
665
735
|
offset: this.state.offset,
|
|
736
|
+
exactTotal: this.state.exactTotal,
|
|
666
737
|
filters: this.state.filters.map((filter) => ({ ...filter })),
|
|
667
738
|
ranges: this.state.ranges.map((range) => ({ ...range }))
|
|
668
739
|
};
|
|
669
740
|
if (this.state.sort) {
|
|
670
741
|
request.sort = { ...this.state.sort };
|
|
671
742
|
}
|
|
743
|
+
if (this.state.geo) {
|
|
744
|
+
request.geo = { ...this.state.geo };
|
|
745
|
+
}
|
|
672
746
|
return request;
|
|
673
747
|
}
|
|
674
748
|
toArray(defaultTenantId = "") {
|
|
@@ -687,8 +761,8 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
687
761
|
if (options.tenantId !== void 0) {
|
|
688
762
|
query = query.tenant(options.tenantId);
|
|
689
763
|
}
|
|
690
|
-
if (options.
|
|
691
|
-
query = query.
|
|
764
|
+
if (options.exactTotal !== void 0) {
|
|
765
|
+
query = query.exactTotal(options.exactTotal);
|
|
692
766
|
}
|
|
693
767
|
if (options.must !== void 0) {
|
|
694
768
|
query = query.must(options.must);
|
|
@@ -737,12 +811,13 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
737
811
|
const next = new _QueryBuilder(this.executor);
|
|
738
812
|
next.state = {
|
|
739
813
|
tenantId: this.state.tenantId,
|
|
740
|
-
locationPrefix: this.state.locationPrefix,
|
|
741
814
|
limit: this.state.limit,
|
|
742
815
|
offset: this.state.offset,
|
|
816
|
+
exactTotal: this.state.exactTotal,
|
|
743
817
|
filters: this.state.filters.map((filter) => ({ ...filter })),
|
|
744
818
|
ranges: this.state.ranges.map((range) => ({ ...range })),
|
|
745
819
|
sort: this.state.sort ? { ...this.state.sort } : null,
|
|
820
|
+
geo: this.state.geo ? { ...this.state.geo } : null,
|
|
746
821
|
nextGroup: this.state.nextGroup
|
|
747
822
|
};
|
|
748
823
|
mutate(next.state);
|
|
@@ -1022,9 +1097,8 @@ var SKIP_ATTRIBUTE_KEYS = /* @__PURE__ */ new Set([
|
|
|
1022
1097
|
"entityId",
|
|
1023
1098
|
"entity_id",
|
|
1024
1099
|
"attributes",
|
|
1025
|
-
"
|
|
1026
|
-
"
|
|
1027
|
-
"location_prefix",
|
|
1100
|
+
"numbers",
|
|
1101
|
+
"points",
|
|
1028
1102
|
"tenantId",
|
|
1029
1103
|
"tenant_id",
|
|
1030
1104
|
"latitude",
|
|
@@ -1056,15 +1130,69 @@ function toUint64String(value, field = "entityId") {
|
|
|
1056
1130
|
}
|
|
1057
1131
|
return trimmed.replace(/^0+(?=\d)/, "");
|
|
1058
1132
|
}
|
|
1059
|
-
function
|
|
1060
|
-
if (value === void 0 || value === null || value === "") {
|
|
1061
|
-
return 0;
|
|
1062
|
-
}
|
|
1133
|
+
function toFieldValue(value, field) {
|
|
1063
1134
|
const numeric = typeof value === "number" ? value : Number(value);
|
|
1064
|
-
if (!Number.isFinite(numeric)
|
|
1065
|
-
throw new PulseIndexQueryError(`${field} must be
|
|
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 };
|
|
1066
1194
|
}
|
|
1067
|
-
return
|
|
1195
|
+
return out;
|
|
1068
1196
|
}
|
|
1069
1197
|
function asRecord(value) {
|
|
1070
1198
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
@@ -1170,11 +1298,8 @@ function encodeEntity(entityIdOrInput, attributes = {}, defaults = {}) {
|
|
|
1170
1298
|
return {
|
|
1171
1299
|
entityId: toUint64String(rawId, "entityId"),
|
|
1172
1300
|
categories: flattenAttributes(merged),
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
merged.locationPrefix ?? merged.location_prefix ?? 0,
|
|
1176
|
-
"locationPrefix"
|
|
1177
|
-
),
|
|
1301
|
+
numbers: collectNumbers(merged),
|
|
1302
|
+
points: collectPoints(merged),
|
|
1178
1303
|
tenantId
|
|
1179
1304
|
};
|
|
1180
1305
|
}
|
|
@@ -1208,10 +1333,11 @@ var PulseIndexClient = class _PulseIndexClient {
|
|
|
1208
1333
|
matchedEntityIds: (raw.matchedEntityIds ?? []).map((id) => String(id)),
|
|
1209
1334
|
totalMatches: Number(raw.totalMatches ?? 0),
|
|
1210
1335
|
executionTimeUs: Number(raw.executionTimeUs ?? 0),
|
|
1211
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
//
|
|
1214
|
-
|
|
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)
|
|
1215
1341
|
};
|
|
1216
1342
|
}
|
|
1217
1343
|
/**
|
|
@@ -1223,22 +1349,14 @@ var PulseIndexClient = class _PulseIndexClient {
|
|
|
1223
1349
|
* reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
|
|
1224
1350
|
* that number is wrong by an order of magnitude and looks fine.
|
|
1225
1351
|
*
|
|
1226
|
-
*
|
|
1227
|
-
*
|
|
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.
|
|
1228
1356
|
*/
|
|
1229
1357
|
async searchWithTotal(query) {
|
|
1230
|
-
const
|
|
1231
|
-
|
|
1232
|
-
return page;
|
|
1233
|
-
}
|
|
1234
|
-
const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query);
|
|
1235
|
-
const counted = await this.search(builder.limit(0));
|
|
1236
|
-
return {
|
|
1237
|
-
matchedEntityIds: page.matchedEntityIds,
|
|
1238
|
-
totalMatches: counted.totalMatches,
|
|
1239
|
-
executionTimeUs: page.executionTimeUs + counted.executionTimeUs,
|
|
1240
|
-
totalIsExact: true
|
|
1241
|
-
};
|
|
1358
|
+
const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query, this);
|
|
1359
|
+
return this.search(builder.exactTotal());
|
|
1242
1360
|
}
|
|
1243
1361
|
async index(entityIdOrInput, attributes = {}) {
|
|
1244
1362
|
const encoded = encodeEntity(entityIdOrInput, attributes, {
|
|
@@ -1249,12 +1367,18 @@ var PulseIndexClient = class _PulseIndexClient {
|
|
|
1249
1367
|
);
|
|
1250
1368
|
return { success: Boolean(raw.success) };
|
|
1251
1369
|
}
|
|
1252
|
-
|
|
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 = "") {
|
|
1253
1378
|
const response = await this.index({
|
|
1254
1379
|
entityId,
|
|
1255
1380
|
categories,
|
|
1256
|
-
|
|
1257
|
-
locationPrefix,
|
|
1381
|
+
numbers,
|
|
1258
1382
|
tenantId: tenantId || this.connection.tenantId
|
|
1259
1383
|
});
|
|
1260
1384
|
return response.success;
|
|
@@ -1404,8 +1528,8 @@ var PulseIndex = class extends PulseIndexClient {
|
|
|
1404
1528
|
function toIndexRequest(encoded) {
|
|
1405
1529
|
return {
|
|
1406
1530
|
entityId: encoded.entityId,
|
|
1407
|
-
|
|
1408
|
-
|
|
1531
|
+
numbers: encoded.numbers,
|
|
1532
|
+
points: encoded.points,
|
|
1409
1533
|
categories: encoded.categories,
|
|
1410
1534
|
tenantId: encoded.tenantId
|
|
1411
1535
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pulseindex/sdk",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
3
|
+
"version": "5.0.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/mohammed-alfarra/pulseindex-js.git"
|
|
7
|
+
},
|
|
8
|
+
"description": "Official Node.js & TypeScript SDK for PulseIndex \u2014 hosted search and filtering for large entity sets",
|
|
5
9
|
"license": "MIT",
|
|
6
10
|
"author": "PulseIndex",
|
|
7
11
|
"keywords": [
|
package/proto/engine.proto
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
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
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
//
|
|
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
|
}
|