@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 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
- should(attribute: string | string[]): QueryBuilder;
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
- should(attribute: string | string[]): QueryBuilder;
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
- should(attribute) {
434
- return this.addFilters(FilterOperation.SHOULD, attribute);
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
- return {
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
- should(attribute) {
411
- return this.addFilters(FilterOperation.SHOULD, attribute);
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
- return {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulseindex/sdk",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "Official Node.js & TypeScript SDK for PulseIndex — hosted search and filtering for large entity sets",
5
5
  "license": "MIT",
6
6
  "author": "PulseIndex",
@@ -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 group: SHOULD predicates are OR'd, then narrow the match set.
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 means no limit, and makes `total_matches` exact.
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.