@dalgo/dalgo2typesense 0.0.0-bootstrap.0 → 0.2.1
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/README.md +257 -4
- package/dist/capabilities.d.ts +185 -0
- package/dist/capabilities.js +76 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/database.d.ts +86 -0
- package/dist/database.js +1050 -0
- package/dist/database.js.map +1 -0
- package/dist/errors.d.ts +43 -0
- package/dist/errors.js +63 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +32 -0
- package/dist/json.js +222 -0
- package/dist/json.js.map +1 -0
- package/dist/metadata.d.ts +54 -0
- package/dist/metadata.js +159 -0
- package/dist/metadata.js.map +1 -0
- package/dist/search.d.ts +82 -0
- package/dist/search.js +238 -0
- package/dist/search.js.map +1 -0
- package/dist/structured.d.ts +79 -0
- package/dist/structured.js +399 -0
- package/dist/structured.js.map +1 -0
- package/dist/transport.d.ts +51 -0
- package/dist/transport.js +317 -0
- package/dist/transport.js.map +1 -0
- package/package.json +50 -7
- package/LICENSE +0 -22
package/dist/database.js
ADDED
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
import { QueryCancelledError, } from "@dalgo/adapter-runtime";
|
|
2
|
+
import { Key, UnsupportedError, identityCodec, } from "@dalgo/core";
|
|
3
|
+
import { TYPESENSE_READ_CAPABILITIES, } from "./capabilities.js";
|
|
4
|
+
import { TypesenseContinuationBoundError, TypesenseCursorError, TypesenseSemanticError, } from "./errors.js";
|
|
5
|
+
import { metadataFailure, parseCollectionList, parseCollectionSchema, schemaFieldTypes, } from "./metadata.js";
|
|
6
|
+
import { buildNativeResult, decodeSearchResponse, } from "./search.js";
|
|
7
|
+
import { canonicalPredicate, collectPredicate, createVerifier, orderFields, predicateRoot, projectionFields, renderPredicate, } from "./structured.js";
|
|
8
|
+
import { TypesenseTransport, byteLength, nonNegative, positive, } from "./transport.js";
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
10
|
+
const DEFAULT_MAX_GET_MANY = 100;
|
|
11
|
+
const DEFAULT_MAX_GET_MANY_BYTES = 1_048_576;
|
|
12
|
+
const DEFAULT_MAX_QUERY_LIMIT = 100;
|
|
13
|
+
const DEFAULT_MAX_ROWS_PER_PAGE = 100;
|
|
14
|
+
const DEFAULT_MAX_QUERY_PAGES = 20;
|
|
15
|
+
const DEFAULT_MAX_QUERY_OFFSET = 10_000;
|
|
16
|
+
const DEFAULT_MAX_REQUEST_BYTES = 1_048_576;
|
|
17
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 1_048_576;
|
|
18
|
+
const DEFAULT_MAX_TOTAL_BYTES = 16_777_216;
|
|
19
|
+
const DEFAULT_MAX_ROW_BYTES = 1_048_576;
|
|
20
|
+
const DEFAULT_MAX_FILTER_BYTES = 8_192;
|
|
21
|
+
const DEFAULT_MAX_COLUMNS = 100;
|
|
22
|
+
const DEFAULT_MAX_SORT_FIELDS = 3;
|
|
23
|
+
const DEFAULT_MAX_FACET_FIELDS = 5;
|
|
24
|
+
const DEFAULT_MAX_FACET_VALUES = 100;
|
|
25
|
+
const DEFAULT_MAX_CURSOR_BYTES = 8_192;
|
|
26
|
+
const DEFAULT_MAX_ISSUED_CURSORS = 1_000;
|
|
27
|
+
const MAX_CONSERVED_CURSORS = 1_000;
|
|
28
|
+
const ABSOLUTE_MAX_GET_MANY = 250;
|
|
29
|
+
const ABSOLUTE_MAX_QUERY_LIMIT = 10_000;
|
|
30
|
+
const ABSOLUTE_MAX_ROWS_PER_PAGE = 250;
|
|
31
|
+
const ABSOLUTE_MAX_QUERY_PAGES = 100;
|
|
32
|
+
const ABSOLUTE_MAX_QUERY_OFFSET = 1_000_000;
|
|
33
|
+
const ABSOLUTE_MAX_REQUEST_BYTES = 16_777_216;
|
|
34
|
+
const ABSOLUTE_MAX_RESPONSE_BYTES = 16_777_216;
|
|
35
|
+
const ABSOLUTE_MAX_TOTAL_BYTES = 67_108_864;
|
|
36
|
+
const ABSOLUTE_MAX_ROW_BYTES = 67_108_864;
|
|
37
|
+
const ABSOLUTE_MAX_FILTER_BYTES = 65_536;
|
|
38
|
+
const ABSOLUTE_MAX_COLUMNS = 250;
|
|
39
|
+
const ABSOLUTE_MAX_SORT_FIELDS = 3;
|
|
40
|
+
const ABSOLUTE_MAX_FACET_FIELDS = 10;
|
|
41
|
+
const ABSOLUTE_MAX_FACET_VALUES = 250;
|
|
42
|
+
const ABSOLUTE_MAX_CURSOR_BYTES = 65_536;
|
|
43
|
+
const ABSOLUTE_MAX_ISSUED_CURSORS = 10_000;
|
|
44
|
+
const ABSOLUTE_MAX_TIMEOUT_MS = 120_000;
|
|
45
|
+
const COLLECTION = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u;
|
|
46
|
+
const NATIVE_FIELD = /^[A-Za-z][A-Za-z0-9._]{0,127}$/u;
|
|
47
|
+
const SIMPLE_NATIVE_FIELD = /^[A-Za-z][A-Za-z0-9_]{0,127}$/u;
|
|
48
|
+
const CURSOR_TOKEN = /^ts1_[A-Za-z0-9_-]{43}$/u;
|
|
49
|
+
const CURSOR_TOKEN_BYTES = 47;
|
|
50
|
+
const MAX_KEY_CHARS = 1_024;
|
|
51
|
+
const MAX_QUERY_TEXT = 4_096;
|
|
52
|
+
function assertCollection(value) {
|
|
53
|
+
if (typeof value !== "string" || !COLLECTION.test(value)) {
|
|
54
|
+
throw new TypeError("collection must be a Typesense collection name");
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function identityScopeName(value) {
|
|
59
|
+
if (value === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 256) {
|
|
62
|
+
throw new TypeError("identityScope must be a non-empty string of at most 256 printable characters");
|
|
63
|
+
}
|
|
64
|
+
for (const character of value) {
|
|
65
|
+
const code = character.codePointAt(0) ?? 0;
|
|
66
|
+
if (code < 0x20 || code === 0x7f) {
|
|
67
|
+
throw new TypeError("identityScope must be a non-empty string of at most 256 printable characters");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
function codecOrIdentity(codec) {
|
|
73
|
+
return (codec ?? identityCodec);
|
|
74
|
+
}
|
|
75
|
+
function assertNativeID(id) {
|
|
76
|
+
if (id.length === 0 || id.length > MAX_KEY_CHARS || !id.isWellFormed()) {
|
|
77
|
+
throw new TypeError("Typesense document ids must be non-empty bounded strings");
|
|
78
|
+
}
|
|
79
|
+
for (let index = 0; index < id.length; index += 1) {
|
|
80
|
+
const code = id.charCodeAt(index);
|
|
81
|
+
if (code < 0x20 || code === 0x7f)
|
|
82
|
+
throw new TypeError("Typesense document ids must be control-free");
|
|
83
|
+
}
|
|
84
|
+
return id;
|
|
85
|
+
}
|
|
86
|
+
function filterID(id) {
|
|
87
|
+
assertNativeID(id);
|
|
88
|
+
if (id.includes("`"))
|
|
89
|
+
throw new UnsupportedError("Typesense document ids containing a backtick cannot be used in an exact filter");
|
|
90
|
+
return `\`${id}\``;
|
|
91
|
+
}
|
|
92
|
+
function nativeField(value, nested) {
|
|
93
|
+
const pattern = nested ? NATIVE_FIELD : SIMPLE_NATIVE_FIELD;
|
|
94
|
+
if (!pattern.test(value) || value.startsWith("_"))
|
|
95
|
+
throw new UnsupportedError("Typesense field name is not supported");
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
function validateNativeFilter(filter, maximum) {
|
|
99
|
+
if (filter.length === 0)
|
|
100
|
+
throw new UnsupportedError("Typesense native filter must not be empty");
|
|
101
|
+
if (byteLength(filter) > maximum)
|
|
102
|
+
throw new RangeError("Typesense native filter exceeds maxFilterBytes");
|
|
103
|
+
if (!filter.isWellFormed())
|
|
104
|
+
throw new UnsupportedError("Typesense native filter is not valid UTF-8");
|
|
105
|
+
let depth = 0;
|
|
106
|
+
let inBacktick = false;
|
|
107
|
+
for (const character of filter) {
|
|
108
|
+
const code = character.codePointAt(0) ?? 0;
|
|
109
|
+
if (code === 0 || code === 0x0d || code === 0x0a || code === 0x7f) {
|
|
110
|
+
throw new UnsupportedError("Typesense native filter contains a control character");
|
|
111
|
+
}
|
|
112
|
+
if (inBacktick) {
|
|
113
|
+
if (character === "`")
|
|
114
|
+
inBacktick = false;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (character === "`") {
|
|
118
|
+
inBacktick = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (character === "(") {
|
|
122
|
+
depth += 1;
|
|
123
|
+
if (depth > 8)
|
|
124
|
+
throw new UnsupportedError("Typesense native filter nesting is too deep");
|
|
125
|
+
}
|
|
126
|
+
else if (character === ")") {
|
|
127
|
+
depth -= 1;
|
|
128
|
+
if (depth < 0)
|
|
129
|
+
throw new UnsupportedError("Typesense native filter has unbalanced parentheses");
|
|
130
|
+
}
|
|
131
|
+
else if (character === "{" || character === "}" || character === "$") {
|
|
132
|
+
throw new UnsupportedError("Typesense native filter uses unsupported join or object-filter syntax");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (inBacktick)
|
|
136
|
+
throw new UnsupportedError("Typesense native filter has an unbalanced literal");
|
|
137
|
+
if (depth !== 0)
|
|
138
|
+
throw new UnsupportedError("Typesense native filter has unbalanced parentheses");
|
|
139
|
+
}
|
|
140
|
+
function digest(secret, value) {
|
|
141
|
+
let hash = 0xcbf29ce484222325n;
|
|
142
|
+
const encoded = new TextEncoder().encode(`${secret}:${value}`);
|
|
143
|
+
for (const byte of encoded) {
|
|
144
|
+
hash ^= BigInt(byte);
|
|
145
|
+
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
|
|
146
|
+
}
|
|
147
|
+
return hash.toString(16).padStart(16, "0");
|
|
148
|
+
}
|
|
149
|
+
function randomId() {
|
|
150
|
+
const crypto = globalThis.crypto;
|
|
151
|
+
if (crypto === undefined || typeof crypto.getRandomValues !== "function") {
|
|
152
|
+
throw new Error("Typesense cursors require crypto.getRandomValues");
|
|
153
|
+
}
|
|
154
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
155
|
+
let binary = "";
|
|
156
|
+
for (const byte of bytes)
|
|
157
|
+
binary += String.fromCharCode(byte);
|
|
158
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
|
|
159
|
+
}
|
|
160
|
+
function failureStatus(reason) {
|
|
161
|
+
switch (reason) {
|
|
162
|
+
case "unauthorized": return "unauthorized";
|
|
163
|
+
case "forbidden": return "forbidden";
|
|
164
|
+
case "not-found": return "not-found";
|
|
165
|
+
case "malformed": return "metadata-malformed";
|
|
166
|
+
default: return "unreachable";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function documentBytes(document) {
|
|
170
|
+
return byteLength(JSON.stringify(document));
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Read-only bounded adapter over one existing Typesense collection.
|
|
174
|
+
*
|
|
175
|
+
* Documents are read natively: the string Typesense `id` is the DALgo key and
|
|
176
|
+
* no DALgo envelope is required or written. Structured queries use a positive
|
|
177
|
+
* native candidate filter plus an exact verifier over the complete bounded
|
|
178
|
+
* candidate prefix. Every read disables curations and filters curated hits.
|
|
179
|
+
*/
|
|
180
|
+
export class TypesenseDatabase {
|
|
181
|
+
readOnly = true;
|
|
182
|
+
#transport;
|
|
183
|
+
#collection;
|
|
184
|
+
#identityScope;
|
|
185
|
+
#timeoutMs;
|
|
186
|
+
#maxRequestBytes;
|
|
187
|
+
#maxResponseBytes;
|
|
188
|
+
#maxTotalBytes;
|
|
189
|
+
#maxRowBytes;
|
|
190
|
+
#maxGetManyKeys;
|
|
191
|
+
#maxGetManyBytes;
|
|
192
|
+
#maxQueryLimit;
|
|
193
|
+
#maxRowsPerPage;
|
|
194
|
+
#maxQueryPages;
|
|
195
|
+
#maxQueryOffset;
|
|
196
|
+
#maxFilterBytes;
|
|
197
|
+
#maxColumns;
|
|
198
|
+
#maxSortFields;
|
|
199
|
+
#maxFacetFields;
|
|
200
|
+
#maxFacetValues;
|
|
201
|
+
#maxCursorBytes;
|
|
202
|
+
#maxIssuedCursors;
|
|
203
|
+
#root = new AbortController();
|
|
204
|
+
#operations = new Set();
|
|
205
|
+
#issuedCursors = new Map();
|
|
206
|
+
#activeCursors = new Set();
|
|
207
|
+
#consumedCursors = new Map();
|
|
208
|
+
#cursorSecret = randomId();
|
|
209
|
+
#closed = false;
|
|
210
|
+
constructor(options) {
|
|
211
|
+
this.#collection = assertCollection(options.collection);
|
|
212
|
+
this.#identityScope = identityScopeName(options.identityScope);
|
|
213
|
+
this.#timeoutMs = positive(options.timeoutMs, DEFAULT_TIMEOUT_MS, "timeoutMs", ABSOLUTE_MAX_TIMEOUT_MS);
|
|
214
|
+
this.#maxRequestBytes = positive(options.maxRequestBytes, DEFAULT_MAX_REQUEST_BYTES, "maxRequestBytes", ABSOLUTE_MAX_REQUEST_BYTES);
|
|
215
|
+
this.#maxResponseBytes = positive(options.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES, "maxResponseBytes", ABSOLUTE_MAX_RESPONSE_BYTES);
|
|
216
|
+
this.#maxTotalBytes = positive(options.maxTotalBytes, DEFAULT_MAX_TOTAL_BYTES, "maxTotalBytes", ABSOLUTE_MAX_TOTAL_BYTES);
|
|
217
|
+
this.#maxRowBytes = positive(options.maxRowBytes, DEFAULT_MAX_ROW_BYTES, "maxRowBytes", ABSOLUTE_MAX_ROW_BYTES);
|
|
218
|
+
this.#maxGetManyKeys = positive(options.maxGetManyKeys, DEFAULT_MAX_GET_MANY, "maxGetManyKeys", ABSOLUTE_MAX_GET_MANY);
|
|
219
|
+
this.#maxGetManyBytes = positive(options.maxGetManyBytes, DEFAULT_MAX_GET_MANY_BYTES, "maxGetManyBytes", ABSOLUTE_MAX_TOTAL_BYTES);
|
|
220
|
+
this.#maxQueryLimit = positive(options.maxQueryLimit, DEFAULT_MAX_QUERY_LIMIT, "maxQueryLimit", ABSOLUTE_MAX_QUERY_LIMIT);
|
|
221
|
+
this.#maxRowsPerPage = positive(options.maxRowsPerPage, DEFAULT_MAX_ROWS_PER_PAGE, "maxRowsPerPage", ABSOLUTE_MAX_ROWS_PER_PAGE);
|
|
222
|
+
this.#maxQueryPages = positive(options.maxQueryPages, DEFAULT_MAX_QUERY_PAGES, "maxQueryPages", ABSOLUTE_MAX_QUERY_PAGES);
|
|
223
|
+
this.#maxQueryOffset = nonNegative(options.maxQueryOffset, DEFAULT_MAX_QUERY_OFFSET, "maxQueryOffset", ABSOLUTE_MAX_QUERY_OFFSET);
|
|
224
|
+
this.#maxFilterBytes = positive(options.maxFilterBytes, DEFAULT_MAX_FILTER_BYTES, "maxFilterBytes", ABSOLUTE_MAX_FILTER_BYTES);
|
|
225
|
+
this.#maxColumns = positive(options.maxColumns, DEFAULT_MAX_COLUMNS, "maxColumns", ABSOLUTE_MAX_COLUMNS);
|
|
226
|
+
this.#maxSortFields = positive(options.maxSortFields, DEFAULT_MAX_SORT_FIELDS, "maxSortFields", ABSOLUTE_MAX_SORT_FIELDS);
|
|
227
|
+
this.#maxFacetFields = positive(options.maxFacetFields, DEFAULT_MAX_FACET_FIELDS, "maxFacetFields", ABSOLUTE_MAX_FACET_FIELDS);
|
|
228
|
+
this.#maxFacetValues = positive(options.maxFacetValues, DEFAULT_MAX_FACET_VALUES, "maxFacetValues", ABSOLUTE_MAX_FACET_VALUES);
|
|
229
|
+
this.#maxCursorBytes = positive(options.maxCursorBytes, DEFAULT_MAX_CURSOR_BYTES, "maxCursorBytes", ABSOLUTE_MAX_CURSOR_BYTES);
|
|
230
|
+
this.#maxIssuedCursors = positive(options.maxIssuedCursors, DEFAULT_MAX_ISSUED_CURSORS, "maxIssuedCursors", ABSOLUTE_MAX_ISSUED_CURSORS);
|
|
231
|
+
if (this.#maxCursorBytes < CURSOR_TOKEN_BYTES)
|
|
232
|
+
throw new TypeError("maxCursorBytes is smaller than an adapter cursor handle");
|
|
233
|
+
this.#transport = new TypesenseTransport({
|
|
234
|
+
baseUrl: options.baseUrl,
|
|
235
|
+
allowInsecureLoopbackHttp: options.allowInsecureLoopbackHttp === true,
|
|
236
|
+
...(options.headers === undefined ? {} : { headers: options.headers }),
|
|
237
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
238
|
+
timeoutMs: this.#timeoutMs,
|
|
239
|
+
maxRequestBytes: this.#maxRequestBytes,
|
|
240
|
+
maxResponseBytes: this.#maxResponseBytes,
|
|
241
|
+
maxTotalBytes: this.#maxTotalBytes,
|
|
242
|
+
root: this.#root.signal,
|
|
243
|
+
onDispose: (operation) => { this.#operations.delete(operation); },
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
get collection() { return this.#collection; }
|
|
247
|
+
get endpoint() { return this.#transport.origin; }
|
|
248
|
+
get limits() { return this.#bounds; }
|
|
249
|
+
get capabilities() {
|
|
250
|
+
return {
|
|
251
|
+
...TYPESENSE_READ_CAPABILITIES,
|
|
252
|
+
read: {
|
|
253
|
+
...TYPESENSE_READ_CAPABILITIES.read,
|
|
254
|
+
maxGetManyKeys: { default: this.#maxGetManyKeys, maximum: ABSOLUTE_MAX_GET_MANY },
|
|
255
|
+
},
|
|
256
|
+
bounds: this.#bounds,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
get #bounds() {
|
|
260
|
+
return {
|
|
261
|
+
maxQueryLimit: { default: this.#maxQueryLimit, maximum: ABSOLUTE_MAX_QUERY_LIMIT },
|
|
262
|
+
maxRequestBytes: { default: this.#maxRequestBytes, maximum: ABSOLUTE_MAX_REQUEST_BYTES },
|
|
263
|
+
maxResponseBytes: { default: this.#maxResponseBytes, maximum: ABSOLUTE_MAX_RESPONSE_BYTES },
|
|
264
|
+
maxGetManyKeys: { default: this.#maxGetManyKeys, maximum: ABSOLUTE_MAX_GET_MANY },
|
|
265
|
+
maxGetManyBytes: { default: this.#maxGetManyBytes, maximum: ABSOLUTE_MAX_TOTAL_BYTES },
|
|
266
|
+
maxRowsPerPage: { default: this.#maxRowsPerPage, maximum: ABSOLUTE_MAX_ROWS_PER_PAGE },
|
|
267
|
+
maxQueryPages: { default: this.#maxQueryPages, maximum: ABSOLUTE_MAX_QUERY_PAGES },
|
|
268
|
+
maxQueryOffset: { default: this.#maxQueryOffset, maximum: ABSOLUTE_MAX_QUERY_OFFSET },
|
|
269
|
+
maxTotalBytes: { default: this.#maxTotalBytes, maximum: ABSOLUTE_MAX_TOTAL_BYTES },
|
|
270
|
+
maxRowBytes: { default: this.#maxRowBytes, maximum: ABSOLUTE_MAX_ROW_BYTES },
|
|
271
|
+
maxFilterBytes: { default: this.#maxFilterBytes, maximum: ABSOLUTE_MAX_FILTER_BYTES },
|
|
272
|
+
maxColumns: { default: this.#maxColumns, maximum: ABSOLUTE_MAX_COLUMNS },
|
|
273
|
+
maxSortFields: { default: this.#maxSortFields, maximum: ABSOLUTE_MAX_SORT_FIELDS },
|
|
274
|
+
maxFacetFields: { default: this.#maxFacetFields, maximum: ABSOLUTE_MAX_FACET_FIELDS },
|
|
275
|
+
maxFacetValues: { default: this.#maxFacetValues, maximum: ABSOLUTE_MAX_FACET_VALUES },
|
|
276
|
+
maxCursorBytes: { default: this.#maxCursorBytes, maximum: ABSOLUTE_MAX_CURSOR_BYTES },
|
|
277
|
+
maxIssuedCursors: { default: this.#maxIssuedCursors, maximum: ABSOLUTE_MAX_ISSUED_CURSORS },
|
|
278
|
+
filterOperators: TYPESENSE_READ_CAPABILITIES.bounds.filterOperators,
|
|
279
|
+
orderDirections: TYPESENSE_READ_CAPABILITIES.bounds.orderDirections,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
async get(key, codec) {
|
|
283
|
+
assertKey(key, this.#collection);
|
|
284
|
+
const results = await this.#getMany([key], undefined, codec);
|
|
285
|
+
const result = results[0];
|
|
286
|
+
if (result === undefined)
|
|
287
|
+
throw new Error("Typesense point read returned no snapshot");
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
async getMany(keys, codec) {
|
|
291
|
+
return this.#getMany(keys, undefined, codec);
|
|
292
|
+
}
|
|
293
|
+
async getManyWithSignal(keys, signal, codec) {
|
|
294
|
+
if (signal.aborted)
|
|
295
|
+
throw new QueryCancelledError("caller");
|
|
296
|
+
return this.#getMany(keys, signal, codec);
|
|
297
|
+
}
|
|
298
|
+
async query(query) {
|
|
299
|
+
return this.#query(query, undefined);
|
|
300
|
+
}
|
|
301
|
+
async queryWithSignal(query, signal) {
|
|
302
|
+
if (signal.aborted)
|
|
303
|
+
throw new QueryCancelledError("caller");
|
|
304
|
+
return this.#query(query, signal);
|
|
305
|
+
}
|
|
306
|
+
async search(request) {
|
|
307
|
+
return this.#search(request, undefined);
|
|
308
|
+
}
|
|
309
|
+
async searchWithSignal(request, signal) {
|
|
310
|
+
if (signal.aborted)
|
|
311
|
+
throw new QueryCancelledError("caller");
|
|
312
|
+
return this.#search(request, signal);
|
|
313
|
+
}
|
|
314
|
+
async listCollections(signal) {
|
|
315
|
+
if (signal?.aborted === true)
|
|
316
|
+
throw new QueryCancelledError("caller");
|
|
317
|
+
const operation = this.#begin(signal);
|
|
318
|
+
try {
|
|
319
|
+
const response = await this.#transport.request("/collections", new URLSearchParams(), operation, [401, 403, 404]);
|
|
320
|
+
if (response.status !== 200) {
|
|
321
|
+
return { status: "unavailable", ...failureForStatus(response.status) };
|
|
322
|
+
}
|
|
323
|
+
const parsed = parseCollectionList(response.body, this.#maxColumns);
|
|
324
|
+
return { status: "available", ...parsed };
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (error instanceof QueryCancelledError || error instanceof RangeError)
|
|
328
|
+
throw error;
|
|
329
|
+
return { status: "unavailable", ...metadataFailure(error) };
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
operation.dispose();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async schema(signal) {
|
|
336
|
+
if (signal?.aborted === true)
|
|
337
|
+
throw new QueryCancelledError("caller");
|
|
338
|
+
const operation = this.#begin(signal);
|
|
339
|
+
try {
|
|
340
|
+
return await this.#schema(operation);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
if (error instanceof QueryCancelledError || error instanceof RangeError)
|
|
344
|
+
throw error;
|
|
345
|
+
return { status: "unavailable", ...metadataFailure(error) };
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
operation.dispose();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async testConnection(signal) {
|
|
352
|
+
const discovery = await this.schema(signal);
|
|
353
|
+
if (discovery.status === "available") {
|
|
354
|
+
return { ok: true, collection: this.#collection, status: "connected" };
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
ok: false,
|
|
358
|
+
collection: this.#collection,
|
|
359
|
+
status: failureStatus(discovery.reason),
|
|
360
|
+
...(discovery.httpStatus === undefined ? {} : { httpStatus: discovery.httpStatus }),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
async describe(signal) {
|
|
364
|
+
const schema = await this.schema(signal);
|
|
365
|
+
return {
|
|
366
|
+
adapter: "@dalgo/dalgo2typesense",
|
|
367
|
+
storage: "native-rows",
|
|
368
|
+
readOnly: true,
|
|
369
|
+
endpoint: this.#transport.origin,
|
|
370
|
+
collection: this.#collection,
|
|
371
|
+
schema,
|
|
372
|
+
limits: this.#bounds,
|
|
373
|
+
capabilities: this.capabilities,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/** Cancels every owned operation. Idempotent. */
|
|
377
|
+
close() {
|
|
378
|
+
if (this.#closed)
|
|
379
|
+
return;
|
|
380
|
+
this.#closed = true;
|
|
381
|
+
this.#issuedCursors.clear();
|
|
382
|
+
this.#activeCursors.clear();
|
|
383
|
+
this.#consumedCursors.clear();
|
|
384
|
+
this.#root.abort();
|
|
385
|
+
}
|
|
386
|
+
/** Alias kept for callers that use a disposal vocabulary. */
|
|
387
|
+
dispose() { this.close(); }
|
|
388
|
+
runReadwriteTransaction(callback) {
|
|
389
|
+
void callback;
|
|
390
|
+
return Promise.reject(new UnsupportedError("Typesense adapter is read-only and has no callback transactions"));
|
|
391
|
+
}
|
|
392
|
+
#begin(signal) {
|
|
393
|
+
if (this.#closed)
|
|
394
|
+
throw new UnsupportedError("Typesense database is closed");
|
|
395
|
+
const operation = this.#transport.begin(signal);
|
|
396
|
+
this.#operations.add(operation);
|
|
397
|
+
return operation;
|
|
398
|
+
}
|
|
399
|
+
async #getMany(keys, signal, codec) {
|
|
400
|
+
if (keys.length > this.#maxGetManyKeys) {
|
|
401
|
+
throw new UnsupportedError(`Typesense getMany above maxGetManyKeys (${String(this.#maxGetManyKeys)})`);
|
|
402
|
+
}
|
|
403
|
+
for (const key of keys)
|
|
404
|
+
assertKey(key, this.#collection);
|
|
405
|
+
const results = new Array(keys.length);
|
|
406
|
+
if (keys.length === 0)
|
|
407
|
+
return results;
|
|
408
|
+
const unique = [];
|
|
409
|
+
const seen = new Set();
|
|
410
|
+
for (const key of keys) {
|
|
411
|
+
const id = key.id;
|
|
412
|
+
if (!seen.has(id)) {
|
|
413
|
+
seen.add(id);
|
|
414
|
+
unique.push(id);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (unique.length > this.#maxRowsPerPage) {
|
|
418
|
+
throw new UnsupportedError(`Typesense exact-id batch above maxRowsPerPage (${String(this.#maxRowsPerPage)})`);
|
|
419
|
+
}
|
|
420
|
+
const operation = this.#begin(signal);
|
|
421
|
+
try {
|
|
422
|
+
const parameters = new URLSearchParams();
|
|
423
|
+
parameters.set("q", "*");
|
|
424
|
+
parameters.set("filter_by", `id:=[${unique.map((id) => filterID(id)).join(",")}]`);
|
|
425
|
+
parameters.set("limit", String(unique.length));
|
|
426
|
+
this.#structuredPolicy(parameters);
|
|
427
|
+
const response = await this.#transport.request(this.#searchPath(), parameters, operation);
|
|
428
|
+
const decoded = decodeSearchResponse(response.body, {
|
|
429
|
+
limit: unique.length,
|
|
430
|
+
offset: 0,
|
|
431
|
+
facetFields: [],
|
|
432
|
+
maxFacetValues: 0,
|
|
433
|
+
maxRowBytes: this.#maxRowBytes,
|
|
434
|
+
});
|
|
435
|
+
if (decoded.cutoff || decoded.found !== decoded.hits.length) {
|
|
436
|
+
throw new UnsupportedError("Typesense exact-id search returned a partial result");
|
|
437
|
+
}
|
|
438
|
+
let total = 0;
|
|
439
|
+
const hits = new Map();
|
|
440
|
+
for (const hit of decoded.hits) {
|
|
441
|
+
total += documentBytes(hit.document);
|
|
442
|
+
if (total > this.#maxGetManyBytes)
|
|
443
|
+
throw new RangeError("Typesense getMany responses exceed maxGetManyBytes");
|
|
444
|
+
hits.set(hit.id, hit);
|
|
445
|
+
}
|
|
446
|
+
for (let index = 0; index < keys.length; index += 1) {
|
|
447
|
+
const key = keys[index];
|
|
448
|
+
if (key === undefined)
|
|
449
|
+
continue;
|
|
450
|
+
const hit = hits.get(key.id);
|
|
451
|
+
results[index] = hit === undefined
|
|
452
|
+
? { key, exists: false }
|
|
453
|
+
: this.#record(hit, key, codec);
|
|
454
|
+
}
|
|
455
|
+
operation.check();
|
|
456
|
+
return results;
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
operation.dispose();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async #query(query, signal) {
|
|
463
|
+
const operation = this.#begin(signal);
|
|
464
|
+
let plan;
|
|
465
|
+
try {
|
|
466
|
+
plan = await this.#plan(query, operation);
|
|
467
|
+
return await this.#execute(plan, query, operation);
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
if (plan?.cursor !== undefined)
|
|
471
|
+
this.#activeCursors.delete(plan.cursor.token);
|
|
472
|
+
throw error;
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
operation.dispose();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
async #plan(query, operation) {
|
|
479
|
+
// Preflight the cheap, secret-free request shape before any metadata or
|
|
480
|
+
// search I/O.
|
|
481
|
+
if (query.source.kind !== "collection" || query.source.parent !== undefined) {
|
|
482
|
+
throw new UnsupportedError("Typesense collection-group or nested-collection queries");
|
|
483
|
+
}
|
|
484
|
+
if (query.source.name !== this.#collection) {
|
|
485
|
+
throw new UnsupportedError(`Typesense database is configured for collection ${this.#collection}`);
|
|
486
|
+
}
|
|
487
|
+
const extended = query;
|
|
488
|
+
const projection = projectionFields(extended.projection, this.#maxColumns);
|
|
489
|
+
const orders = orderFields(query.orders, this.#maxSortFields);
|
|
490
|
+
if (query.startAt !== undefined || query.endAt !== undefined || query.endBefore !== undefined) {
|
|
491
|
+
throw new UnsupportedError("Typesense inclusive/end DALgo query cursors");
|
|
492
|
+
}
|
|
493
|
+
if (query.startAfter !== undefined && query.offset !== undefined) {
|
|
494
|
+
throw new UnsupportedError("Typesense query cursors combined with offset");
|
|
495
|
+
}
|
|
496
|
+
const limit = query.limit ?? this.#maxQueryLimit;
|
|
497
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > this.#maxQueryLimit) {
|
|
498
|
+
throw new UnsupportedError(`Typesense query limit must be from 1 to ${String(this.#maxQueryLimit)}`);
|
|
499
|
+
}
|
|
500
|
+
const requestedOffset = query.offset ?? 0;
|
|
501
|
+
if (!Number.isSafeInteger(requestedOffset) || requestedOffset < 0 || requestedOffset > this.#maxQueryOffset) {
|
|
502
|
+
throw new UnsupportedError(`Typesense query offset is outside the configured bound`);
|
|
503
|
+
}
|
|
504
|
+
if (requestedOffset + limit > this.#maxQueryOffset) {
|
|
505
|
+
throw new UnsupportedError("Typesense query offset plus limit exceeds maxQueryOffset");
|
|
506
|
+
}
|
|
507
|
+
const root = predicateRoot(extended, { maxFilterBytes: this.#maxFilterBytes });
|
|
508
|
+
const { fields, ranges } = root === undefined ? { fields: [], ranges: [] } : collectPredicate(root);
|
|
509
|
+
let filter;
|
|
510
|
+
if (root !== undefined) {
|
|
511
|
+
const numeric = ranges.length === 0 ? new Map() : await this.#numericSchema(operation, ranges);
|
|
512
|
+
filter = renderPredicate(root, numeric, this.#maxFilterBytes);
|
|
513
|
+
if (byteLength(filter) > this.#maxFilterBytes) {
|
|
514
|
+
throw new RangeError("Typesense structured filter exceeds maxFilterBytes");
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const include = this.#includeFields(projection, fields, root !== undefined);
|
|
518
|
+
const filtered = filter !== undefined;
|
|
519
|
+
const fingerprint = digest(this.#cursorSecret, JSON.stringify({
|
|
520
|
+
endpoint: this.#transport.origin,
|
|
521
|
+
collection: this.#collection,
|
|
522
|
+
// The exact admitted AST, not only the widened native candidate string:
|
|
523
|
+
// `>` and `>=` (or `<` and `<=`) normalise to the same native filter and
|
|
524
|
+
// must never share a continuation identity.
|
|
525
|
+
predicate: canonicalPredicate(root),
|
|
526
|
+
orders,
|
|
527
|
+
projection: projection ?? null,
|
|
528
|
+
limit,
|
|
529
|
+
filtered,
|
|
530
|
+
}));
|
|
531
|
+
const prepared = query.startAfter === undefined
|
|
532
|
+
? undefined
|
|
533
|
+
: this.#prepareCursor(query.startAfter, fingerprint, limit, filtered);
|
|
534
|
+
const offset = prepared === undefined ? requestedOffset : prepared.binding.nextOffset;
|
|
535
|
+
const window = filtered ? offset + limit : limit;
|
|
536
|
+
const pageSize = filtered ? Math.min(this.#maxRowsPerPage, Math.max(window, 1)) : Math.min(this.#maxRowsPerPage, limit);
|
|
537
|
+
if (filtered) {
|
|
538
|
+
if (window > this.#maxQueryOffset) {
|
|
539
|
+
throw new UnsupportedError("Typesense filtered candidate window exceeds maxQueryOffset");
|
|
540
|
+
}
|
|
541
|
+
const pages = Math.ceil(window / pageSize);
|
|
542
|
+
if (pages > this.#maxQueryPages) {
|
|
543
|
+
throw new UnsupportedError(`Typesense filtered candidate window needs more pages than maxQueryPages (${String(this.#maxQueryPages)})`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
if (offset + limit > this.#maxQueryOffset) {
|
|
548
|
+
throw new UnsupportedError("Typesense query offset plus limit exceeds maxQueryOffset");
|
|
549
|
+
}
|
|
550
|
+
const pages = Math.ceil(limit / this.#maxRowsPerPage);
|
|
551
|
+
if (pages > this.#maxQueryPages) {
|
|
552
|
+
throw new UnsupportedError(`Typesense unfiltered page needs more requests than maxQueryPages (${String(this.#maxQueryPages)})`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const planner = {
|
|
556
|
+
collection: this.#collection,
|
|
557
|
+
limit,
|
|
558
|
+
offset,
|
|
559
|
+
projection,
|
|
560
|
+
includeFields: include,
|
|
561
|
+
orders,
|
|
562
|
+
filter,
|
|
563
|
+
verifier: root === undefined ? undefined : createVerifier(root),
|
|
564
|
+
filtered,
|
|
565
|
+
pageSize,
|
|
566
|
+
window,
|
|
567
|
+
fingerprint,
|
|
568
|
+
...(prepared === undefined ? {} : { cursor: prepared }),
|
|
569
|
+
};
|
|
570
|
+
// Activation is the final, non-throwing step: no remaining planning error
|
|
571
|
+
// can strand an active lease with an undefined plan.
|
|
572
|
+
if (prepared !== undefined) {
|
|
573
|
+
if (this.#activeCursors.has(prepared.token))
|
|
574
|
+
throw new TypesenseCursorError("used");
|
|
575
|
+
this.#activeCursors.add(prepared.token);
|
|
576
|
+
}
|
|
577
|
+
return planner;
|
|
578
|
+
}
|
|
579
|
+
#includeFields(projection, predicateFields, filtered) {
|
|
580
|
+
if (projection === undefined)
|
|
581
|
+
return undefined;
|
|
582
|
+
const fields = new Set(["id", ...projection]);
|
|
583
|
+
if (filtered) {
|
|
584
|
+
for (const field of predicateFields)
|
|
585
|
+
fields.add(field);
|
|
586
|
+
}
|
|
587
|
+
if (fields.size > this.#maxColumns) {
|
|
588
|
+
throw new UnsupportedError("Typesense predicate helper fields exceed maxColumns");
|
|
589
|
+
}
|
|
590
|
+
return Object.freeze([...fields]);
|
|
591
|
+
}
|
|
592
|
+
async #execute(plan, query, operation) {
|
|
593
|
+
const cursor = plan.cursor;
|
|
594
|
+
const scope = this.#bindCursor(cursor, operation);
|
|
595
|
+
try {
|
|
596
|
+
const page = plan.filtered
|
|
597
|
+
? await this.#filteredPage(plan, scope.observe, operation)
|
|
598
|
+
: await this.#unfilteredPage(plan, scope.observe, operation);
|
|
599
|
+
operation.check();
|
|
600
|
+
const records = page.hits.slice(plan.filtered ? plan.offset : 0, plan.filtered ? plan.offset + plan.limit : plan.limit)
|
|
601
|
+
.map((hit) => this.#recordHit(hit, plan, query.source.codec));
|
|
602
|
+
// A codec can synchronously abort or close the database. The previous
|
|
603
|
+
// attempt must not be consumed and a next handle must not be issued until
|
|
604
|
+
// the complete page, including codec decoding, has succeeded.
|
|
605
|
+
operation.check();
|
|
606
|
+
const hasMore = plan.filtered
|
|
607
|
+
? page.scanned >= plan.window && page.found > plan.window
|
|
608
|
+
: page.hits.length === plan.limit && page.found > plan.offset + page.hits.length;
|
|
609
|
+
let nextCursor;
|
|
610
|
+
let issued;
|
|
611
|
+
if (hasMore) {
|
|
612
|
+
if (!this.#continuationUsable(plan))
|
|
613
|
+
throw new TypesenseContinuationBoundError();
|
|
614
|
+
// Staging performs all fallible token work without touching the cache.
|
|
615
|
+
// The cursor transaction commits only after the final cancellability
|
|
616
|
+
// check, so a failed attempt cannot evict the handle it is resuming.
|
|
617
|
+
issued = {
|
|
618
|
+
token: this.#stageCursor(),
|
|
619
|
+
binding: {
|
|
620
|
+
fingerprint: plan.fingerprint,
|
|
621
|
+
scope: scope.value(),
|
|
622
|
+
limit: plan.limit,
|
|
623
|
+
nextOffset: plan.offset + plan.limit,
|
|
624
|
+
filtered: plan.filtered,
|
|
625
|
+
},
|
|
626
|
+
};
|
|
627
|
+
nextCursor = { values: [issued.token] };
|
|
628
|
+
}
|
|
629
|
+
operation.check();
|
|
630
|
+
this.#commitCursorPage(cursor?.token, issued);
|
|
631
|
+
return nextCursor === undefined ? { records } : { records, nextCursor };
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
// Failed or canceled execute releases the lease and never commits it.
|
|
635
|
+
if (cursor !== undefined)
|
|
636
|
+
this.#activeCursors.delete(cursor.token);
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/** True when the position after this page is inside every configured bound. */
|
|
641
|
+
#continuationUsable(plan) {
|
|
642
|
+
const nextOffset = plan.offset + plan.limit;
|
|
643
|
+
if (!Number.isSafeInteger(nextOffset) || nextOffset < 0 || nextOffset > this.#maxQueryOffset)
|
|
644
|
+
return false;
|
|
645
|
+
if (plan.filtered) {
|
|
646
|
+
const nextWindow = nextOffset + plan.limit;
|
|
647
|
+
if (nextWindow > this.#maxQueryOffset)
|
|
648
|
+
return false;
|
|
649
|
+
const pageSize = Math.min(this.#maxRowsPerPage, Math.max(nextWindow, 1));
|
|
650
|
+
return Math.ceil(nextWindow / pageSize) <= this.#maxQueryPages;
|
|
651
|
+
}
|
|
652
|
+
if (nextOffset + plan.limit > this.#maxQueryOffset)
|
|
653
|
+
return false;
|
|
654
|
+
return Math.ceil(plan.limit / this.#maxRowsPerPage) <= this.#maxQueryPages;
|
|
655
|
+
}
|
|
656
|
+
#bindCursor(prepared, operation) {
|
|
657
|
+
let scope;
|
|
658
|
+
return {
|
|
659
|
+
observe: (headers) => {
|
|
660
|
+
operation.check();
|
|
661
|
+
const current = this.#scopeOf(headers);
|
|
662
|
+
if (prepared !== undefined) {
|
|
663
|
+
if (current !== prepared.binding.scope)
|
|
664
|
+
throw new TypesenseCursorError("changed-query");
|
|
665
|
+
scope = current;
|
|
666
|
+
}
|
|
667
|
+
else if (scope === undefined) {
|
|
668
|
+
scope = current;
|
|
669
|
+
}
|
|
670
|
+
else if (scope !== current) {
|
|
671
|
+
throw new TypesenseCursorError("changed-query");
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
value: () => {
|
|
675
|
+
if (scope === undefined)
|
|
676
|
+
throw new TypesenseCursorError("malformed");
|
|
677
|
+
return scope;
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
async #unfilteredPage(plan, observe, operation) {
|
|
682
|
+
// The native Typesense physical page is at most the configured
|
|
683
|
+
// maxRowsPerPage (and the absolute 250-row server cap). A larger DALgo
|
|
684
|
+
// query limit is honoured with bounded paging, never by asking one physical
|
|
685
|
+
// page for more rows than the adapter admits.
|
|
686
|
+
const hits = [];
|
|
687
|
+
const seen = new Set();
|
|
688
|
+
let found = 0;
|
|
689
|
+
let physicalOffset = plan.offset;
|
|
690
|
+
let pages = 0;
|
|
691
|
+
while (hits.length < plan.limit) {
|
|
692
|
+
operation.check();
|
|
693
|
+
if (pages >= this.#maxQueryPages)
|
|
694
|
+
throw new UnsupportedError("Typesense query exceeded maxQueryPages");
|
|
695
|
+
const size = Math.min(this.#maxRowsPerPage, plan.limit - hits.length);
|
|
696
|
+
const response = await this.#searchRequest(plan, physicalOffset, size, operation, observe);
|
|
697
|
+
if (operation.signal.aborted)
|
|
698
|
+
throw operation.cancelled();
|
|
699
|
+
const decoded = decodeSearchResponse(response, {
|
|
700
|
+
limit: size,
|
|
701
|
+
offset: physicalOffset,
|
|
702
|
+
facetFields: [],
|
|
703
|
+
maxFacetValues: 0,
|
|
704
|
+
maxRowBytes: this.#maxRowBytes,
|
|
705
|
+
});
|
|
706
|
+
pages += 1;
|
|
707
|
+
found = decoded.found;
|
|
708
|
+
operation.check();
|
|
709
|
+
if (decoded.cutoff)
|
|
710
|
+
throw new UnsupportedError("Typesense search returned a partial cutoff result");
|
|
711
|
+
if (decoded.hits.length === 0) {
|
|
712
|
+
if (decoded.found > physicalOffset)
|
|
713
|
+
throw new UnsupportedError("Typesense search returned a partial result");
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
for (const hit of decoded.hits) {
|
|
717
|
+
if (seen.has(hit.id))
|
|
718
|
+
throw new TypeError("Typesense search response repeated a document id across pages");
|
|
719
|
+
seen.add(hit.id);
|
|
720
|
+
hits.push(hit);
|
|
721
|
+
}
|
|
722
|
+
physicalOffset += decoded.hits.length;
|
|
723
|
+
if (decoded.hits.length < size) {
|
|
724
|
+
if (decoded.found > physicalOffset)
|
|
725
|
+
throw new UnsupportedError("Typesense search returned a partial result");
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
operation.check();
|
|
730
|
+
return { hits, found, scanned: hits.length };
|
|
731
|
+
}
|
|
732
|
+
async #filteredPage(plan, observe, operation) {
|
|
733
|
+
const verifier = plan.verifier;
|
|
734
|
+
if (verifier === undefined)
|
|
735
|
+
throw new Error("Typesense filtered plan requires a verifier");
|
|
736
|
+
const verified = [];
|
|
737
|
+
const seen = new Set();
|
|
738
|
+
let physicalOffset = 0;
|
|
739
|
+
let found = 0;
|
|
740
|
+
let pages = 0;
|
|
741
|
+
while (physicalOffset < plan.window) {
|
|
742
|
+
operation.check();
|
|
743
|
+
if (pages >= this.#maxQueryPages)
|
|
744
|
+
throw new UnsupportedError("Typesense query exceeded maxQueryPages");
|
|
745
|
+
const size = Math.min(plan.pageSize, plan.window - physicalOffset);
|
|
746
|
+
const response = await this.#searchRequest(plan, physicalOffset, size, operation, observe);
|
|
747
|
+
const decoded = decodeSearchResponse(response, {
|
|
748
|
+
limit: size,
|
|
749
|
+
offset: physicalOffset,
|
|
750
|
+
facetFields: [],
|
|
751
|
+
maxFacetValues: 0,
|
|
752
|
+
maxRowBytes: this.#maxRowBytes,
|
|
753
|
+
});
|
|
754
|
+
pages += 1;
|
|
755
|
+
found = decoded.found;
|
|
756
|
+
if (decoded.cutoff)
|
|
757
|
+
throw new UnsupportedError("Typesense search returned a partial cutoff result");
|
|
758
|
+
if (decoded.hits.length === 0) {
|
|
759
|
+
if (decoded.found > physicalOffset)
|
|
760
|
+
throw new UnsupportedError("Typesense search returned a partial result");
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
for (const hit of decoded.hits) {
|
|
764
|
+
operation.check();
|
|
765
|
+
if (seen.has(hit.id))
|
|
766
|
+
throw new TypeError("Typesense search response repeated a document id across pages");
|
|
767
|
+
seen.add(hit.id);
|
|
768
|
+
const matches = await verifier(hit.document, () => { operation.check(); });
|
|
769
|
+
operation.check();
|
|
770
|
+
if (!matches)
|
|
771
|
+
throw new TypesenseSemanticError();
|
|
772
|
+
verified.push(hit);
|
|
773
|
+
}
|
|
774
|
+
physicalOffset += decoded.hits.length;
|
|
775
|
+
if (decoded.hits.length < size) {
|
|
776
|
+
if (decoded.found > physicalOffset)
|
|
777
|
+
throw new UnsupportedError("Typesense search returned a partial result");
|
|
778
|
+
break;
|
|
779
|
+
}
|
|
780
|
+
if (verified.length >= plan.window)
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
operation.check();
|
|
784
|
+
return { hits: verified, found: Math.max(found, verified.length), scanned: verified.length };
|
|
785
|
+
}
|
|
786
|
+
async #searchRequest(plan, offset, limit, operation, observe) {
|
|
787
|
+
const parameters = new URLSearchParams();
|
|
788
|
+
parameters.set("q", "*");
|
|
789
|
+
if (plan.filter !== undefined)
|
|
790
|
+
parameters.set("filter_by", plan.filter);
|
|
791
|
+
if (plan.orders.length > 0) {
|
|
792
|
+
parameters.set("sort_by", plan.orders.map((order) => `${order.field}:${order.direction}`).join(","));
|
|
793
|
+
}
|
|
794
|
+
if (plan.includeFields !== undefined)
|
|
795
|
+
parameters.set("include_fields", plan.includeFields.join(","));
|
|
796
|
+
parameters.set("limit", String(limit));
|
|
797
|
+
if (offset > 0)
|
|
798
|
+
parameters.set("offset", String(offset));
|
|
799
|
+
this.#structuredPolicy(parameters);
|
|
800
|
+
const response = await this.#transport.request(this.#searchPath(), parameters, operation, [], observe);
|
|
801
|
+
return response.body;
|
|
802
|
+
}
|
|
803
|
+
#structuredPolicy(parameters) {
|
|
804
|
+
parameters.set("enable_curations", "false");
|
|
805
|
+
parameters.set("filter_curated_hits", "true");
|
|
806
|
+
parameters.set("num_typos", "0");
|
|
807
|
+
parameters.set("prefix", "false");
|
|
808
|
+
parameters.set("drop_tokens_threshold", "0");
|
|
809
|
+
parameters.set("typo_tokens_threshold", "0");
|
|
810
|
+
}
|
|
811
|
+
async #search(request, signal) {
|
|
812
|
+
const plan = this.#nativePlan(request);
|
|
813
|
+
const operation = this.#begin(signal);
|
|
814
|
+
try {
|
|
815
|
+
const parameters = new URLSearchParams();
|
|
816
|
+
parameters.set("q", plan.q);
|
|
817
|
+
if (plan.queryBy.length > 0)
|
|
818
|
+
parameters.set("query_by", plan.queryBy.join(","));
|
|
819
|
+
if (plan.filterBy !== undefined)
|
|
820
|
+
parameters.set("filter_by", plan.filterBy);
|
|
821
|
+
if (plan.sortBy.length > 0) {
|
|
822
|
+
parameters.set("sort_by", plan.sortBy.map((sort) => `${sort.field}:${sort.direction ?? "asc"}`).join(","));
|
|
823
|
+
}
|
|
824
|
+
if (plan.facetBy.length > 0) {
|
|
825
|
+
parameters.set("facet_by", plan.facetBy.join(","));
|
|
826
|
+
parameters.set("max_facet_values", String(plan.maxFacetValues));
|
|
827
|
+
}
|
|
828
|
+
parameters.set("limit", String(plan.limit));
|
|
829
|
+
if (plan.offset > 0)
|
|
830
|
+
parameters.set("offset", String(plan.offset));
|
|
831
|
+
parameters.set("enable_curations", "false");
|
|
832
|
+
parameters.set("filter_curated_hits", "true");
|
|
833
|
+
const response = await this.#transport.request(this.#searchPath(), parameters, operation);
|
|
834
|
+
const decoded = decodeSearchResponse(response.body, {
|
|
835
|
+
limit: plan.limit,
|
|
836
|
+
offset: plan.offset,
|
|
837
|
+
facetFields: plan.facetBy,
|
|
838
|
+
maxFacetValues: plan.maxFacetValues,
|
|
839
|
+
maxRowBytes: this.#maxRowBytes,
|
|
840
|
+
});
|
|
841
|
+
operation.check();
|
|
842
|
+
return buildNativeResult(this.#collection, decoded, plan.limit, plan.offset);
|
|
843
|
+
}
|
|
844
|
+
finally {
|
|
845
|
+
operation.dispose();
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
#nativePlan(request) {
|
|
849
|
+
if (typeof request.q !== "string" || request.q.length === 0 || request.q.length > MAX_QUERY_TEXT || !request.q.isWellFormed()) {
|
|
850
|
+
throw new UnsupportedError("Typesense native search requires a non-empty bounded q");
|
|
851
|
+
}
|
|
852
|
+
for (let index = 0; index < request.q.length; index += 1) {
|
|
853
|
+
const code = request.q.charCodeAt(index);
|
|
854
|
+
if (code < 0x20 && code !== 0x09 || code === 0x7f)
|
|
855
|
+
throw new UnsupportedError("Typesense native q contains a control character");
|
|
856
|
+
}
|
|
857
|
+
const queryBy = request.queryBy ?? [];
|
|
858
|
+
if (request.q !== "*" && queryBy.length === 0) {
|
|
859
|
+
throw new UnsupportedError("Typesense native text search requires query_by");
|
|
860
|
+
}
|
|
861
|
+
if (queryBy.length > this.#maxColumns)
|
|
862
|
+
throw new UnsupportedError("Typesense query_by exceeds maxColumns");
|
|
863
|
+
const queryFields = new Set();
|
|
864
|
+
for (const field of queryBy) {
|
|
865
|
+
const name = nativeField(field, true);
|
|
866
|
+
if (queryFields.has(name))
|
|
867
|
+
throw new UnsupportedError("Typesense query_by fields must be unique");
|
|
868
|
+
queryFields.add(name);
|
|
869
|
+
}
|
|
870
|
+
const filterBy = request.filterBy;
|
|
871
|
+
if (filterBy !== undefined)
|
|
872
|
+
validateNativeFilter(filterBy, this.#maxFilterBytes);
|
|
873
|
+
const sortByInput = request.sortBy ?? [];
|
|
874
|
+
if (sortByInput.length > this.#maxSortFields)
|
|
875
|
+
throw new UnsupportedError("Typesense sort_by exceeds maxSortFields");
|
|
876
|
+
const sorts = [];
|
|
877
|
+
const sortFields = new Set();
|
|
878
|
+
for (const sort of sortByInput) {
|
|
879
|
+
const field = nativeField(sort.field, false);
|
|
880
|
+
if (field === "id" || sortFields.has(field))
|
|
881
|
+
throw new UnsupportedError("Typesense sort field is not supported");
|
|
882
|
+
sortFields.add(field);
|
|
883
|
+
if (sort.direction !== undefined && sort.direction !== "asc" && sort.direction !== "desc") {
|
|
884
|
+
throw new UnsupportedError("Typesense sort direction is not supported");
|
|
885
|
+
}
|
|
886
|
+
sorts.push({ field, direction: sort.direction ?? "asc" });
|
|
887
|
+
}
|
|
888
|
+
const facetByInput = request.facetBy ?? [];
|
|
889
|
+
if (facetByInput.length > this.#maxFacetFields)
|
|
890
|
+
throw new UnsupportedError("Typesense facet_by exceeds maxFacetFields");
|
|
891
|
+
const facets = [];
|
|
892
|
+
const facetFields = new Set();
|
|
893
|
+
for (const field of facetByInput) {
|
|
894
|
+
const name = nativeField(field, true);
|
|
895
|
+
if (facetFields.has(name))
|
|
896
|
+
throw new UnsupportedError("Typesense facet fields must be unique");
|
|
897
|
+
facetFields.add(name);
|
|
898
|
+
facets.push(name);
|
|
899
|
+
}
|
|
900
|
+
const limit = request.limit;
|
|
901
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > this.#maxQueryLimit || limit > this.#maxRowsPerPage) {
|
|
902
|
+
throw new UnsupportedError("Typesense native search requires a positive bounded limit");
|
|
903
|
+
}
|
|
904
|
+
const offset = request.offset ?? 0;
|
|
905
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > this.#maxQueryOffset) {
|
|
906
|
+
throw new UnsupportedError("Typesense native search offset is outside the configured bound");
|
|
907
|
+
}
|
|
908
|
+
let maxFacetValues = request.maxFacetValues ?? Math.min(10, this.#maxFacetValues);
|
|
909
|
+
if (!Number.isSafeInteger(maxFacetValues) || maxFacetValues < 1 || maxFacetValues > this.#maxFacetValues) {
|
|
910
|
+
throw new UnsupportedError("Typesense maxFacetValues is outside the configured bound");
|
|
911
|
+
}
|
|
912
|
+
if (facets.length === 0)
|
|
913
|
+
maxFacetValues = 0;
|
|
914
|
+
return { q: request.q, queryBy: Object.freeze(queryBy), filterBy, sortBy: Object.freeze(sorts), facetBy: Object.freeze(facets), maxFacetValues, limit, offset };
|
|
915
|
+
}
|
|
916
|
+
async #schema(operation) {
|
|
917
|
+
const response = await this.#transport.request(`/collections/${encodeURIComponent(this.#collection)}`, new URLSearchParams(), operation, [401, 403, 404]);
|
|
918
|
+
if (response.status !== 200) {
|
|
919
|
+
return { status: "unavailable", ...failureForStatus(response.status) };
|
|
920
|
+
}
|
|
921
|
+
return { status: "available", schema: parseCollectionSchema(response.body, this.#collection, this.#maxColumns) };
|
|
922
|
+
}
|
|
923
|
+
async #numericSchema(operation, ranges) {
|
|
924
|
+
const response = await this.#transport.request(`/collections/${encodeURIComponent(this.#collection)}`, new URLSearchParams(), operation, [401, 403, 404]);
|
|
925
|
+
if (response.status !== 200) {
|
|
926
|
+
throw new UnsupportedError("Typesense structured numeric ranges require readable collection schema");
|
|
927
|
+
}
|
|
928
|
+
const schema = parseCollectionSchema(response.body, this.#collection, this.#maxColumns);
|
|
929
|
+
const types = schemaFieldTypes(schema);
|
|
930
|
+
for (const field of ranges) {
|
|
931
|
+
const type = types.get(field);
|
|
932
|
+
if (type !== "int32" && type !== "int64" && type !== "float") {
|
|
933
|
+
throw new UnsupportedError("Typesense structured numeric ranges require a schema-declared scalar numeric field");
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return types;
|
|
937
|
+
}
|
|
938
|
+
#searchPath() {
|
|
939
|
+
return `/collections/${encodeURIComponent(this.#collection)}/documents/search`;
|
|
940
|
+
}
|
|
941
|
+
#record(hit, key, codec) {
|
|
942
|
+
if (hit.id !== key.id)
|
|
943
|
+
throw new TypeError("Typesense document does not match the requested key");
|
|
944
|
+
const data = { ...hit.document };
|
|
945
|
+
const decoded = codecOrIdentity(codec).decode(data);
|
|
946
|
+
return { key, exists: true, data: decoded };
|
|
947
|
+
}
|
|
948
|
+
#recordHit(hit, plan, codec) {
|
|
949
|
+
const data = Object.create(null);
|
|
950
|
+
if (plan.projection === undefined) {
|
|
951
|
+
for (const [field, value] of Object.entries(hit.document))
|
|
952
|
+
data[field] = value;
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
for (const field of plan.projection) {
|
|
956
|
+
if (Object.hasOwn(hit.document, field))
|
|
957
|
+
data[field] = hit.document[field];
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
const decoded = codecOrIdentity(codec).decode(data);
|
|
961
|
+
return { key: new Key(plan.collection, hit.id), exists: true, data: decoded };
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Validates a presented continuation without taking its lease. The caller
|
|
965
|
+
* activates it only after every remaining planning bound has passed.
|
|
966
|
+
*/
|
|
967
|
+
#prepareCursor(cursor, fingerprint, limit, filtered) {
|
|
968
|
+
if (cursor === undefined)
|
|
969
|
+
throw new TypesenseCursorError("malformed");
|
|
970
|
+
if (cursor.values.length !== 1)
|
|
971
|
+
throw new TypesenseCursorError("malformed");
|
|
972
|
+
const token = cursor.values[0];
|
|
973
|
+
if (typeof token !== "string" || !CURSOR_TOKEN.test(token) || byteLength(token) > this.#maxCursorBytes) {
|
|
974
|
+
throw new TypesenseCursorError("malformed");
|
|
975
|
+
}
|
|
976
|
+
const binding = this.#issuedCursors.get(token);
|
|
977
|
+
if (binding === undefined) {
|
|
978
|
+
throw new TypesenseCursorError(this.#consumedCursors.has(token) ? "used" : "foreign");
|
|
979
|
+
}
|
|
980
|
+
if (this.#activeCursors.has(token))
|
|
981
|
+
throw new TypesenseCursorError("used");
|
|
982
|
+
if (binding.fingerprint !== fingerprint || binding.limit !== limit || binding.filtered !== filtered) {
|
|
983
|
+
throw new TypesenseCursorError("changed-query");
|
|
984
|
+
}
|
|
985
|
+
if (!Number.isSafeInteger(binding.nextOffset) || binding.nextOffset < 0 || binding.nextOffset > this.#maxQueryOffset) {
|
|
986
|
+
throw new TypesenseCursorError("malformed");
|
|
987
|
+
}
|
|
988
|
+
return { token, binding };
|
|
989
|
+
}
|
|
990
|
+
/** Generates a token without mutating cursor state; this step may fail. */
|
|
991
|
+
#stageCursor() {
|
|
992
|
+
let token = `ts1_${randomId()}`;
|
|
993
|
+
while (this.#issuedCursors.has(token) || this.#activeCursors.has(token) || this.#consumedCursors.has(token)) {
|
|
994
|
+
token = `ts1_${randomId()}`;
|
|
995
|
+
}
|
|
996
|
+
if (byteLength(token) > this.#maxCursorBytes)
|
|
997
|
+
throw new RangeError("Typesense query cursor exceeds maxCursorBytes");
|
|
998
|
+
return token;
|
|
999
|
+
}
|
|
1000
|
+
/** One synchronous, non-throwing commit for a fully successful page. */
|
|
1001
|
+
#commitCursorPage(previous, issued) {
|
|
1002
|
+
// Retire the resumed handle before publishing its replacement: consuming a
|
|
1003
|
+
// handle frees the slot it occupied, so a resume at the cache bound must not
|
|
1004
|
+
// evict an unrelated, still-outstanding handle. All fallible work has
|
|
1005
|
+
// already completed, and these synchronous map updates cannot throw, so the
|
|
1006
|
+
// transaction stays atomic.
|
|
1007
|
+
if (previous !== undefined)
|
|
1008
|
+
this.#commitCursor(previous);
|
|
1009
|
+
if (issued !== undefined) {
|
|
1010
|
+
while (this.#issuedCursors.size >= this.#maxIssuedCursors) {
|
|
1011
|
+
const oldest = this.#issuedCursors.keys().next().value;
|
|
1012
|
+
if (typeof oldest !== "string")
|
|
1013
|
+
break;
|
|
1014
|
+
this.#issuedCursors.delete(oldest);
|
|
1015
|
+
}
|
|
1016
|
+
this.#issuedCursors.set(issued.token, issued.binding);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
#commitCursor(token) {
|
|
1020
|
+
this.#activeCursors.delete(token);
|
|
1021
|
+
this.#issuedCursors.delete(token);
|
|
1022
|
+
this.#consumedCursors.set(token, true);
|
|
1023
|
+
while (this.#consumedCursors.size > MAX_CONSERVED_CURSORS) {
|
|
1024
|
+
const oldest = this.#consumedCursors.keys().next().value;
|
|
1025
|
+
if (typeof oldest !== "string")
|
|
1026
|
+
break;
|
|
1027
|
+
this.#consumedCursors.delete(oldest);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
#scopeOf(headers) {
|
|
1031
|
+
if (this.#identityScope !== undefined)
|
|
1032
|
+
return digest(this.#cursorSecret, `explicit:${this.#identityScope}`);
|
|
1033
|
+
const entries = Object.entries(headers).map(([name, value]) => `${name.toLowerCase()}:${value}`).sort();
|
|
1034
|
+
return digest(this.#cursorSecret, `headers:${entries.join("\n")}`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
function failureForStatus(status) {
|
|
1038
|
+
const reason = status === 401 ? "unauthorized" : status === 403 ? "forbidden" : status === 404 ? "not-found" : "unavailable";
|
|
1039
|
+
return { reason, httpStatus: status };
|
|
1040
|
+
}
|
|
1041
|
+
function assertKey(key, collection) {
|
|
1042
|
+
if (key.parent !== undefined)
|
|
1043
|
+
throw new UnsupportedError("Typesense nested collection keys");
|
|
1044
|
+
if (key.collection !== collection)
|
|
1045
|
+
throw new UnsupportedError(`Typesense database is configured for collection ${collection}`);
|
|
1046
|
+
if (typeof key.id !== "string")
|
|
1047
|
+
throw new TypeError("Typesense keys require a string Typesense id");
|
|
1048
|
+
assertNativeID(key.id);
|
|
1049
|
+
}
|
|
1050
|
+
//# sourceMappingURL=database.js.map
|