elasticlink 0.8.0-beta → 1.0.0-beta.2

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