@delali/narsil 0.1.14 → 0.1.15
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 +25 -15
- package/dist/server.d.ts +8 -0
- package/dist/server.mjs +170 -62
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -560,13 +560,13 @@ await narsil.optimizeVectors('docs', 'embedding')
|
|
|
560
560
|
|
|
561
561
|
## Hybrid search
|
|
562
562
|
|
|
563
|
-
Hybrid mode runs the full-text and vector searches in one query and fuses the two rankings.
|
|
563
|
+
Hybrid mode runs the full-text and vector searches in one query and fuses the two rankings. The vector side needs a query vector: pass a precomputed `value` array, or a `text` string that the index or instance embedding adapter turns into a vector. The `text` form needs an embedding adapter configured first, so passing `text` without one fails with `EMBEDDING_CONFIG_INVALID`; see [Embedding adapters](#embedding-adapters).
|
|
564
564
|
|
|
565
565
|
```ts
|
|
566
566
|
const results = await narsil.query('docs', {
|
|
567
567
|
mode: 'hybrid',
|
|
568
568
|
term: 'how do search engines scale',
|
|
569
|
-
vector: { field: 'embedding',
|
|
569
|
+
vector: { field: 'embedding', value: myQueryVector },
|
|
570
570
|
hybrid: { strategy: 'rrf', k: 60 },
|
|
571
571
|
limit: 10,
|
|
572
572
|
})
|
|
@@ -581,14 +581,14 @@ Two fusion strategies are available:
|
|
|
581
581
|
const weighted = await narsil.query('docs', {
|
|
582
582
|
mode: 'hybrid',
|
|
583
583
|
term: 'partition rebalancing',
|
|
584
|
-
vector: { field: 'embedding',
|
|
584
|
+
vector: { field: 'embedding', value: myQueryVector },
|
|
585
585
|
hybrid: { strategy: 'linear', alpha: 0.7 },
|
|
586
586
|
})
|
|
587
587
|
```
|
|
588
588
|
|
|
589
589
|
## Geosearch
|
|
590
590
|
|
|
591
|
-
Declare a `geopoint` field and insert documents with `{ lat, lon }` values. Geo conditions are filters, so
|
|
591
|
+
Declare a `geopoint` field and insert documents with `{ lat, lon }` values. Geo conditions are filters that refine a search, so a geo query pairs a `term` (or a vector query) with a location filter, and it composes with other filters, facets, and every other query feature. A query with only a location filter and no `term` matches nothing, the same way any other filter refines a term search.
|
|
592
592
|
|
|
593
593
|
```ts
|
|
594
594
|
await narsil.createIndex('stores', {
|
|
@@ -604,6 +604,7 @@ await narsil.insert('stores', {
|
|
|
604
604
|
})
|
|
605
605
|
|
|
606
606
|
const nearby = await narsil.query('stores', {
|
|
607
|
+
term: 'market',
|
|
607
608
|
filters: {
|
|
608
609
|
fields: {
|
|
609
610
|
location: {
|
|
@@ -614,6 +615,7 @@ const nearby = await narsil.query('stores', {
|
|
|
614
615
|
})
|
|
615
616
|
|
|
616
617
|
const inArea = await narsil.query('stores', {
|
|
618
|
+
term: 'market',
|
|
617
619
|
filters: {
|
|
618
620
|
fields: {
|
|
619
621
|
location: {
|
|
@@ -707,7 +709,7 @@ engine.registerEmbeddingAdapter('openai-small', myReplacementAdapter)
|
|
|
707
709
|
|
|
708
710
|
`registerEmbeddingAdapter` rebinds every index referencing that name, which lets you rotate credentials or swap models without recreating indexes.
|
|
709
711
|
|
|
710
|
-
###
|
|
712
|
+
### Bundled adapters
|
|
711
713
|
|
|
712
714
|
| Adapter | Package | Dependencies |
|
|
713
715
|
| --- | --- | --- |
|
|
@@ -733,7 +735,7 @@ When `embedBatch` is missing, Narsil falls back to parallel `embed()` calls with
|
|
|
733
735
|
|
|
734
736
|
## Persistence
|
|
735
737
|
|
|
736
|
-
Persistence stores serialized partitions through a pluggable adapter.
|
|
738
|
+
Persistence stores serialized partitions through a pluggable adapter. The package includes three adapters:
|
|
737
739
|
|
|
738
740
|
| Adapter | Import | Environment |
|
|
739
741
|
| --- | --- | --- |
|
|
@@ -848,7 +850,7 @@ Promotion emits the `workerPromote` event, and a crashed worker emits `workerCra
|
|
|
848
850
|
|
|
849
851
|
## Multi-instance invalidation
|
|
850
852
|
|
|
851
|
-
When several engine instances share one persistence backend, the invalidation adapter tells the others which partitions changed so they evict stale cache instead of serving old data.
|
|
853
|
+
When several engine instances share one persistence backend, the invalidation adapter tells the others which partitions changed so they evict stale cache instead of serving old data. The package includes two adapters, and `@delali/narsil/invalidation/noop` stubs the interface for single-instance deployments:
|
|
852
854
|
|
|
853
855
|
| Adapter | Import | Use case |
|
|
854
856
|
| --- | --- | --- |
|
|
@@ -986,7 +988,7 @@ console.log(memory.workers)
|
|
|
986
988
|
|
|
987
989
|
## Language support
|
|
988
990
|
|
|
989
|
-
Narsil
|
|
991
|
+
Narsil includes language modules for tokenization, stemming (Snowball algorithm), and stop word removal.
|
|
990
992
|
|
|
991
993
|
### Full support (tokenizer + stemmer + stop words)
|
|
992
994
|
|
|
@@ -998,17 +1000,25 @@ Chinese (Mandarin), Japanese
|
|
|
998
1000
|
|
|
999
1001
|
### African language support
|
|
1000
1002
|
|
|
1001
|
-
|
|
1003
|
+
Swahili has full stemmer support. Tokenization and stop word support is available for Dagbani, Ewe, Ga, Hausa, Igbo, Twi (Akan), Yoruba, and Zulu.
|
|
1002
1004
|
|
|
1003
|
-
Each language module is a separate entry point, so you only bundle the languages your application needs:
|
|
1005
|
+
Each language module is a separate entry point, so you only bundle the languages your application needs. Import the module and register it before you create an index that names it:
|
|
1004
1006
|
|
|
1005
1007
|
```ts
|
|
1006
|
-
import '@delali/narsil
|
|
1007
|
-
import '@delali/narsil/languages/
|
|
1008
|
-
import '@delali/narsil/languages/
|
|
1008
|
+
import { createNarsil, registerLanguage } from '@delali/narsil'
|
|
1009
|
+
import { french } from '@delali/narsil/languages/french'
|
|
1010
|
+
import { swahili } from '@delali/narsil/languages/swahili'
|
|
1011
|
+
import { twi } from '@delali/narsil/languages/twi'
|
|
1012
|
+
|
|
1013
|
+
registerLanguage(french)
|
|
1014
|
+
registerLanguage(swahili)
|
|
1015
|
+
registerLanguage(twi)
|
|
1016
|
+
|
|
1017
|
+
const narsil = await createNarsil()
|
|
1018
|
+
await narsil.createIndex('articles', { schema: { title: 'string' }, language: 'french' })
|
|
1009
1019
|
```
|
|
1010
1020
|
|
|
1011
|
-
|
|
1021
|
+
English is registered by default, so it needs no import. Naming a language you have not registered fails with `LANGUAGE_NOT_SUPPORTED`. `registerLanguage(module)` also adds a language of your own: a module carries a name, a tokenizer, a stemmer, and a stop word set, and any of the built-in language modules serves as a reference.
|
|
1012
1022
|
|
|
1013
1023
|
## HTTP server
|
|
1014
1024
|
|
|
@@ -1032,7 +1042,7 @@ const server = createServer(engine, {
|
|
|
1032
1042
|
await server.listen()
|
|
1033
1043
|
```
|
|
1034
1044
|
|
|
1035
|
-
`ServerOptions` also accepts `cors`, an `onRequest` hook for authentication, `limits` for body-size and
|
|
1045
|
+
`ServerOptions` also accepts `cors`, an `onRequest` hook for authentication, `limits` for body-size, concurrency, result-window, and fetch-count caps, `embeddingAdapters` that JSON index configs reference by name, a `taskStore` that keeps long-running task status across restarts, an `instanceId` for task recovery, and `allowInsecure` for trusted private networks. The server refuses to bind a non-loopback address without an `onRequest` hook, because the admin endpoints can destroy data.
|
|
1036
1046
|
|
|
1037
1047
|
The full surface:
|
|
1038
1048
|
|
package/dist/server.d.ts
CHANGED
|
@@ -45,6 +45,14 @@ interface ServerLimits {
|
|
|
45
45
|
importBatchSize?: number;
|
|
46
46
|
/** Maximum requests executing engine work at once; excess is shed with 503. Omit or 0 to disable. */
|
|
47
47
|
maxConcurrentRequests?: number;
|
|
48
|
+
/** Ceiling for a search's `limit`, `offset`, and `group.maxPerGroup`, so one
|
|
49
|
+
* request cannot ask for an unbounded result set. Excess → 400. Defaults to
|
|
50
|
+
* 10000, matching the cluster query result window. */
|
|
51
|
+
maxResultWindow?: number;
|
|
52
|
+
/** Ceiling for the number of document ids in one multi-get request, so one
|
|
53
|
+
* request cannot pull an unbounded number of documents. Excess → 400.
|
|
54
|
+
* Defaults to 10000. */
|
|
55
|
+
maxFetchDocuments?: number;
|
|
48
56
|
}
|
|
49
57
|
interface ServerOptions {
|
|
50
58
|
host?: string;
|
package/dist/server.mjs
CHANGED
|
@@ -118,11 +118,31 @@ var STATUS_TEXT = {
|
|
|
118
118
|
500: "500 Internal Server Error",
|
|
119
119
|
503: "503 Service Unavailable"
|
|
120
120
|
};
|
|
121
|
+
var STREAM_THRESHOLD = 64 * 1024;
|
|
121
122
|
function statusLine(status) {
|
|
122
123
|
return STATUS_TEXT[status] ?? `${status}`;
|
|
123
124
|
}
|
|
124
|
-
function
|
|
125
|
+
function streamBody(res, abort, body, status, contentType) {
|
|
126
|
+
if (abort.aborted) return;
|
|
127
|
+
const totalSize = body.byteLength;
|
|
128
|
+
res.cork(() => {
|
|
129
|
+
res.writeStatus(statusLine(status)).writeHeader("Content-Type", contentType);
|
|
130
|
+
const startOffset = res.getWriteOffset();
|
|
131
|
+
const [, done] = res.tryEnd(body, totalSize);
|
|
132
|
+
if (done) return;
|
|
133
|
+
res.onWritable((offset) => {
|
|
134
|
+
if (abort.aborted) return true;
|
|
135
|
+
const [retryOk] = res.tryEnd(body.subarray(offset - startOffset), totalSize);
|
|
136
|
+
return retryOk;
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function sendJson(res, data, status = 200, abort) {
|
|
125
141
|
const payload = JSON.stringify(data);
|
|
142
|
+
if (abort && payload !== void 0 && Buffer.byteLength(payload) >= STREAM_THRESHOLD) {
|
|
143
|
+
streamBody(res, abort, Buffer.from(payload, "utf8"), status, "application/json");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
126
146
|
res.cork(() => {
|
|
127
147
|
res.writeStatus(statusLine(status)).writeHeader("Content-Type", "application/json").end(payload);
|
|
128
148
|
});
|
|
@@ -134,10 +154,8 @@ function sendError(res, status, code, message, details) {
|
|
|
134
154
|
res.writeStatus(statusLine(status)).writeHeader("Content-Type", "application/json").end(payload);
|
|
135
155
|
});
|
|
136
156
|
}
|
|
137
|
-
function sendBinary(res, data, status = 200) {
|
|
138
|
-
res
|
|
139
|
-
res.writeStatus(statusLine(status)).writeHeader("Content-Type", "application/octet-stream").end(data);
|
|
140
|
-
});
|
|
157
|
+
function sendBinary(res, data, abort, status = 200) {
|
|
158
|
+
streamBody(res, abort, data, status, "application/octet-stream");
|
|
141
159
|
}
|
|
142
160
|
|
|
143
161
|
// src/server/handler-utils.ts
|
|
@@ -171,11 +189,15 @@ function respondError(ctx, err) {
|
|
|
171
189
|
}
|
|
172
190
|
function respondJson(ctx, data, status = 200) {
|
|
173
191
|
if (ctx.abort.aborted) return;
|
|
174
|
-
sendJson(ctx.res, data, status);
|
|
192
|
+
sendJson(ctx.res, data, status, ctx.abort);
|
|
175
193
|
}
|
|
176
194
|
function badRequest(res, message, details) {
|
|
177
195
|
sendError(res, 400, ServerErrorCodes.INVALID_REQUEST, message, details);
|
|
178
196
|
}
|
|
197
|
+
function rejectInvalid(ctx, failure) {
|
|
198
|
+
if (ctx.abort.aborted) return;
|
|
199
|
+
badRequest(ctx.res, failure.message, failure.details);
|
|
200
|
+
}
|
|
179
201
|
function serializeBatchResult(result) {
|
|
180
202
|
return {
|
|
181
203
|
succeeded: result.succeeded,
|
|
@@ -186,6 +208,98 @@ function serializeBatchResult(result) {
|
|
|
186
208
|
};
|
|
187
209
|
}
|
|
188
210
|
|
|
211
|
+
// src/server/validation.ts
|
|
212
|
+
function isPlainObject(value) {
|
|
213
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
214
|
+
}
|
|
215
|
+
function overWindow(field, value, maxWindow) {
|
|
216
|
+
if (typeof value === "number" && value > maxWindow) {
|
|
217
|
+
return {
|
|
218
|
+
message: `Field "${field}" exceeds the maximum result window of ${maxWindow}`,
|
|
219
|
+
details: { value, limit: maxWindow }
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
function validateQuery(params, maxWindow) {
|
|
225
|
+
if (params.group && typeof params.group === "object" && "reduce" in params.group) {
|
|
226
|
+
return {
|
|
227
|
+
message: 'Custom group reducers are not available over HTTP; use "group.fields" and "group.maxPerGroup" only'
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const rowBound = overWindow("limit", params.limit, maxWindow) ?? overWindow("offset", params.offset, maxWindow);
|
|
231
|
+
if (rowBound) return rowBound;
|
|
232
|
+
if (params.group && typeof params.group === "object") {
|
|
233
|
+
const grouped = overWindow("group.maxPerGroup", params.group.maxPerGroup, maxWindow);
|
|
234
|
+
if (grouped) return grouped;
|
|
235
|
+
}
|
|
236
|
+
if (params.facets && typeof params.facets === "object") {
|
|
237
|
+
for (const [field, facet] of Object.entries(params.facets)) {
|
|
238
|
+
if (facet && typeof facet === "object") {
|
|
239
|
+
const faceted = overWindow(`facets.${field}.limit`, facet.limit, maxWindow);
|
|
240
|
+
if (faceted) return faceted;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
function validateSuggest(params) {
|
|
247
|
+
if (typeof params.prefix !== "string") {
|
|
248
|
+
return { message: 'Field "prefix" is required and must be a string' };
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
function validateDocumentBody(body) {
|
|
253
|
+
if (!isPlainObject(body.document)) {
|
|
254
|
+
return { message: 'Field "document" is required and must be an object' };
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
function validateMultiGet(body, maxFetch) {
|
|
259
|
+
if (!Array.isArray(body.docIds)) {
|
|
260
|
+
return { message: 'Field "docIds" is required and must be an array of strings' };
|
|
261
|
+
}
|
|
262
|
+
if (body.docIds.length > maxFetch) {
|
|
263
|
+
return {
|
|
264
|
+
message: `Field "docIds" exceeds the maximum of ${maxFetch} ids per request`,
|
|
265
|
+
details: { count: body.docIds.length, limit: maxFetch }
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
function validateBatch(body) {
|
|
271
|
+
const action = body.action ?? "insert";
|
|
272
|
+
if (action === "insert") {
|
|
273
|
+
if (!Array.isArray(body.documents)) return { message: 'Batch insert requires a "documents" array' };
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
if (action === "update") {
|
|
277
|
+
if (!Array.isArray(body.updates)) return { message: 'Batch update requires an "updates" array' };
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
if (action === "delete") {
|
|
281
|
+
if (!Array.isArray(body.docIds)) return { message: 'Batch delete requires a "docIds" array' };
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
return { message: 'Field "action" must be one of "insert", "update", or "delete"' };
|
|
285
|
+
}
|
|
286
|
+
function validateCreateIndex(body) {
|
|
287
|
+
if (typeof body.name !== "string" || body.name.length === 0) {
|
|
288
|
+
return { message: 'Field "name" is required and must be a non-empty string' };
|
|
289
|
+
}
|
|
290
|
+
if (!isPlainObject(body.config)) {
|
|
291
|
+
return { message: 'Field "config" is required and must be an object' };
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
function validateRebalance(body) {
|
|
296
|
+
const target = body.targetPartitionCount;
|
|
297
|
+
if (typeof target !== "number" || !Number.isInteger(target) || target <= 0) {
|
|
298
|
+
return { message: 'Field "targetPartitionCount" is required and must be a positive integer' };
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
189
303
|
// src/server/handlers/admin.ts
|
|
190
304
|
function taskResponse(record) {
|
|
191
305
|
return { taskId: record.id, type: record.type, status: record.status };
|
|
@@ -204,7 +318,7 @@ function createAdminHandlers(deps) {
|
|
|
204
318
|
try {
|
|
205
319
|
const bytes = await engine.snapshot(ctx.params[0]);
|
|
206
320
|
if (ctx.abort.aborted) return;
|
|
207
|
-
sendBinary(ctx.res, bytes);
|
|
321
|
+
sendBinary(ctx.res, bytes, ctx.abort);
|
|
208
322
|
} catch (err) {
|
|
209
323
|
respondError(ctx, err);
|
|
210
324
|
}
|
|
@@ -262,11 +376,12 @@ function createAdminHandlers(deps) {
|
|
|
262
376
|
async function rebalance(ctx) {
|
|
263
377
|
const body = parseJsonOptional(ctx);
|
|
264
378
|
if (!body) return;
|
|
265
|
-
const
|
|
266
|
-
if (
|
|
267
|
-
|
|
379
|
+
const failure = validateRebalance(body);
|
|
380
|
+
if (failure) {
|
|
381
|
+
rejectInvalid(ctx, failure);
|
|
268
382
|
return;
|
|
269
383
|
}
|
|
384
|
+
const target = body.targetPartitionCount;
|
|
270
385
|
const name = ctx.params[0];
|
|
271
386
|
try {
|
|
272
387
|
requireIndexExists(name);
|
|
@@ -315,16 +430,14 @@ function createAdminHandlers(deps) {
|
|
|
315
430
|
}
|
|
316
431
|
|
|
317
432
|
// src/server/handlers/documents.ts
|
|
318
|
-
function isPlainObject(value) {
|
|
319
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
320
|
-
}
|
|
321
433
|
function createDocumentHandlers(deps) {
|
|
322
|
-
const { engine } = deps;
|
|
434
|
+
const { engine, limits } = deps;
|
|
323
435
|
async function insert(ctx) {
|
|
324
436
|
const body = parseJson(ctx);
|
|
325
437
|
if (!body) return;
|
|
326
|
-
|
|
327
|
-
|
|
438
|
+
const failure = validateDocumentBody(body);
|
|
439
|
+
if (failure) {
|
|
440
|
+
rejectInvalid(ctx, failure);
|
|
328
441
|
return;
|
|
329
442
|
}
|
|
330
443
|
try {
|
|
@@ -356,8 +469,9 @@ function createDocumentHandlers(deps) {
|
|
|
356
469
|
async function put(ctx) {
|
|
357
470
|
const body = parseJson(ctx);
|
|
358
471
|
if (!body) return;
|
|
359
|
-
|
|
360
|
-
|
|
472
|
+
const failure = validateDocumentBody(body);
|
|
473
|
+
if (failure) {
|
|
474
|
+
rejectInvalid(ctx, failure);
|
|
361
475
|
return;
|
|
362
476
|
}
|
|
363
477
|
const [name, id] = ctx.params;
|
|
@@ -376,8 +490,9 @@ function createDocumentHandlers(deps) {
|
|
|
376
490
|
async function patch(ctx) {
|
|
377
491
|
const body = parseJson(ctx);
|
|
378
492
|
if (!body) return;
|
|
379
|
-
|
|
380
|
-
|
|
493
|
+
const failure = validateDocumentBody(body);
|
|
494
|
+
if (failure) {
|
|
495
|
+
rejectInvalid(ctx, failure);
|
|
381
496
|
return;
|
|
382
497
|
}
|
|
383
498
|
try {
|
|
@@ -405,8 +520,9 @@ function createDocumentHandlers(deps) {
|
|
|
405
520
|
async function multiGet(ctx) {
|
|
406
521
|
const body = parseJson(ctx);
|
|
407
522
|
if (!body) return;
|
|
408
|
-
|
|
409
|
-
|
|
523
|
+
const failure = validateMultiGet(body, limits.maxFetchDocuments);
|
|
524
|
+
if (failure) {
|
|
525
|
+
rejectInvalid(ctx, failure);
|
|
410
526
|
return;
|
|
411
527
|
}
|
|
412
528
|
try {
|
|
@@ -421,28 +537,22 @@ function createDocumentHandlers(deps) {
|
|
|
421
537
|
async function batch(ctx) {
|
|
422
538
|
const body = parseJson(ctx);
|
|
423
539
|
if (!body) return;
|
|
540
|
+
const failure = validateBatch(body);
|
|
541
|
+
if (failure) {
|
|
542
|
+
rejectInvalid(ctx, failure);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
424
545
|
const action = body.action ?? "insert";
|
|
425
546
|
try {
|
|
426
|
-
if (action === "
|
|
427
|
-
|
|
428
|
-
badRequest(ctx.res, 'Batch insert requires a "documents" array');
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
respondJson(ctx, serializeBatchResult(await engine.insertBatch(ctx.params[0], body.documents, body.options)));
|
|
432
|
-
} else if (action === "update") {
|
|
433
|
-
if (!Array.isArray(body.updates)) {
|
|
434
|
-
badRequest(ctx.res, 'Batch update requires an "updates" array');
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
respondJson(ctx, serializeBatchResult(await engine.updateBatch(ctx.params[0], body.updates)));
|
|
547
|
+
if (action === "update") {
|
|
548
|
+
respondJson(ctx, serializeBatchResult(await engine.updateBatch(ctx.params[0], body.updates ?? [])));
|
|
438
549
|
} else if (action === "delete") {
|
|
439
|
-
|
|
440
|
-
badRequest(ctx.res, 'Batch delete requires a "docIds" array');
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
respondJson(ctx, serializeBatchResult(await engine.removeBatch(ctx.params[0], body.docIds)));
|
|
550
|
+
respondJson(ctx, serializeBatchResult(await engine.removeBatch(ctx.params[0], body.docIds ?? [])));
|
|
444
551
|
} else {
|
|
445
|
-
|
|
552
|
+
respondJson(
|
|
553
|
+
ctx,
|
|
554
|
+
serializeBatchResult(await engine.insertBatch(ctx.params[0], body.documents ?? [], body.options))
|
|
555
|
+
);
|
|
446
556
|
}
|
|
447
557
|
} catch (err) {
|
|
448
558
|
respondError(ctx, err);
|
|
@@ -627,12 +737,9 @@ function createIndexHandlers(deps) {
|
|
|
627
737
|
async function create(ctx) {
|
|
628
738
|
const body = parseJson(ctx);
|
|
629
739
|
if (!body) return;
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
}
|
|
634
|
-
if (typeof body.config !== "object" || body.config === null) {
|
|
635
|
-
badRequest(ctx.res, 'Field "config" is required and must be an object');
|
|
740
|
+
const failure = validateCreateIndex(body);
|
|
741
|
+
if (failure) {
|
|
742
|
+
rejectInvalid(ctx, failure);
|
|
636
743
|
return;
|
|
637
744
|
}
|
|
638
745
|
try {
|
|
@@ -684,22 +791,16 @@ function createIndexHandlers(deps) {
|
|
|
684
791
|
}
|
|
685
792
|
|
|
686
793
|
// src/server/handlers/search.ts
|
|
687
|
-
function rejectsCustomReducer(ctx, params) {
|
|
688
|
-
if (params.group && typeof params.group === "object" && "reduce" in params.group) {
|
|
689
|
-
badRequest(
|
|
690
|
-
ctx.res,
|
|
691
|
-
'Custom group reducers are not available over HTTP; use "group.fields" and "group.maxPerGroup" only'
|
|
692
|
-
);
|
|
693
|
-
return true;
|
|
694
|
-
}
|
|
695
|
-
return false;
|
|
696
|
-
}
|
|
697
794
|
function createSearchHandlers(deps) {
|
|
698
|
-
const { engine } = deps;
|
|
795
|
+
const { engine, limits } = deps;
|
|
699
796
|
async function search(ctx) {
|
|
700
797
|
const params = parseJson(ctx);
|
|
701
798
|
if (!params) return;
|
|
702
|
-
|
|
799
|
+
const failure = validateQuery(params, limits.maxResultWindow);
|
|
800
|
+
if (failure) {
|
|
801
|
+
rejectInvalid(ctx, failure);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
703
804
|
try {
|
|
704
805
|
respondJson(ctx, await engine.query(ctx.params[0], params));
|
|
705
806
|
} catch (err) {
|
|
@@ -709,7 +810,11 @@ function createSearchHandlers(deps) {
|
|
|
709
810
|
async function preflight(ctx) {
|
|
710
811
|
const params = parseJson(ctx);
|
|
711
812
|
if (!params) return;
|
|
712
|
-
|
|
813
|
+
const failure = validateQuery(params, limits.maxResultWindow);
|
|
814
|
+
if (failure) {
|
|
815
|
+
rejectInvalid(ctx, failure);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
713
818
|
try {
|
|
714
819
|
respondJson(ctx, await engine.preflight(ctx.params[0], params));
|
|
715
820
|
} catch (err) {
|
|
@@ -719,8 +824,9 @@ function createSearchHandlers(deps) {
|
|
|
719
824
|
async function suggest(ctx) {
|
|
720
825
|
const params = parseJson(ctx);
|
|
721
826
|
if (!params) return;
|
|
722
|
-
|
|
723
|
-
|
|
827
|
+
const failure = validateSuggest(params);
|
|
828
|
+
if (failure) {
|
|
829
|
+
rejectInvalid(ctx, failure);
|
|
724
830
|
return;
|
|
725
831
|
}
|
|
726
832
|
try {
|
|
@@ -1007,7 +1113,9 @@ function resolveLimits(limits) {
|
|
|
1007
1113
|
maxImportBytes: limits?.maxImportBytes ?? 100 * MB,
|
|
1008
1114
|
maxLineBytes: limits?.maxLineBytes ?? 4 * MB,
|
|
1009
1115
|
importBatchSize: limits?.importBatchSize ?? 1e3,
|
|
1010
|
-
maxConcurrentRequests: limits?.maxConcurrentRequests ?? 0
|
|
1116
|
+
maxConcurrentRequests: limits?.maxConcurrentRequests ?? 0,
|
|
1117
|
+
maxResultWindow: limits?.maxResultWindow ?? 1e4,
|
|
1118
|
+
maxFetchDocuments: limits?.maxFetchDocuments ?? 1e4
|
|
1011
1119
|
};
|
|
1012
1120
|
}
|
|
1013
1121
|
function resolveBuild(build) {
|
package/package.json
CHANGED