@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 +102 -0
- package/LICENSE +21 -0
- package/README.md +326 -0
- package/dist/index.d.mts +324 -0
- package/dist/index.d.ts +324 -0
- package/dist/index.js +1195 -0
- package/dist/index.mjs +1158 -0
- package/package.json +69 -0
- package/proto/engine.proto +191 -0
- package/proto/health.proto +32 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var grpc2 = require('@grpc/grpc-js');
|
|
6
|
+
var fs = require('fs');
|
|
7
|
+
var path = require('path');
|
|
8
|
+
var url = require('url');
|
|
9
|
+
var protoLoader = require('@grpc/proto-loader');
|
|
10
|
+
|
|
11
|
+
function _interopNamespace(e) {
|
|
12
|
+
if (e && e.__esModule) return e;
|
|
13
|
+
var n = Object.create(null);
|
|
14
|
+
if (e) {
|
|
15
|
+
Object.keys(e).forEach(function (k) {
|
|
16
|
+
if (k !== 'default') {
|
|
17
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
18
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
19
|
+
enumerable: true,
|
|
20
|
+
get: function () { return e[k]; }
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
n.default = e;
|
|
26
|
+
return Object.freeze(n);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var grpc2__namespace = /*#__PURE__*/_interopNamespace(grpc2);
|
|
30
|
+
var protoLoader__namespace = /*#__PURE__*/_interopNamespace(protoLoader);
|
|
31
|
+
|
|
32
|
+
// node_modules/tsup/assets/cjs_shims.js
|
|
33
|
+
var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
|
|
34
|
+
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
35
|
+
|
|
36
|
+
// src/geo/GeoHash.ts
|
|
37
|
+
var GeoHash = class {
|
|
38
|
+
static TAG_PREFIX = "geo:";
|
|
39
|
+
static MIN_PRECISION = 1;
|
|
40
|
+
static MAX_PRECISION = 12;
|
|
41
|
+
static INDEX_PRECISIONS = [5, 6];
|
|
42
|
+
static BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz";
|
|
43
|
+
static EARTH_RADIUS_KM = 6371;
|
|
44
|
+
static MAX_COVERING_CELLS = 64;
|
|
45
|
+
static NEIGHBORS = {
|
|
46
|
+
n: ["p0r21436x8zb9dcf5h7kjnmqesgutwvy", "bc01fg45238967deuvhjyznpkmstqrwx"],
|
|
47
|
+
s: ["14365h7k9dcfesgujnmqp0r2twvyx8zb", "238967debc01fg45kmstqrwxuvhjyznp"],
|
|
48
|
+
e: ["bc01fg45238967deuvhjyznpkmstqrwx", "p0r21436x8zb9dcf5h7kjnmqesgutwvy"],
|
|
49
|
+
w: ["238967debc01fg45kmstqrwxuvhjyznp", "14365h7k9dcfesgujnmqp0r2twvyx8zb"]
|
|
50
|
+
};
|
|
51
|
+
static BORDERS = {
|
|
52
|
+
n: ["prxz", "bcfguvyz"],
|
|
53
|
+
s: ["028b", "0145hjnp"],
|
|
54
|
+
e: ["bcfguvyz", "prxz"],
|
|
55
|
+
w: ["0145hjnp", "028b"]
|
|
56
|
+
};
|
|
57
|
+
static encode(lat, lon, precision = 6) {
|
|
58
|
+
this.assertLatitude(lat);
|
|
59
|
+
this.assertLongitude(lon);
|
|
60
|
+
this.assertPrecision(precision);
|
|
61
|
+
let latMin = -90;
|
|
62
|
+
let latMax = 90;
|
|
63
|
+
let lonMin = -180;
|
|
64
|
+
let lonMax = 180;
|
|
65
|
+
let hash = "";
|
|
66
|
+
let bit = 0;
|
|
67
|
+
let ch = 0;
|
|
68
|
+
let even = true;
|
|
69
|
+
while (hash.length < precision) {
|
|
70
|
+
if (even) {
|
|
71
|
+
const mid = (lonMin + lonMax) / 2;
|
|
72
|
+
if (lon >= mid) {
|
|
73
|
+
ch |= 1 << 4 - bit;
|
|
74
|
+
lonMin = mid;
|
|
75
|
+
} else {
|
|
76
|
+
lonMax = mid;
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
const mid = (latMin + latMax) / 2;
|
|
80
|
+
if (lat >= mid) {
|
|
81
|
+
ch |= 1 << 4 - bit;
|
|
82
|
+
latMin = mid;
|
|
83
|
+
} else {
|
|
84
|
+
latMax = mid;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
even = !even;
|
|
88
|
+
if (bit < 4) {
|
|
89
|
+
bit += 1;
|
|
90
|
+
} else {
|
|
91
|
+
hash += this.BASE32[ch];
|
|
92
|
+
bit = 0;
|
|
93
|
+
ch = 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return hash;
|
|
97
|
+
}
|
|
98
|
+
static decode(hash) {
|
|
99
|
+
const bounds = this.decodeBounds(hash);
|
|
100
|
+
return {
|
|
101
|
+
lat: (bounds.latMin + bounds.latMax) / 2,
|
|
102
|
+
lon: (bounds.lonMin + bounds.lonMax) / 2
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
static decodeBounds(hash) {
|
|
106
|
+
const normalized = this.normalizeHash(hash);
|
|
107
|
+
let latMin = -90;
|
|
108
|
+
let latMax = 90;
|
|
109
|
+
let lonMin = -180;
|
|
110
|
+
let lonMax = 180;
|
|
111
|
+
let even = true;
|
|
112
|
+
for (const character of normalized) {
|
|
113
|
+
const cd = this.BASE32.indexOf(character);
|
|
114
|
+
if (cd < 0) {
|
|
115
|
+
throw new Error(`Invalid GeoHash character "${character}".`);
|
|
116
|
+
}
|
|
117
|
+
for (let mask = 16; mask > 0; mask >>= 1) {
|
|
118
|
+
if (even) {
|
|
119
|
+
const mid = (lonMin + lonMax) / 2;
|
|
120
|
+
if ((cd & mask) !== 0) {
|
|
121
|
+
lonMin = mid;
|
|
122
|
+
} else {
|
|
123
|
+
lonMax = mid;
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
const mid = (latMin + latMax) / 2;
|
|
127
|
+
if ((cd & mask) !== 0) {
|
|
128
|
+
latMin = mid;
|
|
129
|
+
} else {
|
|
130
|
+
latMax = mid;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
even = !even;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { latMin, latMax, lonMin, lonMax };
|
|
137
|
+
}
|
|
138
|
+
static neighbor(hash, direction) {
|
|
139
|
+
const normalizedDirection = direction.toLowerCase();
|
|
140
|
+
if (!this.isCardinal(normalizedDirection)) {
|
|
141
|
+
throw new Error("Direction must be one of: n, s, e, w.");
|
|
142
|
+
}
|
|
143
|
+
return this.adjacent(this.normalizeHash(hash), normalizedDirection);
|
|
144
|
+
}
|
|
145
|
+
static neighbors(hash) {
|
|
146
|
+
const normalized = this.normalizeHash(hash);
|
|
147
|
+
const north = this.adjacent(normalized, "n");
|
|
148
|
+
const south = this.adjacent(normalized, "s");
|
|
149
|
+
const east = this.adjacent(normalized, "e");
|
|
150
|
+
const west = this.adjacent(normalized, "w");
|
|
151
|
+
return [
|
|
152
|
+
north,
|
|
153
|
+
this.adjacent(north, "e"),
|
|
154
|
+
east,
|
|
155
|
+
this.adjacent(south, "e"),
|
|
156
|
+
south,
|
|
157
|
+
this.adjacent(south, "w"),
|
|
158
|
+
west,
|
|
159
|
+
this.adjacent(north, "w")
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
static neighborhood3x3(hash) {
|
|
163
|
+
const center = this.normalizeHash(hash);
|
|
164
|
+
return [center, ...this.neighbors(center)];
|
|
165
|
+
}
|
|
166
|
+
static neighborhoodTags(lat, lon, precision = 6) {
|
|
167
|
+
return this.neighborhood3x3(this.encode(lat, lon, precision)).map((cell) => this.tag(cell));
|
|
168
|
+
}
|
|
169
|
+
static optimalPrecisionForRadius(radiusKm) {
|
|
170
|
+
if (radiusKm < 0) {
|
|
171
|
+
throw new Error("Radius must be non-negative.");
|
|
172
|
+
}
|
|
173
|
+
if (radiusKm <= 1.5) {
|
|
174
|
+
return 6;
|
|
175
|
+
}
|
|
176
|
+
if (radiusKm <= 8) {
|
|
177
|
+
return 5;
|
|
178
|
+
}
|
|
179
|
+
return 4;
|
|
180
|
+
}
|
|
181
|
+
static precisionForRadius(radiusKm) {
|
|
182
|
+
return this.optimalPrecisionForRadius(radiusKm);
|
|
183
|
+
}
|
|
184
|
+
static getCoveringHashes(lat, lon, radiusKm, precision) {
|
|
185
|
+
if (radiusKm < 0) {
|
|
186
|
+
throw new Error("Radius must be non-negative.");
|
|
187
|
+
}
|
|
188
|
+
const resolvedPrecision = precision ?? this.optimalPrecisionForRadius(radiusKm);
|
|
189
|
+
this.assertPrecision(resolvedPrecision);
|
|
190
|
+
const center = this.encode(lat, lon, resolvedPrecision);
|
|
191
|
+
const covering = [];
|
|
192
|
+
const visited = /* @__PURE__ */ new Set();
|
|
193
|
+
const queue = [center];
|
|
194
|
+
while (queue.length > 0) {
|
|
195
|
+
const current = queue.shift();
|
|
196
|
+
if (current === void 0 || visited.has(current)) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
visited.add(current);
|
|
200
|
+
if (!this.cellIntersectsCircle(current, lat, lon, radiusKm)) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
covering.push(current);
|
|
204
|
+
if (covering.length >= this.MAX_COVERING_CELLS) {
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
for (const neighbor of this.neighbors(current)) {
|
|
208
|
+
if (!visited.has(neighbor)) {
|
|
209
|
+
queue.push(neighbor);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return covering;
|
|
214
|
+
}
|
|
215
|
+
static tag(geohash) {
|
|
216
|
+
const hash = this.normalizeHash(geohash);
|
|
217
|
+
return `${this.TAG_PREFIX}${hash.length}:${hash}`;
|
|
218
|
+
}
|
|
219
|
+
static encodeTag(lat, lon, precision = 6) {
|
|
220
|
+
return this.tag(this.encode(lat, lon, precision));
|
|
221
|
+
}
|
|
222
|
+
static encodeMultiTags(lat, lon) {
|
|
223
|
+
return this.INDEX_PRECISIONS.map((precision) => this.encodeTag(lat, lon, precision));
|
|
224
|
+
}
|
|
225
|
+
static haversineKm(lat1, lon1, lat2, lon2) {
|
|
226
|
+
const dLat = this.toRadians(lat2 - lat1);
|
|
227
|
+
const dLon = this.toRadians(lon2 - lon1);
|
|
228
|
+
const a = Math.sin(dLat / 2) ** 2 + Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) * Math.sin(dLon / 2) ** 2;
|
|
229
|
+
return 2 * this.EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
|
230
|
+
}
|
|
231
|
+
static cellIntersectsCircle(hash, lat, lon, radiusKm) {
|
|
232
|
+
const bounds = this.decodeBounds(hash);
|
|
233
|
+
const closestLat = Math.min(Math.max(lat, bounds.latMin), bounds.latMax);
|
|
234
|
+
const closestLon = Math.min(Math.max(lon, bounds.lonMin), bounds.lonMax);
|
|
235
|
+
return this.haversineKm(lat, lon, closestLat, closestLon) <= radiusKm;
|
|
236
|
+
}
|
|
237
|
+
static adjacent(hash, direction) {
|
|
238
|
+
if (hash.length === 0) {
|
|
239
|
+
throw new Error("GeoHash must not be empty.");
|
|
240
|
+
}
|
|
241
|
+
const lastChar = hash[hash.length - 1] ?? "";
|
|
242
|
+
const type = hash.length % 2;
|
|
243
|
+
let parent = hash.slice(0, -1);
|
|
244
|
+
const borders = this.BORDERS[direction][type] ?? "";
|
|
245
|
+
if (parent.length > 0 && borders.includes(lastChar)) {
|
|
246
|
+
parent = this.adjacent(parent, direction);
|
|
247
|
+
}
|
|
248
|
+
const neighborCharset = this.NEIGHBORS[direction][type] ?? "";
|
|
249
|
+
const index = neighborCharset.indexOf(lastChar);
|
|
250
|
+
if (index < 0) {
|
|
251
|
+
throw new Error(`Invalid GeoHash character "${lastChar}".`);
|
|
252
|
+
}
|
|
253
|
+
return parent + (this.BASE32[index] ?? "");
|
|
254
|
+
}
|
|
255
|
+
static normalizeHash(hash) {
|
|
256
|
+
let normalized = hash.trim().toLowerCase();
|
|
257
|
+
if (normalized.startsWith(this.TAG_PREFIX)) {
|
|
258
|
+
normalized = normalized.slice(this.TAG_PREFIX.length);
|
|
259
|
+
}
|
|
260
|
+
const tagged = normalized.match(/^([1-9]|1[0-2]):([0-9bcdefghjkmnpqrstuvwxyz]+)$/);
|
|
261
|
+
if (tagged) {
|
|
262
|
+
normalized = tagged[2] ?? "";
|
|
263
|
+
}
|
|
264
|
+
if (normalized.length === 0) {
|
|
265
|
+
throw new Error("GeoHash must not be empty.");
|
|
266
|
+
}
|
|
267
|
+
for (const character of normalized) {
|
|
268
|
+
if (!this.BASE32.includes(character)) {
|
|
269
|
+
throw new Error(`Invalid GeoHash "${normalized}".`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return normalized;
|
|
273
|
+
}
|
|
274
|
+
static assertLatitude(lat) {
|
|
275
|
+
if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
|
|
276
|
+
throw new Error("Latitude must be between -90 and 90.");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
static assertLongitude(lon) {
|
|
280
|
+
if (!Number.isFinite(lon) || lon < -180 || lon > 180) {
|
|
281
|
+
throw new Error("Longitude must be between -180 and 180.");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
static assertPrecision(precision) {
|
|
285
|
+
if (!Number.isInteger(precision) || precision < this.MIN_PRECISION || precision > this.MAX_PRECISION) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`GeoHash precision must be between ${this.MIN_PRECISION} and ${this.MAX_PRECISION}.`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
static isCardinal(direction) {
|
|
292
|
+
return direction === "n" || direction === "s" || direction === "e" || direction === "w";
|
|
293
|
+
}
|
|
294
|
+
static toRadians(degrees) {
|
|
295
|
+
return degrees * Math.PI / 180;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
var PulseIndexError = class _PulseIndexError extends Error {
|
|
299
|
+
code;
|
|
300
|
+
grpcStatusCode;
|
|
301
|
+
grpcDetails;
|
|
302
|
+
constructor(message, options = {}) {
|
|
303
|
+
super(message, options.cause !== void 0 ? { cause: options.cause } : void 0);
|
|
304
|
+
this.name = "PulseIndexError";
|
|
305
|
+
this.code = options.code ?? "PULSEINDEX_ERROR";
|
|
306
|
+
this.grpcStatusCode = options.grpcStatusCode;
|
|
307
|
+
this.grpcDetails = options.grpcDetails;
|
|
308
|
+
}
|
|
309
|
+
static fromGrpc(error) {
|
|
310
|
+
const serviceError = error;
|
|
311
|
+
const grpcStatusCode = typeof serviceError.code === "number" ? serviceError.code : grpc2.status.UNKNOWN;
|
|
312
|
+
const grpcDetails = typeof serviceError.details === "string" && serviceError.details.length > 0 ? serviceError.details : error.message;
|
|
313
|
+
const message = grpcDetails.length > 0 ? grpcDetails : `gRPC call failed with status ${grpcStatusName(grpcStatusCode)} (${grpcStatusCode})`;
|
|
314
|
+
const base = {
|
|
315
|
+
grpcStatusCode,
|
|
316
|
+
grpcDetails,
|
|
317
|
+
cause: error
|
|
318
|
+
};
|
|
319
|
+
switch (grpcStatusCode) {
|
|
320
|
+
case grpc2.status.UNAUTHENTICATED:
|
|
321
|
+
case grpc2.status.PERMISSION_DENIED:
|
|
322
|
+
return new PulseIndexAuthError(message, {
|
|
323
|
+
...base,
|
|
324
|
+
code: grpcStatusCode === grpc2.status.PERMISSION_DENIED ? "PERMISSION_DENIED" : "UNAUTHENTICATED"
|
|
325
|
+
});
|
|
326
|
+
case grpc2.status.UNAVAILABLE:
|
|
327
|
+
case grpc2.status.DEADLINE_EXCEEDED:
|
|
328
|
+
case grpc2.status.CANCELLED:
|
|
329
|
+
case grpc2.status.ABORTED:
|
|
330
|
+
return new PulseIndexConnectionError(message, {
|
|
331
|
+
...base,
|
|
332
|
+
code: grpcStatusName(grpcStatusCode)
|
|
333
|
+
});
|
|
334
|
+
case grpc2.status.INVALID_ARGUMENT:
|
|
335
|
+
case grpc2.status.FAILED_PRECONDITION:
|
|
336
|
+
case grpc2.status.OUT_OF_RANGE:
|
|
337
|
+
case grpc2.status.NOT_FOUND:
|
|
338
|
+
case grpc2.status.ALREADY_EXISTS:
|
|
339
|
+
case grpc2.status.RESOURCE_EXHAUSTED:
|
|
340
|
+
return new PulseIndexQueryError(message, {
|
|
341
|
+
...base,
|
|
342
|
+
code: grpcStatusName(grpcStatusCode)
|
|
343
|
+
});
|
|
344
|
+
default:
|
|
345
|
+
return new _PulseIndexError(message, {
|
|
346
|
+
...base,
|
|
347
|
+
code: grpcStatusName(grpcStatusCode)
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
var PulseIndexConnectionError = class extends PulseIndexError {
|
|
353
|
+
constructor(message, options = {}) {
|
|
354
|
+
super(message, { ...options, code: options.code ?? "CONNECTION_ERROR" });
|
|
355
|
+
this.name = "PulseIndexConnectionError";
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
var PulseIndexAuthError = class extends PulseIndexError {
|
|
359
|
+
constructor(message, options = {}) {
|
|
360
|
+
super(message, { ...options, code: options.code ?? "AUTH_ERROR" });
|
|
361
|
+
this.name = "PulseIndexAuthError";
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
var PulseIndexQueryError = class extends PulseIndexError {
|
|
365
|
+
constructor(message, options = {}) {
|
|
366
|
+
super(message, { ...options, code: options.code ?? "QUERY_ERROR" });
|
|
367
|
+
this.name = "PulseIndexQueryError";
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
function grpcStatusName(code) {
|
|
371
|
+
const names = Object.entries(grpc2.status).find(([, value]) => value === code);
|
|
372
|
+
return names?.[0] ?? `STATUS_${code}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// src/types/index.ts
|
|
376
|
+
var FilterOperation = {
|
|
377
|
+
MUST: 0,
|
|
378
|
+
SHOULD: 1,
|
|
379
|
+
MUST_NOT: 2
|
|
380
|
+
};
|
|
381
|
+
var UINT32_MAX = 4294967295;
|
|
382
|
+
var DEFAULT_ENDPOINT = "localhost:50051";
|
|
383
|
+
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
384
|
+
var DEFAULT_POOL_SIZE = 1;
|
|
385
|
+
|
|
386
|
+
// src/builder/QueryBuilder.ts
|
|
387
|
+
function emptyState() {
|
|
388
|
+
return {
|
|
389
|
+
tenantId: "",
|
|
390
|
+
locationPrefix: "0",
|
|
391
|
+
limit: 0,
|
|
392
|
+
offset: 0,
|
|
393
|
+
filters: [],
|
|
394
|
+
ranges: []
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function asAttributeList(value) {
|
|
398
|
+
const items = Array.isArray(value) ? value : [value];
|
|
399
|
+
const normalized = items.map((item) => item.trim()).filter((item) => item.length > 0);
|
|
400
|
+
if (normalized.length === 0) {
|
|
401
|
+
throw new PulseIndexQueryError("Attribute filter must not be empty.");
|
|
402
|
+
}
|
|
403
|
+
return normalized;
|
|
404
|
+
}
|
|
405
|
+
function resolveLongitude(options) {
|
|
406
|
+
const value = options.lng ?? options.lon;
|
|
407
|
+
if (value === void 0) {
|
|
408
|
+
throw new PulseIndexQueryError("withinRadius requires lng or lon.");
|
|
409
|
+
}
|
|
410
|
+
return value;
|
|
411
|
+
}
|
|
412
|
+
var QueryBuilder = class _QueryBuilder {
|
|
413
|
+
executor;
|
|
414
|
+
state;
|
|
415
|
+
constructor(executor = null) {
|
|
416
|
+
this.executor = executor;
|
|
417
|
+
this.state = emptyState();
|
|
418
|
+
}
|
|
419
|
+
tenant(tenantId) {
|
|
420
|
+
return this.fork((state) => {
|
|
421
|
+
state.tenantId = tenantId;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
location(locationPrefix) {
|
|
425
|
+
return this.fork((state) => {
|
|
426
|
+
state.locationPrefix = String(locationPrefix);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
must(attribute) {
|
|
430
|
+
return this.addFilters(FilterOperation.MUST, attribute);
|
|
431
|
+
}
|
|
432
|
+
should(attribute) {
|
|
433
|
+
return this.addFilters(FilterOperation.SHOULD, attribute);
|
|
434
|
+
}
|
|
435
|
+
mustNot(attribute) {
|
|
436
|
+
return this.addFilters(FilterOperation.MUST_NOT, attribute);
|
|
437
|
+
}
|
|
438
|
+
whereGeoHash(geohash) {
|
|
439
|
+
return this.must(GeoHash.tag(geohash));
|
|
440
|
+
}
|
|
441
|
+
inGeoHash(geohash) {
|
|
442
|
+
return this.whereGeoHash(geohash);
|
|
443
|
+
}
|
|
444
|
+
withinRadius(latOrOptions, lon, radiusKm, precision) {
|
|
445
|
+
let lat;
|
|
446
|
+
let longitude;
|
|
447
|
+
let radius;
|
|
448
|
+
let resolvedPrecision;
|
|
449
|
+
if (typeof latOrOptions === "object") {
|
|
450
|
+
lat = latOrOptions.lat;
|
|
451
|
+
longitude = resolveLongitude(latOrOptions);
|
|
452
|
+
radius = latOrOptions.radiusKm;
|
|
453
|
+
resolvedPrecision = latOrOptions.precision;
|
|
454
|
+
} else {
|
|
455
|
+
if (lon === void 0 || radiusKm === void 0) {
|
|
456
|
+
throw new PulseIndexQueryError("withinRadius(lat, lon, radiusKm) requires all three arguments.");
|
|
457
|
+
}
|
|
458
|
+
lat = latOrOptions;
|
|
459
|
+
longitude = lon;
|
|
460
|
+
radius = radiusKm;
|
|
461
|
+
resolvedPrecision = precision;
|
|
462
|
+
}
|
|
463
|
+
const covering = GeoHash.getCoveringHashes(lat, longitude, radius, resolvedPrecision);
|
|
464
|
+
return this.fork((state) => {
|
|
465
|
+
for (const hash of covering) {
|
|
466
|
+
state.filters.push({
|
|
467
|
+
op: FilterOperation.SHOULD,
|
|
468
|
+
attribute: GeoHash.tag(hash)
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
range(field, min, max) {
|
|
474
|
+
if (!field.trim()) {
|
|
475
|
+
throw new PulseIndexQueryError("Range field must not be empty.");
|
|
476
|
+
}
|
|
477
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
478
|
+
throw new PulseIndexQueryError("Range bounds must be finite numbers.");
|
|
479
|
+
}
|
|
480
|
+
if (min > max) {
|
|
481
|
+
throw new PulseIndexQueryError(`Range min (${min}) must be <= max (${max}).`);
|
|
482
|
+
}
|
|
483
|
+
return this.fork((state) => {
|
|
484
|
+
state.ranges.push({
|
|
485
|
+
field,
|
|
486
|
+
minVal: Math.floor(min),
|
|
487
|
+
maxVal: Math.floor(max)
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
limit(limit) {
|
|
492
|
+
return this.fork((state) => {
|
|
493
|
+
state.limit = Math.max(0, Math.floor(limit));
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
offset(offset) {
|
|
497
|
+
return this.fork((state) => {
|
|
498
|
+
state.offset = Math.max(0, Math.floor(offset));
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
toRequest(defaultTenantId = "") {
|
|
502
|
+
return {
|
|
503
|
+
tenantId: this.state.tenantId || defaultTenantId,
|
|
504
|
+
locationPrefix: this.state.locationPrefix,
|
|
505
|
+
limit: this.state.limit,
|
|
506
|
+
offset: this.state.offset,
|
|
507
|
+
filters: this.state.filters.map((filter) => ({ ...filter })),
|
|
508
|
+
ranges: this.state.ranges.map((range) => ({ ...range }))
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
toArray(defaultTenantId = "") {
|
|
512
|
+
return this.toRequest(defaultTenantId);
|
|
513
|
+
}
|
|
514
|
+
execute() {
|
|
515
|
+
if (!this.executor) {
|
|
516
|
+
throw new PulseIndexQueryError(
|
|
517
|
+
"QueryBuilder has no client; pass the builder to client.search() or create it via client.query()."
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
return this.executor.search(this);
|
|
521
|
+
}
|
|
522
|
+
static fromOptions(options, executor = null) {
|
|
523
|
+
let query = new _QueryBuilder(executor);
|
|
524
|
+
if (options.tenantId !== void 0) {
|
|
525
|
+
query = query.tenant(options.tenantId);
|
|
526
|
+
}
|
|
527
|
+
if (options.locationPrefix !== void 0) {
|
|
528
|
+
query = query.location(options.locationPrefix);
|
|
529
|
+
}
|
|
530
|
+
if (options.must !== void 0) {
|
|
531
|
+
query = query.must(options.must);
|
|
532
|
+
}
|
|
533
|
+
if (options.should !== void 0) {
|
|
534
|
+
query = query.should(options.should);
|
|
535
|
+
}
|
|
536
|
+
if (options.mustNot !== void 0) {
|
|
537
|
+
query = query.mustNot(options.mustNot);
|
|
538
|
+
}
|
|
539
|
+
if (options.ranges) {
|
|
540
|
+
for (const range of options.ranges) {
|
|
541
|
+
query = query.range(range.field, range.min, range.max);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (options.withinRadius) {
|
|
545
|
+
query = query.withinRadius(options.withinRadius);
|
|
546
|
+
}
|
|
547
|
+
if (options.geoHash) {
|
|
548
|
+
query = query.whereGeoHash(options.geoHash);
|
|
549
|
+
}
|
|
550
|
+
if (options.limit !== void 0) {
|
|
551
|
+
query = query.limit(options.limit);
|
|
552
|
+
}
|
|
553
|
+
if (options.offset !== void 0) {
|
|
554
|
+
query = query.offset(options.offset);
|
|
555
|
+
}
|
|
556
|
+
return query;
|
|
557
|
+
}
|
|
558
|
+
addFilters(op, attribute) {
|
|
559
|
+
const attributes = asAttributeList(attribute);
|
|
560
|
+
return this.fork((state) => {
|
|
561
|
+
for (const value of attributes) {
|
|
562
|
+
state.filters.push({ op, attribute: value });
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
fork(mutate) {
|
|
567
|
+
const next = new _QueryBuilder(this.executor);
|
|
568
|
+
next.state = {
|
|
569
|
+
tenantId: this.state.tenantId,
|
|
570
|
+
locationPrefix: this.state.locationPrefix,
|
|
571
|
+
limit: this.state.limit,
|
|
572
|
+
offset: this.state.offset,
|
|
573
|
+
filters: this.state.filters.map((filter) => ({ ...filter })),
|
|
574
|
+
ranges: this.state.ranges.map((range) => ({ ...range }))
|
|
575
|
+
};
|
|
576
|
+
mutate(next.state);
|
|
577
|
+
return next;
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
var PROTO_LOADER_OPTIONS = {
|
|
581
|
+
keepCase: false,
|
|
582
|
+
longs: String,
|
|
583
|
+
enums: Number,
|
|
584
|
+
defaults: true,
|
|
585
|
+
oneofs: true
|
|
586
|
+
};
|
|
587
|
+
var cache = /* @__PURE__ */ new Map();
|
|
588
|
+
function resolveProtoPath(fileName, explicit) {
|
|
589
|
+
if (explicit) {
|
|
590
|
+
if (!fs.existsSync(explicit)) {
|
|
591
|
+
throw new PulseIndexError(`${fileName} not found at ${explicit}`, {
|
|
592
|
+
code: "PROTO_NOT_FOUND"
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return explicit;
|
|
596
|
+
}
|
|
597
|
+
const here = typeof __dirname === "string" && __dirname.length > 0 ? __dirname : path.dirname(url.fileURLToPath(importMetaUrl));
|
|
598
|
+
const candidates = [
|
|
599
|
+
path.join(here, "..", "..", "proto", fileName),
|
|
600
|
+
path.join(here, "..", "proto", fileName),
|
|
601
|
+
path.join(here, "proto", fileName),
|
|
602
|
+
path.join(process.cwd(), "proto", fileName)
|
|
603
|
+
];
|
|
604
|
+
for (const candidate of candidates) {
|
|
605
|
+
if (fs.existsSync(candidate)) {
|
|
606
|
+
return candidate;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
throw new PulseIndexError(
|
|
610
|
+
`${fileName} not found. Looked in: ${candidates.join(", ")}`,
|
|
611
|
+
{ code: "PROTO_NOT_FOUND" }
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
function resolveEngineProtoPath(explicit) {
|
|
615
|
+
return resolveProtoPath("engine.proto", explicit);
|
|
616
|
+
}
|
|
617
|
+
function resolveHealthProtoPath(explicit) {
|
|
618
|
+
return resolveProtoPath("health.proto", explicit);
|
|
619
|
+
}
|
|
620
|
+
function loadEngineProto(protoPath) {
|
|
621
|
+
const resolved = resolveEngineProtoPath(protoPath);
|
|
622
|
+
const cached = cache.get(resolved);
|
|
623
|
+
if (cached) {
|
|
624
|
+
return cached;
|
|
625
|
+
}
|
|
626
|
+
const definition = protoLoader__namespace.loadSync(resolved, PROTO_LOADER_OPTIONS);
|
|
627
|
+
const grpcObject = grpc2__namespace.loadPackageDefinition(definition);
|
|
628
|
+
const serviceCtor = grpcObject.pulseindex?.engine?.v1?.SearchEngineService;
|
|
629
|
+
if (!serviceCtor) {
|
|
630
|
+
throw new PulseIndexError(
|
|
631
|
+
"Failed to load pulseindex.engine.v1.SearchEngineService from engine.proto",
|
|
632
|
+
{ code: "PROTO_LOAD_FAILED" }
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
const loaded = {
|
|
636
|
+
protoPath: resolved,
|
|
637
|
+
SearchEngineService: serviceCtor,
|
|
638
|
+
service: serviceCtor.service
|
|
639
|
+
};
|
|
640
|
+
cache.set(resolved, loaded);
|
|
641
|
+
return loaded;
|
|
642
|
+
}
|
|
643
|
+
var SERVING_STATUS = {
|
|
644
|
+
UNKNOWN: 0,
|
|
645
|
+
SERVING: 1,
|
|
646
|
+
NOT_SERVING: 2,
|
|
647
|
+
SERVICE_UNKNOWN: 3
|
|
648
|
+
};
|
|
649
|
+
var healthCache = /* @__PURE__ */ new Map();
|
|
650
|
+
function loadHealthProto(protoPath) {
|
|
651
|
+
const resolved = resolveHealthProtoPath(protoPath);
|
|
652
|
+
const cached = healthCache.get(resolved);
|
|
653
|
+
if (cached) {
|
|
654
|
+
return cached;
|
|
655
|
+
}
|
|
656
|
+
const definition = protoLoader__namespace.loadSync(resolved, PROTO_LOADER_OPTIONS);
|
|
657
|
+
const grpcObject = grpc2__namespace.loadPackageDefinition(definition);
|
|
658
|
+
const ctor = grpcObject.grpc?.health?.v1?.Health;
|
|
659
|
+
if (!ctor) {
|
|
660
|
+
throw new PulseIndexError(
|
|
661
|
+
"Failed to load grpc.health.v1.Health from health.proto",
|
|
662
|
+
{ code: "PROTO_LOAD_FAILED" }
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
healthCache.set(resolved, ctor);
|
|
666
|
+
return ctor;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// src/client/ConnectionManager.ts
|
|
670
|
+
var DEFAULT_CHANNEL_OPTIONS = {
|
|
671
|
+
"grpc.keepalive_time_ms": 3e4,
|
|
672
|
+
"grpc.keepalive_timeout_ms": 5e3,
|
|
673
|
+
"grpc.keepalive_permit_without_calls": 1,
|
|
674
|
+
"grpc.http2.min_time_between_pings_ms": 1e4,
|
|
675
|
+
"grpc.max_receive_message_length": 16 * 1024 * 1024,
|
|
676
|
+
"grpc.max_send_message_length": 16 * 1024 * 1024
|
|
677
|
+
};
|
|
678
|
+
function resolveEndpoint(config) {
|
|
679
|
+
return config.endpoint ?? config.host ?? process.env.PULSEINDEX_ENDPOINT ?? process.env.PULSEINDEX_HOST ?? DEFAULT_ENDPOINT;
|
|
680
|
+
}
|
|
681
|
+
function resolveApiKey(config) {
|
|
682
|
+
const key = config.apiKey ?? process.env.PULSEINDEX_API_KEY ?? void 0;
|
|
683
|
+
if (typeof key !== "string" || key.trim().length === 0) {
|
|
684
|
+
return void 0;
|
|
685
|
+
}
|
|
686
|
+
return key.trim();
|
|
687
|
+
}
|
|
688
|
+
function resolveAuthorization(config) {
|
|
689
|
+
const token = config.authorization ?? process.env.PULSEINDEX_AUTHORIZATION ?? void 0;
|
|
690
|
+
if (typeof token !== "string" || token.trim().length === 0) {
|
|
691
|
+
return void 0;
|
|
692
|
+
}
|
|
693
|
+
return token.trim();
|
|
694
|
+
}
|
|
695
|
+
function resolveTenantId(config) {
|
|
696
|
+
return config.tenantId ?? process.env.PULSEINDEX_TENANT_ID ?? "";
|
|
697
|
+
}
|
|
698
|
+
function sslEnabled(config) {
|
|
699
|
+
if (config.ssl !== void 0) {
|
|
700
|
+
return parseBoolean(config.ssl);
|
|
701
|
+
}
|
|
702
|
+
const env = process.env.PULSEINDEX_SSL;
|
|
703
|
+
if (env === void 0 || env === "") {
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
return parseBoolean(env);
|
|
707
|
+
}
|
|
708
|
+
function parseBoolean(value) {
|
|
709
|
+
if (typeof value === "boolean") {
|
|
710
|
+
return value;
|
|
711
|
+
}
|
|
712
|
+
if (typeof value === "number") {
|
|
713
|
+
return value === 1;
|
|
714
|
+
}
|
|
715
|
+
const normalized = value.trim().toLowerCase();
|
|
716
|
+
return normalized === "1" || normalized === "true" || normalized === "yes";
|
|
717
|
+
}
|
|
718
|
+
var ConnectionManager = class {
|
|
719
|
+
endpoint;
|
|
720
|
+
tenantId;
|
|
721
|
+
timeoutMs;
|
|
722
|
+
ssl;
|
|
723
|
+
apiKey;
|
|
724
|
+
authorization;
|
|
725
|
+
clients = [];
|
|
726
|
+
cursor = 0;
|
|
727
|
+
closed = false;
|
|
728
|
+
// Kept so the health stub can be built on demand with exactly the same
|
|
729
|
+
// address, credentials and options. grpc-js pools subchannels by that
|
|
730
|
+
// triple, so it rides the connection the engine pool already opened rather
|
|
731
|
+
// than dialling a second one.
|
|
732
|
+
credentials;
|
|
733
|
+
channelOptions;
|
|
734
|
+
healthProtoPath;
|
|
735
|
+
healthStub;
|
|
736
|
+
constructor(config = {}) {
|
|
737
|
+
this.endpoint = resolveEndpoint(config);
|
|
738
|
+
this.tenantId = resolveTenantId(config);
|
|
739
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
740
|
+
this.ssl = sslEnabled(config);
|
|
741
|
+
this.apiKey = resolveApiKey(config);
|
|
742
|
+
this.authorization = resolveAuthorization(config);
|
|
743
|
+
const poolSize = Math.max(1, Math.floor(config.poolSize ?? DEFAULT_POOL_SIZE));
|
|
744
|
+
const credentials2 = this.createCredentials(config);
|
|
745
|
+
const { SearchEngineService } = loadEngineProto(config.protoPath);
|
|
746
|
+
const channelOptions = {
|
|
747
|
+
...DEFAULT_CHANNEL_OPTIONS,
|
|
748
|
+
...config.channelOptions ?? {}
|
|
749
|
+
};
|
|
750
|
+
for (let i = 0; i < poolSize; i += 1) {
|
|
751
|
+
this.clients.push(new SearchEngineService(this.endpoint, credentials2, channelOptions));
|
|
752
|
+
}
|
|
753
|
+
this.credentials = credentials2;
|
|
754
|
+
this.channelOptions = channelOptions;
|
|
755
|
+
this.healthProtoPath = config.healthProtoPath;
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* `grpc.health.v1.Health` stub, created on first use.
|
|
759
|
+
*
|
|
760
|
+
* A separate service from the query API, and unauthenticated: the health
|
|
761
|
+
* protocol needs no scope, so it works with any key or none.
|
|
762
|
+
*/
|
|
763
|
+
getHealthStub() {
|
|
764
|
+
this.assertOpen();
|
|
765
|
+
if (!this.healthStub) {
|
|
766
|
+
const Health = loadHealthProto(this.healthProtoPath);
|
|
767
|
+
this.healthStub = new Health(this.endpoint, this.credentials, this.channelOptions);
|
|
768
|
+
}
|
|
769
|
+
return this.healthStub;
|
|
770
|
+
}
|
|
771
|
+
getStub() {
|
|
772
|
+
this.assertOpen();
|
|
773
|
+
const stub = this.clients[this.cursor % this.clients.length];
|
|
774
|
+
this.cursor += 1;
|
|
775
|
+
if (!stub) {
|
|
776
|
+
throw new PulseIndexConnectionError("PulseIndex gRPC channel pool is empty.");
|
|
777
|
+
}
|
|
778
|
+
return stub;
|
|
779
|
+
}
|
|
780
|
+
createMetadata() {
|
|
781
|
+
const metadata = new grpc2__namespace.Metadata();
|
|
782
|
+
if (this.apiKey) {
|
|
783
|
+
metadata.set("x-api-key", this.apiKey);
|
|
784
|
+
}
|
|
785
|
+
const bearer = this.authorization ? this.authorization.toLowerCase().startsWith("bearer ") ? this.authorization : `Bearer ${this.authorization}` : this.apiKey ? `Bearer ${this.apiKey}` : void 0;
|
|
786
|
+
if (bearer) {
|
|
787
|
+
metadata.set("authorization", bearer);
|
|
788
|
+
}
|
|
789
|
+
return metadata;
|
|
790
|
+
}
|
|
791
|
+
createCallOptions() {
|
|
792
|
+
return {
|
|
793
|
+
deadline: Date.now() + this.timeoutMs
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
async waitForReady(timeoutMs = this.timeoutMs) {
|
|
797
|
+
this.assertOpen();
|
|
798
|
+
const deadline = new Date(Date.now() + timeoutMs);
|
|
799
|
+
await Promise.all(
|
|
800
|
+
this.clients.map(
|
|
801
|
+
(client) => new Promise((resolve, reject) => {
|
|
802
|
+
client.waitForReady(deadline, (error) => {
|
|
803
|
+
if (error) {
|
|
804
|
+
reject(
|
|
805
|
+
new PulseIndexConnectionError(
|
|
806
|
+
`Unable to connect to PulseIndex at ${this.endpoint}: ${error.message}`,
|
|
807
|
+
{ cause: error, code: "UNAVAILABLE" }
|
|
808
|
+
)
|
|
809
|
+
);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
resolve();
|
|
813
|
+
});
|
|
814
|
+
})
|
|
815
|
+
)
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
close() {
|
|
819
|
+
if (this.closed) {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
this.closed = true;
|
|
823
|
+
for (const client of this.clients) {
|
|
824
|
+
client.close();
|
|
825
|
+
}
|
|
826
|
+
this.clients.length = 0;
|
|
827
|
+
this.healthStub?.close();
|
|
828
|
+
this.healthStub = void 0;
|
|
829
|
+
}
|
|
830
|
+
assertOpen() {
|
|
831
|
+
if (this.closed) {
|
|
832
|
+
throw new PulseIndexConnectionError("PulseIndex client has been closed.");
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
createCredentials(config) {
|
|
836
|
+
if (!this.ssl) {
|
|
837
|
+
return grpc2__namespace.credentials.createInsecure();
|
|
838
|
+
}
|
|
839
|
+
return grpc2__namespace.credentials.createSsl(
|
|
840
|
+
config.rootCerts ?? null,
|
|
841
|
+
config.privateKey ?? null,
|
|
842
|
+
config.certChain ?? null
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
// src/client/encodeEntity.ts
|
|
848
|
+
var SKIP_ATTRIBUTE_KEYS = /* @__PURE__ */ new Set([
|
|
849
|
+
"id",
|
|
850
|
+
"entityId",
|
|
851
|
+
"entity_id",
|
|
852
|
+
"attributes",
|
|
853
|
+
"price",
|
|
854
|
+
"locationPrefix",
|
|
855
|
+
"location_prefix",
|
|
856
|
+
"tenantId",
|
|
857
|
+
"tenant_id",
|
|
858
|
+
"latitude",
|
|
859
|
+
"longitude",
|
|
860
|
+
"lat",
|
|
861
|
+
"lng",
|
|
862
|
+
"lon",
|
|
863
|
+
"categories",
|
|
864
|
+
"tags"
|
|
865
|
+
]);
|
|
866
|
+
function toUint64String(value, field = "entityId") {
|
|
867
|
+
if (typeof value === "bigint") {
|
|
868
|
+
if (value < 0n) {
|
|
869
|
+
throw new PulseIndexQueryError(`${field} must be a non-negative integer.`);
|
|
870
|
+
}
|
|
871
|
+
return value.toString(10);
|
|
872
|
+
}
|
|
873
|
+
if (typeof value === "number") {
|
|
874
|
+
if (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) {
|
|
875
|
+
throw new PulseIndexQueryError(
|
|
876
|
+
`${field} must be a non-negative safe integer, bigint, or digit string.`
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
return String(value);
|
|
880
|
+
}
|
|
881
|
+
const trimmed = value.trim();
|
|
882
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
883
|
+
throw new PulseIndexQueryError(`${field} must be a non-negative integer string.`);
|
|
884
|
+
}
|
|
885
|
+
return trimmed.replace(/^0+(?=\d)/, "");
|
|
886
|
+
}
|
|
887
|
+
function toUint32(value, field) {
|
|
888
|
+
if (value === void 0 || value === null || value === "") {
|
|
889
|
+
return 0;
|
|
890
|
+
}
|
|
891
|
+
const numeric = typeof value === "number" ? value : Number(value);
|
|
892
|
+
if (!Number.isFinite(numeric) || numeric < 0 || numeric > UINT32_MAX) {
|
|
893
|
+
throw new PulseIndexQueryError(`${field} must be an integer between 0 and ${UINT32_MAX}.`);
|
|
894
|
+
}
|
|
895
|
+
return Math.floor(numeric);
|
|
896
|
+
}
|
|
897
|
+
function asRecord(value) {
|
|
898
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
899
|
+
return value;
|
|
900
|
+
}
|
|
901
|
+
return {};
|
|
902
|
+
}
|
|
903
|
+
function pushScalarTag(target, value) {
|
|
904
|
+
if (typeof value === "boolean") {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") {
|
|
908
|
+
const text = String(value).trim();
|
|
909
|
+
if (text.length > 0) {
|
|
910
|
+
target.push(text);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
function flattenAttributes(attributes) {
|
|
915
|
+
const categories = [];
|
|
916
|
+
for (const listKey of ["categories", "tags"]) {
|
|
917
|
+
const list = attributes[listKey];
|
|
918
|
+
if (!Array.isArray(list)) {
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
for (const item of list) {
|
|
922
|
+
pushScalarTag(categories, item);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
926
|
+
if (SKIP_ATTRIBUTE_KEYS.has(key)) {
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
if (typeof value === "boolean") {
|
|
930
|
+
if (value) {
|
|
931
|
+
categories.push(key);
|
|
932
|
+
}
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (Array.isArray(value)) {
|
|
936
|
+
for (const item of value) {
|
|
937
|
+
if (typeof item === "boolean") {
|
|
938
|
+
if (item) {
|
|
939
|
+
categories.push(`${key}:true`);
|
|
940
|
+
}
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
if (item !== void 0 && item !== null && item !== "") {
|
|
944
|
+
categories.push(`${key}:${String(item)}`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
if (value !== void 0 && value !== null && value !== "") {
|
|
950
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") {
|
|
951
|
+
categories.push(`${key}:${String(value)}`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const lat = firstNumber(attributes, ["latitude", "lat"]);
|
|
956
|
+
const lon = firstNumber(attributes, ["longitude", "lng", "lon"]);
|
|
957
|
+
if (lat !== void 0 && lon !== void 0) {
|
|
958
|
+
categories.push(...GeoHash.encodeMultiTags(lat, lon));
|
|
959
|
+
}
|
|
960
|
+
return [...new Set(categories)];
|
|
961
|
+
}
|
|
962
|
+
function firstNumber(source, keys) {
|
|
963
|
+
for (const key of keys) {
|
|
964
|
+
const value = source[key];
|
|
965
|
+
if (value === void 0 || value === null || value === "") {
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
const numeric = typeof value === "number" ? value : Number(value);
|
|
969
|
+
if (Number.isFinite(numeric)) {
|
|
970
|
+
return numeric;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return void 0;
|
|
974
|
+
}
|
|
975
|
+
function firstString(source, keys) {
|
|
976
|
+
for (const key of keys) {
|
|
977
|
+
const value = source[key];
|
|
978
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
979
|
+
return value.trim();
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return void 0;
|
|
983
|
+
}
|
|
984
|
+
function encodeEntity(entityIdOrInput, attributes = {}, defaults = {}) {
|
|
985
|
+
const isObjectInput = typeof entityIdOrInput === "object" && entityIdOrInput !== null && !Array.isArray(entityIdOrInput);
|
|
986
|
+
const input = isObjectInput ? entityIdOrInput : {};
|
|
987
|
+
const nested = asRecord(input.attributes);
|
|
988
|
+
const merged = {
|
|
989
|
+
...input,
|
|
990
|
+
...nested,
|
|
991
|
+
...attributes
|
|
992
|
+
};
|
|
993
|
+
const rawId = (!isObjectInput ? entityIdOrInput : void 0) ?? input.id ?? input.entityId ?? input.entity_id ?? nested.id ?? nested.entityId ?? nested.entity_id;
|
|
994
|
+
if (rawId === void 0 || rawId === null || rawId === "") {
|
|
995
|
+
throw new PulseIndexQueryError("entityId is required.");
|
|
996
|
+
}
|
|
997
|
+
const tenantId = firstString(merged, ["tenantId", "tenant_id"]) ?? defaults.tenantId ?? "";
|
|
998
|
+
return {
|
|
999
|
+
entityId: toUint64String(rawId, "entityId"),
|
|
1000
|
+
categories: flattenAttributes(merged),
|
|
1001
|
+
price: toUint32(merged.price, "price"),
|
|
1002
|
+
locationPrefix: toUint64String(
|
|
1003
|
+
merged.locationPrefix ?? merged.location_prefix ?? 0,
|
|
1004
|
+
"locationPrefix"
|
|
1005
|
+
),
|
|
1006
|
+
tenantId
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// src/client/PulseIndexClient.ts
|
|
1011
|
+
var PulseIndexClient = class _PulseIndexClient {
|
|
1012
|
+
connection;
|
|
1013
|
+
constructor(config = {}) {
|
|
1014
|
+
this.connection = new ConnectionManager(config);
|
|
1015
|
+
}
|
|
1016
|
+
static create(endpoint, apiKey, ssl, extra = {}) {
|
|
1017
|
+
return new _PulseIndexClient({
|
|
1018
|
+
...extra,
|
|
1019
|
+
endpoint,
|
|
1020
|
+
apiKey,
|
|
1021
|
+
ssl
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
static query() {
|
|
1025
|
+
return new QueryBuilder();
|
|
1026
|
+
}
|
|
1027
|
+
query() {
|
|
1028
|
+
return new QueryBuilder(this);
|
|
1029
|
+
}
|
|
1030
|
+
async search(query) {
|
|
1031
|
+
const builder = query instanceof QueryBuilder ? query : QueryBuilder.fromOptions(query, this);
|
|
1032
|
+
const raw = await this.unary(
|
|
1033
|
+
(stub, metadata, options, callback) => stub.search(builder.toRequest(this.connection.tenantId), metadata, options, callback)
|
|
1034
|
+
);
|
|
1035
|
+
return {
|
|
1036
|
+
matchedEntityIds: (raw.matchedEntityIds ?? []).map((id) => String(id)),
|
|
1037
|
+
totalMatches: Number(raw.totalMatches ?? 0),
|
|
1038
|
+
executionTimeUs: Number(raw.executionTimeUs ?? 0)
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
async index(entityIdOrInput, attributes = {}) {
|
|
1042
|
+
const encoded = encodeEntity(entityIdOrInput, attributes, {
|
|
1043
|
+
tenantId: this.connection.tenantId
|
|
1044
|
+
});
|
|
1045
|
+
const raw = await this.unary(
|
|
1046
|
+
(stub, metadata, options, callback) => stub.indexEntity(toIndexRequest(encoded), metadata, options, callback)
|
|
1047
|
+
);
|
|
1048
|
+
return { success: Boolean(raw.success) };
|
|
1049
|
+
}
|
|
1050
|
+
async indexEntity(entityId, categories = [], price = 0, locationPrefix = 0, tenantId = "") {
|
|
1051
|
+
const response = await this.index({
|
|
1052
|
+
entityId,
|
|
1053
|
+
categories,
|
|
1054
|
+
price,
|
|
1055
|
+
locationPrefix,
|
|
1056
|
+
tenantId: tenantId || this.connection.tenantId
|
|
1057
|
+
});
|
|
1058
|
+
return response.success;
|
|
1059
|
+
}
|
|
1060
|
+
async batchIndex(entities) {
|
|
1061
|
+
const requests = entities.map(
|
|
1062
|
+
(entity) => toIndexRequest(
|
|
1063
|
+
encodeEntity(entity, {}, { tenantId: this.connection.tenantId })
|
|
1064
|
+
)
|
|
1065
|
+
);
|
|
1066
|
+
const raw = await this.unary(
|
|
1067
|
+
(stub, metadata, options, callback) => stub.batchIndexEntities({ entities: requests }, metadata, options, callback)
|
|
1068
|
+
);
|
|
1069
|
+
return { indexedCount: Number(raw.indexedCount ?? 0) };
|
|
1070
|
+
}
|
|
1071
|
+
async delete(entityId, tenantId) {
|
|
1072
|
+
const raw = await this.unary(
|
|
1073
|
+
(stub, metadata, options, callback) => stub.deleteEntity(
|
|
1074
|
+
{
|
|
1075
|
+
entityId: toUint64String(entityId, "entityId"),
|
|
1076
|
+
tenantId: tenantId ?? this.connection.tenantId
|
|
1077
|
+
},
|
|
1078
|
+
metadata,
|
|
1079
|
+
options,
|
|
1080
|
+
callback
|
|
1081
|
+
)
|
|
1082
|
+
);
|
|
1083
|
+
return { success: Boolean(raw.success) };
|
|
1084
|
+
}
|
|
1085
|
+
async deleteEntity(entityId, tenantId = "") {
|
|
1086
|
+
const response = await this.delete(entityId, tenantId || this.connection.tenantId);
|
|
1087
|
+
return response.success;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* True only when the engine can serve reads.
|
|
1091
|
+
*
|
|
1092
|
+
* Asks `grpc.health.v1.Health`, which needs no particular scope and tracks
|
|
1093
|
+
* whether the service can currently answer queries. So this distinguishes a
|
|
1094
|
+
* reachable-but-unavailable service from a healthy one.
|
|
1095
|
+
*
|
|
1096
|
+
* Returns `false` rather than throwing, so unreachable and unavailable look
|
|
1097
|
+
* the same here. Use {@link servingStatus} to tell them apart.
|
|
1098
|
+
*/
|
|
1099
|
+
async health() {
|
|
1100
|
+
try {
|
|
1101
|
+
await this.connection.waitForReady();
|
|
1102
|
+
const status2 = await this.servingStatus();
|
|
1103
|
+
return status2 === SERVING_STATUS.SERVING;
|
|
1104
|
+
} catch {
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Raw `grpc.health.v1` serving status for a service name.
|
|
1110
|
+
*
|
|
1111
|
+
* Defaults to `''`, the overall-server key defined by the health spec. The
|
|
1112
|
+
* service answers for both that and its named service.
|
|
1113
|
+
*/
|
|
1114
|
+
async servingStatus(service = "") {
|
|
1115
|
+
const stub = this.connection.getHealthStub();
|
|
1116
|
+
return new Promise((resolve, reject) => {
|
|
1117
|
+
stub.check(
|
|
1118
|
+
{ service },
|
|
1119
|
+
this.connection.createMetadata(),
|
|
1120
|
+
this.connection.createCallOptions(),
|
|
1121
|
+
(error, response) => {
|
|
1122
|
+
if (error) {
|
|
1123
|
+
reject(PulseIndexError.fromGrpc(error));
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
resolve(response?.status ?? SERVING_STATUS.UNKNOWN);
|
|
1127
|
+
}
|
|
1128
|
+
);
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
close() {
|
|
1132
|
+
this.connection.close();
|
|
1133
|
+
}
|
|
1134
|
+
unary(invoke) {
|
|
1135
|
+
return new Promise((resolve, reject) => {
|
|
1136
|
+
let stub;
|
|
1137
|
+
try {
|
|
1138
|
+
stub = this.connection.getStub();
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
reject(
|
|
1141
|
+
error instanceof PulseIndexError ? error : new PulseIndexConnectionError("Failed to acquire a gRPC channel.", {
|
|
1142
|
+
cause: error
|
|
1143
|
+
})
|
|
1144
|
+
);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
invoke(
|
|
1148
|
+
stub,
|
|
1149
|
+
this.connection.createMetadata(),
|
|
1150
|
+
this.connection.createCallOptions(),
|
|
1151
|
+
(error, response) => {
|
|
1152
|
+
if (error) {
|
|
1153
|
+
reject(PulseIndexError.fromGrpc(error));
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
if (response === void 0 || response === null) {
|
|
1157
|
+
reject(new PulseIndexError("Empty gRPC response."));
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
resolve(response);
|
|
1161
|
+
}
|
|
1162
|
+
);
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
var PulseIndex = class extends PulseIndexClient {
|
|
1167
|
+
};
|
|
1168
|
+
function toIndexRequest(encoded) {
|
|
1169
|
+
return {
|
|
1170
|
+
entityId: encoded.entityId,
|
|
1171
|
+
locationPrefix: encoded.locationPrefix,
|
|
1172
|
+
price: encoded.price,
|
|
1173
|
+
categories: encoded.categories,
|
|
1174
|
+
tenantId: encoded.tenantId
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// src/index.ts
|
|
1179
|
+
var index_default = PulseIndex;
|
|
1180
|
+
|
|
1181
|
+
exports.ConnectionManager = ConnectionManager;
|
|
1182
|
+
exports.FilterOperation = FilterOperation;
|
|
1183
|
+
exports.GeoHash = GeoHash;
|
|
1184
|
+
exports.PulseIndex = PulseIndex;
|
|
1185
|
+
exports.PulseIndexAuthError = PulseIndexAuthError;
|
|
1186
|
+
exports.PulseIndexClient = PulseIndexClient;
|
|
1187
|
+
exports.PulseIndexConnectionError = PulseIndexConnectionError;
|
|
1188
|
+
exports.PulseIndexError = PulseIndexError;
|
|
1189
|
+
exports.PulseIndexQueryError = PulseIndexQueryError;
|
|
1190
|
+
exports.QueryBuilder = QueryBuilder;
|
|
1191
|
+
exports.SERVING_STATUS = SERVING_STATUS;
|
|
1192
|
+
exports.default = index_default;
|
|
1193
|
+
exports.encodeEntity = encodeEntity;
|
|
1194
|
+
exports.sslEnabled = sslEnabled;
|
|
1195
|
+
exports.toUint64String = toUint64String;
|