@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.mjs
CHANGED
|
@@ -496,7 +496,6 @@ var FilterOperation = {
|
|
|
496
496
|
SHOULD: 1,
|
|
497
497
|
MUST_NOT: 2
|
|
498
498
|
};
|
|
499
|
-
var UINT32_MAX = 4294967295;
|
|
500
499
|
var DEFAULT_ENDPOINT = "localhost:50051";
|
|
501
500
|
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
502
501
|
var DEFAULT_POOL_SIZE = 1;
|
|
@@ -506,7 +505,8 @@ var DEFAULT_LIMIT = 100;
|
|
|
506
505
|
function emptyState() {
|
|
507
506
|
return {
|
|
508
507
|
tenantId: "",
|
|
509
|
-
|
|
508
|
+
exactTotal: false,
|
|
509
|
+
geo: null,
|
|
510
510
|
limit: DEFAULT_LIMIT,
|
|
511
511
|
offset: 0,
|
|
512
512
|
filters: [],
|
|
@@ -542,9 +542,17 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
542
542
|
state.tenantId = tenantId;
|
|
543
543
|
});
|
|
544
544
|
}
|
|
545
|
-
|
|
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) {
|
|
546
554
|
return this.fork((state) => {
|
|
547
|
-
state.
|
|
555
|
+
state.exactTotal = enabled;
|
|
548
556
|
});
|
|
549
557
|
}
|
|
550
558
|
must(attribute) {
|
|
@@ -592,7 +600,11 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
592
600
|
resolvedPrecision = precision;
|
|
593
601
|
}
|
|
594
602
|
const covering = GeoHash.getCoveringHashes(lat, longitude, radius, resolvedPrecision);
|
|
603
|
+
const field = typeof latOrOptions === "object" ? latOrOptions.field : void 0;
|
|
595
604
|
return this.fork((state) => {
|
|
605
|
+
if (field) {
|
|
606
|
+
state.geo = { field, lat, lon: longitude, radiusKm: radius };
|
|
607
|
+
}
|
|
596
608
|
const group = state.nextGroup;
|
|
597
609
|
state.nextGroup += 1;
|
|
598
610
|
for (const hash of covering) {
|
|
@@ -604,6 +616,15 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
604
616
|
}
|
|
605
617
|
});
|
|
606
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
|
+
*/
|
|
607
628
|
range(field, min, max) {
|
|
608
629
|
if (!field.trim()) {
|
|
609
630
|
throw new PulseIndexQueryError("Range field must not be empty.");
|
|
@@ -651,9 +672,12 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
651
672
|
return this.sortBy(field, true);
|
|
652
673
|
}
|
|
653
674
|
/**
|
|
654
|
-
* Order the page by a numeric field.
|
|
655
|
-
*
|
|
656
|
-
*
|
|
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.
|
|
657
681
|
*/
|
|
658
682
|
sortBy(field, descending = false) {
|
|
659
683
|
if (!field.trim()) {
|
|
@@ -663,18 +687,62 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
663
687
|
state.sort = { field, descending };
|
|
664
688
|
});
|
|
665
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
|
+
}
|
|
666
731
|
toRequest(defaultTenantId = "") {
|
|
667
732
|
const request = {
|
|
668
733
|
tenantId: this.state.tenantId || defaultTenantId,
|
|
669
|
-
locationPrefix: this.state.locationPrefix,
|
|
670
734
|
limit: this.state.limit,
|
|
671
735
|
offset: this.state.offset,
|
|
736
|
+
exactTotal: this.state.exactTotal,
|
|
672
737
|
filters: this.state.filters.map((filter) => ({ ...filter })),
|
|
673
738
|
ranges: this.state.ranges.map((range) => ({ ...range }))
|
|
674
739
|
};
|
|
675
740
|
if (this.state.sort) {
|
|
676
741
|
request.sort = { ...this.state.sort };
|
|
677
742
|
}
|
|
743
|
+
if (this.state.geo) {
|
|
744
|
+
request.geo = { ...this.state.geo };
|
|
745
|
+
}
|
|
678
746
|
return request;
|
|
679
747
|
}
|
|
680
748
|
toArray(defaultTenantId = "") {
|
|
@@ -693,8 +761,8 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
693
761
|
if (options.tenantId !== void 0) {
|
|
694
762
|
query = query.tenant(options.tenantId);
|
|
695
763
|
}
|
|
696
|
-
if (options.
|
|
697
|
-
query = query.
|
|
764
|
+
if (options.exactTotal !== void 0) {
|
|
765
|
+
query = query.exactTotal(options.exactTotal);
|
|
698
766
|
}
|
|
699
767
|
if (options.must !== void 0) {
|
|
700
768
|
query = query.must(options.must);
|
|
@@ -743,12 +811,13 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
743
811
|
const next = new _QueryBuilder(this.executor);
|
|
744
812
|
next.state = {
|
|
745
813
|
tenantId: this.state.tenantId,
|
|
746
|
-
locationPrefix: this.state.locationPrefix,
|
|
747
814
|
limit: this.state.limit,
|
|
748
815
|
offset: this.state.offset,
|
|
816
|
+
exactTotal: this.state.exactTotal,
|
|
749
817
|
filters: this.state.filters.map((filter) => ({ ...filter })),
|
|
750
818
|
ranges: this.state.ranges.map((range) => ({ ...range })),
|
|
751
819
|
sort: this.state.sort ? { ...this.state.sort } : null,
|
|
820
|
+
geo: this.state.geo ? { ...this.state.geo } : null,
|
|
752
821
|
nextGroup: this.state.nextGroup
|
|
753
822
|
};
|
|
754
823
|
mutate(next.state);
|
|
@@ -1028,9 +1097,8 @@ var SKIP_ATTRIBUTE_KEYS = /* @__PURE__ */ new Set([
|
|
|
1028
1097
|
"entityId",
|
|
1029
1098
|
"entity_id",
|
|
1030
1099
|
"attributes",
|
|
1031
|
-
"
|
|
1032
|
-
"
|
|
1033
|
-
"location_prefix",
|
|
1100
|
+
"numbers",
|
|
1101
|
+
"points",
|
|
1034
1102
|
"tenantId",
|
|
1035
1103
|
"tenant_id",
|
|
1036
1104
|
"latitude",
|
|
@@ -1062,15 +1130,69 @@ function toUint64String(value, field = "entityId") {
|
|
|
1062
1130
|
}
|
|
1063
1131
|
return trimmed.replace(/^0+(?=\d)/, "");
|
|
1064
1132
|
}
|
|
1065
|
-
function
|
|
1066
|
-
if (value === void 0 || value === null || value === "") {
|
|
1067
|
-
return 0;
|
|
1068
|
-
}
|
|
1133
|
+
function toFieldValue(value, field) {
|
|
1069
1134
|
const numeric = typeof value === "number" ? value : Number(value);
|
|
1070
|
-
if (!Number.isFinite(numeric)
|
|
1071
|
-
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 };
|
|
1072
1194
|
}
|
|
1073
|
-
return
|
|
1195
|
+
return out;
|
|
1074
1196
|
}
|
|
1075
1197
|
function asRecord(value) {
|
|
1076
1198
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
@@ -1176,11 +1298,8 @@ function encodeEntity(entityIdOrInput, attributes = {}, defaults = {}) {
|
|
|
1176
1298
|
return {
|
|
1177
1299
|
entityId: toUint64String(rawId, "entityId"),
|
|
1178
1300
|
categories: flattenAttributes(merged),
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
merged.locationPrefix ?? merged.location_prefix ?? 0,
|
|
1182
|
-
"locationPrefix"
|
|
1183
|
-
),
|
|
1301
|
+
numbers: collectNumbers(merged),
|
|
1302
|
+
points: collectPoints(merged),
|
|
1184
1303
|
tenantId
|
|
1185
1304
|
};
|
|
1186
1305
|
}
|
|
@@ -1214,10 +1333,11 @@ var PulseIndexClient = class _PulseIndexClient {
|
|
|
1214
1333
|
matchedEntityIds: (raw.matchedEntityIds ?? []).map((id) => String(id)),
|
|
1215
1334
|
totalMatches: Number(raw.totalMatches ?? 0),
|
|
1216
1335
|
executionTimeUs: Number(raw.executionTimeUs ?? 0),
|
|
1217
|
-
//
|
|
1218
|
-
//
|
|
1219
|
-
//
|
|
1220
|
-
|
|
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)
|
|
1221
1341
|
};
|
|
1222
1342
|
}
|
|
1223
1343
|
/**
|
|
@@ -1229,22 +1349,14 @@ var PulseIndexClient = class _PulseIndexClient {
|
|
|
1229
1349
|
* reported 10,866 for a page of 100. Anything that prints "page 1 of N" from
|
|
1230
1350
|
* that number is wrong by an order of magnitude and looks fine.
|
|
1231
1351
|
*
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
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.
|
|
1234
1356
|
*/
|
|
1235
1357
|
async searchWithTotal(query) {
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1238
|
-
return page;
|
|
1239
|
-
}
|
|
1240
|
-
const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query);
|
|
1241
|
-
const counted = await this.search(builder.limit(0));
|
|
1242
|
-
return {
|
|
1243
|
-
matchedEntityIds: page.matchedEntityIds,
|
|
1244
|
-
totalMatches: counted.totalMatches,
|
|
1245
|
-
executionTimeUs: page.executionTimeUs + counted.executionTimeUs,
|
|
1246
|
-
totalIsExact: true
|
|
1247
|
-
};
|
|
1358
|
+
const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query, this);
|
|
1359
|
+
return this.search(builder.exactTotal());
|
|
1248
1360
|
}
|
|
1249
1361
|
async index(entityIdOrInput, attributes = {}) {
|
|
1250
1362
|
const encoded = encodeEntity(entityIdOrInput, attributes, {
|
|
@@ -1255,12 +1367,18 @@ var PulseIndexClient = class _PulseIndexClient {
|
|
|
1255
1367
|
);
|
|
1256
1368
|
return { success: Boolean(raw.success) };
|
|
1257
1369
|
}
|
|
1258
|
-
|
|
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 = "") {
|
|
1259
1378
|
const response = await this.index({
|
|
1260
1379
|
entityId,
|
|
1261
1380
|
categories,
|
|
1262
|
-
|
|
1263
|
-
locationPrefix,
|
|
1381
|
+
numbers,
|
|
1264
1382
|
tenantId: tenantId || this.connection.tenantId
|
|
1265
1383
|
});
|
|
1266
1384
|
return response.success;
|
|
@@ -1410,8 +1528,8 @@ var PulseIndex = class extends PulseIndexClient {
|
|
|
1410
1528
|
function toIndexRequest(encoded) {
|
|
1411
1529
|
return {
|
|
1412
1530
|
entityId: encoded.entityId,
|
|
1413
|
-
|
|
1414
|
-
|
|
1531
|
+
numbers: encoded.numbers,
|
|
1532
|
+
points: encoded.points,
|
|
1415
1533
|
categories: encoded.categories,
|
|
1416
1534
|
tenantId: encoded.tenantId
|
|
1417
1535
|
};
|
package/package.json
CHANGED
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
|
}
|