elasticlink 0.7.0-beta → 1.0.0-beta.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.
Files changed (42) hide show
  1. package/README.md +571 -828
  2. package/dist/aggregation.builder.d.ts.map +1 -1
  3. package/dist/aggregation.builder.js +33 -17
  4. package/dist/aggregation.types.d.ts +90 -5
  5. package/dist/aggregation.types.d.ts.map +1 -1
  6. package/dist/bulk.builder.d.ts +2 -1
  7. package/dist/bulk.builder.d.ts.map +1 -1
  8. package/dist/bulk.builder.js +6 -6
  9. package/dist/bulk.types.d.ts +4 -3
  10. package/dist/bulk.types.d.ts.map +1 -1
  11. package/dist/field.helpers.d.ts +92 -18
  12. package/dist/field.helpers.d.ts.map +1 -1
  13. package/dist/field.helpers.js +108 -57
  14. package/dist/field.types.d.ts +108 -15
  15. package/dist/field.types.d.ts.map +1 -1
  16. package/dist/index-management.builder.d.ts +8 -3
  17. package/dist/index-management.builder.d.ts.map +1 -1
  18. package/dist/index-management.builder.js +6 -1
  19. package/dist/index-management.types.d.ts +40 -30
  20. package/dist/index-management.types.d.ts.map +1 -1
  21. package/dist/index.d.ts +10 -9
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -2
  24. package/dist/mapping.builder.d.ts +9 -2
  25. package/dist/mapping.builder.d.ts.map +1 -1
  26. package/dist/mapping.types.d.ts +46 -3
  27. package/dist/mapping.types.d.ts.map +1 -1
  28. package/dist/multi-search.builder.d.ts +5 -4
  29. package/dist/multi-search.builder.d.ts.map +1 -1
  30. package/dist/multi-search.builder.js +15 -6
  31. package/dist/multi-search.types.d.ts +17 -3
  32. package/dist/multi-search.types.d.ts.map +1 -1
  33. package/dist/query.builder.d.ts.map +1 -1
  34. package/dist/query.builder.js +257 -192
  35. package/dist/query.types.d.ts +161 -20
  36. package/dist/query.types.d.ts.map +1 -1
  37. package/dist/settings.presets.d.ts +29 -10
  38. package/dist/settings.presets.d.ts.map +1 -1
  39. package/dist/settings.presets.js +27 -5
  40. package/dist/suggester.types.d.ts +54 -14
  41. package/dist/suggester.types.d.ts.map +1 -1
  42. package/package.json +10 -10
package/README.md CHANGED
@@ -1,7 +1,5 @@
1
1
  # elasticlink
2
2
 
