@pulseindex/sdk 2.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 ADDED
@@ -0,0 +1,102 @@
1
+ # Changelog
2
+
3
+ ## 2.0.0
4
+
5
+ ### Breaking: three methods removed
6
+
7
+ `createSnapshot()`, `getRecoveryState()` and `setCdcOffset()` are gone, along
8
+ with the `RecoveryState`, `CreateSnapshotResponse` and `SetCdcOffsetResponse`
9
+ types and the RPCs behind them in the bundled proto.
10
+
11
+ No key issued from the dashboard could call them — every attempt returned a
12
+ permission error — so nothing that worked before stops working. If you were
13
+ calling them and getting errors, that is the code to delete.
14
+
15
+ **Checking readiness:** use `health()`, or `servingStatus()` when you need to
16
+ tell "not answering" apart from "not reachable". Both work with any key.
17
+
18
+ ### The bundled proto now describes only the client's contract
19
+
20
+ Four RPCs: `IndexEntity`, `BatchIndexEntities`, `DeleteEntity`, `Search`. The
21
+ drift check that keeps this file honest was changed to match: it verifies every
22
+ declaration here exists identically in the service, and no longer requires the
23
+ two to be identical.
24
+
25
+ ## 1.1.4
26
+
27
+ Documentation only.
28
+
29
+ The README documented three methods no ordinary API key can call, and explained
30
+ that they need an elevated one. That is operator surface, and it was dead
31
+ reading for anyone the README is written for. It also named the server-side
32
+ environment variable that holds API keys, which is not the client's business.
33
+
34
+ `health()` is now described by what it tells you rather than by the protocol
35
+ underneath it, and the production example uses a real endpoint.
36
+
37
+ The methods still exist on the client; they are simply no longer presented as
38
+ part of the product.
39
+
40
+ ## 1.1.3
41
+
42
+ No code change. The package now ships only what is needed to use it.
43
+
44
+ - Source maps are no longer published. They embedded the complete TypeScript
45
+ source of every bundled file and were 59% of the tarball. The shipped
46
+ JavaScript is unminified, so stack traces still land somewhere readable.
47
+ - Build, release and proto-sync notes moved out of the README; they described
48
+ how the SDK is maintained, not how to call it.
49
+ - The RPC table lists the calls a normal API key can make. The three
50
+ operator-only ones are noted rather than tabulated.
51
+
52
+ 318 KB to 130 KB.
53
+
54
+ ## 1.1.2
55
+
56
+ Documentation only; the code is identical to 1.1.0.
57
+
58
+ The vendored `engine.proto` still described how the service works rather than
59
+ how to call it. Found by reading the file rather than searching it for known
60
+ words — which is the only method that finds what you did not already know to
61
+ look for.
62
+
63
+ ## 1.1.1
64
+
65
+ Documentation only; the code is identical to 1.1.0.
66
+
67
+ 1.1.0 shipped internal maintainer comments in its type declarations and source
68
+ maps — `tsup` emits JSDoc into `.d.ts` and embeds the whole TypeScript source
69
+ into `.map`, and both are in the tarball. 1.1.0 has been unpublished.
70
+
71
+ ## 1.1.0
72
+
73
+ ### `health()` no longer reports false for every key
74
+
75
+ `health()` used an operator-only call that customer API keys are not permitted to
76
+ make. The permission error was caught and turned into `false`, so the method
77
+ reported an unusable service no matter how healthy it actually was.
78
+
79
+ It now uses the standard `grpc.health.v1.Health` protocol, which requires no
80
+ particular scope. The signature is unchanged. If you were working around this by
81
+ ignoring `health()`, you can stop.
82
+
83
+ ### Added
84
+
85
+ - `client.servingStatus(service?)` — the raw serving status, for telling
86
+ "reachable but not serving" apart from "no answer at all". Defaults to `''`,
87
+ the overall-server name from the health spec.
88
+ - `SERVING_STATUS` — the status constants, exported from the package root.
89
+ - `healthProtoPath` on the client config, for the rare case of overriding the
90
+ bundled `health.proto`.
91
+
92
+ `proto/health.proto` now ships with the package. It is the standard health
93
+ protocol, vendored rather than pulled in as a dependency.
94
+
95
+ ### Compatibility
96
+
97
+ Against a service deployed before this release, the health protocol answers but
98
+ always reports `SERVING`. `health()` is then equivalent to a reachability check.
99
+
100
+ ## 1.0.0
101
+
102
+ Initial release.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PulseIndex
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,326 @@
1
+ # PulseIndex JavaScript / TypeScript SDK
2
+
3
+ Official Node.js & TypeScript client for **PulseIndex** — hosted search and filtering for large entity sets.
4
+
5
+ You send attributes to index and queries to run; PulseIndex returns matching entity IDs, which you hydrate from your own database. Your records stay in your primary store — the service holds only what it needs to answer queries.
6
+
7
+ [![npm version](https://img.shields.io/npm/v/@pulseindex/sdk.svg)](https://www.npmjs.com/package/@pulseindex/sdk)
8
+ [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](#installation)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
10
+
11
+ ## Key features
12
+
13
+ - Dual ESM / CommonJS build for Node.js 18+
14
+ - Typed fluent `QueryBuilder` matching `pulseindex-php`
15
+ - Zero-dependency GeoHash radius coverage (`geo:{precision}:{hash}`)
16
+ - Connection pooling, deadlines, and `x-api-key` / `Authorization: Bearer` metadata
17
+ - Attribute flattening so plain objects index without a schema (categories, flags, geo tags, price)
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install @pulseindex/sdk
23
+ ```
24
+
25
+ ```bash
26
+ pnpm add @pulseindex/sdk
27
+ ```
28
+
29
+ ESM:
30
+
31
+ ```ts
32
+ import PulseIndex, { GeoHash } from '@pulseindex/sdk';
33
+ ```
34
+
35
+ CommonJS:
36
+
37
+ ```js
38
+ const { PulseIndex, GeoHash } = require('@pulseindex/sdk');
39
+ ```
40
+
41
+ Requires Node.js 18 or later. The engine gRPC endpoint defaults to `localhost:50051`.
42
+
43
+ ## Quickstart
44
+
45
+ ```ts
46
+ import PulseIndex, { GeoHash } from '@pulseindex/sdk';
47
+
48
+ const client = new PulseIndex({
49
+ endpoint: 'localhost:50051',
50
+ apiKey: process.env.PULSEINDEX_API_KEY,
51
+ tenantId: 'acme_corp',
52
+ });
53
+
54
+ await client.index('1001', {
55
+ categories: ['features:swimming_pool'],
56
+ category: 'villa',
57
+ status: 'listed',
58
+ price: 250000,
59
+ lat: 41.0082,
60
+ lng: 28.9784,
61
+ });
62
+
63
+ const result = await client.search(
64
+ PulseIndex.query()
65
+ .tenant('acme_corp')
66
+ .must('features:swimming_pool')
67
+ .should(['category:villa', 'category:apartment'])
68
+ .mustNot('status:sold')
69
+ .range('price', 100000, 500000)
70
+ .withinRadius({ lat: 41.0082, lng: 28.9784, radiusKm: 5 })
71
+ .limit(50),
72
+ );
73
+
74
+ const ids = result.matchedEntityIds;
75
+ await client.close();
76
+ ```
77
+
78
+ Index-time geo tags should use the same dual precision as radius queries:
79
+
80
+ ```ts
81
+ const tags = GeoHash.encodeMultiTags(41.0082, 28.9784);
82
+ // ['geo:5:sxk97', 'geo:6:sxk976'] (example)
83
+ ```
84
+
85
+ ## gRPC configuration
86
+
87
+ Create a client against any PulseIndex Engine endpoint. Production customer gRPC should set `ssl: true` (or `PULSEINDEX_SSL=true`). The default is plaintext for local development.
88
+
89
+ ```ts
90
+ const client = PulseIndex.create(
91
+ 'api.usepulseindex.com:50051',
92
+ process.env.PULSEINDEX_API_KEY,
93
+ true,
94
+ );
95
+ ```
96
+
97
+ | Option | Env var | Default | Description |
98
+ | --- | --- | --- | --- |
99
+ | `endpoint` / `host` | `PULSEINDEX_ENDPOINT`, `PULSEINDEX_HOST` | `localhost:50051` | Engine `host:port` |
100
+ | `apiKey` | `PULSEINDEX_API_KEY` | — | Sent as `x-api-key` and `Authorization: Bearer` |
101
+ | `authorization` | `PULSEINDEX_AUTHORIZATION` | — | Overrides the Bearer token when set |
102
+ | `tenantId` | `PULSEINDEX_TENANT_ID` | `''` (engine uses `default`) | Default tenant for index / search / delete |
103
+ | `timeoutMs` | — | `5000` | Per-RPC deadline |
104
+ | `ssl` | `PULSEINDEX_SSL` | `false` | Enable TLS |
105
+ | `rootCerts` / `privateKey` / `certChain` | — | — | Optional custom TLS materials |
106
+ | `poolSize` | — | `1` | Number of multiplexed gRPC clients |
107
+ | `protoPath` | — | packaged `proto/engine.proto` | Override the proto file |
108
+ | `channelOptions` | — | keepalive defaults | Extra `@grpc/grpc-js` channel options |
109
+
110
+ The client sends your key as `x-api-key`, and also as `Authorization: Bearer` — either is accepted. Every indexing and query call carries `tenant_id`; an empty value means `"default"`.
111
+
112
+ ```ts
113
+ const client = new PulseIndex({
114
+ endpoint: process.env.PULSEINDEX_HOST,
115
+ apiKey: process.env.PULSEINDEX_API_KEY,
116
+ tenantId: 'acme_corp',
117
+ timeoutMs: 5000,
118
+ ssl: true,
119
+ poolSize: 2,
120
+ });
121
+ ```
122
+
123
+ ## Indexing
124
+
125
+ `index()` accepts a string/number entity id plus a flat attribute object. Reserved keys (`price`, `tenantId`, `lat` / `lng`, `categories`, …) map onto dedicated fields; every other key becomes a namespaced term (`status:listed`, `amenities:parking`) you can filter on. Coordinates automatically add `geo:5:…` and `geo:6:…` tags.
126
+
127
+ ```ts
128
+ await client.index('1001', {
129
+ categories: ['feature:pool'],
130
+ amenities: ['parking', 'gym'],
131
+ furnished: true,
132
+ price: 1500,
133
+ lat: 24.7136,
134
+ lng: 46.6753,
135
+ });
136
+
137
+ await client.batchIndex([
138
+ { id: '1002', attributes: { categories: ['feature:garden'], price: 900 } },
139
+ { entityId: 1003, categories: ['feature:pool'], price: 2000 },
140
+ ]);
141
+
142
+ await client.delete('1001');
143
+ ```
144
+
145
+ Low-level PHP-compatible helper:
146
+
147
+ ```ts
148
+ await client.indexEntity(1001, ['feature:pool', 'amenity:parking'], 1500, 0, 'acme');
149
+ ```
150
+
151
+ `entity_id` is a proto `uint64`. Pass a string when the id may exceed `Number.MAX_SAFE_INTEGER`.
152
+
153
+ ## QueryBuilder
154
+
155
+ The engine evaluates MUST (AND), SHOULD (OR group, then AND), MUST_NOT, optional `price` ranges, and returns ids only. `QueryBuilder` is immutable: each chained call returns a new builder.
156
+
157
+ ```ts
158
+ const query = client
159
+ .query()
160
+ .tenant('acme_corp')
161
+ .must('feature:pool')
162
+ .should(['category:villa', 'category:apartment'])
163
+ .mustNot('status:sold')
164
+ .range('price', 1000, 5000)
165
+ .withinRadius({ lat: 24.7136, lng: 46.6753, radiusKm: 5 })
166
+ .limit(50)
167
+ .offset(0);
168
+
169
+ const page = await query.execute();
170
+ ```
171
+
172
+ Equivalent object form:
173
+
174
+ ```ts
175
+ await client.search({
176
+ tenantId: 'acme_corp',
177
+ must: 'feature:pool',
178
+ should: ['category:villa', 'category:apartment'],
179
+ mustNot: 'status:sold',
180
+ ranges: [{ field: 'price', min: 100000, max: 500000 }],
181
+ withinRadius: { lat: 41.0082, lng: 28.9784, radiusKm: 5 },
182
+ limit: 50,
183
+ });
184
+ ```
185
+
186
+ | Method | Effect |
187
+ | --- | --- |
188
+ | `tenant(id)` | Set `tenant_id` |
189
+ | `must(attr \| attr[])` | MUST filters |
190
+ | `should(attr \| attr[])` | SHOULD filters (OR group) |
191
+ | `mustNot(attr \| attr[])` | MUST_NOT filters |
192
+ | `range(field, min, max)` | Numeric range (currently `price`) |
193
+ | `withinRadius(lat, lon, km)` / `withinRadius({ lat, lng, radiusKm })` | SHOULD geo covering |
194
+ | `whereGeoHash(hash)` / `inGeoHash(hash)` | MUST exact geo cell |
195
+ | `location(prefix)` | Coarse `location_prefix` |
196
+ | `limit(n)` / `offset(n)` | Pagination (`0` = unlimited) |
197
+ | `toRequest()` | Compile the proto-shaped payload |
198
+ | `execute()` | Search via the bound client |
199
+
200
+ ## GeoHash usage
201
+
202
+ Precision is chosen from radius, then covering cells are emitted as SHOULD `geo:{precision}:{hash}` tags:
203
+
204
+ | Radius | Precision | Approximate cell |
205
+ | --- | --- | --- |
206
+ | ≤ 1.5 km | 6 | ~1.2 km × 0.6 km |
207
+ | ≤ 8.0 km | 5 | ~4.9 km × 4.9 km |
208
+ | > 8.0 km | 4 | ~39 km × 19 km |
209
+
210
+ `GeoHash.neighborhood3x3()` returns the centre cell plus eight neighbors. `withinRadius()` uses intersecting covering cells (same algorithm as `pulseindex-php`) so oversized neighbors are not OR'd in.
211
+
212
+ ```ts
213
+ import { GeoHash } from '@pulseindex/sdk';
214
+
215
+ GeoHash.encode(42.6, -5.6, 5); // 'ezs42'
216
+ GeoHash.tag('ezs42'); // 'geo:5:ezs42'
217
+ GeoHash.encodeMultiTags(41.0082, 28.9784);
218
+ GeoHash.neighborhood3x3('ezs42'); // centre + 8 neighbors
219
+ query.whereGeoHash('ezs42'); // MUST geo:5:ezs42
220
+ ```
221
+
222
+ Also available: `decode`, `decodeBounds`, `neighbor`, `neighbors`, `neighborhoodTags`, `optimalPrecisionForRadius`, `getCoveringHashes`, `encodeTag`, `haversineKm`.
223
+
224
+ ## Error handling
225
+
226
+ All RPC failures wrap gRPC status codes. Catch the typed subclass that matches the failure mode:
227
+
228
+ ```ts
229
+ import {
230
+ PulseIndexAuthError,
231
+ PulseIndexConnectionError,
232
+ PulseIndexQueryError,
233
+ } from '@pulseindex/sdk';
234
+
235
+ try {
236
+ await client.search(PulseIndex.query().must('feature:pool').limit(20));
237
+ } catch (error) {
238
+ if (error instanceof PulseIndexAuthError) {
239
+ // UNAUTHENTICATED / PERMISSION_DENIED — check x-api-key
240
+ } else if (error instanceof PulseIndexConnectionError) {
241
+ // UNAVAILABLE / DEADLINE_EXCEEDED — engine down or timeout
242
+ } else if (error instanceof PulseIndexQueryError) {
243
+ // INVALID_ARGUMENT / RESOURCE_EXHAUSTED — bad query or capacity
244
+ } else {
245
+ throw error;
246
+ }
247
+ }
248
+ ```
249
+
250
+ | Class | Typical gRPC statuses |
251
+ | --- | --- |
252
+ | `PulseIndexError` | Base class (`code`, `grpcStatusCode`, `grpcDetails`) |
253
+ | `PulseIndexConnectionError` | `UNAVAILABLE`, `DEADLINE_EXCEEDED`, `CANCELLED`, `ABORTED` |
254
+ | `PulseIndexAuthError` | `UNAUTHENTICATED`, `PERMISSION_DENIED` |
255
+ | `PulseIndexQueryError` | `INVALID_ARGUMENT`, `FAILED_PRECONDITION`, `RESOURCE_EXHAUSTED`, … |
256
+
257
+ `client.health()` returns `false` instead of throwing when the channel is not ready.
258
+
259
+ ### Health
260
+
261
+ `client.health()` is `true` only when the service can **serve reads**: the channel
262
+ is ready and the standard health protocol reports `SERVING`. A reachable service
263
+ that cannot currently answer queries reports `false`, so reachability alone is not
264
+ treated as health.
265
+
266
+ It returns `false` rather than throwing, which means unreachable and unavailable
267
+ look the same. Use `servingStatus()` when you need to tell them apart:
268
+
269
+ ```ts
270
+ import { SERVING_STATUS } from '@pulseindex/sdk';
271
+
272
+ const status = await client.servingStatus();
273
+ status === SERVING_STATUS.SERVING; // ready for queries
274
+ status === SERVING_STATUS.NOT_SERVING; // reachable, not currently serving
275
+ ```
276
+
277
+ `health()` needs no particular scope on your API key — it does not send one.
278
+
279
+ If `health()` stays `false` for more than a few minutes, retry with backoff rather
280
+ than failing your own requests immediately; if it persists, contact support.
281
+
282
+ ## API reference
283
+
284
+ ### `PulseIndex` / `PulseIndexClient`
285
+
286
+ | Method | Returns | Description |
287
+ | --- | --- | --- |
288
+ | `new PulseIndex(config)` | client | Create a pooled gRPC client |
289
+ | `PulseIndex.create(host, apiKey?, ssl?)` | client | Convenience constructor |
290
+ | `PulseIndex.query()` | `QueryBuilder` | Unbound fluent query |
291
+ | `client.query()` | `QueryBuilder` | Bound builder (`execute()` calls `search`) |
292
+ | `client.search(query \| options)` | `SearchResponse` | Run `Search` |
293
+ | `client.index(id, attributes)` | `{ success }` | Upsert one entity |
294
+ | `client.batchIndex(entities)` | `{ indexedCount }` | Batch upsert |
295
+ | `client.delete(id)` | `{ success }` | Soft-delete an entity |
296
+ | `client.health()` | `boolean` | Whether the service is ready to answer queries |
297
+ | `client.servingStatus()` | `number` | Readiness as a status code, when you need more than a boolean |
298
+ | `client.close()` | `void` | Shut down the channel pool |
299
+
300
+ `SearchResponse`:
301
+
302
+ ```ts
303
+ {
304
+ matchedEntityIds: string[];
305
+ totalMatches: number;
306
+ executionTimeUs: number;
307
+ }
308
+ ```
309
+
310
+ ## gRPC contract
311
+
312
+ Service: `pulseindex.engine.v1.SearchEngineService`
313
+
314
+ | RPC | Request | Response |
315
+ | --- | --- | --- |
316
+ | `IndexEntity` | `IndexEntityRequest` | `IndexEntityResponse` |
317
+ | `BatchIndexEntities` | `BatchIndexEntitiesRequest` | `BatchIndexEntitiesResponse` |
318
+ | `DeleteEntity` | `DeleteEntityRequest` | `DeleteEntityResponse` |
319
+ | `Search` | `SearchQueryRequest` | `SearchQueryResponse` |
320
+
321
+ `health()` reports whether the service is ready to answer queries. It needs no
322
+ particular scope, so it works with any key.
323
+
324
+ ## License
325
+
326
+ MIT