@pulseindex/sdk 3.0.0 → 3.2.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 +70 -0
- package/README.md +5 -0
- package/dist/index.d.mts +84 -2
- package/dist/index.d.ts +84 -2
- package/dist/index.js +97 -8
- package/dist/index.mjs +97 -8
- package/package.json +1 -1
- package/proto/engine.proto +58 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,75 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.2.0
|
|
4
|
+
|
|
5
|
+
### Delete many entities in one call
|
|
6
|
+
|
|
7
|
+
`delete()` takes a single id, so clearing a catalogue meant one round trip per
|
|
8
|
+
row. There was no other way to do it through the API at all.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
for (const page of pages(idsToRemove, 10_000)) {
|
|
12
|
+
const { deletedCount } = await client.batchDelete(page);
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Up to 10,000 ids per call. A larger page is refused by name rather than
|
|
17
|
+
truncated, so a page that is too big fails loudly instead of deleting part of
|
|
18
|
+
itself and reporting success.
|
|
19
|
+
|
|
20
|
+
Ids that are unknown or already deleted are skipped rather than refused, so
|
|
21
|
+
retrying a page that half-applied is safe. `deletedCount` is the number of rows
|
|
22
|
+
that actually changed, which is lower than the number of ids you sent whenever
|
|
23
|
+
some were already gone.
|
|
24
|
+
|
|
25
|
+
## 3.1.0
|
|
26
|
+
|
|
27
|
+
### A radius no longer merges with your own OR
|
|
28
|
+
|
|
29
|
+
`withinRadius` turns a circle into one SHOULD filter per covering geohash cell.
|
|
30
|
+
Every SHOULD went into the same disjunction, so a radius sat in the same OR as
|
|
31
|
+
anything else you had asked for:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
PulseIndex.query().should(['color:red', 'color:blue']).withinRadius(lat, lon, 5)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
asked for "within 5 km **or** red **or** blue". It returned a plausible page of
|
|
38
|
+
results and said nothing about it. The cells now form a disjunction of their
|
|
39
|
+
own, and each further radius gets another, so that query means what it reads
|
|
40
|
+
like. Nothing changes for a query that used one or the other but not both.
|
|
41
|
+
|
|
42
|
+
### Groups: (red or blue) and (small or medium)
|
|
43
|
+
|
|
44
|
+
`should()` takes a group number. Members of a group are OR'd together and the
|
|
45
|
+
groups are AND'd with each other:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
PulseIndex.query()
|
|
49
|
+
.should(['color:red', 'color:blue'], 1)
|
|
50
|
+
.should(['size:s', 'size:m'], 2);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Left unset it is 0, which is one disjunction — exactly what every existing
|
|
54
|
+
query already does.
|
|
55
|
+
|
|
56
|
+
### Ordering
|
|
57
|
+
|
|
58
|
+
`sortAsc(field)`, `sortDesc(field)` and `sortBy(field, descending)`, plus
|
|
59
|
+
`sortBy` on the plain options form:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
await client.search(PulseIndex.query().must('status:active').sortAsc('price'));
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Rows carrying no value for the field sort last in both directions; they still
|
|
66
|
+
count towards `totalMatches`, they simply have nothing to be ordered by.
|
|
67
|
+
|
|
68
|
+
An ordered search cannot stop as soon as the page is full — the cheapest
|
|
69
|
+
remaining row may be anywhere in the tenant — so it costs more than the same
|
|
70
|
+
filter unordered. `offset + limit` is capped at 100,000 and a request past it
|
|
71
|
+
is refused with the ceiling named.
|
|
72
|
+
|
|
3
73
|
## 3.0.0
|
|
4
74
|
|
|
5
75
|
### Breaking: a query returns a page instead of everything
|
package/README.md
CHANGED
|
@@ -140,6 +140,10 @@ await client.batchIndex([
|
|
|
140
140
|
]);
|
|
141
141
|
|
|
142
142
|
await client.delete('1001');
|
|
143
|
+
|
|
144
|
+
// Clearing many rows: send ids in pages of up to 10,000. A larger page is
|
|
145
|
+
// refused by name rather than truncated.
|
|
146
|
+
await client.batchDelete([1002, 1003, 1004]);
|
|
143
147
|
```
|
|
144
148
|
|
|
145
149
|
Low-level PHP-compatible helper:
|
|
@@ -293,6 +297,7 @@ than failing your own requests immediately; if it persists, contact support.
|
|
|
293
297
|
| `client.index(id, attributes)` | `{ success }` | Upsert one entity |
|
|
294
298
|
| `client.batchIndex(entities)` | `{ indexedCount }` | Batch upsert |
|
|
295
299
|
| `client.delete(id)` | `{ success }` | Soft-delete an entity |
|
|
300
|
+
| `client.batchDelete(ids)` | `{ deletedCount }` | Soft-delete up to 10,000 entities in one call |
|
|
296
301
|
| `client.health()` | `boolean` | Whether the service is ready to answer queries |
|
|
297
302
|
| `client.servingStatus()` | `number` | Readiness as a status code, when you need more than a boolean |
|
|
298
303
|
| `client.close()` | `void` | Shut down the channel pool |
|
package/dist/index.d.mts
CHANGED
|
@@ -11,6 +11,22 @@ type EntityId = string | number | bigint;
|
|
|
11
11
|
interface FilterPredicate {
|
|
12
12
|
op: FilterOperationCode;
|
|
13
13
|
attribute: string;
|
|
14
|
+
/**
|
|
15
|
+
* Which disjunction a SHOULD predicate belongs to. Ignored for MUST and
|
|
16
|
+
* MUST_NOT.
|
|
17
|
+
*
|
|
18
|
+
* Members of a group are OR'd together and the groups are AND'd with each
|
|
19
|
+
* other, so "(red or blue) and (small or medium)" is two groups. Predicates
|
|
20
|
+
* that leave this unset share group 0.
|
|
21
|
+
*/
|
|
22
|
+
group?: number;
|
|
23
|
+
}
|
|
24
|
+
/** Orders a page by a numeric field. */
|
|
25
|
+
interface SortSpec {
|
|
26
|
+
/** Numeric field name, the same one a range would name. */
|
|
27
|
+
field: string;
|
|
28
|
+
/** Largest first when true; smallest first otherwise. */
|
|
29
|
+
descending: boolean;
|
|
14
30
|
}
|
|
15
31
|
interface RangePredicate {
|
|
16
32
|
field: string;
|
|
@@ -24,6 +40,8 @@ interface SearchQueryRequest {
|
|
|
24
40
|
limit: number;
|
|
25
41
|
offset: number;
|
|
26
42
|
tenantId: string;
|
|
43
|
+
/** Absent returns matches in entity-id order. */
|
|
44
|
+
sort?: SortSpec;
|
|
27
45
|
}
|
|
28
46
|
interface SearchResponse {
|
|
29
47
|
matchedEntityIds: string[];
|
|
@@ -46,6 +64,13 @@ interface BatchIndexResponse {
|
|
|
46
64
|
interface DeleteResponse {
|
|
47
65
|
success: boolean;
|
|
48
66
|
}
|
|
67
|
+
interface BatchDeleteResponse {
|
|
68
|
+
/**
|
|
69
|
+
* How many rows actually changed. Lower than the number of ids sent when
|
|
70
|
+
* some were unknown or already deleted, which is not an error.
|
|
71
|
+
*/
|
|
72
|
+
deletedCount: number;
|
|
73
|
+
}
|
|
49
74
|
interface RadiusOptions {
|
|
50
75
|
lat: number;
|
|
51
76
|
lng?: number;
|
|
@@ -68,6 +93,16 @@ interface SearchRequestOptions {
|
|
|
68
93
|
offset?: number;
|
|
69
94
|
withinRadius?: RadiusOptions;
|
|
70
95
|
geoHash?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Order the page by a numeric field. `descending` defaults to false.
|
|
98
|
+
*
|
|
99
|
+
* Rows carrying no value for the field sort last in both directions. They
|
|
100
|
+
* still count towards `totalMatches`; they have nothing to be ordered by.
|
|
101
|
+
*/
|
|
102
|
+
sortBy?: {
|
|
103
|
+
field: string;
|
|
104
|
+
descending?: boolean;
|
|
105
|
+
};
|
|
71
106
|
}
|
|
72
107
|
interface EntityAttributes {
|
|
73
108
|
categories?: unknown;
|
|
@@ -154,7 +189,17 @@ declare class QueryBuilder {
|
|
|
154
189
|
tenant(tenantId: string): QueryBuilder;
|
|
155
190
|
location(locationPrefix: string | number | bigint): QueryBuilder;
|
|
156
191
|
must(attribute: string | string[]): QueryBuilder;
|
|
157
|
-
|
|
192
|
+
/**
|
|
193
|
+
* At least one of these has to match.
|
|
194
|
+
*
|
|
195
|
+
* Pass a `group` to keep a disjunction separate from another one. Members of
|
|
196
|
+
* a group are OR'd together and the groups are AND'd with each other, so
|
|
197
|
+
* `.should(['color:red', 'color:blue'], 1).should(['size:s', 'size:m'], 2)`
|
|
198
|
+
* asks for a red or blue shirt in small or medium. Without the group numbers
|
|
199
|
+
* all four collapse into a single OR, which answers a different question and
|
|
200
|
+
* says nothing about it.
|
|
201
|
+
*/
|
|
202
|
+
should(attribute: string | string[], group?: number): QueryBuilder;
|
|
158
203
|
mustNot(attribute: string | string[]): QueryBuilder;
|
|
159
204
|
whereGeoHash(geohash: string): QueryBuilder;
|
|
160
205
|
inGeoHash(geohash: string): QueryBuilder;
|
|
@@ -167,6 +212,22 @@ declare class QueryBuilder {
|
|
|
167
212
|
*/
|
|
168
213
|
limit(limit: number): QueryBuilder;
|
|
169
214
|
offset(offset: number): QueryBuilder;
|
|
215
|
+
/**
|
|
216
|
+
* Order the page by a numeric field, smallest first.
|
|
217
|
+
*
|
|
218
|
+
* An ordered search cannot stop as soon as the page is full — the cheapest
|
|
219
|
+
* remaining row may be anywhere in the tenant — so it costs more than the
|
|
220
|
+
* same filter unordered. `offset + limit` is capped at 100,000.
|
|
221
|
+
*/
|
|
222
|
+
sortAsc(field: string): QueryBuilder;
|
|
223
|
+
/** Order the page by a numeric field, largest first. */
|
|
224
|
+
sortDesc(field: string): QueryBuilder;
|
|
225
|
+
/**
|
|
226
|
+
* Order the page by a numeric field. Rows carrying no value for it sort last
|
|
227
|
+
* in both directions; they still count towards `totalMatches`, they simply
|
|
228
|
+
* have nothing to be ordered by.
|
|
229
|
+
*/
|
|
230
|
+
sortBy(field: string, descending?: boolean): QueryBuilder;
|
|
170
231
|
toRequest(defaultTenantId?: string): SearchQueryRequest;
|
|
171
232
|
toArray(defaultTenantId?: string): SearchQueryRequest;
|
|
172
233
|
execute(): Promise<SearchResponse>;
|
|
@@ -179,6 +240,7 @@ interface SearchEngineServiceClient extends grpc.Client {
|
|
|
179
240
|
indexEntity(request: unknown, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<unknown>): grpc.ClientUnaryCall;
|
|
180
241
|
batchIndexEntities(request: unknown, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<unknown>): grpc.ClientUnaryCall;
|
|
181
242
|
deleteEntity(request: unknown, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<unknown>): grpc.ClientUnaryCall;
|
|
243
|
+
batchDeleteEntities(request: unknown, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<unknown>): grpc.ClientUnaryCall;
|
|
182
244
|
search(request: unknown, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<unknown>): grpc.ClientUnaryCall;
|
|
183
245
|
}
|
|
184
246
|
/** `grpc.health.v1.Health` — the readiness check that needs no scope. */
|
|
@@ -241,6 +303,26 @@ declare class PulseIndexClient implements QueryExecutor {
|
|
|
241
303
|
batchIndex(entities: Array<EntityInput | BatchEntityInput>): Promise<BatchIndexResponse>;
|
|
242
304
|
delete(entityId: EntityId, tenantId?: string): Promise<DeleteResponse>;
|
|
243
305
|
deleteEntity(entityId: EntityId, tenantId?: string): Promise<boolean>;
|
|
306
|
+
/**
|
|
307
|
+
* Delete many entities in one call.
|
|
308
|
+
*
|
|
309
|
+
* `delete` takes a single id, so clearing a catalogue that way is one round
|
|
310
|
+
* trip per row. Send ids in pages of up to 10,000; the engine refuses a
|
|
311
|
+
* larger batch by name rather than truncating it, so a page that is too big
|
|
312
|
+
* fails loudly instead of deleting part of itself.
|
|
313
|
+
*
|
|
314
|
+
* Ids that are unknown or already deleted are skipped, so retrying a page
|
|
315
|
+
* that half-applied is safe. `deletedCount` is the number of rows that
|
|
316
|
+
* actually changed, which is lower than `entityIds.length` whenever some of
|
|
317
|
+
* them were already gone.
|
|
318
|
+
*
|
|
319
|
+
* ```ts
|
|
320
|
+
* for (const page of pages(allIds, 10_000)) {
|
|
321
|
+
* await client.batchDelete(page);
|
|
322
|
+
* }
|
|
323
|
+
* ```
|
|
324
|
+
*/
|
|
325
|
+
batchDelete(entityIds: readonly EntityId[], tenantId?: string): Promise<BatchDeleteResponse>;
|
|
244
326
|
/**
|
|
245
327
|
* True only when the engine can serve reads.
|
|
246
328
|
*
|
|
@@ -334,4 +416,4 @@ declare class PulseIndexQueryError extends PulseIndexError {
|
|
|
334
416
|
constructor(message: string, options?: ConstructorParameters<typeof PulseIndexError>[1]);
|
|
335
417
|
}
|
|
336
418
|
|
|
337
|
-
export { type BatchEntityInput, type BatchIndexResponse, ConnectionManager, DEFAULT_LIMIT, type DeleteResponse, type EncodedEntity, type EntityAttributes, type EntityId, type EntityInput, FilterOperation, type FilterPredicate, GeoHash, type IndexEntityRequest, type IndexEntityResponse, PulseIndex, PulseIndexAuthError, PulseIndexClient, type PulseIndexClientConfig, PulseIndexConnectionError, PulseIndexError, PulseIndexQueryError, QueryBuilder, type RadiusOptions, type RangePredicate, SERVING_STATUS, type SearchQueryRequest, type SearchRequestOptions, type SearchResponse, PulseIndex as default, encodeEntity, sslEnabled, toUint64String };
|
|
419
|
+
export { type BatchDeleteResponse, type BatchEntityInput, type BatchIndexResponse, ConnectionManager, DEFAULT_LIMIT, type DeleteResponse, type EncodedEntity, type EntityAttributes, type EntityId, type EntityInput, FilterOperation, type FilterPredicate, GeoHash, type IndexEntityRequest, type IndexEntityResponse, PulseIndex, PulseIndexAuthError, PulseIndexClient, type PulseIndexClientConfig, PulseIndexConnectionError, PulseIndexError, PulseIndexQueryError, QueryBuilder, type RadiusOptions, type RangePredicate, SERVING_STATUS, type SearchQueryRequest, type SearchRequestOptions, type SearchResponse, type SortSpec, PulseIndex as default, encodeEntity, sslEnabled, toUint64String };
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,22 @@ type EntityId = string | number | bigint;
|
|
|
11
11
|
interface FilterPredicate {
|
|
12
12
|
op: FilterOperationCode;
|
|
13
13
|
attribute: string;
|
|
14
|
+
/**
|
|
15
|
+
* Which disjunction a SHOULD predicate belongs to. Ignored for MUST and
|
|
16
|
+
* MUST_NOT.
|
|
17
|
+
*
|
|
18
|
+
* Members of a group are OR'd together and the groups are AND'd with each
|
|
19
|
+
* other, so "(red or blue) and (small or medium)" is two groups. Predicates
|
|
20
|
+
* that leave this unset share group 0.
|
|
21
|
+
*/
|
|
22
|
+
group?: number;
|
|
23
|
+
}
|
|
24
|
+
/** Orders a page by a numeric field. */
|
|
25
|
+
interface SortSpec {
|
|
26
|
+
/** Numeric field name, the same one a range would name. */
|
|
27
|
+
field: string;
|
|
28
|
+
/** Largest first when true; smallest first otherwise. */
|
|
29
|
+
descending: boolean;
|
|
14
30
|
}
|
|
15
31
|
interface RangePredicate {
|
|
16
32
|
field: string;
|
|
@@ -24,6 +40,8 @@ interface SearchQueryRequest {
|
|
|
24
40
|
limit: number;
|
|
25
41
|
offset: number;
|
|
26
42
|
tenantId: string;
|
|
43
|
+
/** Absent returns matches in entity-id order. */
|
|
44
|
+
sort?: SortSpec;
|
|
27
45
|
}
|
|
28
46
|
interface SearchResponse {
|
|
29
47
|
matchedEntityIds: string[];
|
|
@@ -46,6 +64,13 @@ interface BatchIndexResponse {
|
|
|
46
64
|
interface DeleteResponse {
|
|
47
65
|
success: boolean;
|
|
48
66
|
}
|
|
67
|
+
interface BatchDeleteResponse {
|
|
68
|
+
/**
|
|
69
|
+
* How many rows actually changed. Lower than the number of ids sent when
|
|
70
|
+
* some were unknown or already deleted, which is not an error.
|
|
71
|
+
*/
|
|
72
|
+
deletedCount: number;
|
|
73
|
+
}
|
|
49
74
|
interface RadiusOptions {
|
|
50
75
|
lat: number;
|
|
51
76
|
lng?: number;
|
|
@@ -68,6 +93,16 @@ interface SearchRequestOptions {
|
|
|
68
93
|
offset?: number;
|
|
69
94
|
withinRadius?: RadiusOptions;
|
|
70
95
|
geoHash?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Order the page by a numeric field. `descending` defaults to false.
|
|
98
|
+
*
|
|
99
|
+
* Rows carrying no value for the field sort last in both directions. They
|
|
100
|
+
* still count towards `totalMatches`; they have nothing to be ordered by.
|
|
101
|
+
*/
|
|
102
|
+
sortBy?: {
|
|
103
|
+
field: string;
|
|
104
|
+
descending?: boolean;
|
|
105
|
+
};
|
|
71
106
|
}
|
|
72
107
|
interface EntityAttributes {
|
|
73
108
|
categories?: unknown;
|
|
@@ -154,7 +189,17 @@ declare class QueryBuilder {
|
|
|
154
189
|
tenant(tenantId: string): QueryBuilder;
|
|
155
190
|
location(locationPrefix: string | number | bigint): QueryBuilder;
|
|
156
191
|
must(attribute: string | string[]): QueryBuilder;
|
|
157
|
-
|
|
192
|
+
/**
|
|
193
|
+
* At least one of these has to match.
|
|
194
|
+
*
|
|
195
|
+
* Pass a `group` to keep a disjunction separate from another one. Members of
|
|
196
|
+
* a group are OR'd together and the groups are AND'd with each other, so
|
|
197
|
+
* `.should(['color:red', 'color:blue'], 1).should(['size:s', 'size:m'], 2)`
|
|
198
|
+
* asks for a red or blue shirt in small or medium. Without the group numbers
|
|
199
|
+
* all four collapse into a single OR, which answers a different question and
|
|
200
|
+
* says nothing about it.
|
|
201
|
+
*/
|
|
202
|
+
should(attribute: string | string[], group?: number): QueryBuilder;
|
|
158
203
|
mustNot(attribute: string | string[]): QueryBuilder;
|
|
159
204
|
whereGeoHash(geohash: string): QueryBuilder;
|
|
160
205
|
inGeoHash(geohash: string): QueryBuilder;
|
|
@@ -167,6 +212,22 @@ declare class QueryBuilder {
|
|
|
167
212
|
*/
|
|
168
213
|
limit(limit: number): QueryBuilder;
|
|
169
214
|
offset(offset: number): QueryBuilder;
|
|
215
|
+
/**
|
|
216
|
+
* Order the page by a numeric field, smallest first.
|
|
217
|
+
*
|
|
218
|
+
* An ordered search cannot stop as soon as the page is full — the cheapest
|
|
219
|
+
* remaining row may be anywhere in the tenant — so it costs more than the
|
|
220
|
+
* same filter unordered. `offset + limit` is capped at 100,000.
|
|
221
|
+
*/
|
|
222
|
+
sortAsc(field: string): QueryBuilder;
|
|
223
|
+
/** Order the page by a numeric field, largest first. */
|
|
224
|
+
sortDesc(field: string): QueryBuilder;
|
|
225
|
+
/**
|
|
226
|
+
* Order the page by a numeric field. Rows carrying no value for it sort last
|
|
227
|
+
* in both directions; they still count towards `totalMatches`, they simply
|
|
228
|
+
* have nothing to be ordered by.
|
|
229
|
+
*/
|
|
230
|
+
sortBy(field: string, descending?: boolean): QueryBuilder;
|
|
170
231
|
toRequest(defaultTenantId?: string): SearchQueryRequest;
|
|
171
232
|
toArray(defaultTenantId?: string): SearchQueryRequest;
|
|
172
233
|
execute(): Promise<SearchResponse>;
|
|
@@ -179,6 +240,7 @@ interface SearchEngineServiceClient extends grpc.Client {
|
|
|
179
240
|
indexEntity(request: unknown, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<unknown>): grpc.ClientUnaryCall;
|
|
180
241
|
batchIndexEntities(request: unknown, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<unknown>): grpc.ClientUnaryCall;
|
|
181
242
|
deleteEntity(request: unknown, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<unknown>): grpc.ClientUnaryCall;
|
|
243
|
+
batchDeleteEntities(request: unknown, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<unknown>): grpc.ClientUnaryCall;
|
|
182
244
|
search(request: unknown, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<unknown>): grpc.ClientUnaryCall;
|
|
183
245
|
}
|
|
184
246
|
/** `grpc.health.v1.Health` — the readiness check that needs no scope. */
|
|
@@ -241,6 +303,26 @@ declare class PulseIndexClient implements QueryExecutor {
|
|
|
241
303
|
batchIndex(entities: Array<EntityInput | BatchEntityInput>): Promise<BatchIndexResponse>;
|
|
242
304
|
delete(entityId: EntityId, tenantId?: string): Promise<DeleteResponse>;
|
|
243
305
|
deleteEntity(entityId: EntityId, tenantId?: string): Promise<boolean>;
|
|
306
|
+
/**
|
|
307
|
+
* Delete many entities in one call.
|
|
308
|
+
*
|
|
309
|
+
* `delete` takes a single id, so clearing a catalogue that way is one round
|
|
310
|
+
* trip per row. Send ids in pages of up to 10,000; the engine refuses a
|
|
311
|
+
* larger batch by name rather than truncating it, so a page that is too big
|
|
312
|
+
* fails loudly instead of deleting part of itself.
|
|
313
|
+
*
|
|
314
|
+
* Ids that are unknown or already deleted are skipped, so retrying a page
|
|
315
|
+
* that half-applied is safe. `deletedCount` is the number of rows that
|
|
316
|
+
* actually changed, which is lower than `entityIds.length` whenever some of
|
|
317
|
+
* them were already gone.
|
|
318
|
+
*
|
|
319
|
+
* ```ts
|
|
320
|
+
* for (const page of pages(allIds, 10_000)) {
|
|
321
|
+
* await client.batchDelete(page);
|
|
322
|
+
* }
|
|
323
|
+
* ```
|
|
324
|
+
*/
|
|
325
|
+
batchDelete(entityIds: readonly EntityId[], tenantId?: string): Promise<BatchDeleteResponse>;
|
|
244
326
|
/**
|
|
245
327
|
* True only when the engine can serve reads.
|
|
246
328
|
*
|
|
@@ -334,4 +416,4 @@ declare class PulseIndexQueryError extends PulseIndexError {
|
|
|
334
416
|
constructor(message: string, options?: ConstructorParameters<typeof PulseIndexError>[1]);
|
|
335
417
|
}
|
|
336
418
|
|
|
337
|
-
export { type BatchEntityInput, type BatchIndexResponse, ConnectionManager, DEFAULT_LIMIT, type DeleteResponse, type EncodedEntity, type EntityAttributes, type EntityId, type EntityInput, FilterOperation, type FilterPredicate, GeoHash, type IndexEntityRequest, type IndexEntityResponse, PulseIndex, PulseIndexAuthError, PulseIndexClient, type PulseIndexClientConfig, PulseIndexConnectionError, PulseIndexError, PulseIndexQueryError, QueryBuilder, type RadiusOptions, type RangePredicate, SERVING_STATUS, type SearchQueryRequest, type SearchRequestOptions, type SearchResponse, PulseIndex as default, encodeEntity, sslEnabled, toUint64String };
|
|
419
|
+
export { type BatchDeleteResponse, type BatchEntityInput, type BatchIndexResponse, ConnectionManager, DEFAULT_LIMIT, type DeleteResponse, type EncodedEntity, type EntityAttributes, type EntityId, type EntityInput, FilterOperation, type FilterPredicate, GeoHash, type IndexEntityRequest, type IndexEntityResponse, PulseIndex, PulseIndexAuthError, PulseIndexClient, type PulseIndexClientConfig, PulseIndexConnectionError, PulseIndexError, PulseIndexQueryError, QueryBuilder, type RadiusOptions, type RangePredicate, SERVING_STATUS, type SearchQueryRequest, type SearchRequestOptions, type SearchResponse, type SortSpec, PulseIndex as default, encodeEntity, sslEnabled, toUint64String };
|
package/dist/index.js
CHANGED
|
@@ -392,7 +392,9 @@ function emptyState() {
|
|
|
392
392
|
limit: DEFAULT_LIMIT,
|
|
393
393
|
offset: 0,
|
|
394
394
|
filters: [],
|
|
395
|
-
ranges: []
|
|
395
|
+
ranges: [],
|
|
396
|
+
sort: null,
|
|
397
|
+
nextGroup: 1
|
|
396
398
|
};
|
|
397
399
|
}
|
|
398
400
|
function asAttributeList(value) {
|
|
@@ -430,8 +432,18 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
430
432
|
must(attribute) {
|
|
431
433
|
return this.addFilters(FilterOperation.MUST, attribute);
|
|
432
434
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
+
/**
|
|
436
|
+
* At least one of these has to match.
|
|
437
|
+
*
|
|
438
|
+
* Pass a `group` to keep a disjunction separate from another one. Members of
|
|
439
|
+
* a group are OR'd together and the groups are AND'd with each other, so
|
|
440
|
+
* `.should(['color:red', 'color:blue'], 1).should(['size:s', 'size:m'], 2)`
|
|
441
|
+
* asks for a red or blue shirt in small or medium. Without the group numbers
|
|
442
|
+
* all four collapse into a single OR, which answers a different question and
|
|
443
|
+
* says nothing about it.
|
|
444
|
+
*/
|
|
445
|
+
should(attribute, group = 0) {
|
|
446
|
+
return this.addFilters(FilterOperation.SHOULD, attribute, group);
|
|
435
447
|
}
|
|
436
448
|
mustNot(attribute) {
|
|
437
449
|
return this.addFilters(FilterOperation.MUST_NOT, attribute);
|
|
@@ -463,10 +475,13 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
463
475
|
}
|
|
464
476
|
const covering = GeoHash.getCoveringHashes(lat, longitude, radius, resolvedPrecision);
|
|
465
477
|
return this.fork((state) => {
|
|
478
|
+
const group = state.nextGroup;
|
|
479
|
+
state.nextGroup += 1;
|
|
466
480
|
for (const hash of covering) {
|
|
467
481
|
state.filters.push({
|
|
468
482
|
op: FilterOperation.SHOULD,
|
|
469
|
-
attribute: GeoHash.tag(hash)
|
|
483
|
+
attribute: GeoHash.tag(hash),
|
|
484
|
+
group
|
|
470
485
|
});
|
|
471
486
|
}
|
|
472
487
|
});
|
|
@@ -503,8 +518,35 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
503
518
|
state.offset = Math.max(0, Math.floor(offset));
|
|
504
519
|
});
|
|
505
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* Order the page by a numeric field, smallest first.
|
|
523
|
+
*
|
|
524
|
+
* An ordered search cannot stop as soon as the page is full — the cheapest
|
|
525
|
+
* remaining row may be anywhere in the tenant — so it costs more than the
|
|
526
|
+
* same filter unordered. `offset + limit` is capped at 100,000.
|
|
527
|
+
*/
|
|
528
|
+
sortAsc(field) {
|
|
529
|
+
return this.sortBy(field, false);
|
|
530
|
+
}
|
|
531
|
+
/** Order the page by a numeric field, largest first. */
|
|
532
|
+
sortDesc(field) {
|
|
533
|
+
return this.sortBy(field, true);
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Order the page by a numeric field. Rows carrying no value for it sort last
|
|
537
|
+
* in both directions; they still count towards `totalMatches`, they simply
|
|
538
|
+
* have nothing to be ordered by.
|
|
539
|
+
*/
|
|
540
|
+
sortBy(field, descending = false) {
|
|
541
|
+
if (!field.trim()) {
|
|
542
|
+
throw new PulseIndexQueryError("Sort field must not be empty.");
|
|
543
|
+
}
|
|
544
|
+
return this.fork((state) => {
|
|
545
|
+
state.sort = { field, descending };
|
|
546
|
+
});
|
|
547
|
+
}
|
|
506
548
|
toRequest(defaultTenantId = "") {
|
|
507
|
-
|
|
549
|
+
const request = {
|
|
508
550
|
tenantId: this.state.tenantId || defaultTenantId,
|
|
509
551
|
locationPrefix: this.state.locationPrefix,
|
|
510
552
|
limit: this.state.limit,
|
|
@@ -512,6 +554,10 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
512
554
|
filters: this.state.filters.map((filter) => ({ ...filter })),
|
|
513
555
|
ranges: this.state.ranges.map((range) => ({ ...range }))
|
|
514
556
|
};
|
|
557
|
+
if (this.state.sort) {
|
|
558
|
+
request.sort = { ...this.state.sort };
|
|
559
|
+
}
|
|
560
|
+
return request;
|
|
515
561
|
}
|
|
516
562
|
toArray(defaultTenantId = "") {
|
|
517
563
|
return this.toRequest(defaultTenantId);
|
|
@@ -558,13 +604,20 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
558
604
|
if (options.offset !== void 0) {
|
|
559
605
|
query = query.offset(options.offset);
|
|
560
606
|
}
|
|
607
|
+
if (options.sortBy !== void 0) {
|
|
608
|
+
query = query.sortBy(options.sortBy.field, options.sortBy.descending ?? false);
|
|
609
|
+
}
|
|
561
610
|
return query;
|
|
562
611
|
}
|
|
563
|
-
addFilters(op, attribute) {
|
|
612
|
+
addFilters(op, attribute, group = 0) {
|
|
564
613
|
const attributes = asAttributeList(attribute);
|
|
614
|
+
const normalizedGroup = Math.max(0, Math.floor(group));
|
|
565
615
|
return this.fork((state) => {
|
|
566
616
|
for (const value of attributes) {
|
|
567
|
-
state.filters.push({ op, attribute: value });
|
|
617
|
+
state.filters.push({ op, attribute: value, group: normalizedGroup });
|
|
618
|
+
}
|
|
619
|
+
if (normalizedGroup >= state.nextGroup) {
|
|
620
|
+
state.nextGroup = normalizedGroup + 1;
|
|
568
621
|
}
|
|
569
622
|
});
|
|
570
623
|
}
|
|
@@ -576,7 +629,9 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
576
629
|
limit: this.state.limit,
|
|
577
630
|
offset: this.state.offset,
|
|
578
631
|
filters: this.state.filters.map((filter) => ({ ...filter })),
|
|
579
|
-
ranges: this.state.ranges.map((range) => ({ ...range }))
|
|
632
|
+
ranges: this.state.ranges.map((range) => ({ ...range })),
|
|
633
|
+
sort: this.state.sort ? { ...this.state.sort } : null,
|
|
634
|
+
nextGroup: this.state.nextGroup
|
|
580
635
|
};
|
|
581
636
|
mutate(next.state);
|
|
582
637
|
return next;
|
|
@@ -1091,6 +1146,40 @@ var PulseIndexClient = class _PulseIndexClient {
|
|
|
1091
1146
|
const response = await this.delete(entityId, tenantId || this.connection.tenantId);
|
|
1092
1147
|
return response.success;
|
|
1093
1148
|
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Delete many entities in one call.
|
|
1151
|
+
*
|
|
1152
|
+
* `delete` takes a single id, so clearing a catalogue that way is one round
|
|
1153
|
+
* trip per row. Send ids in pages of up to 10,000; the engine refuses a
|
|
1154
|
+
* larger batch by name rather than truncating it, so a page that is too big
|
|
1155
|
+
* fails loudly instead of deleting part of itself.
|
|
1156
|
+
*
|
|
1157
|
+
* Ids that are unknown or already deleted are skipped, so retrying a page
|
|
1158
|
+
* that half-applied is safe. `deletedCount` is the number of rows that
|
|
1159
|
+
* actually changed, which is lower than `entityIds.length` whenever some of
|
|
1160
|
+
* them were already gone.
|
|
1161
|
+
*
|
|
1162
|
+
* ```ts
|
|
1163
|
+
* for (const page of pages(allIds, 10_000)) {
|
|
1164
|
+
* await client.batchDelete(page);
|
|
1165
|
+
* }
|
|
1166
|
+
* ```
|
|
1167
|
+
*/
|
|
1168
|
+
async batchDelete(entityIds, tenantId) {
|
|
1169
|
+
const ids = entityIds.map((id, i) => toUint64String(id, `entityIds[${i}]`));
|
|
1170
|
+
const raw = await this.unary(
|
|
1171
|
+
(stub, metadata, options, callback) => stub.batchDeleteEntities(
|
|
1172
|
+
{
|
|
1173
|
+
entityIds: ids,
|
|
1174
|
+
tenantId: tenantId ?? this.connection.tenantId
|
|
1175
|
+
},
|
|
1176
|
+
metadata,
|
|
1177
|
+
options,
|
|
1178
|
+
callback
|
|
1179
|
+
)
|
|
1180
|
+
);
|
|
1181
|
+
return { deletedCount: Number(raw.deletedCount ?? 0) };
|
|
1182
|
+
}
|
|
1094
1183
|
/**
|
|
1095
1184
|
* True only when the engine can serve reads.
|
|
1096
1185
|
*
|
package/dist/index.mjs
CHANGED
|
@@ -369,7 +369,9 @@ function emptyState() {
|
|
|
369
369
|
limit: DEFAULT_LIMIT,
|
|
370
370
|
offset: 0,
|
|
371
371
|
filters: [],
|
|
372
|
-
ranges: []
|
|
372
|
+
ranges: [],
|
|
373
|
+
sort: null,
|
|
374
|
+
nextGroup: 1
|
|
373
375
|
};
|
|
374
376
|
}
|
|
375
377
|
function asAttributeList(value) {
|
|
@@ -407,8 +409,18 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
407
409
|
must(attribute) {
|
|
408
410
|
return this.addFilters(FilterOperation.MUST, attribute);
|
|
409
411
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
+
/**
|
|
413
|
+
* At least one of these has to match.
|
|
414
|
+
*
|
|
415
|
+
* Pass a `group` to keep a disjunction separate from another one. Members of
|
|
416
|
+
* a group are OR'd together and the groups are AND'd with each other, so
|
|
417
|
+
* `.should(['color:red', 'color:blue'], 1).should(['size:s', 'size:m'], 2)`
|
|
418
|
+
* asks for a red or blue shirt in small or medium. Without the group numbers
|
|
419
|
+
* all four collapse into a single OR, which answers a different question and
|
|
420
|
+
* says nothing about it.
|
|
421
|
+
*/
|
|
422
|
+
should(attribute, group = 0) {
|
|
423
|
+
return this.addFilters(FilterOperation.SHOULD, attribute, group);
|
|
412
424
|
}
|
|
413
425
|
mustNot(attribute) {
|
|
414
426
|
return this.addFilters(FilterOperation.MUST_NOT, attribute);
|
|
@@ -440,10 +452,13 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
440
452
|
}
|
|
441
453
|
const covering = GeoHash.getCoveringHashes(lat, longitude, radius, resolvedPrecision);
|
|
442
454
|
return this.fork((state) => {
|
|
455
|
+
const group = state.nextGroup;
|
|
456
|
+
state.nextGroup += 1;
|
|
443
457
|
for (const hash of covering) {
|
|
444
458
|
state.filters.push({
|
|
445
459
|
op: FilterOperation.SHOULD,
|
|
446
|
-
attribute: GeoHash.tag(hash)
|
|
460
|
+
attribute: GeoHash.tag(hash),
|
|
461
|
+
group
|
|
447
462
|
});
|
|
448
463
|
}
|
|
449
464
|
});
|
|
@@ -480,8 +495,35 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
480
495
|
state.offset = Math.max(0, Math.floor(offset));
|
|
481
496
|
});
|
|
482
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* Order the page by a numeric field, smallest first.
|
|
500
|
+
*
|
|
501
|
+
* An ordered search cannot stop as soon as the page is full — the cheapest
|
|
502
|
+
* remaining row may be anywhere in the tenant — so it costs more than the
|
|
503
|
+
* same filter unordered. `offset + limit` is capped at 100,000.
|
|
504
|
+
*/
|
|
505
|
+
sortAsc(field) {
|
|
506
|
+
return this.sortBy(field, false);
|
|
507
|
+
}
|
|
508
|
+
/** Order the page by a numeric field, largest first. */
|
|
509
|
+
sortDesc(field) {
|
|
510
|
+
return this.sortBy(field, true);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Order the page by a numeric field. Rows carrying no value for it sort last
|
|
514
|
+
* in both directions; they still count towards `totalMatches`, they simply
|
|
515
|
+
* have nothing to be ordered by.
|
|
516
|
+
*/
|
|
517
|
+
sortBy(field, descending = false) {
|
|
518
|
+
if (!field.trim()) {
|
|
519
|
+
throw new PulseIndexQueryError("Sort field must not be empty.");
|
|
520
|
+
}
|
|
521
|
+
return this.fork((state) => {
|
|
522
|
+
state.sort = { field, descending };
|
|
523
|
+
});
|
|
524
|
+
}
|
|
483
525
|
toRequest(defaultTenantId = "") {
|
|
484
|
-
|
|
526
|
+
const request = {
|
|
485
527
|
tenantId: this.state.tenantId || defaultTenantId,
|
|
486
528
|
locationPrefix: this.state.locationPrefix,
|
|
487
529
|
limit: this.state.limit,
|
|
@@ -489,6 +531,10 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
489
531
|
filters: this.state.filters.map((filter) => ({ ...filter })),
|
|
490
532
|
ranges: this.state.ranges.map((range) => ({ ...range }))
|
|
491
533
|
};
|
|
534
|
+
if (this.state.sort) {
|
|
535
|
+
request.sort = { ...this.state.sort };
|
|
536
|
+
}
|
|
537
|
+
return request;
|
|
492
538
|
}
|
|
493
539
|
toArray(defaultTenantId = "") {
|
|
494
540
|
return this.toRequest(defaultTenantId);
|
|
@@ -535,13 +581,20 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
535
581
|
if (options.offset !== void 0) {
|
|
536
582
|
query = query.offset(options.offset);
|
|
537
583
|
}
|
|
584
|
+
if (options.sortBy !== void 0) {
|
|
585
|
+
query = query.sortBy(options.sortBy.field, options.sortBy.descending ?? false);
|
|
586
|
+
}
|
|
538
587
|
return query;
|
|
539
588
|
}
|
|
540
|
-
addFilters(op, attribute) {
|
|
589
|
+
addFilters(op, attribute, group = 0) {
|
|
541
590
|
const attributes = asAttributeList(attribute);
|
|
591
|
+
const normalizedGroup = Math.max(0, Math.floor(group));
|
|
542
592
|
return this.fork((state) => {
|
|
543
593
|
for (const value of attributes) {
|
|
544
|
-
state.filters.push({ op, attribute: value });
|
|
594
|
+
state.filters.push({ op, attribute: value, group: normalizedGroup });
|
|
595
|
+
}
|
|
596
|
+
if (normalizedGroup >= state.nextGroup) {
|
|
597
|
+
state.nextGroup = normalizedGroup + 1;
|
|
545
598
|
}
|
|
546
599
|
});
|
|
547
600
|
}
|
|
@@ -553,7 +606,9 @@ var QueryBuilder = class _QueryBuilder {
|
|
|
553
606
|
limit: this.state.limit,
|
|
554
607
|
offset: this.state.offset,
|
|
555
608
|
filters: this.state.filters.map((filter) => ({ ...filter })),
|
|
556
|
-
ranges: this.state.ranges.map((range) => ({ ...range }))
|
|
609
|
+
ranges: this.state.ranges.map((range) => ({ ...range })),
|
|
610
|
+
sort: this.state.sort ? { ...this.state.sort } : null,
|
|
611
|
+
nextGroup: this.state.nextGroup
|
|
557
612
|
};
|
|
558
613
|
mutate(next.state);
|
|
559
614
|
return next;
|
|
@@ -1068,6 +1123,40 @@ var PulseIndexClient = class _PulseIndexClient {
|
|
|
1068
1123
|
const response = await this.delete(entityId, tenantId || this.connection.tenantId);
|
|
1069
1124
|
return response.success;
|
|
1070
1125
|
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Delete many entities in one call.
|
|
1128
|
+
*
|
|
1129
|
+
* `delete` takes a single id, so clearing a catalogue that way is one round
|
|
1130
|
+
* trip per row. Send ids in pages of up to 10,000; the engine refuses a
|
|
1131
|
+
* larger batch by name rather than truncating it, so a page that is too big
|
|
1132
|
+
* fails loudly instead of deleting part of itself.
|
|
1133
|
+
*
|
|
1134
|
+
* Ids that are unknown or already deleted are skipped, so retrying a page
|
|
1135
|
+
* that half-applied is safe. `deletedCount` is the number of rows that
|
|
1136
|
+
* actually changed, which is lower than `entityIds.length` whenever some of
|
|
1137
|
+
* them were already gone.
|
|
1138
|
+
*
|
|
1139
|
+
* ```ts
|
|
1140
|
+
* for (const page of pages(allIds, 10_000)) {
|
|
1141
|
+
* await client.batchDelete(page);
|
|
1142
|
+
* }
|
|
1143
|
+
* ```
|
|
1144
|
+
*/
|
|
1145
|
+
async batchDelete(entityIds, tenantId) {
|
|
1146
|
+
const ids = entityIds.map((id, i) => toUint64String(id, `entityIds[${i}]`));
|
|
1147
|
+
const raw = await this.unary(
|
|
1148
|
+
(stub, metadata, options, callback) => stub.batchDeleteEntities(
|
|
1149
|
+
{
|
|
1150
|
+
entityIds: ids,
|
|
1151
|
+
tenantId: tenantId ?? this.connection.tenantId
|
|
1152
|
+
},
|
|
1153
|
+
metadata,
|
|
1154
|
+
options,
|
|
1155
|
+
callback
|
|
1156
|
+
)
|
|
1157
|
+
);
|
|
1158
|
+
return { deletedCount: Number(raw.deletedCount ?? 0) };
|
|
1159
|
+
}
|
|
1071
1160
|
/**
|
|
1072
1161
|
* True only when the engine can serve reads.
|
|
1073
1162
|
*
|
package/package.json
CHANGED
package/proto/engine.proto
CHANGED
|
@@ -36,6 +36,10 @@ service SearchEngineService {
|
|
|
36
36
|
// The entity id is excluded from subsequent Search results for that tenant.
|
|
37
37
|
rpc DeleteEntity (DeleteEntityRequest) returns (DeleteEntityResponse);
|
|
38
38
|
|
|
39
|
+
// BatchDeleteEntities soft-deletes many entities in one RPC.
|
|
40
|
+
// Use this to clear a catalogue: DeleteEntity takes a single id.
|
|
41
|
+
rpc BatchDeleteEntities (BatchDeleteEntitiesRequest) returns (BatchDeleteEntitiesResponse);
|
|
42
|
+
|
|
39
43
|
// Search applies the boolean filters (MUST / SHOULD / MUST_NOT) plus optional
|
|
40
44
|
// numeric range predicates and returns matching entity IDs only.
|
|
41
45
|
// When `limit` > 0, `total_matches` may be approximate; it is exact when
|
|
@@ -114,6 +118,28 @@ message DeleteEntityResponse {
|
|
|
114
118
|
bool success = 1;
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
// BatchDeleteEntitiesRequest deletes many entities in one tenant.
|
|
122
|
+
message BatchDeleteEntitiesRequest {
|
|
123
|
+
// Entity ids to delete. Ids that are unknown or already deleted are skipped
|
|
124
|
+
// rather than refused, so retrying a batch that half-applied is safe.
|
|
125
|
+
// Repeating an id inside one batch deletes it once.
|
|
126
|
+
//
|
|
127
|
+
// A batch above the server's maximum is refused with INVALID_ARGUMENT naming
|
|
128
|
+
// the ceiling, never silently truncated.
|
|
129
|
+
repeated uint64 entity_ids = 1;
|
|
130
|
+
|
|
131
|
+
// Tenant that owns the entities. Empty → "default". One tenant per batch.
|
|
132
|
+
string tenant_id = 2;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// BatchDeleteEntitiesResponse reports how many rows actually changed.
|
|
136
|
+
message BatchDeleteEntitiesResponse {
|
|
137
|
+
// Number of entities that were live and are now deleted. Lower than the
|
|
138
|
+
// number of ids sent when some were unknown or already deleted; that is not
|
|
139
|
+
// an error.
|
|
140
|
+
uint32 deleted_count = 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
117
143
|
// ---------------------------------------------------------------------------
|
|
118
144
|
// Search predicates
|
|
119
145
|
// ---------------------------------------------------------------------------
|
|
@@ -124,7 +150,8 @@ message FilterPredicate {
|
|
|
124
150
|
enum Operation {
|
|
125
151
|
// Conjunction: narrows the current match set.
|
|
126
152
|
MUST = 0;
|
|
127
|
-
// Disjunction
|
|
153
|
+
// Disjunction: SHOULD predicates sharing a `group` are OR'd together, and
|
|
154
|
+
// each group then narrows the match set.
|
|
128
155
|
SHOULD = 1;
|
|
129
156
|
// Exclusion: removes matches carrying this attribute.
|
|
130
157
|
MUST_NOT = 2;
|
|
@@ -135,6 +162,14 @@ message FilterPredicate {
|
|
|
135
162
|
|
|
136
163
|
// Attribute token identical to those used at index time, e.g. "feature:pool".
|
|
137
164
|
string attribute = 2;
|
|
165
|
+
|
|
166
|
+
// Which disjunction this SHOULD predicate belongs to. Ignored for MUST and
|
|
167
|
+
// MUST_NOT.
|
|
168
|
+
//
|
|
169
|
+
// Members of a group are OR'd together; the groups are AND'd with each other,
|
|
170
|
+
// so "(red or blue) and (size 42 or 43)" is two groups. Predicates that leave
|
|
171
|
+
// this unset share group 0, which is the single-group behaviour.
|
|
172
|
+
uint32 group = 3;
|
|
138
173
|
}
|
|
139
174
|
|
|
140
175
|
// RangePredicate filters a continuous numeric field to an inclusive range.
|
|
@@ -149,6 +184,23 @@ message RangePredicate {
|
|
|
149
184
|
uint32 max_val = 3;
|
|
150
185
|
}
|
|
151
186
|
|
|
187
|
+
// SortSpec orders a page by a numeric field.
|
|
188
|
+
//
|
|
189
|
+
// Without it, results come back in entity-id order. Entities carrying no value
|
|
190
|
+
// for the field sort last in both directions; they are still counted in
|
|
191
|
+
// total_matches, they simply have nothing to be ordered by.
|
|
192
|
+
//
|
|
193
|
+
// An ordered search cannot stop early, because the best remaining row may be
|
|
194
|
+
// anywhere in the tenant, so it costs more than the same filter unordered.
|
|
195
|
+
// offset + limit is capped at 100,000.
|
|
196
|
+
message SortSpec {
|
|
197
|
+
// Numeric field name, the same one a RangePredicate would name.
|
|
198
|
+
string field = 1;
|
|
199
|
+
|
|
200
|
+
// Largest first when true; smallest first otherwise.
|
|
201
|
+
bool descending = 2;
|
|
202
|
+
}
|
|
203
|
+
|
|
152
204
|
// ---------------------------------------------------------------------------
|
|
153
205
|
// Search
|
|
154
206
|
// ---------------------------------------------------------------------------
|
|
@@ -167,7 +219,8 @@ message SearchQueryRequest {
|
|
|
167
219
|
repeated RangePredicate ranges = 3;
|
|
168
220
|
|
|
169
221
|
// Maximum number of entity IDs to return.
|
|
170
|
-
// 0
|
|
222
|
+
// 0 asks for the exact `total_matches` and no ids at all — the cheap way to
|
|
223
|
+
// count. It does not mean "no limit".
|
|
171
224
|
uint32 limit = 4;
|
|
172
225
|
|
|
173
226
|
// Number of matches to skip before collecting results (pagination).
|
|
@@ -175,6 +228,9 @@ message SearchQueryRequest {
|
|
|
175
228
|
|
|
176
229
|
// Tenant / namespace to search. Empty → "default".
|
|
177
230
|
string tenant_id = 6;
|
|
231
|
+
|
|
232
|
+
// Optional ordering. Absent returns matches in entity-id order.
|
|
233
|
+
SortSpec sort = 7;
|
|
178
234
|
}
|
|
179
235
|
|
|
180
236
|
// SearchQueryResponse returns matched ids and timing metadata.
|