3
- > **Beta**: elasticlink is in active beta. APIs may change before the first stable release.
4
-
5
3
  [![npm version](https://img.shields.io/npm/v/elasticlink.svg)](https://www.npmjs.com/package/elasticlink)
6
4
  [![Build Status](https://github.com/misterrodger/elasticlink/actions/workflows/ci.yml/badge.svg)](https://github.com/misterrodger/elasticlink/actions)
7
5
  [![codecov](https://codecov.io/gh/misterrodger/elasticlink/branch/main/graph/badge.svg)](https://codecov.io/gh/misterrodger/elasticlink)
@@ -9,30 +7,16 @@
9
7
 
10
8
  > **Type-safe, fluent API for Elasticsearch queries and index management**
11
9
 
12
- elasticlink simplifies building Elasticsearch queries and index management in TypeScript. Write type-checked queries that compile to valid Elasticsearch DSL with zero runtime overhead.
10
+ Define your Elasticsearch mappings once, then build queries with full IntelliSense and compile-time safety. `match()` only accepts text fields, `term()` only keyword fields, etc. Compiles to standard Elasticsearch DSL with no runtime overhead.
13
11
 
14
12
  ## Features
15
13
 
16
- - **Mapping-Aware Type Safety**: Define ES field types once via `mappings()`, get compile-time constraints `match()` only accepts text fields, `term()` only keyword fields, etc.
17
- - **Fluent API**: Chainable query builder with intuitive method names
18
- - **Zero Runtime Overhead**: Compiles directly to Elasticsearch DSL objects
19
- - **Well-Tested**: Comprehensive unit and integration test suite against live Elasticsearch
20
- - **Great DX**: Excellent IntelliSense and error messages
21
- - **Types from the source**: Types derived directly from `@elastic/elasticsearch` for accuracy and automatic alignment with Elasticsearch updates
22
-
23
- ## Compatibility
24
-
25
- | elasticlink | Node.js | Elasticsearch |
26
- |-------------|-------------|---------------|
27
- | 0.7.0-beta | 20, 22, 24 | 9.x (≥9.0.0) |
28
- | 0.6.0-beta | 20, 22, 24 | 9.x (≥9.0.0) |
29
- | 0.5.0-beta | 20, 22, 24 | 9.x (≥9.0.0) |
30
- | 0.4.0-beta | 20, 22, 24 | 9.x (≥9.0.0) |
31
- | 0.3.0-beta | 20, 22, 24 | 9.x (≥9.0.0) |
32
- | 0.2.0-beta | 20, 22 | 9.x (≥9.0.0) |
33
- | 0.1.0-beta | 20, 22 | 9.x (≥9.0.0) |
34
-
35
- Tested against the versions listed. Peer dependency is `@elastic/elasticsearch >=9.0.0`.
14
+ - **Mapping-Aware Type Safety**: define your ES mappings once with `mappings()`, and every query method constrains its field arguments to the correct type.
15
+ - **Types from the source**: autocomplete for field names, query options, and aggregation methods. Structural types are hand-rolled, leaf option types are derived from `@elastic/elasticsearch` types, so they stay current.
16
+ - **Fluent Chainable API**: readable query building with boolean logic, conditional branches (using `.when()`), aggregations, and pagination in a single chain.
17
+ - **No Runtime Overhead or Lock-In**: builds plain Elasticsearch DSL objects. Pass them to the official `@elastic/elasticsearch` client or any HTTP client.
18
+ - **Production-Ready Settings Presets**: helpers for production search, fast ingest, and index sort presets for common lifecycle stages, with full type support.
19
+ - **40+ Field Helpers**: `text()`, `keyword()`, `denseVector()`, `nested()`, and more with options for IDE tooltips.
36
20
 
37
21
  ## Installation
38
22
 
@@ -47,516 +31,443 @@ Requires Node.js 20+ and `@elastic/elasticsearch` 9.x as a peer dependency.
47
31
  ## Quick Start
48
32
 
49
33
  ```typescript
50
- import { query, mappings, text, keyword, float, type Infer } from 'elasticlink';
34
+ import { queryBuilder, mappings, text, keyword, float, type Infer } from 'elasticlink';
51
35
 
52
36
  // Define field types once — this is the single source of truth
53
37
  const productMappings = mappings({
54
38
  name: text(),
39
+ description: text(),
55
40
  price: float(),
56
- category: keyword(),
41
+ category: keyword()
57
42
  });
58
43
 
59
- // Derive a TS type from mappings (optional, for use elsewhere)
44
+ // Derive a TS type from mappings (optional, for use elsewhere in your application)
60
45
  type Product = Infer<typeof productMappings>;
61
46
 
62
47
  // Build a type-safe query — field constraints are enforced at compile time
63
- const q = query(productMappings)
64
- .match('name', 'laptop') // ✅ 'name' is a text field
48
+ const elasticQuery = queryBuilder(productMappings)
49
+ .match('name', 'laptop') // ✅ 'name' is a text field
65
50
  .range('price', { gte: 500, lte: 2000 })
51
+ .sort('price', 'asc')
66
52
  .from(0)
67
53
  .size(20)
68
54
  .build();
69
55
 
70
56
  // Send to Elasticsearch
71
- const response = await client.search({ index: 'products', ...q });
57
+ const response = await client.search({ index: 'products', ...elasticQuery });
72
58
  ```
73
59
 
74
- ## API Overview
75
-
76
- ### Core Query Methods
77
-
78
- #### Basic Queries
79
-
80
- - `match(field, value, options?)` - Full-text search
81
- - `multiMatch(fields, query, options?)` - Search multiple fields
82
- - `matchPhrase(field, query)` - Exact phrase matching
83
- - `matchPhrasePrefix(field, query, options?)` - Prefix phrase matching
84
- - `matchBoolPrefix(field, value, options?)` - Analyze and build a bool query from query terms, with the last term as a prefix (search-as-you-type)
85
- - `combinedFields(fields, query, options?)` - Search across multiple fields treating them as one combined field
86
- - `queryString(query, options?)` - Lucene query string syntax
87
- - `simpleQueryString(query, options?)` - Simplified query string syntax with limited operators
88
- - `moreLikeThis(fields, like, options?)` - Find documents similar to a given document or text
89
- - `term(field, value)` - Exact term matching
90
- - `terms(field, values)` - Multiple exact values
91
- - `range(field, conditions)` - Range queries (gte, lte, gt, lt)
92
- - `exists(field)` - Field existence check
93
- - `wildcard(field, pattern)` - Wildcard pattern matching
94
- - `prefix(field, value)` - Prefix matching
95
- - `fuzzy(field, value, options?)` - Typo tolerance
96
- - `ids(values)` - Match by document IDs
97
- - `matchAll()` - Match all documents
98
-
99
- #### Geo Queries
100
-
101
- - `geoDistance(field, center, options)` - Distance-based search
102
- - `geoBoundingBox(field, options)` - Bounding box search
103
- - `geoPolygon(field, options)` - Polygon search
104
-
105
- #### Vector Search (KNN)
106
-
107
- - `knn(field, queryVector, options)` - K-nearest neighbors semantic search
108
-
109
- #### Advanced Queries
110
-
111
- - `nested(path, fn, options?)` - Nested object queries
112
- - `regexp(field, pattern, options?)` - Regular expression matching
113
- - `constantScore(fn, options?)` - Constant scoring for filters
114
- - `script(options)` - Script-based filtering
115
- - `scriptScore(query, script, options?)` - Custom scoring with scripts
116
- - `percolate(options)` - Match documents against stored queries
117
-
118
- #### Suggestions & Autocomplete
119
-
120
- - `suggest(fn)` - Add query suggestions (term, phrase, completion)
121
- - `term(name, text, options)` - Term-level spell checking
122
- - `phrase(name, text, options)` - Phrase-level corrections
123
- - `completion(name, prefix, options)` - Fast autocomplete
124
-
125
- ### Boolean Logic
60
+ Boolean queries with aggregations:
126
61
 
127
62
  ```typescript
128
- query(productMappings)
63
+ const facetedSearch = queryBuilder(productMappings)
129
64
  .bool()
130
- .must(q => q.match('name', 'laptop')) // AND
131
- .filter(q => q.range('price', { gte: 500 }))
132
- .should(q => q.term('category', 'featured')) // OR
133
- .mustNot(q => q.term('category', 'discontinued')) // NOT
134
- .minimumShouldMatch(1)
65
+ .must((q) => q.match('name', 'gaming laptop', { operator: 'and' }))
66
+ .filter((q) => q.term('category', 'electronics'))
67
+ .filter((q) => q.range('price', { gte: 800, lte: 2000 }))
68
+ .aggs((agg) => agg.terms('by_category', 'category', { size: 10 }).avg('avg_price', 'price'))
69
+ .highlight(['name', 'description'])
70
+ .size(20)
135
71
  .build();
136
72
  ```
137
73
 
138
- ### Conditional Building
139
-
140
- Build queries dynamically based on runtime values. `.when(condition, fn)` — the chain is never broken. When the condition is falsy, the builder is returned unchanged, so no extra clauses or arrays are added. If you already called `.bool()`, that empty wrapper is preserved.
141
-
142
- `condition` resolves as: functions are called, booleans are used as-is, and any other value uses a nullish check (`!= null`) — so numeric `0` and empty string `''` are treated as truthy.
74
+ Type safety in action — wrong field types are caught at compile time:
143
75
 
144
76
  ```typescript
145
- const searchTerm: string | undefined = param.searchTerm;
146
- const minPrice: number | undefined = param.minPrice;
77
+ queryBuilder(productMappings).match('category', 'electronics');
78
+ // ^^^^^^^^^^
79
+ // TypeScript error: 'category' is a keyword field — use term(), not match()
147
80
 
148
- query(productMappings)
149
- .bool()
150
- .when(searchTerm, q => q.must(q2 => q2.match('name', searchTerm!)))
151
- .when(minPrice, q => q.filter(q2 => q2.range('price', { gte: minPrice! })))
152
- .build();
81
+ queryBuilder(productMappings).term('category', 'electronics'); // ✅ Correct
153
82
  ```
154
83
 
155
- > **Note:** TypeScript cannot narrow closure variables inside callbacks. Even though `.when(searchTerm, fn)` guarantees the value is defined inside `fn`, you still need a non-null assertion (`!`) to satisfy the type checker.
156
- >
157
- > **Note:** When the condition is false, any already-introduced `.bool()` wrapper is preserved. A `.bool().when(false, fn)` chain produces `{ query: { bool: {} } }` — not an error, just an empty bool clause.
84
+ ## Examples
85
+
86
+ More examples available in [src/\_\_tests\_\_/examples.test.ts](src/__tests__/examples.test.ts).
87
+
88
+ ### E-commerce Product Search
158
89
 
159
- ### Query Parameters
90
+ A complete search request: boolean query with must/filter/should, aggregations for facets and price ranges, highlights, `_source` filtering, and pagination.
160
91
 
161
92
  ```typescript
162
- query(productMappings)
163
- .match('name', 'laptop')
164
- .from(0) // Pagination offset
165
- .size(20) // Results per page
166
- .sort('price', 'asc') // Sort by field
167
- ._source(['name', 'price']) // Which fields to return
168
- .timeout('5s') // Query timeout
169
- .trackScores(true) // Enable scoring in filter context
170
- .trackTotalHits(true) // Track total hit count (or pass a number threshold)
171
- .explain(true) // Return scoring explanation
172
- .minScore(10) // Minimum relevance score
173
- .version(true) // Include document version in results
174
- .seqNoPrimaryTerm(true) // Include seq_no and primary_term for optimistic concurrency
93
+ const ecommerceMappings = mappings({
94
+ name: text(),
95
+ description: text(),
96
+ category: keyword(),
97
+ price: float(),
98
+ tags: keyword(),
99
+ in_stock: boolean()
100
+ });
101
+
102
+ const searchTerm = 'gaming laptop';
103
+ const category = 'electronics';
104
+ const minPrice = 800;
105
+ const maxPrice = 2000;
106
+
107
+ const result = queryBuilder(ecommerceMappings)
108
+ .bool()
109
+ .must((q) => q.match('name', searchTerm, { operator: 'and', boost: 2 }))
110
+ .should((q) => q.fuzzy('description', searchTerm, { fuzziness: 'AUTO' }))
111
+ .filter((q) => q.term('category', category))
112
+ .filter((q) => q.range('price', { gte: minPrice, lte: maxPrice }))
113
+ .filter((q) => q.term('in_stock', true))
114
+ .minimumShouldMatch(1)
115
+ .aggs((agg) =>
116
+ agg.terms('by_category', 'category', { size: 10 }).range('price_ranges', 'price', {
117
+ ranges: [{ to: 800 }, { from: 800, to: 1500 }, { from: 1500 }]
118
+ })
119
+ )
175
120
  .highlight(['name', 'description'], {
176
- fragment_size: 150, // Per-field options (fragment_size, number_of_fragments, etc.)
177
- pre_tags: ['<mark>'], // Top-level highlight options
121
+ fragment_size: 150,
122
+ pre_tags: ['<mark>'],
178
123
  post_tags: ['</mark>']
179
124
  })
125
+ ._source(['name', 'price', 'category', 'tags'])
126
+ .timeout('5s')
127
+ .from(0)
128
+ .size(20)
129
+ .sort('_score', 'desc')
180
130
  .build();
181
131
  ```
182
132
 
183
- ### Aggregations
184
-
185
- Aggregations can be combined with queries or used standalone:
133
+ <details>
134
+ <summary><b>Produced DSL</b> (click to expand)</summary>
186
135
 
187
- - **Bucket**: `terms()`, `dateHistogram()`, `histogram()`, `range()`
188
- - **Metric**: `avg()`, `sum()`, `min()`, `max()`, `cardinality()`, `percentiles()`, `stats()`, `valueCount()`
189
- - **Composition**: `subAgg()` for nested aggregations — attaches sub-aggregations to the **last** aggregation defined before the call; chain order matters
136
+ ```json
137
+ {
138
+ "query": {
139
+ "bool": {
140
+ "must": [{ "match": { "name": { "query": "gaming laptop", "operator": "and", "boost": 2 } } }],
141
+ "should": [{ "fuzzy": { "description": { "value": "gaming laptop", "fuzziness": "AUTO" } } }],
142
+ "filter": [
143
+ { "term": { "category": "electronics" } },
144
+ { "range": { "price": { "gte": 800, "lte": 2000 } } },
145
+ { "term": { "in_stock": true } }
146
+ ],
147
+ "minimum_should_match": 1
148
+ }
149
+ },
150
+ "aggs": {
151
+ "by_category": { "terms": { "field": "category", "size": 10 } },
152
+ "price_ranges": {
153
+ "range": {
154
+ "field": "price",
155
+ "ranges": [{ "to": 800 }, { "from": 800, "to": 1500 }, { "from": 1500 }]
156
+ }
157
+ }
158
+ },
159
+ "highlight": {
160
+ "fields": { "name": { "fragment_size": 150 }, "description": { "fragment_size": 150 } },
161
+ "pre_tags": ["<mark>"],
162
+ "post_tags": ["</mark>"]
163
+ },
164
+ "_source": ["name", "price", "category", "tags"],
165
+ "timeout": "5s",
166
+ "from": 0,
167
+ "size": 20,
168
+ "sort": [{ "_score": "desc" }]
169
+ }
170
+ ```
190
171
 
191
- ```typescript
192
- import { query, aggregations } from 'elasticlink';
172
+ </details>
193
173
 
194
- // Combined query + aggregations (inline aggs auto-thread mappings)
195
- const result = query(productMappings)
196
- .term('category', 'electronics')
197
- .aggs(agg =>
198
- agg
199
- .terms('by_category', 'category', { size: 10 })
200
- .avg('avg_price', 'price')
201
- )
202
- .size(20)
203
- .build();
174
+ ### Dynamic Search with Conditional Filters
204
175
 
205
- // Standalone aggregations (no query) — use query(mappings, false)
206
- const aggsOnly = query(productMappings, false)
207
- .aggs(agg =>
208
- agg
209
- .terms('by_category', 'category')
210
- .subAgg(sub =>
211
- sub.avg('avg_price', 'price').max('max_price', 'price')
212
- )
213
- )
214
- .size(0) // Common pattern: size=0 when only wanting agg results
215
- .build();
176
+ Build queries dynamically based on runtime values. `.when(condition, fn)`when the condition is falsy, the builder is returned unchanged.
216
177
 
217
- // Standalone aggregation builder (for manual composition)
218
- const standaloneAgg = aggregations(productMappings)
219
- .avg('avg_price', 'price')
220
- .terms('by_category', 'category', { size: 10 })
221
- .build();
178
+ ```typescript
179
+ const buildDynamicQuery = (filters: SearchFilters) => {
180
+ return queryBuilder(productMappings)
181
+ .bool()
182
+ .when(filters.searchTerm, (q) => q.must((q2) => q2.match('name', filters.searchTerm!, { boost: 2 })))
183
+ .when(filters.category, (q) => q.filter((q2) => q2.term('category', filters.category!)))
184
+ .when(filters.minPrice != null && filters.maxPrice != null, (q) =>
185
+ q.filter((q2) => q2.range('price', { gte: filters.minPrice!, lte: filters.maxPrice! }))
186
+ )
187
+ .from(filters.offset || 0)
188
+ .size(filters.limit || 20)
189
+ .build();
190
+ };
222
191
  ```
223
192
 
224
- ### Vector Search & Semantic Search
193
+ ### Aggregations Portfolio Analytics
225
194
 
226
- KNN (k-nearest neighbors) queries enable semantic search using vector embeddings from machine learning models.
195
+ Terms + sub-aggregation + date histogram in one request.
227
196
 
228
197
  ```typescript
229
- import { query, mappings, text, keyword, float, denseVector } from 'elasticlink';
230
-
231
- const productWithEmbeddingMappings = mappings({
198
+ const instrumentMappings = mappings({
232
199
  name: text(),
233
- description: text(),
200
+ asset_class: keyword(),
201
+ sector: keyword(),
234
202
  price: float(),
235
- category: keyword(),
236
- embedding: denseVector({ dims: 384 }),
203
+ yield_rate: float(),
204
+ listed_date: date()
237
205
  });
238
206
 
239
- // Basic semantic search
240
- const searchEmbedding = [0.23, 0.45, 0.67, 0.12, 0.89]; // From your ML model
241
-
242
- const result = query(productWithEmbeddingMappings)
243
- .knn('embedding', searchEmbedding, {
244
- k: 10, // Return top 10 nearest neighbors
245
- num_candidates: 100 // Consider 100 candidates per shard
246
- })
247
- .size(10)
248
- .build();
249
-
250
- // Semantic search with filters
251
- const filtered = query(productWithEmbeddingMappings)
252
- .knn('embedding', searchEmbedding, {
253
- k: 20,
254
- num_candidates: 200,
255
- filter: {
256
- bool: {
257
- must: [{ term: { category: 'electronics' } }],
258
- filter: [{ range: { price: { gte: 100, lte: 1000 } } }]
259
- }
260
- },
261
- boost: 1.2, // Boost relevance scores
262
- similarity: 0.7 // Minimum similarity threshold
263
- })
264
- .size(20)
265
- .build();
266
-
267
- // Hybrid search with aggregations
268
- const hybridSearch = query(productWithEmbeddingMappings)
269
- .knn('embedding', searchEmbedding, {
270
- k: 100,
271
- num_candidates: 1000,
272
- filter: { term: { category: 'electronics' } }
273
- })
274
- .aggs(agg =>
207
+ const result = queryBuilder(instrumentMappings)
208
+ .bool()
209
+ .filter((q) => q.term('asset_class', 'fixed-income'))
210
+ .filter((q) => q.range('yield_rate', { gte: 3.0 }))
211
+ .aggs((agg) =>
275
212
  agg
276
- .terms('categories', 'category', { size: 10 })
277
- .range('price_ranges', 'price', {
278
- ranges: [
279
- { to: 100 },
280
- { from: 100, to: 500 },
281
- { from: 500 }
282
- ]
213
+ .terms('by_sector', 'sector', { size: 10 })
214
+ .subAgg((sub) => sub.avg('avg_yield', 'yield_rate').max('max_price', 'price'))
215
+ .dateHistogram('listings_over_time', 'listed_date', {
216
+ interval: 'quarter',
217
+ min_doc_count: 1
283
218
  })
219
+ .subAgg((sub) => sub.percentiles('yield_percentiles', 'yield_rate', { percents: [25, 50, 75, 95] }))
284
220
  )
285
- .size(20)
221
+ .size(0)
286
222
  .build();
287
223
  ```
288
224
 
289
- **Common Vector Dimensions:**
290
-
291
- - **384-768**: Sentence transformers (all-MiniLM, all-mpnet)
292
- - **512**: Image embeddings (ResNet, ViT)
293
- - **1536**: OpenAI text-embedding-ada-002
294
- - **3072**: OpenAI text-embedding-3-large
295
-
296
- **Dense Vector Field Mapping Example:**
225
+ <details>
226
+ <summary><b>Produced DSL</b> (click to expand)</summary>
297
227
 
298
- ```typescript
299
- import type { DenseVectorOptions } from 'elasticlink';
300
-
301
- const mapping: DenseVectorOptions = {
302
- dims: 384,
303
- index: true,
304
- similarity: 'cosine', // 'l2_norm', 'dot_product', or 'cosine'
305
- index_options: {
306
- type: 'hnsw',
307
- m: 16,
308
- ef_construction: 100
309
- }
310
- };
228
+ ```json
229
+ {
230
+ "query": {
231
+ "bool": {
232
+ "filter": [{ "term": { "asset_class": "fixed-income" } }, { "range": { "yield_rate": { "gte": 3.0 } } }]
233
+ }
234
+ },
235
+ "aggs": {
236
+ "by_sector": {
237
+ "terms": { "field": "sector", "size": 10 },
238
+ "aggs": {
239
+ "avg_yield": { "avg": { "field": "yield_rate" } },
240
+ "max_price": { "max": { "field": "price" } }
241
+ }
242
+ },
243
+ "listings_over_time": {
244
+ "date_histogram": { "field": "listed_date", "interval": "quarter", "min_doc_count": 1 },
245
+ "aggs": {
246
+ "yield_percentiles": { "percentiles": { "field": "yield_rate", "percents": [25, 50, 75, 95] } }
247
+ }
248
+ }
249
+ },
250
+ "size": 0
251
+ }
311
252
  ```
312
253
 
313
- ### Script Queries & Custom Scoring
254
+ </details>
314
255
 
315
- **Note:** Scripts must be enabled in Elasticsearch configuration. Use with caution as they can impact performance.
256
+ ### Geospatial Search
316
257
 
317
258
  ```typescript
318
- import { query, mappings, text, keyword, float, long } from 'elasticlink';
319
-
320
- const scoredProductMappings = mappings({
259
+ const restaurantMappings = mappings({
321
260
  name: text(),
322
- price: float(),
323
- popularity: long(),
324
- quality_score: float(),
261
+ cuisine: keyword(),
262
+ location: geoPoint(),
263
+ rating: float()
325
264
  });
326
265
 
327
- // Script-based filtering
328
- const filtered = query(scoredProductMappings)
266
+ const result = queryBuilder(restaurantMappings)
329
267
  .bool()
330
- .must((q) => q.match('name', 'laptop'))
331
- .filter((q) =>
332
- q.script({
333
- source: "doc['price'].value > params.threshold",
334
- params: { threshold: 500 }
335
- })
336
- )
337
- .build();
338
-
339
- // Custom scoring with script_score
340
- const customScored = query(scoredProductMappings)
341
- .scriptScore(
342
- (q) => q.match('name', 'smartphone'),
343
- {
344
- source: "_score * Math.log(2 + doc['popularity'].value)",
345
- lang: 'painless'
346
- }
347
- )
268
+ .filter((q) => q.term('cuisine', 'italian'))
269
+ .filter((q) => q.geoDistance('location', { lat: 40.7128, lon: -74.006 }, { distance: '5km' }))
270
+ .sort('rating', 'desc')
348
271
  .size(20)
349
272
  .build();
350
273
  ```
351
274
 
352
- **Script Languages:**
353
-
354
- - **painless** (default): Elasticsearch's primary scripting language
355
- - **expression**: Fast, limited expression language
356
- - **mustache**: Template-based scripting
275
+ ## TypeScript Support
357
276
 
358
- ### Percolate Queries
277
+ elasticlink provides mapping-aware TypeScript safety:
359
278
 
360
- Percolate queries enable reverse search - match documents against stored queries. Perfect for alerting, content classification, and saved searches.
279
+ - **Field-Type Constraints**: enforced at compile time across all methods — `match()` only accepts text fields, `term()` only keyword/numeric fields, `sort()` only sortable fields (keyword, numeric, date, boolean, ip), `collapse()` only keyword/numeric fields, `highlight()` only text/keyword fields
280
+ - **Field Autocomplete**: IntelliSense knows your field names and their types
281
+ - **`Infer<S>`**: Derive TS document types from your mappings schema for use elsewhere in your application
282
+ - **Exported Field Group Types**: `TextFields<M>`, `KeywordFields<M>`, `NumericFields<M>`, `DateFields<M>`, `BooleanFields<M>`, `GeoPointFields<M>`, `GeoShapeFields<M>`, `VectorFields<M>`, `DenseVectorFields<M>`, `SparseVectorFields<M>`, `RankFeatureFields<M>`, `CompletionFields<M>`, `IpFields<M>`, `FieldsOfType<M, T>`, and others are exported for use in your own typed utilities
283
+ - **Exported Builder Types**: `QueryBuilder<M>`, `ClauseBuilder<M>`, `RootAggregationBuilder<M>`, `NestedAggregationBuilder<M>`, `NestedEntryBuilder<M>`, `MSearchBuilder<M>`, `BulkBuilder<T>`, `SuggesterBuilder<M>`, `IndexBuilder<M>`, `AnalysisConfig`, `IndexSortFieldSpec`, `MSearchHeader`, `MSearchRequestParams`
361
284
 
362
285
  ```typescript
363
- import { query, mappings, keyword, percolator } from 'elasticlink';
286
+ import { queryBuilder, mappings, text, keyword, integer, type Infer } from 'elasticlink';
364
287
 
365
- const alertRuleMappings = mappings({
366
- query: percolator(),
367
- name: keyword(),
368
- severity: keyword(),
288
+ const userMappings = mappings({
289
+ name: text(),
290
+ email: keyword(),
291
+ age: integer()
369
292
  });
370
293
 
371
- // Match document against stored queries
372
- const alerts = query(alertRuleMappings)
373
- .percolate({
374
- field: 'query',
375
- document: {
376
- level: 'ERROR',
377
- message: 'Database connection failed',
378
- timestamp: '2024-01-15T10:30:00Z'
379
- }
380
- })
381
- .size(100)
382
- .build();
383
- ```
294
+ type User = Infer<typeof userMappings>;
295
+ // => { name: string; email: string; age: number }
384
296
 
385
- **Common Use Cases:**
297
+ // 'name' is a text field — match() accepts it
298
+ const q1 = queryBuilder(userMappings).match('name', 'John').build();
299
+
300
+ // ❌ TypeScript error: 'email' is keyword, not text — use term() instead
301
+ const q2 = queryBuilder(userMappings).match('email', 'john@example.com').build();
386
302
 
387
- - **Alerting:** Match events against alert rules
388
- - **Content Classification:** Categorize documents in real-time
389
- - **Saved Searches:** Notify users when new content matches their searches
390
- - **Monitoring:** Trigger actions based on metric thresholds
303
+ // Correct: use term() for keyword fields
304
+ const q3 = queryBuilder(userMappings).term('email', 'john@example.com').build();
305
+ ```
391
306
 
392
- ### Query Suggestions & Autocomplete
307
+ ## Settings Presets
393
308
 
394
- Elasticsearch Suggesters provide spell-checking, phrase correction, and autocomplete functionality. Perfect for search-as-you-type experiences and fixing user typos.
309
+ Ready-made index settings for common lifecycle stages. Use with `.settings()` on `indexBuilder()` or pass directly to the ES `_settings` API.
395
310
 
396
311
  ```typescript
397
- import { query, suggest, mappings, text, keyword, completion } from 'elasticlink';
312
+ import { indexBuilder, productionSearchSettings, indexSortSettings, fastIngestSettings } from 'elasticlink';
398
313
 
399
- const searchableMappings = mappings({
400
- name: text(),
401
- description: text(),
402
- suggest_field: completion(), // Must be type: completion
403
- });
314
+ // Create index with production settings
315
+ const indexConfig = indexBuilder().mappings(myMappings).settings(productionSearchSettings()).build();
404
316
 
405
- // Term suggester - Fix typos in individual terms
406
- const termSuggestions = suggest(searchableMappings)
407
- .term('name-suggestions', 'laptpo', {
408
- field: 'name',
409
- size: 5,
410
- suggest_mode: 'popular', // 'missing' | 'popular' | 'always'
411
- string_distance: 'levenshtein',
412
- max_edits: 2
317
+ // Index-time sort for compression and early termination
318
+ const sortedConfig = indexBuilder()
319
+ .mappings(myMappings)
320
+ .settings({
321
+ ...productionSearchSettings(),
322
+ index: indexSortSettings({ timestamp: 'desc', status: 'asc' })
413
323
  })
414
324
  .build();
415
325
 
416
- // Completion suggester - Fast autocomplete
417
- const autocomplete = suggest(searchableMappings)
418
- .completion('autocomplete', 'lap', {
419
- field: 'suggest_field',
420
- size: 10,
421
- skip_duplicates: true,
422
- fuzzy: {
423
- fuzziness: 'AUTO',
424
- transpositions: true,
425
- min_length: 3,
426
- prefix_length: 1
427
- }
428
- })
429
- .build();
326
+ // Before bulk ingest disables refresh, removes replicas, async translog
327
+ await client.indices.putSettings({ index: 'my-index', body: fastIngestSettings() });
430
328
 
431
- // Combine with query - Search with autocomplete
432
- const searchWithSuggestions = query(searchableMappings)
433
- .match('name', 'laptpo')
434
- .suggest((s) =>
435
- s.term('spelling-correction', 'laptpo', {
436
- field: 'name',
437
- size: 3,
438
- suggest_mode: 'popular'
439
- })
440
- )
441
- .size(20)
442
- .build();
329
+ // Perform bulk ingest...
330
+
331
+ // Restore production settings afterward
332
+ await client.indices.putSettings({ index: 'my-index', body: productionSearchSettings() });
333
+ await client.indices.refresh({ index: 'my-index' });
443
334
  ```
444
335
 
445
- **Suggester Types:**
336
+ | Preset | Purpose |
337
+ | -------------------------------------- | --------------------------------------------------------------------------- |
338
+ | `productionSearchSettings(overrides?)` | Balanced production defaults — 1 replica, 5s refresh |
339
+ | `indexSortSettings(fields)` | Configure index-time sort order for disk compression and early termination |
340
+ | `fastIngestSettings(overrides?)` | Maximum indexing throughput — async translog, no replicas, refresh disabled |
446
341
 
447
- - **Term:** Suggests corrections for individual terms based on edit distance
448
- - **Phrase:** Suggests corrections for entire phrases using n-gram language models
449
- - **Completion:** Fast prefix-based autocomplete (requires `completion` field type)
342
+ All presets accept an optional `overrides` argument typed as `Partial<IndicesIndexSettings>`. `fastIngestSettings` deep-merges the `translog` key so individual translog overrides don't clobber the other defaults.
450
343
 
451
- ### Multi-Search API
344
+ ## API Overview
452
345
 
453
- Batch multiple search requests in a single API call using the NDJSON format.
346
+ ### Query Builder
454
347
 
455
- ```typescript
456
- import { query, msearch } from 'elasticlink';
348
+ `queryBuilder(schema, includeQuery?)` creates a fluent, immutable query builder. Every chain method returns a new builder instance.
457
349
 
458
- const laptopQuery = query(productMappings)
459
- .match('name', 'laptop')
460
- .range('price', { gte: 500, lte: 2000 })
350
+ ```typescript
351
+ queryBuilder(productMappings)
352
+ .bool()
353
+ .must((q) => q.match('name', 'laptop'))
354
+ .filter((q) => q.range('price', { gte: 500 }))
355
+ .should((q) => q.term('category', 'featured'))
356
+ .mustNot((q) => q.term('category', 'discontinued'))
357
+ .minimumShouldMatch(1)
461
358
  .build();
359
+ ```
462
360
 
463
- const phoneQuery = query(productMappings)
464
- .match('name', 'smartphone')
465
- .range('price', { gte: 300, lte: 1000 })
466
- .build();
361
+ #### Query Methods
467
362
 
468
- // Build as NDJSON string for Elasticsearch API
469
- const ndjson = msearch(productMappings)
470
- .addQuery(laptopQuery, { index: 'products', preference: '_local' })
471
- .addQuery(phoneQuery, { index: 'products', preference: '_local' })
472
- .build();
363
+ | Category | Methods | Description |
364
+ | -------------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
365
+ | **Full-text** | `match`, `multiMatch`, `matchPhrase`, `matchPhrasePrefix`, `matchBoolPrefix`, `combinedFields` | Analyzed text search — field must be a text field |
366
+ | **Term-level** | `term`, `terms`, `prefix`, `wildcard`, `exists`, `ids`, `matchAll`, `matchNone` | Exact value matching — field must be keyword, numeric, date, boolean, or ip |
367
+ | **Range** | `range` | Range queries (`gte`, `lte`, `gt`, `lt`) on numeric, date, keyword, or ip fields |
368
+ | **Fuzzy & Pattern** | `fuzzy`, `regexp`, `queryString`, `simpleQueryString`, `moreLikeThis` | Typo tolerance, regex, Lucene syntax, and similarity search |
369
+ | **Boolean** | `bool` → `must`, `mustNot`, `should`, `filter`, `minimumShouldMatch` | Combine clauses with AND/OR/NOT/filter logic |
370
+ | **Conditional** | `when(condition, fn)` | Dynamic query building — skips the branch when condition is falsy |
371
+ | **Geo** | `geoDistance`, `geoBoundingBox`, `geoPolygon`, `geoShape` | Spatial queries — field must be geo_point or geo_shape |
372
+ | **Vector & Relevance** | `knn`, `sparseVector`, `rankFeature`, `distanceFeature` | Semantic/vector search, rank features, and decay functions |
373
+ | **Nested & Structure** | `nested`, `constantScore`, `hasChild`, `hasParent`, `parentId` | Query nested objects, parent/child joins, or wrap in constant-score filter |
374
+ | **Sorting & Pagination** | `from`, `size`, `sort`, `searchAfter`, `collapse` | Result ordering, pagination, and field collapsing |
375
+ | **Source & Fields** | `_source`, `sourceIncludes`, `sourceExcludes`, `fields`, `docValueFields`, `storedFields` | Control which fields are returned |
376
+ | **Highlighting & Scoring** | `highlight`, `rescore`, `indicesBoost`, `minScore`, `trackScores`, `explain` | Result highlighting, rescoring, and relevance tuning |
377
+ | **Positional** | `intervals`, `spanTerm`, `spanNear`, `spanOr`, `spanNot`, `spanFirst`, `spanContaining`, `spanWithin`, `spanMultiTerm`, `spanFieldMasking` | Term proximity and ordering queries |
378
+ | **Advanced** | `postFilter`, `scriptFields`, `runtimeMappings`, `script`, `scriptScore`, `functionScore`, `percolate` | Post-query filtering, computed fields, and custom scoring |
379
+ | **Configuration** | `timeout`, `preference`, `pit`, `terminateAfter`, `version`, `seqNoPrimaryTerm`, `trackTotalHits` | Search execution options |
380
+ | **Inline builders** | `aggs(fn)`, `suggest(fn)` | Attach aggregations or suggesters to the query |
381
+ | **Output** | `build()` | Returns the final Elasticsearch DSL object |
473
382
 
474
- // Or build as array of objects
475
- const array = msearch(productMappings)
476
- .addQuery(laptopQuery, { index: 'products' })
477
- .addQuery(phoneQuery, { index: 'products' })
478
- .buildArray();
479
- ```
383
+ All query methods accept options from their corresponding `@elastic/elasticsearch` type. See [`query.types.ts`](src/query.types.ts) for complete signatures.
480
384
 
481
- **NDJSON Format (for Elasticsearch `_msearch` endpoint):**
385
+ #### Conditional Building
482
386
 
483
- ```ndjson
484
- {"index":"products","preference":"_local"}
485
- {"query":{"bool":{"must":[{"match":{"name":"laptop"}},{"range":{"price":{"gte":500,"lte":2000}}}]}}}
486
- {"index":"products","preference":"_local"}
487
- {"query":{"bool":{"must":[{"match":{"name":"smartphone"}},{"range":{"price":{"gte":300,"lte":1000}}}]}}}
387
+ `.when(condition, fn)` — the chain is never broken. When the condition is falsy, the builder is returned unchanged. `condition` resolves as: functions are called, booleans are used as-is, and any other value uses a nullish check (`!= null`) — so numeric `0` and empty string `''` are treated as truthy.
488
388
 
389
+ ```typescript
390
+ const searchTerm: string | undefined = param.searchTerm;
391
+ const minPrice: number | undefined = param.minPrice;
392
+
393
+ queryBuilder(productMappings)
394
+ .bool()
395
+ .when(searchTerm, (q) => q.must((q2) => q2.match('name', searchTerm!)))
396
+ .when(minPrice, (q) => q.filter((q2) => q2.range('price', { gte: minPrice! })))
397
+ .build();
489
398
  ```
490
399
 
491
- **Header Options:**
400
+ > **Note:** TypeScript cannot narrow closure variables inside callbacks. Even though `.when(searchTerm, fn)` guarantees the value is defined inside `fn`, you still need a non-null assertion (`!`).
492
401
 
493
- - `index`: Target index/indices (string or array)
494
- - `routing`: Routing value for sharding
495
- - `preference`: Node preference (\_local, \_primary, etc.)
496
- - `search_type`: Search type (dfs_query_then_fetch, etc.)
402
+ #### Query Parameters
497
403
 
498
- ### Bulk Operations
404
+ ```typescript
405
+ queryBuilder(productMappings)
406
+ .match('name', 'laptop')
407
+ .from(0) // Pagination offset
408
+ .size(20) // Results per page
409
+ .sort('price', 'asc') // Sort by field
410
+ ._source(['name', 'price']) // Which fields to return
411
+ .timeout('5s') // Query timeout
412
+ .trackScores(true) // Enable scoring in filter context
413
+ .trackTotalHits(true) // Track total hit count (or pass a number threshold)
414
+ .explain(true) // Return scoring explanation
415
+ .minScore(10) // Minimum relevance score
416
+ .version(true) // Include document version in results
417
+ .seqNoPrimaryTerm(true) // Include seq_no and primary_term for optimistic concurrency
418
+ .highlight(['name', 'description'], {
419
+ fragment_size: 150,
420
+ pre_tags: ['<mark>'],
421
+ post_tags: ['</mark>']
422
+ })
423
+ .build();
424
+ ```
499
425
 
500
- Batch create, index, update, and delete operations efficiently.
426
+ ### Aggregations
501
427
 
502
- ```typescript
503
- import { bulk, mappings, keyword, text, float } from 'elasticlink';
428
+ Aggregations can be combined with queries via `.aggs()` or used standalone with the `aggregations()` builder.
504
429
 
505
- const productMappings = mappings({
506
- id: keyword(),
507
- name: text(),
508
- price: float(),
509
- category: keyword(),
510
- });
430
+ ```typescript
431
+ import { queryBuilder, aggregations } from 'elasticlink';
511
432
 
512
- const bulkOp = bulk(productMappings)
513
- // Index (create or replace)
514
- .index(
515
- { id: '1', name: 'Laptop Pro', price: 1299, category: 'electronics' },
516
- { _index: 'products', _id: '1' }
517
- )
518
- // Create (fail if exists)
519
- .create(
520
- { id: '2', name: 'Wireless Mouse', price: 29, category: 'accessories' },
521
- { _index: 'products', _id: '2' }
433
+ // Inline aggregations (most common)
434
+ const result = queryBuilder(productMappings)
435
+ .term('category', 'electronics')
436
+ .aggs((agg) =>
437
+ agg
438
+ .terms('by_category', 'category', { size: 10 })
439
+ .subAgg((sub) => sub.avg('avg_price', 'price').max('max_price', 'price'))
522
440
  )
523
- // Update (partial document)
524
- .update({
525
- _index: 'products',
526
- _id: '3',
527
- doc: { price: 999 }
528
- })
529
- // Delete
530
- .delete({ _index: 'products', _id: '4' })
441
+ .size(20)
531
442
  .build();
532
443
 
533
- // POST /_bulk with Content-Type: application/x-ndjson
534
- ```
535
-
536
- **NDJSON Format:**
537
-
538
- ```ndjson
539
- {"index":{"_index":"products","_id":"1"}}
540
- {"id":"1","name":"Laptop Pro","price":1299,"category":"electronics"}
541
- {"create":{"_index":"products","_id":"2"}}
542
- {"id":"2","name":"Wireless Mouse","price":29,"category":"accessories"}
543
- {"update":{"_index":"products","_id":"3"}}
544
- {"doc":{"price":999}}
545
- {"delete":{"_index":"products","_id":"4"}}
444
+ // Aggregations-only (no query) use queryBuilder(mappings, false)
445
+ const aggsOnly = queryBuilder(productMappings, false)
446
+ .aggs((agg) => agg.terms('by_category', 'category'))
447
+ .size(0)
448
+ .build();
546
449
 
450
+ // Standalone aggregation builder (for manual composition)
451
+ const standaloneAgg = aggregations(productMappings)
452
+ .avg('avg_price', 'price')
453
+ .terms('by_category', 'category', { size: 10 })
454
+ .build();
547
455
  ```
548
456
 
549
- **Update Options:**
457
+ #### Aggregation Methods
458
+
459
+ | Category | Methods | Description |
460
+ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
461
+ | **Bucket** | `terms`, `dateHistogram`, `histogram`, `range`, `dateRange`, `filters`, `significantTerms`, `rareTerms`, `multiTerms`, `autoDateHistogram`, `composite`, `filter`, `missing`, `geoDistance`, `geohashGrid`, `geotileGrid` | Group documents into buckets |
462
+ | **Metric** | `avg`, `sum`, `min`, `max`, `cardinality`, `percentiles`, `stats`, `valueCount`, `extendedStats`, `topHits`, `topMetrics`, `weightedAvg`, `geoBounds`, `geoCentroid` | Compute metrics over documents |
463
+ | **Pipeline** | `derivative`, `cumulativeSum`, `bucketScript`, `bucketSelector` | Compute over other aggregation results |
464
+ | **Structure** | `subAgg`, `nested`, `reverseNested`, `global` | Nest aggregations, navigate nested fields, or escape to global scope |
550
465
 
551
- - `doc`: Partial document merge
552
- - `script`: Script-based update (Painless)
553
- - `upsert`: Document to insert if not exists
554
- - `doc_as_upsert`: Use doc as upsert document
555
- - `retry_on_conflict`: Retry count for version conflicts
466
+ `subAgg()` attaches sub-aggregations to the **last** aggregation defined before the call — chain order matters. See [`aggregation.types.ts`](src/aggregation.types.ts) for complete option types.
556
467
 
557
468
  ### Index Management
558
469
 
559
- Configure index mappings, settings, and aliases declaratively.
470
+ Configure index mappings, settings, and aliases declaratively with `indexBuilder()`.
560
471
 
561
472
  ```typescript
562
473
  import { indexBuilder, mappings, keyword, integer, float, date, text } from 'elasticlink';
@@ -566,7 +477,7 @@ const matterMappings = mappings({
566
477
  practice_area: keyword(),
567
478
  billing_rate: integer(),
568
479
  risk_score: float(),
569
- opened_at: date(),
480
+ opened_at: date()
570
481
  });
571
482
 
572
483
  const indexConfig = indexBuilder()
@@ -581,11 +492,11 @@ const indexConfig = indexBuilder()
581
492
  .build();
582
493
 
583
494
  // PUT /matters-v1
584
- // Content-Type: application/json
585
- // Body: JSON.stringify(indexConfig)
495
+ await client.indices.create({ index: 'matters-v1', ...indexConfig });
586
496
  ```
587
497
 
588
- Produces:
498
+ <details>
499
+ <summary><b>Produced DSL</b> (click to expand)</summary>
589
500
 
590
501
  ```json
591
502
  {
@@ -610,9 +521,21 @@ Produces:
610
521
  }
611
522
  ```
612
523
 
524
+ </details>
525
+
526
+ #### IndexBuilder Methods
527
+
528
+ | Method | Description |
529
+ | ------------------------------------ | ------------------------------------------------------------------------------ |
530
+ | `mappings(schemaOrFields, options?)` | Set index mappings from a `MappingsSchema` or raw field definitions |
531
+ | `settings(settings)` | Set index settings (`IndicesIndexSettings` from `@elastic/elasticsearch`) |
532
+ | `analysis(config)` | Configure custom analyzers, tokenizers, filters, char filters, and normalizers |
533
+ | `alias(name, options?)` | Add an index alias with optional filter, routing, and write index settings |
534
+ | `build()` | Returns the final `CreateIndexOptions` object |
535
+
613
536
  #### Object and Nested Fields
614
537
 
615
- Use `object()` for structured sub-documents queried with dot-notation — the common case for addresses, names, and similar JSON-like objects. Use `nested()` for arrays of objects where cross-field queries within the same element must be accurate.
538
+ Use `object()` for structured sub-documents queried with dot-notation. Use `nested()` for arrays of objects where cross-field queries within the same element must be accurate.
616
539
 
617
540
  ```typescript
618
541
  import { mappings, text, keyword, float, integer, boolean, object, nested, type Infer } from 'elasticlink';
@@ -620,111 +543,85 @@ import { mappings, text, keyword, float, integer, boolean, object, nested, type
620
543
  const productMappings = mappings({
621
544
  name: text(),
622
545
  in_stock: boolean(),
623
-
624
- // object() — single structured value, queried with dot-notation (no wrapper needed)
625
546
  address: object({
547
+ // Single structured value — queried with dot-notation
626
548
  street: text(),
627
549
  city: keyword(),
628
- country: keyword(),
550
+ country: keyword()
629
551
  }),
630
-
631
- // nested() — array of objects; cross-field queries require the .nested() wrapper
632
552
  variants: nested({
553
+ // Array of objects — cross-field accuracy preserved
633
554
  sku: keyword(),
634
555
  color: keyword(),
635
556
  price: float(),
636
- stock: integer(),
637
- }),
557
+ stock: integer()
558
+ })
638
559
  });
639
560
 
640
- // Infer<> produces the correct nested TypeScript types:
641
561
  type Product = Infer<typeof productMappings>;
642
- // {
643
- // name: string;
644
- // in_stock: boolean;
562
+ // { name: string; in_stock: boolean;
645
563
  // address: { street: string; city: string; country: string };
646
- // variants: Array<{ sku: string; color: string; price: number; stock: number }>;
647
- // }
564
+ // variants: Array<{ sku: string; color: string; price: number; stock: number }> }
648
565
 
649
566
  // object sub-fields — query with dot-notation directly
650
- query(productMappings)
567
+ queryBuilder(productMappings)
651
568
  .bool()
652
- .filter(q => q.term('address.country', 'US')) // ✅ 'address.country' is a keyword field
653
- .filter(q => q.match('address.street', 'Main')) // ✅ 'address.street' is a text field
569
+ .filter((q) => q.term('address.country', 'US'))
570
+ .filter((q) => q.match('address.street', 'Main'))
654
571
  .build();
655
572
 
656
- // nested sub-fields — must use .nested() wrapper; inner builder is fully typed
657
- query(productMappings)
658
- .nested('variants', q => q.term('color', 'black')) // ✅ 'color' is typed as keyword
573
+ // nested sub-fields — must use .nested() wrapper; field names are relative
574
+ queryBuilder(productMappings)
575
+ .nested('variants', (q) => q.term('color', 'black'))
659
576
  .build();
660
577
 
661
- query(productMappings)
662
- .nested('variants', q => q.range('price', { lte: 150 }), { score_mode: 'min' })
578
+ queryBuilder(productMappings)
579
+ .nested('variants', (q) => q.range('price', { lte: 150 }), { score_mode: 'min' })
663
580
  .build();
664
581
  ```
665
582
 
666
- > **Inner field names are relative.** Inside a `.nested()` callback, you write field names relative to the nested path (e.g. `'color'`, not `'variants.color'`). The library automatically qualifies them to the correct full path in the generated DSL.
667
-
668
- Object and nested fields can be composed to any depth. A 2-level deep mapping works the same way:
669
-
670
- ```typescript
671
- const orderMappings = mappings({
672
- title: text(),
673
- shipments: nested({
674
- tracking: keyword(),
675
- address: object({ // object() inside nested()
676
- city: keyword(),
677
- country: keyword(),
678
- }),
679
- }),
680
- });
681
-
682
- // Root-level fields are queried directly
683
- query(orderMappings)
684
- .term('title', 'urgent')
685
- .build();
686
-
687
- // Nested sub-fields use .nested(); dot-notation inside is relative to the nested path
688
- query(orderMappings)
689
- .nested('shipments', q => q.term('address.city', 'London')) // ✅ 'address.city' relative to 'shipments'
690
- .build();
691
- ```
583
+ > **Inner field names are relative.** Inside a `.nested()` callback, you write field names relative to the nested path (e.g. `'color'`, not `'variants.color'`). The library automatically qualifies them in the generated DSL.
692
584
 
693
- > **Why the difference?**
694
- > `object` fields are stored inline in the parent document — Elasticsearch flattens their sub-fields and you query them directly. `nested` fields are stored as separate hidden documents to preserve the relationship between sub-fields within each array element. Without the `.nested()` wrapper, a query like `color=black AND price<50` could incorrectly match a product where different variants have those values.
585
+ #### Field Helpers
695
586
 
696
- **Field Helpers** (shorthand for common field types):
587
+ | Category | Helpers |
588
+ | ---------- | ------------------------------------------------------------------------------------------------- |
589
+ | Text | `text`, `keyword`, `constantKeyword`, `matchOnlyText`, `searchAsYouType`, `wildcardField` |
590
+ | Numeric | `long`, `integer`, `short`, `byte`, `double`, `float`, `halfFloat`, `scaledFloat`, `unsignedLong` |
591
+ | Date | `date`, `dateNanos` |
592
+ | Boolean | `boolean` |
593
+ | Binary | `binary` |
594
+ | IP | `ip` |
595
+ | Range | `integerRange`, `floatRange`, `longRange`, `doubleRange`, `dateRange`, `ipRange` |
596
+ | Objects | `object`, `nested`, `flattened` |
597
+ | Spatial | `geoPoint`, `geoShape` |
598
+ | Vector | `denseVector`, `quantizedDenseVector`, `sparseVector` |
599
+ | Rank | `rankFeature`, `rankFeatures` |
600
+ | Semantic | `semanticText` |
601
+ | Completion | `completion` |
602
+ | Special | `percolator`, `alias`, `tokenCount`, `murmur3Hash`, `join` |
697
603
 
698
604
  ```typescript
699
- import { text, keyword, integer, float, double, date, boolean, denseVector, scaledFloat, halfFloat } from 'elasticlink';
700
-
701
605
  // Shorthand — pass options or use defaults
702
- keyword() // { type: 'keyword' }
703
- integer() // { type: 'integer' }
704
- float() // { type: 'float' }
705
- date() // { type: 'date' }
706
- text({ analyzer: 'english' }) // { type: 'text', analyzer: 'english' }
707
- denseVector({ dims: 384, index: true, similarity: 'cosine' })
606
+ keyword(); // { type: 'keyword' }
607
+ integer(); // { type: 'integer' }
608
+ text({ analyzer: 'english' }); // { type: 'text', analyzer: 'english' }
609
+ denseVector({ dims: 384, index: true, similarity: 'cosine' });
610
+
611
+ // Multi-fields carry type info dot-notation paths work in queries
612
+ const m = mappings({
613
+ name: text({ fields: { raw: keyword() } }),
614
+ price: float({ fields: { string: keyword() } }),
615
+ });
616
+ const qb = queryBuilder(m);
617
+ qb.term('name.raw', 'exact match'); // ✓ name.raw is a KeywordFields<M> path
618
+ qb.match('name', 'full text'); // ✓ name is a TextFields<M> path
708
619
  ```
709
620
 
710
- #### Field Types (25+ supported)
711
-
712
- | Category | Helpers |
713
- | ----------- | ----------------------------------------------------------------------------------------------------------------------------- |
714
- | Text | `text`, `keyword`, `constantKeyword`, `matchOnlyText`, `searchAsYouType`, `wildcardField` |
715
- | Numeric | `long`, `integer`, `short`, `byte`, `double`, `float`, `halfFloat`, `scaledFloat` |
716
- | Date | `date` |
717
- | Boolean | `boolean` |
718
- | Range | `integerRange`, `floatRange`, `longRange`, `doubleRange`, `dateRange` |
719
- | Objects | `object`, `nested`, `flattened` |
720
- | Spatial | `geoPoint`, `geoShape` |
721
- | Vector | `denseVector`, `quantizedDenseVector` |
722
- | Specialized | `ip`, `binary`, `completion`, `percolator` |
723
- | Alias | `alias` |
724
-
725
- #### Mapping Properties
621
+ See [`field.types.ts`](src/field.types.ts) for all field helper option types.
726
622
 
727
- All field helpers accept an optional options object. Common options across types:
623
+ <details>
624
+ <summary><b>Mapping Properties Reference</b> (click to expand)</summary>
728
625
 
729
626
  | Option | Types | Description |
730
627
  | ------------------------------ | ---------------------- | ------------------------------------------------------------------------------------ |
@@ -741,7 +638,7 @@ All field helpers accept an optional options object. Common options across types
741
638
  | `dims` | `denseVector` | Number of dimensions (required for KNN indexing) |
742
639
  | `similarity` | `denseVector` | Similarity function: `'cosine'`, `'dot_product'`, `'l2_norm'`, `'max_inner_product'` |
743
640
  | `element_type` | `denseVector` | Element type: `'float'` (default), `'byte'`, `'bit'` |
744
- | `fields` | text, keyword | Multi-fields for indexing the same value in multiple ways |
641
+ | `fields` | text, keyword, numeric, date | Multi-fields type-safe dot-notation paths (e.g. `name.raw`) in query constraints |
745
642
  | `properties` | object, nested | Sub-field mappings |
746
643
  | `enabled` | `object` | Disable indexing of object fields |
747
644
  | `path` | `alias` | Path to the target field |
@@ -750,423 +647,269 @@ All field helpers accept an optional options object. Common options across types
750
647
  | `preserve_position_increments` | `completion` | Preserve position increments |
751
648
  | `orientation` | `geoShape` | Default orientation for polygons |
752
649
 
753
- #### Index Settings
650
+ </details>
651
+
652
+ <details>
653
+ <summary><b>Index Settings Reference</b> (click to expand)</summary>
754
654
 
755
655
  The `.settings()` method accepts the full `IndicesIndexSettings` type from `@elastic/elasticsearch`. Common options:
756
656
 
757
- | Setting | Type | Description |
758
- | -------------------- | -------- | -------------------------------------------------- |
759
- | `number_of_shards` | `number` | Primary shard count (set at creation, immutable) |
760
- | `number_of_replicas` | `number` | Replica count (can be changed after creation) |
761
- | `refresh_interval` | `string` | How often to refresh (`'1s'`, `'-1s'` to disable) |
762
- | `max_result_window` | `number` | Max `from + size` (default: 10000) |
763
- | `analysis` | `object` | Custom analyzers, tokenizers, filters |
764
- | `codec` | `string` | Compression codec (`'best_compression'`) |
657
+ | Setting | Type | Description |
658
+ | -------------------- | -------- | ------------------------------------------------- |
659
+ | `number_of_shards` | `number` | Primary shard count (set at creation, immutable) |
660
+ | `number_of_replicas` | `number` | Replica count (can be changed after creation) |
661
+ | `refresh_interval` | `string` | How often to refresh (`'1s'`, `'-1s'` to disable) |
662
+ | `max_result_window` | `number` | Max `from + size` (default: 10000) |
663
+ | `analysis` | `object` | Custom analyzers, tokenizers, filters |
664
+ | `codec` | `string` | Compression codec (`'best_compression'`) |
665
+
666
+ </details>
765
667
 
766
- #### Alias Options
668
+ <details>
669
+ <summary><b>Alias Options Reference</b> (click to expand)</summary>
767
670
 
768
671
  The `.alias()` method accepts an optional `IndicesAlias` object:
769
672
 
770
- | Option | Type | Description |
771
- | ----------------- | --------- | ----------------------------------------------------------------- |
772
- | `filter` | `object` | Query filter — only matching documents visible through alias |
773
- | `is_write_index` | `boolean` | Designate this index as the write target for the alias |
774
- | `routing` | `string` | Custom routing value for alias operations |
775
- | `index_routing` | `string` | Routing value for index operations only |
776
- | `search_routing` | `string` | Routing value for search operations only |
777
- | `is_hidden` | `boolean` | Hide alias from wildcard expressions |
673
+ | Option | Type | Description |
674
+ | ---------------- | --------- | ------------------------------------------------------------ |
675
+ | `filter` | `object` | Query filter — only matching documents visible through alias |
676
+ | `is_write_index` | `boolean` | Designate this index as the write target for the alias |
677
+ | `routing` | `string` | Custom routing value for alias operations |
678
+ | `index_routing` | `string` | Routing value for index operations only |
679
+ | `search_routing` | `string` | Routing value for search operations only |
680
+ | `is_hidden` | `boolean` | Hide alias from wildcard expressions |
778
681
 
779
- ### Settings Presets
682
+ </details>
780
683
 
781
- Ready-made index settings for common lifecycle stages. Use with `.settings()` on `indexBuilder()` or pass directly to the ES `_settings` API.
684
+ ### Suggesters & Autocomplete
685
+
686
+ `suggest(schema)` creates a standalone suggester builder. Suggesters can also be attached inline via `queryBuilder().suggest(fn)`.
782
687
 
783
688
  ```typescript
784
- import { productionSearchSettings, indexSortSettings, fastIngestSettings } from 'elasticlink';
689
+ import { queryBuilder, suggest, mappings, text, keyword, completion } from 'elasticlink';
785
690
 
786
- // Create index with production settings
787
- const indexConfig = indexBuilder()
788
- .mappings(myMappings)
789
- .settings(productionSearchSettings())
790
- .build();
691
+ const searchableMappings = mappings({
692
+ name: text(),
693
+ description: text(),
694
+ suggest_field: completion()
695
+ });
791
696
 
792
- // Index-time sort for compression and early termination
793
- const sortedConfig = indexBuilder()
794
- .mappings(myMappings)
795
- .settings({
796
- ...productionSearchSettings(),
797
- index: indexSortSettings({ timestamp: 'desc', status: 'asc' })
697
+ // Standalone suggest
698
+ const autocomplete = suggest(searchableMappings)
699
+ .completion('autocomplete', 'lap', {
700
+ field: 'suggest_field',
701
+ size: 10,
702
+ skip_duplicates: true,
703
+ fuzzy: { fuzziness: 'AUTO', min_length: 3, prefix_length: 1 }
798
704
  })
799
705
  .build();
800
706
 
801
- // Before bulk ingestdisables refresh, removes replicas, async translog
802
- await client.indices.putSettings({ index: 'my-index', body: fastIngestSettings() });
803
-
804
- // Perform bulk ingest...
805
-
806
- // Restore production settings afterward
807
- await client.indices.putSettings({ index: 'my-index', body: productionSearchSettings() });
808
- await client.indices.refresh({ index: 'my-index' });
707
+ // Inline with querysearch + spell-check in one request
708
+ const searchWithSuggestions = queryBuilder(searchableMappings)
709
+ .match('name', 'laptpo')
710
+ .suggest((s) =>
711
+ s
712
+ .completion('autocomplete', 'lap', { field: 'suggest_field', size: 5 })
713
+ .term('spelling', 'laptpo', { field: 'name', size: 3, suggest_mode: 'popular' })
714
+ )
715
+ .size(20)
716
+ .build();
809
717
  ```
810
718
 
811
- | Preset | Purpose |
812
- | ------ | ------- |
813
- | `productionSearchSettings(overrides?)` | Balanced production defaults 1 replica, 5s refresh |
814
- | `indexSortSettings(fields)` | Configure index-time sort order for disk compression and early termination |
815
- | `fastIngestSettings(overrides?)` | Maximum indexing throughput async translog, no replicas, refresh disabled |
816
-
817
- `fastIngestSettings` deep-merges the `translog` key so individual translog overrides don't clobber the other defaults. All presets accept an optional `overrides` argument typed as `Partial<IndicesIndexSettings>`.
719
+ | Method | Description |
720
+ | ----------------------------------- | ------------------------------------------------------------------- |
721
+ | `term(name, text, options)` | Suggest corrections for individual terms based on edit distance |
722
+ | `phrase(name, text, options)` | Suggest corrections for entire phrases using n-gram language models |
723
+ | `completion(name, prefix, options)` | Fast prefix-based autocomplete (requires `completion` field type) |
724
+ | `build()` | Returns `{ suggest: ... }` object |
818
725
 
819
- ## Examples
820
-
821
- More examples available in [src/\_\_tests\_\_/examples.test.ts](src/__tests__/examples.test.ts).
726
+ See [`suggester.types.ts`](src/suggester.types.ts) for complete option types (`TermSuggesterOptions`, `PhraseSuggesterOptions`, `CompletionSuggesterOptions`).
822
727
 
823
- ### E-commerce Product Search
728
+ ### Multi-Search
824
729
 
825
- A complete search request: boolean query with must/filter/should, aggregations for facets and price ranges, highlights, `_source` filtering, and pagination.
730
+ `msearch(schema)` batches multiple search requests into a single API call using NDJSON format.
826
731
 
827
732
  ```typescript
828
- const ecommerceMappings = mappings({
829
- name: text(),
830
- description: text(),
831
- category: keyword(),
832
- price: float(),
833
- tags: keyword(),
834
- in_stock: boolean(),
835
- });
733
+ import { queryBuilder, msearch } from 'elasticlink';
836
734
 
837
- const searchTerm = 'gaming laptop';
838
- const category = 'electronics';
839
- const minPrice = 800;
840
- const maxPrice = 2000;
735
+ const laptopQuery = queryBuilder(productMappings).match('name', 'laptop').range('price', { gte: 500, lte: 2000 }).build();
841
736
 
842
- const result = query(ecommerceMappings)
843
- .bool()
844
- .must(q => q.match('name', searchTerm, { operator: 'and', boost: 2 }))
845
- .should(q => q.fuzzy('description', searchTerm, { fuzziness: 'AUTO' }))
846
- .filter(q => q.term('category', category))
847
- .filter(q => q.range('price', { gte: minPrice, lte: maxPrice }))
848
- .filter(q => q.term('in_stock', true))
849
- .minimumShouldMatch(1)
850
- .aggs(agg =>
851
- agg
852
- .terms('by_category', 'category', { size: 10 })
853
- .range('price_ranges', 'price', {
854
- ranges: [
855
- { to: 800 },
856
- { from: 800, to: 1500 },
857
- { from: 1500 }
858
- ]
859
- })
860
- )
861
- .highlight(['name', 'description'], {
862
- fragment_size: 150,
863
- pre_tags: ['<mark>'],
864
- post_tags: ['</mark>']
865
- })
866
- ._source(['name', 'price', 'category', 'tags'])
867
- .timeout('5s')
868
- .from(0)
869
- .size(20)
870
- .sort('_score', 'desc')
737
+ const phoneQuery = queryBuilder(productMappings).match('name', 'smartphone').range('price', { gte: 300, lte: 1000 }).build();
738
+
739
+ // NDJSON string for Elasticsearch _msearch endpoint
740
+ const ndjson = msearch(productMappings)
741
+ .addQuery(laptopQuery, { index: 'products', preference: '_local' })
742
+ .addQuery(phoneQuery, { index: 'products', preference: '_local' })
871
743
  .build();
744
+
745
+ // Or as an array of objects
746
+ const array = msearch(productMappings)
747
+ .addQuery(laptopQuery, { index: 'products' })
748
+ .addQuery(phoneQuery, { index: 'products' })
749
+ .buildArray();
872
750
  ```
873
751
 
874
- Produces:
752
+ | Method | Description |
753
+ | ------------------------------ | -------------------------------------- |
754
+ | `add(request)` | Add a raw request (header + body) |
755
+ | `addQuery(body, header?)` | Add a built query with optional header |
756
+ | `addQueryBuilder(qb, header?)` | Add a QueryBuilder instance directly |
757
+ | `withParams(params)` | Set shared request parameters |
758
+ | `build()` | Returns NDJSON string |
759
+ | `buildArray()` | Returns array of header/body pairs |
760
+ | `buildParams()` | Returns shared request parameters |
875
761
 
876
- ```json
877
- {
878
- "query": {
879
- "bool": {
880
- "must": [{ "match": { "name": { "query": "gaming laptop", "operator": "and", "boost": 2 } } }],
881
- "should": [{ "fuzzy": { "description": { "value": "gaming laptop", "fuzziness": "AUTO" } } }],
882
- "filter": [
883
- { "term": { "category": "electronics" } },
884
- { "range": { "price": { "gte": 800, "lte": 2000 } } },
885
- { "term": { "in_stock": true } }
886
- ],
887
- "minimum_should_match": 1
888
- }
889
- },
890
- "aggs": {
891
- "by_category": { "terms": { "field": "category", "size": 10 } },
892
- "price_ranges": {
893
- "range": {
894
- "field": "price",
895
- "ranges": [{ "to": 800 }, { "from": 800, "to": 1500 }, { "from": 1500 }]
896
- }
897
- }
898
- },
899
- "highlight": {
900
- "fields": { "name": { "fragment_size": 150 }, "description": { "fragment_size": 150 } },
901
- "pre_tags": ["<mark>"],
902
- "post_tags": ["</mark>"]
903
- },
904
- "_source": ["name", "price", "category", "tags"],
905
- "timeout": "5s",
906
- "from": 0,
907
- "size": 20,
908
- "sort": [{ "_score": "desc" }]
909
- }
910
- ```
762
+ Header options: `index`, `routing`, `preference`, `search_type`. See [`multi-search.types.ts`](src/multi-search.types.ts) for full types.
911
763
 
912
- ### Aggregations — Portfolio Analytics
764
+ ### Bulk Operations
913
765
 
914
- Terms + sub-aggregation + date histogram in one request.
766
+ `bulk(schema)` batches create, index, update, and delete operations efficiently.
915
767
 
916
768
  ```typescript
917
- const instrumentMappings = mappings({
769
+ import { bulk, mappings, keyword, text, float } from 'elasticlink';
770
+
771
+ const productMappings = mappings({
772
+ id: keyword(),
918
773
  name: text(),
919
- asset_class: keyword(),
920
- sector: keyword(),
921
774
  price: float(),
922
- yield_rate: float(),
923
- listed_date: date(),
775
+ category: keyword()
924
776
  });
925
777
 
926
- const result = query(instrumentMappings)
927
- .bool()
928
- .filter(q => q.term('asset_class', 'fixed-income'))
929
- .filter(q => q.range('yield_rate', { gte: 3.0 }))
930
- .aggs(agg =>
931
- agg
932
- .terms('by_sector', 'sector', { size: 10 })
933
- .subAgg(sub => sub.avg('avg_yield', 'yield_rate').max('max_price', 'price'))
934
- .dateHistogram('listings_over_time', 'listed_date', {
935
- interval: 'quarter',
936
- min_doc_count: 1
937
- })
938
- .subAgg(sub => sub.percentiles('yield_percentiles', 'yield_rate', { percents: [25, 50, 75, 95] }))
939
- )
940
- .size(0)
778
+ const bulkOp = bulk(productMappings)
779
+ .index({ id: '1', name: 'Laptop Pro', price: 1299, category: 'electronics' }, { _index: 'products', _id: '1' })
780
+ .create({ id: '2', name: 'Wireless Mouse', price: 29, category: 'accessories' }, { _index: 'products', _id: '2' })
781
+ .update({ _index: 'products', _id: '3', doc: { price: 999 } })
782
+ .delete({ _index: 'products', _id: '4' })
941
783
  .build();
942
- ```
943
784
 
944
- Produces:
945
-
946
- ```json
947
- {
948
- "query": {
949
- "bool": {
950
- "filter": [
951
- { "term": { "asset_class": "fixed-income" } },
952
- { "range": { "yield_rate": { "gte": 3.0 } } }
953
- ]
954
- }
955
- },
956
- "aggs": {
957
- "by_sector": {
958
- "terms": { "field": "sector", "size": 10 },
959
- "aggs": {
960
- "avg_yield": { "avg": { "field": "yield_rate" } },
961
- "max_price": { "max": { "field": "price" } }
962
- }
963
- },
964
- "listings_over_time": {
965
- "date_histogram": { "field": "listed_date", "interval": "quarter", "min_doc_count": 1 },
966
- "aggs": {
967
- "yield_percentiles": { "percentiles": { "field": "yield_rate", "percents": [25, 50, 75, 95] } }
968
- }
969
- }
970
- },
971
- "size": 0
972
- }
785
+ // POST /_bulk with Content-Type: application/x-ndjson
973
786
  ```
974
787
 
975
- ### Multi-Search — Parallel Queries
788
+ | Method | Description |
789
+ | -------------------- | ------------------------------------------------------------------------------ |
790
+ | `index(doc, meta?)` | Index a document (create or replace) |
791
+ | `create(doc, meta?)` | Create a document (fail if exists) |
792
+ | `update(meta)` | Update with `doc`, `script`, `upsert`, `doc_as_upsert`, or `retry_on_conflict` |
793
+ | `delete(meta)` | Delete a document |
794
+ | `build()` | Returns NDJSON string |
795
+ | `buildArray()` | Returns array of action/document pairs |
796
+
797
+ ### Vector Search & Semantic Search
976
798
 
977
- Batch multiple independent searches into a single HTTP request.
799
+ KNN (k-nearest neighbors) queries enable semantic search using vector embeddings.
978
800
 
979
801
  ```typescript
980
- const listingMappings = mappings({
981
- address: text(),
982
- property_class: keyword(),
983
- list_price: long(),
802
+ import { queryBuilder, mappings, text, keyword, float, denseVector } from 'elasticlink';
803
+
804
+ const productWithEmbeddingMappings = mappings({
805
+ name: text(),
806
+ description: text(),
807
+ price: float(),
808
+ category: keyword(),
809
+ embedding: denseVector({ dims: 384 })
984
810
  });
985
811
 
986
- const condoSearch = query(listingMappings)
987
- .bool()
988
- .filter(q => q.term('property_class', 'condo'))
989
- .filter(q => q.range('list_price', { lte: 2_000_000 }))
990
- .aggs(agg => agg.avg('avg_price', 'list_price'))
991
- .size(0)
992
- .build();
812
+ const searchEmbedding = [0.23, 0.45, 0.67, 0.12, 0.89]; // From your ML model
993
813
 
994
- const townhouseSearch = query(listingMappings)
995
- .bool()
996
- .filter(q => q.term('property_class', 'townhouse'))
997
- .aggs(agg => agg.avg('avg_price', 'list_price').min('min_price', 'list_price'))
998
- .size(0)
814
+ // Basic semantic search
815
+ const result = queryBuilder(productWithEmbeddingMappings)
816
+ .knn('embedding', searchEmbedding, {
817
+ k: 10,
818
+ num_candidates: 100
819
+ })
820
+ .size(10)
999
821
  .build();
1000
822
 
1001
- const ndjson = msearch(listingMappings)
1002
- .addQuery(condoSearch, { index: 'listings' })
1003
- .addQuery(townhouseSearch, { index: 'listings' })
823
+ // Hybrid search — KNN + filters + aggregations
824
+ const hybridSearch = queryBuilder(productWithEmbeddingMappings)
825
+ .knn('embedding', searchEmbedding, {
826
+ k: 100,
827
+ num_candidates: 1000,
828
+ filter: { term: { category: 'electronics' } },
829
+ boost: 1.2,
830
+ similarity: 0.7
831
+ })
832
+ .aggs((agg) => agg.terms('categories', 'category', { size: 10 }))
833
+ .size(20)
1004
834
  .build();
1005
835
  ```
1006
836
 
1007
- Produces:
1008
-
1009
- ```ndjson
1010
- {"index":"listings"}
1011
- {"query":{"bool":{"filter":[{"term":{"property_class":"condo"}},{"range":{"list_price":{"lte":2000000}}}]}},"aggs":{"avg_price":{"avg":{"field":"list_price"}}},"size":0}
1012
- {"index":"listings"}
1013
- {"query":{"bool":{"filter":[{"term":{"property_class":"townhouse"}}]}},"aggs":{"avg_price":{"avg":{"field":"list_price"}},"min_price":{"min":{"field":"list_price"}}},"size":0}
837
+ Additional vector and relevance methods: `sparseVector(field, options)` for learned sparse retrieval, `rankFeature(field, options?)` for rank feature scoring, and `distanceFeature(field, options)` for recency/proximity decay functions.
1014
838
 
1015
- ```
839
+ ### Script Queries & Custom Scoring
1016
840
 
1017
- ### Suggesters Autocomplete & Spell Check
841
+ Script-based filtering and custom scoring for advanced relevance tuning.
1018
842
 
1019
843
  ```typescript
1020
- const attorneyMappings = mappings({
844
+ import { queryBuilder, mappings, text, float, long } from 'elasticlink';
845
+
846
+ const scoredProductMappings = mappings({
1021
847
  name: text(),
1022
- practice_area: keyword(),
1023
- name_suggest: completion(),
848
+ price: float(),
849
+ popularity: long()
1024
850
  });
1025
851
 
1026
- // Standalone suggest request
1027
- const suggestions = query(attorneyMappings)
1028
- .suggest(s =>
1029
- s
1030
- .completion('autocomplete', 'kap', { field: 'name_suggest', size: 5 })
1031
- .term('spelling', 'wiliams', { field: 'name', size: 3 })
852
+ // Script-based filtering
853
+ const filtered = queryBuilder(scoredProductMappings)
854
+ .bool()
855
+ .must((q) => q.match('name', 'laptop'))
856
+ .filter((q) =>
857
+ q.script({
858
+ source: "doc['price'].value > params.threshold",
859
+ params: { threshold: 500 }
860
+ })
1032
861
  )
1033
- .size(0)
1034
862
  .build();
1035
- ```
1036
-
1037
- Produces:
1038
-
1039
- ```json
1040
- {
1041
- "suggest": {
1042
- "autocomplete": {
1043
- "prefix": "kap",
1044
- "completion": { "field": "name_suggest", "size": 5 }
1045
- },
1046
- "spelling": {
1047
- "text": "wiliams",
1048
- "term": { "field": "name", "size": 3 }
1049
- }
1050
- },
1051
- "size": 0
1052
- }
1053
- ```
1054
-
1055
- ### Dynamic Search with Conditional Filters
1056
-
1057
- ```typescript
1058
- const buildDynamicQuery = (filters: SearchFilters) => {
1059
- return query(productMappings)
1060
- .bool()
1061
- .when(filters.searchTerm, q => q.must(q2 => q2.match('name', filters.searchTerm!, { boost: 2 })))
1062
- .when(filters.category, q => q.filter(q2 => q2.term('category', filters.category!)))
1063
- .when(filters.minPrice != null && filters.maxPrice != null,
1064
- q => q.filter(q2 => q2.range('price', { gte: filters.minPrice!, lte: filters.maxPrice! }))
1065
- )
1066
- .from(filters.offset || 0)
1067
- .size(filters.limit || 20)
1068
- .build();
1069
- };
1070
- ```
1071
-
1072
- ### Geospatial Search
1073
863
 
1074
- ```typescript
1075
- const restaurantMappings = mappings({
1076
- name: text(),
1077
- cuisine: keyword(),
1078
- location: geoPoint(),
1079
- rating: float(),
1080
- });
1081
-
1082
- const result = query(restaurantMappings)
1083
- .term('cuisine', 'italian')
1084
- .geoDistance(
1085
- 'location',
1086
- { lat: 40.7128, lon: -74.006 },
1087
- { distance: '5km' }
1088
- )
1089
- .from(0)
864
+ // Custom scoring with script_score
865
+ const customScored = queryBuilder(scoredProductMappings)
866
+ .scriptScore((q) => q.match('name', 'smartphone'), {
867
+ source: "_score * Math.log(2 + doc['popularity'].value)",
868
+ lang: 'painless'
869
+ })
1090
870
  .size(20)
1091
871
  .build();
1092
872
  ```
1093
873
 
1094
- ## TypeScript Support
1095
-
1096
- elasticlink provides mapping-aware TypeScript safety:
874
+ ### Percolate Queries
1097
875
 
1098
- - **Field-Type Constraints**: enforced at compile time across all methods `match()` only accepts text fields, `term()` only keyword/numeric fields, `sort()` only sortable fields (keyword, numeric, date, boolean, ip), `collapse()` only keyword/numeric fields, `highlight()` only text/keyword fields
1099
- - **Field Autocomplete**: IntelliSense knows your field names and their types
1100
- - **`Infer<S>`**: Derive TS document types from your mappings schema
1101
- - **Exported Field Group Types**: `SortableFields<M>`, `CollapsibleFields<M>`, `HighlightableFields<M>`, `TextFields<M>`, `KeywordFields<M>`, and others are exported for use in your own typed utilities
876
+ Percolate queries enable reverse search — match documents against stored queries.
1102
877
 
1103
878
  ```typescript
1104
- import { query, mappings, text, keyword, integer, type Infer } from 'elasticlink';
879
+ import { queryBuilder, mappings, keyword, percolator } from 'elasticlink';
1105
880
 
1106
- const userMappings = mappings({
1107
- name: text(),
1108
- email: keyword(),
1109
- age: integer(),
881
+ const alertRuleMappings = mappings({
882
+ query: percolator(),
883
+ name: keyword(),
884
+ severity: keyword()
1110
885
  });
1111
886
 
1112
- type User = Infer<typeof userMappings>;
1113
- // => { name: string; email: string; age: number }
1114
-
1115
- // ✅ 'name' is a text field — match() accepts it
1116
- const q1 = query(userMappings).match('name', 'John').build();
1117
-
1118
- // ❌ TypeScript error: 'email' is keyword, not text — use term() instead
1119
- const q2 = query(userMappings).match('email', 'john@example.com').build();
1120
-
1121
- // ✅ Correct: use term() for keyword fields
1122
- const q3 = query(userMappings).term('email', 'john@example.com').build();
887
+ const alerts = queryBuilder(alertRuleMappings)
888
+ .percolate({
889
+ field: 'query',
890
+ document: {
891
+ level: 'ERROR',
892
+ message: 'Database connection failed',
893
+ timestamp: '2024-01-15T10:30:00Z'
894
+ }
895
+ })
896
+ .size(100)
897
+ .build();
1123
898
  ```
1124
899
 
1125
- ## Testing
900
+ **Common use cases:** alerting (match events against rules), content classification, saved search notifications, and metric threshold monitoring.
1126
901
 
1127
- ```bash
1128
- # Run tests
1129
- npm test
1130
-
1131
- # Watch mode
1132
- npm test:watch
902
+ ## Compatibility
1133
903
 
1134
- # Coverage report
1135
- npm test:coverage
904
+ | elasticlink | Node.js | Elasticsearch |
905
+ | ------------ | ---------- | ------------- |
906
+ | 1.0.0-beta.1 | 20, 22, 24 | 9.x (≥9.0.0) |
1136
907
 
1137
- # Type check
1138
- npm run type-check
1139
- ```
1140
-
1141
- ## Roadmap
1142
-
1143
- ### Current Release ✅
1144
-
1145
- - [x] Core query types (match, term, range, bool, etc.)
1146
- - [x] Fuzzy queries for typo tolerance
1147
- - [x] Query parameters (from, size, sort, timeout, etc.)
1148
- - [x] Conditional query building
1149
- - [x] Highlight support
1150
- - [x] Aggregations (bucket and metric)
1151
- - [x] Geo queries (distance, bounding box, polygon)
1152
- - [x] Advanced patterns (regexp, constant_score)
1153
- - [x] Sub-aggregation support
1154
- - [x] Query + aggregations integration
1155
- - [x] KNN (k-nearest neighbors) queries for vector search
1156
- - [x] Semantic search with vector embeddings
1157
- - [x] Dense vector field support
1158
- - [x] Script queries and custom scoring
1159
- - [x] Percolate queries for reverse search
1160
- - [x] Multi-search API (NDJSON batched queries)
1161
- - [x] Bulk operations (create, index, update, delete)
1162
- - [x] Index management (mappings, settings, aliases)
1163
- - [x] Query suggestions/completions (term, phrase, completion)
1164
- - [x] Integration test suite against live Elasticsearch 9.x
1165
- - [x] Types derived from official `@elastic/elasticsearch` for accuracy and completeness
908
+ Tested against the versions listed. Peer dependency is `@elastic/elasticsearch >=9.0.0`.
1166
909
 
1167
910
  ## Development
1168
911
 
1169
- See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and code style.
912
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, design principles, and code style.
1170
913
 
1171
914
  ## License
1172
915