From 6d88857ceb1906685839dd3a209f28bb8570e1ec Mon Sep 17 00:00:00 2001 From: oxdev03 <140103378+oxdev03@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:24:27 +0200 Subject: [PATCH] feat: sync with tantivy-py 0.26.2 Update the tantivy-py submodule from 0.24.0-22 to 0.26.2 and port every upstream change back, keeping the file-for-file, method-for-method mapping the port is built on. Bumps tantivy 0.25 -> 0.26.2 and the napi-rs JS toolchain. Ported APIs: - Searcher: orderByField over u64/i64/f64/bool/date/str fast fields, weightByField, cardinality(), fastFieldValues(), termsWithPrefix(); aggregate() now takes and returns objects instead of JSON strings - Query: andMustMatch/orShouldMatch/andMustNotMatch with flat chaining, booleanQuery minimumNumberShouldMatch, moreLikeThisDocumentFieldsQuery, unbounded range bounds, useInvertedIndex, offset pairs for phrase words, existsQuery(fastFieldName, jsonSubpaths) - Index: isCompatible(), registerFastFieldTokenizer(), conjunctionByDefault and allowRegexes on both parse methods, deleteDocuments alias - Module-level parseQuery/parseQueryLenient, addJsonField expandDotsEnabled Fixes found while aligning: - garbageCollectFiles was a silent no-op - makeTerm truncated u64 values through get_uint32 - aggregate coerced its argument to "[object Object]" - dates lost sub-second precision - a negative value for an unsigned field silently became its absolute value - parseQueryLenient returned formatted strings while the 17 error classes it should have returned were exported but unreachable - .husky/pre-commit invoked yarn in a pnpm project, so it always failed Deduplication: one term_from_value instead of two copies of the type match, one more_like_this_builder, get_field instead of four inlined lookups. FieldType variants now use tantivy-py's names (Text, Unsigned, Integer, Float, Boolean, Json). Docs: four new tutorial sections, each backed by an executable example. BREAKING CHANGE: existsQuery drops its schema argument; aggregate takes an object; parseQueryLenient returns error instances rather than strings; phrasePrefixQuery and regexPhraseQuery drop maxExpansions and the two-term minimum; FieldType variants renamed; SearchHit.order widened to number | boolean | string; parser error messages now quote field names with backticks. --- .husky/pre-commit | 5 +- .oxlintrc.json | 4 + .prettierignore | 4 +- .taplo.toml | 2 +- Cargo.toml | 4 +- README.md | 81 +- __test__/fixtures.ts | 72 ++ __test__/index.spec.ts | 981 ++++++++++++++++++++++-- __test__/test-document-scoring.spec.ts | 71 ++ docs/tutorials.md | 239 ++++++ examples/aggregations.ts | 48 ++ examples/autocomplete.ts | 51 ++ examples/boolean-query-helpers.ts | 51 ++ examples/sorted-search.ts | 47 ++ index.d.ts | 528 ++++++++++--- index.js | 413 ++++++++--- package.json | 12 +- pnpm-lock.yaml | 983 ++++++++++++++----------- src/document.rs | 147 ++-- src/index.rs | 214 ++++-- src/lib.rs | 99 +-- src/parser_error.rs | 46 ++ src/query.rs | 753 +++++++++++-------- src/query_grammar.rs | 53 ++ src/schema.rs | 41 +- src/schemabuilder.rs | 41 +- src/searcher.rs | 692 ++++++++++++++--- src/tokenizer.rs | 22 +- tantivy-py | 2 +- wasi-worker-browser.mjs | 34 - 30 files changed, 4344 insertions(+), 1396 deletions(-) create mode 100644 .oxlintrc.json create mode 100644 __test__/test-document-scoring.spec.ts create mode 100644 examples/aggregations.ts create mode 100644 examples/autocomplete.ts create mode 100644 examples/boolean-query-helpers.ts create mode 100644 examples/sorted-search.ts create mode 100644 src/query_grammar.rs delete mode 100644 wasi-worker-browser.mjs diff --git a/.husky/pre-commit b/.husky/pre-commit index d2ae35e..cb2c84d 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -yarn lint-staged +pnpm lint-staged diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..ec354f3 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "ignorePatterns": ["tantivy-py/**"] +} diff --git a/.prettierignore b/.prettierignore index 901e6b6..ea3445f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,4 +5,6 @@ package-template.wasi-browser.js package-template.wasi.cjs wasi-worker-browser.mjs wasi-worker.mjs -.yarnrc.yml \ No newline at end of file +.yarnrc.yml +tantivy-py +pnpm-lock.yaml diff --git a/.taplo.toml b/.taplo.toml index b2d27a7..ea0a341 100644 --- a/.taplo.toml +++ b/.taplo.toml @@ -1,4 +1,4 @@ -exclude = ["node_modules/**/*.toml"] +exclude = ["node_modules/**/*.toml", "tantivy-py/**/*.toml"] # https://taplo.tamasfe.dev/configuration/formatter-options.html [formatting] diff --git a/Cargo.toml b/Cargo.toml index ace98a5..a941be2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["cdylib"] [dependencies] chrono = { version = "0.4", features = ["serde"] } +futures = "0.3" napi = { version = "3.12", default-features = false, features = [ "napi8", "serde-json", @@ -17,7 +18,8 @@ napi = { version = "3.12", default-features = false, features = [ napi-derive = "3.6" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } -tantivy = "0.25.0" +tantivy = "0.26.2" +tantivy-common = "0.11" [build-dependencies] napi-build = "2.4" diff --git a/README.md b/README.md index f170d09..236188f 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ Node.js bindings for [Tantivy](https://github.com/quickwit-oss/tantivy), the ful This project is a Node.js port of [tantivy-py](https://github.com/quickwit-inc/tantivy-py), providing JavaScript/TypeScript bindings for the Tantivy search engine. The implementation closely follows the Python API to maintain consistency across language bindings. - - # Installation The bindings can be installed using npm: @@ -60,8 +58,11 @@ This Node.js binding provides access to most of Tantivy's functionality: - **Snippet generation** for search result highlighting - **Query explanation** for debugging relevance scoring - **Multiple field types**: text, integers, floats, dates, facets -- **Flexible tokenization** and text analysis -- **JSON document support** +- **Flexible tokenization** and text analysis, including fast-field tokenizers +- **JSON document support**, with optional dot-path expansion +- **Aggregations**, including cardinality +- **Fast-field reads** (`fastFieldValues`) and prefix term lookup (`termsWithPrefix`) +- **Schema-free query parsing** via `parseQuery` / `parseQueryLenient` ## API Compatibility @@ -115,18 +116,21 @@ The Node.js implementation differs from the Python version in several ways: #### 🔴 Critical Validation Issues -##### Numeric Field Validation (Too Lenient) +##### Multi-valued Single Fields (Too Lenient) -**Current behavior**: Node.js version accepts invalid values that Python rejects -**TODO**: Implement strict validation to match Python behavior +**Current behavior**: an array is accepted wherever a single value is expected +**TODO**: decide whether to match Python, which rejects it ```javascript -// ❌ These currently PASS in Node.js but should FAIL: -Document.fromDict({ unsigned: -50 }, schema) // Should reject negative for unsigned -Document.fromDict({ signed: 50.4 }, schema) // Should reject float for integer -Document.fromDict({ unsigned: [1000, -50] }, schema) // Should reject arrays for single fields +// ❌ This currently PASSES in Node.js but should FAIL: +Document.fromDict({ unsigned: [1000, 50] }, schema) // Should reject arrays for single fields ``` +Numeric values themselves are validated as in tantivy-py: a negative value for an +unsigned field, or a fractional value for an integer field, throws rather than +being silently coerced. Values beyond `Number.MAX_SAFE_INTEGER` can be passed as +a `BigInt`. + ##### Bytes Field Validation (Too Restrictive) **Current behavior**: Only accepts Buffer objects @@ -157,26 +161,55 @@ Document.fromDict({ json: 123 }, schema) // Should reject numbers Document.fromDict({ json: 'hello' }, schema) // Should reject strings ``` -#### 🟠 Error Handling Differences +#### 🔵 Type System Differences -##### Fast Field Configuration +##### Date Handling -**Current**: Throws exception when field not configured as fast -**Python**: Returns empty results -**TODO**: Decide on consistent error handling approach +**Current**: Dates cross the boundary as millisecond timestamps. `Document.fromDict()` +and the query builders also accept a JavaScript `Date` or an ISO 8601 string, and +`toDict()` returns milliseconds since the epoch. +**Python**: Uses `datetime` objects with nanosecond storage. -##### Query Parser Errors +Sub-millisecond precision is therefore not representable from JavaScript. -**Current**: Different error message formats -**TODO**: Align error messages with Python version +#### 🔵 Resource Management -#### 🔵 Type System Differences +`IndexWriter` has no equivalent of tantivy-py's `with index.writer() as writer:` +block, because JavaScript has no deterministic destructor. Call +`writer.waitMergingThreads()` when you are done with a writer — it commits nothing +on its own, so commit first, and it is what releases the directory lock: -##### Date Handling +```javascript +const writer = index.writer() +try { + writer.addDocument(doc) + writer.commit() +} finally { + writer.waitMergingThreads() +} +``` + +#### 🔵 Concurrency + +tantivy-py releases the GIL around the blocking calls (`addDocument`, `commit`, +`search`, …). Node has no GIL, but these methods run synchronously on the main +thread and are not offloaded to the libuv thread pool. + +#### 🔵 Naming + +Two shapes differ from tantivy-py because napi-rs models them differently: + +- Static constructors live on a companion class: `FilterStatic.lowercase()` and + `TokenizerStatic.simple()` rather than `Filter.lowercase()` / `Tokenizer.simple()`. +- `DocAddress`, `SearchResult` and `SearchHit` are plain objects rather than + classes, so they have no constructors or getters — read `hit.docAddress`, + `hit.score` and `hit.order` directly. + +`FieldType` uses tantivy-py's variant names (`Text`, `Unsigned`, `Integer`, +`Float`, `Boolean`, `Json`), not tantivy's Rust `Type` names. -**Current**: Uses getTime() timestamps -**Python**: Uses datetime objects -**TODO**: Consider more intuitive date API +Python's pickle hooks (`__reduce__`, `__getnewargs__`) have no counterpart; +`Schema` exposes `toJSON()` / `Schema.fromJson()` instead. ## Architecture diff --git a/__test__/fixtures.ts b/__test__/fixtures.ts index b0609be..3d3bff1 100644 --- a/__test__/fixtures.ts +++ b/__test__/fixtures.ts @@ -214,3 +214,75 @@ export const createSpanishIndex = () => { index.reload() return index } + +export const schemaOrderFastFields = () => { + return new SchemaBuilder() + .addTextField('title', { stored: true }) + .addUnsignedField('u64_field', { fast: true }) + .addIntegerField('i64_field', { fast: true }) + .addFloatField('f64_field', { fast: true }) + .addBooleanField('bool_field', { fast: true }) + .addDateField('date_field', { fast: true }) + .addTextField('str_field', { fast: true }) + .build() +} + +export const createIndexWithOrderFastFields = (dir?: string) => { + const index = new Index(schemaOrderFastFields(), dir) + const writer = index.writer(15_000_000, 1) + + const low = new Document() + low.addText('title', 'low title') + low.addUnsigned('u64_field', 0) + low.addInteger('i64_field', -10) + low.addFloat('f64_field', 1.5) + low.addBoolean('bool_field', false) + low.addDate('date_field', Date.UTC(2024, 0, 1)) + low.addText('str_field', 'apple') + writer.addDocument(low) + + const high = new Document() + high.addText('title', 'high title') + high.addUnsigned('u64_field', 2) + high.addInteger('i64_field', 5) + high.addFloat('f64_field', 3.14) + high.addBoolean('bool_field', true) + high.addDate('date_field', Date.UTC(2026, 0, 1)) + high.addText('str_field', 'cherry') + writer.addDocument(high) + + writer.commit() + writer.waitMergingThreads() + index.reload() + return index +} + +export const createIndexWithEmptyFastField = () => { + const indexSchema = new SchemaBuilder() + .addTextField('title', { fast: true, stored: true }) + .addTextField('body', { fast: true }) + .build() + + const index = new Index(indexSchema) + const writer = index.writer(15_000_000, 1) + + writer.addDocument( + Document.fromDict({ + title: + 'A record of the statesmanship and political achievements of Gen. Winfield Scott Hancock, ' + + 'regular Democratic nominee for president of the United States', + }), + ) + writer.addDocument(Document.fromDict({ title: 'Political Achievements of the Earl of Dalkeith' })) + writer.addDocument( + Document.fromDict({ + title: 'The Old Man and the Sea', + body: 'He was an old man who fished alone inthe Gulf Stream and he had gone eighty-four days now without taking a fish.', + }), + ) + + writer.commit() + writer.waitMergingThreads() + index.reload() + return index +} diff --git a/__test__/index.spec.ts b/__test__/index.spec.ts index 0cf4d4f..97b7865 100644 --- a/__test__/index.spec.ts +++ b/__test__/index.spec.ts @@ -6,10 +6,13 @@ import { join } from 'path' import { Document, Index, + parseQuery, + parseQueryLenient, SchemaBuilder, Schema, Query, Order, + Occur, FieldType, TokenizerStatic, FilterStatic, @@ -17,6 +20,10 @@ import { TextAnalyzerBuilder, Facet, DocAddress, + FieldDoesNotExistError, + UnsupportedQueryError, + ExpectedIntError, + ExpectedFloatError, } from '../index' import { @@ -26,6 +33,8 @@ import { createIndexWithDateField, createIndexWithIpAddrField, createSpanishIndex, + createIndexWithOrderFastFields, + createIndexWithEmptyFastField, TestDoc, } from './fixtures' import { rm } from 'fs/promises' @@ -36,6 +45,8 @@ let ramIndex: Index let ramIndexNumericFields: Index let ramIndexWithDateField: Index let ramIndexWithIpAddrField: Index +let indexWithOrderFastFields: Index +let indexWithEmptyFastField: Index let spanishIndex: Index let tempDir: string beforeAll(() => { @@ -43,6 +54,8 @@ beforeAll(() => { ramIndexNumericFields = createIndexWithNumericFields() ramIndexWithDateField = createIndexWithDateField() ramIndexWithIpAddrField = createIndexWithIpAddrField() + indexWithOrderFastFields = createIndexWithOrderFastFields() + indexWithEmptyFastField = createIndexWithEmptyFastField() spanishIndex = createSpanishIndex() tempDir = mkdtempSync(join(tmpdir(), 'tantivy-test-')) }) @@ -73,8 +86,8 @@ describe('TestClass', () => { expect(ramIndex.schema.fieldNames()).toEqual(['title', 'body']) expect(ramIndex.schema.hasField('title')).toBe(true) expect(ramIndex.schema.hasField('body')).toBe(true) - expect(ramIndex.schema.getFieldType('title')).toBe(FieldType.Str) - expect(ramIndex.schema.getFieldType('body')).toBe(FieldType.Str) + expect(ramIndex.schema.getFieldType('title')).toBe(FieldType.Text) + expect(ramIndex.schema.getFieldType('body')).toBe(FieldType.Text) const query = ramIndex.parseQuery('sea whale', ['title', 'body']) const result = ramIndex.searcher().search(query, 10) @@ -125,7 +138,7 @@ describe('TestClass', () => { }, } const searcher = ramIndexNumericFields.searcher() - const result = JSON.parse(searcher.aggregate(query, JSON.stringify(aggQuery))) + const result = searcher.aggregate(query, aggQuery) as any expect(typeof result).toBe('object') expect('top_hits_req' in result).toBe(true) expect(result.top_hits_req.hits.length).toBe(2) @@ -137,7 +150,7 @@ describe('TestClass', () => { top_hits_req: { hits: [ { - sort: [13840124604862955520], + sort: [13840124604862955520n], docvalue_fields: { id: [2], rating: [4.5], @@ -253,7 +266,7 @@ describe('TestClass', () => { }, }, { - sort: [13838435755002691584], + sort: [13838435755002691584n], docvalue_fields: { body: [ 'he', @@ -298,10 +311,10 @@ describe('TestClass', () => { // Test numeric fields schema expect(ramIndexNumericFields.schema.numFields()).toBe(4) expect(ramIndexNumericFields.schema.fieldNames()).toEqual(['id', 'rating', 'is_good', 'body']) - expect(ramIndexNumericFields.schema.getFieldType('id')).toBe(FieldType.I64) - expect(ramIndexNumericFields.schema.getFieldType('rating')).toBe(FieldType.F64) - expect(ramIndexNumericFields.schema.getFieldType('is_good')).toBe(FieldType.Bool) - expect(ramIndexNumericFields.schema.getFieldType('body')).toBe(FieldType.Str) + expect(ramIndexNumericFields.schema.getFieldType('id')).toBe(FieldType.Integer) + expect(ramIndexNumericFields.schema.getFieldType('rating')).toBe(FieldType.Float) + expect(ramIndexNumericFields.schema.getFieldType('is_good')).toBe(FieldType.Boolean) + expect(ramIndexNumericFields.schema.getFieldType('body')).toBe(FieldType.Text) const searcher = ramIndexNumericFields.searcher() @@ -353,14 +366,18 @@ describe('TestClass', () => { expect(errors.length).toBe(0) expect(query.toString()).toBe('Query(TermQuery(Term(field=1, type=F64, 3.5)))') - // Test with field that doesn't exist - should have 1 error + // Test with field that doesn't exist - should have 1 typed error let [_, errors2] = ramIndexNumericFields.parseQueryLenient('bod:men') expect(errors2.length).toBe(1) - expect(errors2[0]).toContain('bod') + expect(errors2[0]).toBeInstanceOf(FieldDoesNotExistError) + expect((errors2[0] as FieldDoesNotExistError).field).toBe('bod') + expect(String(errors2[0])).toContain('bod') // Test with multiple errors in complex query let [query3, errors3] = ramIndexNumericFields.parseQueryLenient("body:'hello' AND id:<3.5 OR rating:'hi'") expect(errors3.length).toBe(2) + expect(errors3[0]).toBeInstanceOf(ExpectedIntError) + expect(errors3[1]).toBeInstanceOf(ExpectedFloatError) // Check that query still parses partially expect(query3.toString()).toContain('TermQuery(Term(field=3, type=Str, "hello"))') }) @@ -557,12 +574,9 @@ describe('TestClass', () => { const query = index.parseQuery('test') const searcher = index.searcher() - // In Node.js version, this throws an error about fast field configuration expect(() => { searcher.search(query, 10, true, 'order') - }).toThrowErrorMatchingInlineSnapshot( - `[Error: An invalid argument was passed: 'Field "order" is not configured as fast field']`, - ) + }).toThrowErrorMatchingInlineSnapshot(`[Error: Schema error: 'Field \`order\` is not a fast field.']`) }) it('test_order_by_search_date', () => { @@ -677,60 +691,30 @@ describe('TestClass', () => { schema, ) - // Note: Node.js version is more lenient than Python version - // It accepts negative values for unsigned fields and doesn't validate integer/float type mismatches - // Only string values for numeric fields are rejected - - // This does NOT throw in Node.js version (unlike Python) - Document.fromDict( - { - unsigned: -50, - signed: -5, - float: 0.4, - }, - schema, - ) + // A negative value is rejected for an unsigned field, as in tantivy-py. + expect(() => { + Document.fromDict({ unsigned: -50, signed: -5, float: 0.4 }, schema) + }).toThrowErrorMatchingInlineSnapshot(`[Error: Expected U64 type for field unsigned, got unexpected value]`) - // This does NOT throw in Node.js version (unlike Python) - Document.fromDict( - { - unsigned: 1000, - signed: 50.4, - float: 0.4, - }, - schema, - ) + // A fractional value is rejected for an integer field rather than truncated. + expect(() => { + Document.fromDict({ unsigned: 1000, signed: 50.4, float: 0.4 }, schema) + }).toThrowErrorMatchingInlineSnapshot(`[Error: Expected I64 type for field signed, got unexpected value]`) - // This DOES throw in Node.js version (same as Python) expect(() => { - Document.fromDict( - { - unsigned: 1000, - signed: -5, - float: 'bad_string', - }, - schema, - ) + Document.fromDict({ unsigned: 1000, signed: -5, float: 'bad_string' }, schema) }).toThrowErrorMatchingInlineSnapshot(`[Error: Expected F64 type for field float, got unexpected value]`) - // Arrays are supported for single value fields in Node.js version (unlike Python) - Document.fromDict( - { - unsigned: [1000, -50], - signed: -5, - float: 0.4, - }, - schema, - ) + // Values beyond Number.MAX_SAFE_INTEGER can be passed as BigInt. + Document.fromDict({ unsigned: 18446744073709551615n, signed: -5, float: 0.4 }, schema) - Document.fromDict( - { - unsigned: 1000, - signed: [-5, 150, -3.14], - float: 0.4, - }, - schema, - ) + // Arrays are supported for single value fields in Node.js version (unlike Python), + // but every element is still validated. + Document.fromDict({ unsigned: [1000, 50], signed: -5, float: 0.4 }, schema) + + expect(() => { + Document.fromDict({ unsigned: 1000, signed: [-5, 150, -3.14], float: 0.4 }, schema) + }).toThrowErrorMatchingInlineSnapshot(`[Error: Expected I64 type for field signed, got unexpected value]`) }) it('test_doc_from_dict_bytes_validation', () => { @@ -1308,12 +1292,12 @@ describe('TestQuery', () => { const searcher = ramIndexNumericFields.searcher() // Test integer range - const intQuery = Query.rangeQuery(ramIndexNumericFields.schema, 'id', FieldType.I64, 1, 2, true, true) + const intQuery = Query.rangeQuery(ramIndexNumericFields.schema, 'id', FieldType.Integer, 1, 2, true, true) let result = searcher.search(intQuery) expect(result.hits.length).toBe(2) // Test float range - const floatQuery = Query.rangeQuery(ramIndexNumericFields.schema, 'rating', FieldType.F64, 3.0, 4.0, true, false) + const floatQuery = Query.rangeQuery(ramIndexNumericFields.schema, 'rating', FieldType.Float, 3.0, 4.0, true, false) result = searcher.search(floatQuery) expect(result.hits.length).toBe(1) }) @@ -1444,8 +1428,18 @@ describe('TestQuery', () => { it('test_range_query_invalid_types', () => { // Test that invalid range queries throw errors expect(() => { - Query.rangeQuery(ramIndexNumericFields.schema, 'nonexistent_field', FieldType.I64, 1, 10, true, true) - }).toThrowErrorMatchingInlineSnapshot(`[Error: Field 'nonexistent_field' is not defined in the schema.]`) + Query.rangeQuery(ramIndexNumericFields.schema, 'nonexistent_field', FieldType.Integer, 1, 10, true, true) + }).toThrowErrorMatchingInlineSnapshot(`[Error: Field \`nonexistent_field\` is not defined in the schema.]`) + }) + + it('test_delete_documents_deprecated_alias', () => { + // Kept for parity with tantivy-py; behaves exactly like deleteDocumentsByTerm. + const index = createIndex() + const writer = index.writer() + expect(writer.deleteDocuments('title', 'sea')).toBe(writer.deleteDocumentsByTerm('title', 'sea') - 1n) + writer.commit() + writer.waitMergingThreads() + index.reload() }) it('test_delete_documents_by_term', () => { @@ -1490,10 +1484,10 @@ describe('TestQuery', () => { expect((searchedDoc.toDict() as TestDoc).title).toEqual(['The Old Man and the Sea']) }) - it('test_phrase_prefix_query_requires_two_terms', () => { + it('test_phrase_prefix_query_empty_words', () => { expect(() => { - Query.phrasePrefixQuery(ramIndex.schema, 'body', ['old']) - }).toThrow('PhrasePrefixQuery requires at least two terms') + Query.phrasePrefixQuery(ramIndex.schema, 'body', []) + }).toThrow('words must not be empty') }) it('test_regex_phrase_query', () => { @@ -1510,7 +1504,7 @@ describe('TestQuery', () => { it('test_regex_phrase_query_empty_patterns', () => { expect(() => { Query.regexPhraseQuery(ramIndex.schema, 'body', []) - }).toThrow('patterns must not be empty') + }).toThrow('words must not be empty') }) it('test_index_exists', () => { @@ -1526,13 +1520,13 @@ describe('TestQuery', () => { // Test with text field (should be unsupported) expect(() => { - Query.rangeQuery(index.schema, 'title', FieldType.Str, 'a', 'z', true, true) + Query.rangeQuery(index.schema, 'title', FieldType.Text, 'a', 'z', true, true) }).toThrowErrorMatchingInlineSnapshot(`[Error: Text fields are not supported for range queries.]`) // Test with field that doesn't exist expect(() => { - Query.rangeQuery(index.schema, 'nonexistent', FieldType.I64, 1, 10, true, true) - }).toThrowErrorMatchingInlineSnapshot(`[Error: Field 'nonexistent' is not defined in the schema.]`) + Query.rangeQuery(index.schema, 'nonexistent', FieldType.Integer, 1, 10, true, true) + }).toThrowErrorMatchingInlineSnapshot(`[Error: Field \`nonexistent\` is not defined in the schema.]`) }) }) @@ -1603,6 +1597,14 @@ describe('TestTokenizers', () => { expect(analyzer.analyze(docText)).toEqual(['bad', 'wolf', 'buys', 'axe']) }) + for (const language of ['arabic', 'greek', 'romanian', 'tamil', 'turkish']) { + it(`test_build_tokenizer_w_stopword_filter_no_builtin_list_${language}`, () => { + // These languages have a stemmer, but tantivy has no builtin stop word list + const builder = new TextAnalyzerBuilder(TokenizerStatic.simple()) + expect(() => builder.filter(FilterStatic.stopword(language))).toThrow('stop word list') + }) + } + it('test_build_tokenizer_w_custom_stopwords_filter', () => { const analyzer = new TextAnalyzerBuilder(TokenizerStatic.simple()) .filter(FilterStatic.stopword('english')) @@ -1690,4 +1692,835 @@ describe('TestFacet', () => { const result = index.searcher().search(query) expect(result.hits.length).toBe(1) }) + + it('test_simple_search_facet', () => { + const indexSchema = new SchemaBuilder().addTextField('title', { stored: true }).addFacetField('category').build() + const index = new Index(indexSchema) + const writer = index.writer(15_000_000, 1) + + const doc = new Document() + doc.addText('title', 'Book about whales') + doc.addFacet('category', Facet.fromString('/books/fiction')) + writer.addDocument(doc) + writer.commit() + writer.waitMergingThreads() + index.reload() + + expect(index.searcher().search(index.parseQuery('+category:/books'), 10).hits.length).toBe(1) + expect(index.searcher().search(index.parseQuery('about', ['title']), 10).hits.length).toBe(1) + }) +}) + +describe('TestParseQueryOptions', () => { + it('test_parse_query_conjunction_by_default', () => { + // not a perfect comparison, but it's simple and does the job + expect(ramIndex.parseQuery('men winter', ['title'], undefined, undefined, false).toString()).toBe( + ramIndex.parseQuery('men OR winter', ['title'], undefined, undefined, true).toString(), + ) + expect(ramIndex.parseQuery('men winter', ['title'], undefined, undefined, true).toString()).toBe( + ramIndex.parseQuery('men AND winter', ['title'], undefined, undefined, true).toString(), + ) + }) + + it('test_parse_query_allow_regexes', () => { + const query = ramIndex.parseQuery('title:/(?:man|men)/', undefined, undefined, undefined, undefined, true) + const searcher = ramIndex.searcher() + const result = searcher.search(query, 10) + expect(result.hits.length).toBe(2) + const titles = result.hits.map((hit) => (searcher.doc(hit.docAddress).toDict() as TestDoc).title?.[0]) + expect(titles).toContain('The Old Man and the Sea') + expect(titles).toContain('Of Mice and Men') + + expect(() => ramIndex.parseQuery('title:/(?:man|men)/')).toThrow('Regex queries are not allowed') + + const [, errors] = ramIndex.parseQueryLenient('title:/(?:man|men)/') + expect(errors.length).toBe(1) + expect(errors[0]).toBeInstanceOf(UnsupportedQueryError) + }) +}) + +describe('TestAggregations', () => { + it('test_aggregate_terms', () => { + // terms aggregation on a fast text field returns buckets with doc counts + const searcher = ramIndexNumericFields.searcher() + const result = searcher.aggregate(Query.allQuery(), { + body_terms: { terms: { field: 'body', size: 5 } }, + }) as any + + const buckets = result.body_terms.buckets + expect(buckets.length).toBe(5) // capped by size=5 + for (const bucket of buckets) { + expect(typeof bucket.key).toBe('string') + expect(Number(bucket.doc_count)).toBeGreaterThanOrEqual(1) + } + // Results are sorted by doc_count descending. + const counts = buckets.map((b: any) => Number(b.doc_count)) + expect(counts).toEqual([...counts].sort((a, b) => b - a)) + // "and" appears in both documents so it has the highest doc_count (2). + const topKeys = buckets.filter((b: any) => b.doc_count === buckets[0].doc_count).map((b: any) => b.key) + expect(topKeys).toContain('and') + expect(Number(buckets[0].doc_count)).toBe(2) + }) + + it('test_cardinality', () => { + const searcher = ramIndexNumericFields.searcher() + const query = Query.allQuery() + + // rating has 2 unique values: 3.5 and 4.5 + expect(searcher.cardinality(query, 'rating')).toBe(2) + // id has 2 unique values: 1 and 2 + expect(searcher.cardinality(query, 'id')).toBe(2) + + // a query that filters to one document + const singleDoc = Query.termQuery(ramIndexNumericFields.schema, 'id', 1) + expect(searcher.cardinality(singleDoc, 'rating')).toBe(1) + + // the body text fast field contains many unique tokens + expect(searcher.cardinality(query, 'body')).toBeGreaterThan(10) + }) +}) + +describe('TestOrderByFastField', () => { + const cases: [string, number | boolean | string, number | boolean | string][] = [ + ['u64_field', 0, 2], + ['i64_field', -10, 5], + ['f64_field', 1.5, 3.14], + ['bool_field', false, true], + ['str_field', 'apple', 'cherry'], + ['date_field', Date.UTC(2024, 0, 1), Date.UTC(2026, 0, 1)], + ] + + for (const [field, lowValue, highValue] of cases) { + it(`test_order_by_fast_field_${field}`, () => { + const searcher = indexWithOrderFastFields.searcher() + const query = indexWithOrderFastFields.parseQuery('title', ['title']) + + let result = searcher.search(query, 10, true, field) + expect(result.hits.length).toBe(2) + expect(result.hits[0].order).toBe(highValue) + expect(result.hits[1].order).toBe(lowValue) + expect((searcher.doc(result.hits[0].docAddress).toDict() as TestDoc).title).toEqual(['high title']) + expect((searcher.doc(result.hits[1].docAddress).toDict() as TestDoc).title).toEqual(['low title']) + + result = searcher.search(query, 10, true, field, 0, Order.Asc) + expect(result.hits.length).toBe(2) + expect(result.hits[0].order).toBe(lowValue) + expect(result.hits[1].order).toBe(highValue) + expect((searcher.doc(result.hits[0].docAddress).toDict() as TestDoc).title).toEqual(['low title']) + expect((searcher.doc(result.hits[1].docAddress).toDict() as TestDoc).title).toEqual(['high title']) + }) + } +}) + +describe('TestIndexCompatibility', () => { + it('test_is_compatible_for_current_index', () => { + const dir = mkdtempSync(join(tmpdir(), 'tantivy-compat-')) + createIndex(dir) + expect(Index.isCompatible(dir)).toBe(true) + }) + + it('test_is_compatible_raises_for_missing_index', () => { + expect(() => Index.isCompatible(join(tempDir, 'does-not-exist'))).toThrow() + }) +}) + +describe('TestDateRoundtrip', () => { + it('test_date_index_roundtrip', () => { + const indexSchema = new SchemaBuilder() + .addDateField('date', { stored: true, indexed: true }) + .addTextField('title', { stored: true }) + .build() + const index = new Index(indexSchema) + const writer = index.writer() + + // JavaScript Date is always an absolute instant; a millisecond value + // survives the round-trip unchanged. + const utc = Date.UTC(2019, 7, 12, 13, 0, 0, 123) + const offset = new Date('2019-08-12T13:00:00+05:30').getTime() + + const doc1 = new Document() + doc1.addText('title', 'utc') + doc1.addDate('date', utc) + writer.addDocument(doc1) + + const doc2 = new Document() + doc2.addText('title', 'offset') + doc2.addDate('date', offset) + writer.addDocument(doc2) + + writer.commit() + index.reload() + + const searcher = index.searcher() + const hits = searcher.search(index.parseQuery('utc OR offset', ['title']), 10).hits + const byTitle = Object.fromEntries( + hits.map((hit) => { + const d = searcher.doc(hit.docAddress).toDict() as any + return [d.title[0], d.date[0]] + }), + ) + + expect(byTitle.utc).toBe(utc) + expect(byTitle.offset).toBe(Date.UTC(2019, 7, 12, 7, 30, 0)) + }) + + it('test_document_date_milliseconds_preserved', () => { + const date = Date.UTC(2019, 7, 12, 13, 0, 0, 123) + const doc = Document.fromDict({ name: 'Bill', date }) + expect(doc.getFirst('date')).toBe(date) + }) + + it('test_document_accepts_js_date', () => { + const indexSchema = new SchemaBuilder().addDateField('date', { stored: true, indexed: true }).build() + const date = new Date('2019-08-12T13:00:00.123Z') + const doc = Document.fromDict({ date }, indexSchema) + expect(doc.getFirst('date')).toBe(date.getTime()) + }) +}) + +describe('TestJsonFieldExpandDots', () => { + it('test_json_field_expand_dots_enabled', () => { + // Without expandDots, a literal "." in a JSON key is NOT treated as a + // path separator - querying it as a path must fail to match, and the + // literal key must be reachable only via an escaped dot. + const plainIndex = new Index(new SchemaBuilder().addJsonField('attrs', { stored: true }).build()) + let writer = plainIndex.writer() + const plainDoc = new Document() + plainDoc.addJson('attrs', { 'a.b': 'hello' }) + writer.addDocument(plainDoc) + writer.commit() + plainIndex.reload() + + expect(plainIndex.searcher().search(plainIndex.parseQuery('attrs.a.b:hello', ['attrs']), 10).hits.length).toBe(0) + expect(plainIndex.searcher().search(plainIndex.parseQuery('attrs.a\\.b:hello', ['attrs']), 10).hits.length).toBe(1) + + // With expandDots enabled, the same flat key "a.b" is treated as a + // nested path a -> b, so the unescaped dotted query now matches. + const expandIndex = new Index( + new SchemaBuilder().addJsonField('attrs', { stored: true, expandDotsEnabled: true }).build(), + ) + writer = expandIndex.writer() + const expandDoc = new Document() + expandDoc.addJson('attrs', { 'a.b': 'hello' }) + writer.addDocument(expandDoc) + writer.commit() + expandIndex.reload() + + expect(expandIndex.searcher().search(expandIndex.parseQuery('attrs.a.b:hello', ['attrs']), 10).hits.length).toBe(1) + }) +}) + +describe('TestQueryAdditions', () => { + it('test_empty_query', () => { + expect(ramIndex.searcher().search(Query.emptyQuery(), 10).hits.length).toBe(0) + }) + + it('test_exists_query', () => { + const query = Query.existsQuery('body') + expect(indexWithEmptyFastField.searcher().search(query, 10).hits.length).toBe(1) + }) + + it('test_not_exists_query', () => { + const query = Query.booleanQuery([ + { occur: Occur.Must, query: Query.allQuery() }, + { occur: Occur.MustNot, query: Query.existsQuery('body') }, + ]) + expect(indexWithEmptyFastField.searcher().search(query, 10).hits.length).toBe(2) + }) + + it('test_regex_phrase_query', () => { + const searcher = ramIndex.searcher() + const schema = ramIndex.schema + + // should match the title "The Old Man and the Sea" + expect(searcher.search(Query.regexPhraseQuery(schema, 'title', ['o.d', 'ma[nm]']), 10).hits.length).toBe(1) + // shouldn't match any document + expect(searcher.search(Query.regexPhraseQuery(schema, 'title', ['man', 'old']), 10).hits.length).toBe(0) + // should match "The Old Man and the Sea" with the given offsets + expect( + searcher.search( + Query.regexPhraseQuery(schema, 'title', [ + [1, '[a-m]an'], + [0, 'old'], + ]), + 10, + ).hits.length, + ).toBe(1) + // shouldn't match any document with the default slop of 0 + expect(searcher.search(Query.regexPhraseQuery(schema, 'title', ['ma.', 'se.']), 10).hits.length).toBe(0) + // should match the title "The Old Man and the Sea" with slop 2 + expect(searcher.search(Query.regexPhraseQuery(schema, 'title', ['ma.', 'se.'], 2), 10).hits.length).toBe(1) + + expect(() => Query.regexPhraseQuery(schema, 'title', [])).toThrow('words must not be empty.') + }) + + it('test_phrase_prefix_query', () => { + const searcher = ramIndex.searcher() + const schema = ramIndex.schema + + expect(searcher.search(Query.phrasePrefixQuery(schema, 'title', ['old', 'man']), 10).hits.length).toBe(1) + // should still match the title "The Old Man and the Sea" + expect(searcher.search(Query.phrasePrefixQuery(schema, 'title', ['old', 'ma']), 10).hits.length).toBe(1) + // shouldn't match any document + expect(searcher.search(Query.phrasePrefixQuery(schema, 'title', ['man', 'old']), 10).hits.length).toBe(0) + // should match "The Old Man and the Sea" with the given offsets + expect( + searcher.search( + Query.phrasePrefixQuery(schema, 'title', [ + [1, 'm'], + [0, 'old'], + ]), + 10, + ).hits.length, + ).toBe(1) + + expect(() => Query.phrasePrefixQuery(schema, 'title', [])).toThrow('words must not be empty.') + }) + + it('test_boolean_query_minimum_number_should_match', () => { + const schema = ramIndex.schema + const query1 = Query.termQuery(schema, 'title', 'sea') + const query2 = Query.termQuery(schema, 'title', 'mice') + const subqueries = [ + { occur: Occur.Should, query: query1 }, + { occur: Occur.Should, query: query2 }, + ] + + // no document matches both clauses + expect(ramIndex.searcher().search(Query.booleanQuery(subqueries, 2), 10).hits.length).toBe(0) + expect(ramIndex.searcher().search(Query.booleanQuery(subqueries, 1), 10).hits.length).toBe(2) + }) + + it('test_boolean_query_helpers', () => { + const searcher = ramIndex.searcher() + const schema = ramIndex.schema + + const querySea = Query.termQuery(schema, 'title', 'sea') + const queryMice = Query.termQuery(schema, 'title', 'mice') + const queryOld = Query.termQuery(schema, 'title', 'old') + const queryMan = Query.termQuery(schema, 'title', 'man') + + // No document contains both "sea" and "mice" in the title + expect(searcher.search(querySea.andMustMatch([queryMice]), 10).hits.length).toBe(0) + + // "The Old Man and the Sea" contains both "old" and "man" + let result = searcher.search(queryOld.andMustMatch([queryMan]), 10) + expect(result.hits.length).toBe(1) + expect((searcher.doc(result.hits[0].docAddress).toDict() as TestDoc).title).toEqual(['The Old Man and the Sea']) + + // the same, but through a long chain, which must stay flat + let chained = queryOld + for (let i = 0; i < 11; i++) chained = chained.andMustMatch([queryMan]) + expect(searcher.search(chained, 10).hits.length).toBe(1) + + // Should match documents containing either "sea" or "mice" + result = searcher.search(querySea.orShouldMatch([queryMice]), 10) + expect(result.hits.length).toBe(2) + + // All 3 docs contain "and" in the body; exclude the one titled "...Sea" + const queryAndBody = Query.termQuery(schema, 'body', 'and') + result = searcher.search(queryAndBody.andMustNotMatch([querySea]), 10) + expect(result.hits.length).toBe(2) + const titles = result.hits.map((hit) => (searcher.doc(hit.docAddress).toDict() as TestDoc).title?.[0]) + expect(titles).not.toContain('The Old Man and the Sea') + }) + + it('test_boolean_query_helpers_multiple_queries', () => { + const searcher = ramIndex.searcher() + const schema = ramIndex.schema + + const querySea = Query.termQuery(schema, 'title', 'sea') + const queryMice = Query.termQuery(schema, 'title', 'mice') + const queryOld = Query.termQuery(schema, 'title', 'old') + const queryMan = Query.termQuery(schema, 'title', 'man') + const queryFrankenstein = Query.termQuery(schema, 'title', 'frankenstein') + const queryAndBody = Query.termQuery(schema, 'body', 'and') + + expect(searcher.search(queryOld.andMustMatch([queryMan, querySea]), 10).hits.length).toBe(1) + expect(searcher.search(queryOld.orShouldMatch([queryMice, queryFrankenstein]), 10).hits.length).toBe(3) + + const result = searcher.search(queryAndBody.andMustNotMatch([querySea, queryMice]), 10) + expect(result.hits.length).toBe(1) + expect((searcher.doc(result.hits[0].docAddress).toDict() as TestDoc).title).toContain('Frankenstein') + + // Calling with no queries returns an equivalent query + expect(searcher.search(queryOld.andMustMatch([]), 10).hits.length).toBe(1) + }) + + it('test_boolean_query_helpers_mixed_chains', () => { + // Chains mixing AND and OR must group the left-hand side correctly. + const searcher = ramIndex.searcher() + const schema = ramIndex.schema + + const queryMice = Query.termQuery(schema, 'title', 'mice') + const queryOld = Query.termQuery(schema, 'title', 'old') + const queryMan = Query.termQuery(schema, 'title', 'man') + const queryFrankenstein = Query.termQuery(schema, 'title', 'frankenstein') + + // (old OR mice) AND frankenstein: no document satisfies both sides. + expect(searcher.search(queryOld.orShouldMatch([queryMice]).andMustMatch([queryFrankenstein]), 10).hits.length).toBe( + 0, + ) + + // (old AND man) OR mice: the AND group must stay grouped. + const result = searcher.search(queryOld.andMustMatch([queryMan]).orShouldMatch([queryMice]), 10) + expect(result.hits.length).toBe(2) + const titles = result.hits.map((hit) => (searcher.doc(hit.docAddress).toDict() as TestDoc).title?.[0]) + expect(titles.sort()).toEqual(['Of Mice and Men', 'The Old Man and the Sea']) + }) + + it('test_more_like_this_document_fields_query', () => { + const indexSchema = new SchemaBuilder() + .addUnsignedField('id', { stored: true, indexed: true }) + .addTextField('title') + .addTextField('body') + .build() + const index = new Index(indexSchema) + const writer = index.writer() + writer.addDocument(Document.fromDict({ id: 1, title: 'aaa', body: 'the old man and the sea' }, indexSchema)) + writer.addDocument(Document.fromDict({ id: 2, title: 'bbb', body: 'an old man sailing on the sea' }, indexSchema)) + writer.addDocument(Document.fromDict({ id: 3, title: 'ccc', body: 'send this message to alice' }, indexSchema)) + writer.commit() + writer.waitMergingThreads() + index.reload() + + const mltQuery = Query.moreLikeThisDocumentFieldsQuery( + indexSchema, + { title: 'aaa', body: 'the old man and the sea' }, + 1, + undefined, + 1, + 10, + ) + expect(mltQuery.toString()).toContain('target: DocumentFields(') + + const searcher = index.searcher() + const hitIds = searcher.search(mltQuery, 10).hits.map((hit) => (searcher.doc(hit.docAddress).toDict() as any).id[0]) + expect(hitIds.sort()).toEqual([1, 2]) + }) + + it('test_more_like_this_document_fields_query_rejects_unknown_fields', () => { + expect(() => Query.moreLikeThisDocumentFieldsQuery(ramIndex.schema, { unknown_field: 'value' })).toThrow( + 'Field `unknown_field` is not defined in the schema.', + ) + }) + + it('test_range_query_unbounded', () => { + const index = ramIndexNumericFields + const searcher = index.searcher() + + // unbounded upper (rating >= 4.0) + let result = searcher.search(Query.rangeQuery(index.schema, 'rating', FieldType.Float, 4.0, null), 10) + expect(result.hits.length).toBe(1) + expect((searcher.doc(result.hits[0].docAddress).toDict() as TestDoc).id).toEqual([2]) + + // unbounded lower (rating <= 4.0) + result = searcher.search(Query.rangeQuery(index.schema, 'rating', FieldType.Float, null, 4.0), 10) + expect(result.hits.length).toBe(1) + expect((searcher.doc(result.hits[0].docAddress).toDict() as TestDoc).id).toEqual([1]) + + // both bounds null is an error + expect(() => Query.rangeQuery(index.schema, 'rating', FieldType.Float, null, null)).toThrow('At least one') + + // integer field, unbounded upper (id >= 2) + result = searcher.search(Query.rangeQuery(index.schema, 'id', FieldType.Integer, 2, null), 10) + expect(result.hits.length).toBe(1) + expect((searcher.doc(result.hits[0].docAddress).toDict() as TestDoc).id).toEqual([2]) + + // integer field, unbounded lower (id <= 1) + result = searcher.search(Query.rangeQuery(index.schema, 'id', FieldType.Integer, null, 1), 10) + expect(result.hits.length).toBe(1) + expect((searcher.doc(result.hits[0].docAddress).toDict() as TestDoc).id).toEqual([1]) + }) + + it('test_range_query_contradictory_include_and_null_bound', () => { + const index = ramIndexNumericFields + expect(() => Query.rangeQuery(index.schema, 'rating', FieldType.Float, null, 4.0, false, true)).toThrow( + 'includeLower', + ) + expect(() => Query.rangeQuery(index.schema, 'rating', FieldType.Float, 3.0, null, true, false)).toThrow( + 'includeUpper', + ) + }) + + it('test_range_query_numerics_with_inverted_index', () => { + const index = ramIndexNumericFields + const searcher = index.searcher() + + // including both bounds + expect( + searcher.search(Query.rangeQuery(index.schema, 'id', FieldType.Integer, 1, 2, true, true, true), 10).hits.length, + ).toBe(2) + // excluding the lower bound + expect( + searcher.search(Query.rangeQuery(index.schema, 'id', FieldType.Integer, 1, 2, false, true, true), 10).hits.length, + ).toBe(1) + // unbounded upper (id >= 2) + expect( + searcher.search(Query.rangeQuery(index.schema, 'id', FieldType.Integer, 2, null, true, true, true), 10).hits + .length, + ).toBe(1) + }) + + it('test_term_query_dates', () => { + const index = ramIndexWithDateField + const searcher = index.searcher() + + let result = searcher.search(Query.termQuery(index.schema, 'date', Date.UTC(2021, 0, 1)), 10) + expect(result.hits.length).toBe(1) + expect((searcher.doc(result.hits[0].docAddress).toDict() as TestDoc).id).toEqual([1]) + + // a JS Date instance points at the same instant + result = searcher.search(Query.termQuery(index.schema, 'date', new Date('2021-01-01T00:00:00Z')), 10) + expect(result.hits.length).toBe(1) + expect((searcher.doc(result.hits[0].docAddress).toDict() as TestDoc).id).toEqual([1]) + + // a date no document was indexed with matches nothing + result = searcher.search(Query.termQuery(index.schema, 'date', Date.UTC(2021, 0, 3)), 10) + expect(result.hits.length).toBe(0) + }) + + it('test_term_set_query_dates', () => { + const index = ramIndexWithDateField + const searcher = index.searcher() + const query = Query.termSetQuery(index.schema, 'date', [new Date('2021-01-01T00:00:00Z'), Date.UTC(2021, 0, 2)]) + const result = searcher.search(query, 10) + expect(result.hits.length).toBe(2) + const ids = result.hits.map((hit) => (searcher.doc(hit.docAddress).toDict() as TestDoc).id?.[0]).sort() + expect(ids).toEqual([1, 2]) + }) + + it('test_term_query_unsigned_field', () => { + // term_query must correctly match documents on unsigned (u64) fields. + // A generic value extraction path infers integers as i64, which for a + // u64 schema field produces a mistyped Term that never matches. + const indexSchema = new SchemaBuilder() + .addUnsignedField('uid', { stored: true, indexed: true }) + .addTextField('body', { stored: true }) + .build() + const index = new Index(indexSchema) + const writer = index.writer(15_000_000, 1) + + const doc1 = new Document() + doc1.addUnsigned('uid', 1) + doc1.addText('body', 'hello world') + writer.addDocument(doc1) + + const doc2 = new Document() + doc2.addUnsigned('uid', 2) + doc2.addText('body', 'goodbye world') + writer.addDocument(doc2) + + writer.commit() + index.reload() + const searcher = index.searcher() + + let result = searcher.search(Query.termQuery(index.schema, 'uid', 1), 10) + expect(result.hits.length).toBe(1) + expect((searcher.doc(result.hits[0].docAddress).toDict() as any).uid[0]).toBe(1) + + result = searcher.search(Query.termQuery(index.schema, 'uid', 2), 10) + expect(result.hits.length).toBe(1) + expect((searcher.doc(result.hits[0].docAddress).toDict() as any).uid[0]).toBe(2) + + expect(searcher.search(Query.termQuery(index.schema, 'uid', 999), 10).hits.length).toBe(0) + }) + + it('test_bytes_term_query', () => { + const indexSchema = new SchemaBuilder().addBytesField('data', { indexed: true }).build() + const index = new Index(indexSchema) + const writer = index.writer() + + const doc1 = new Document() + doc1.addBytes('data', Buffer.from([1, 2, 3])) + writer.addDocument(doc1) + const doc2 = new Document() + doc2.addBytes('data', Buffer.from([4, 5, 6])) + writer.addDocument(doc2) + writer.commit() + index.reload() + + const query = Query.termQuery(indexSchema, 'data', Buffer.from([1, 2, 3])) + expect(index.searcher().search(query, 10).hits.length).toBe(1) + }) +}) + +describe('TestQueryGrammar', () => { + const valid = [ + 'hello world', + 'title:hello', + 'title:hello AND body:world', + 'title:hello OR title:world', + 'title:hello NOT body:spam', + '"hello world"', + 'year:[2000 TO 2020]', + 'title:hello^2.0', + 'title:hel*', + 'title:hello~2', + '', + ] + + for (const queryString of valid) { + it(`test_parse_query_valid: ${JSON.stringify(queryString)}`, () => { + const ast = parseQuery(queryString) + expect(ast).not.toBeNull() + expect(typeof ast).toBe('object') + }) + } + + it('test_parse_query_invalid', () => { + expect(() => parseQuery('title:')).toThrow() + }) + + for (const queryString of ['hello world', 'title: AND body:world', 'title:hello AND body:world OR author:john', '']) { + it(`test_parse_query_lenient: ${JSON.stringify(queryString)}`, () => { + const [ast, errors] = parseQueryLenient(queryString) + expect(ast).not.toBeNull() + expect(typeof ast).toBe('object') + expect(Array.isArray(errors)).toBe(true) + }) + } + + it('test_parse_query_lenient_valid_no_errors', () => { + const [ast, errors] = parseQueryLenient('hello world') + expect(ast).not.toBeNull() + expect(errors).toEqual([]) + }) +}) + +describe('TestFastFieldValues', () => { + let fastIndex: Index + + beforeAll(() => { + const indexSchema = new SchemaBuilder() + .addUnsignedField('doc_id', { stored: true, indexed: true, fast: true }) + .addIntegerField('rank', { stored: true, indexed: true, fast: true }) + .addFloatField('score', { stored: true, indexed: true, fast: true }) + .addBooleanField('active', { stored: true, indexed: true }) + .addBooleanField('flag', { stored: true, indexed: true, fast: true }) + .addTextField('body', { stored: true }) + .addTextField('tag', { stored: true, fast: true }) + .build() + fastIndex = new Index(indexSchema) + const writer = fastIndex.writer(15_000_000, 1) + + const rows: [number, number, number, boolean, string, string][] = [ + [101, -10, 1.5, true, 'alpha beta gamma', 'news'], + [202, 0, 2.5, false, 'beta gamma delta', 'sports'], + [303, 10, 0.5, true, 'gamma delta epsilon', 'news'], + ] + for (const [docId, rank, score, flag, body, tag] of rows) { + const doc = new Document() + doc.addUnsigned('doc_id', docId) + doc.addInteger('rank', rank) + doc.addFloat('score', score) + doc.addBoolean('active', flag) + doc.addBoolean('flag', flag) + doc.addText('body', body) + doc.addText('tag', tag) + writer.addDocument(doc) + } + writer.commit() + writer.waitMergingThreads() + fastIndex.reload() + }) + + const addressesFor = (term: string, limit = 10) => { + const searcher = fastIndex.searcher() + return searcher.search(fastIndex.parseQuery(term, ['body']), limit).hits.map((hit) => hit.docAddress) + } + + it('test_returns_values_in_hit_order', () => { + const searcher = fastIndex.searcher() + const addrs = addressesFor('beta') + const ids = searcher.fastFieldValues('doc_id', addrs) + expect(ids.length).toBe(addrs.length) + addrs.forEach((addr, i) => { + expect(ids[i]).toBe((searcher.doc(addr).toDict() as any).doc_id[0]) + }) + }) + + it('test_all_docs_covered', () => { + const ids = fastIndex.searcher().fastFieldValues('doc_id', addressesFor('gamma')) + expect(new Set(ids)).toEqual(new Set([101, 202, 303])) + }) + + it('test_empty_input_returns_empty', () => { + expect(fastIndex.searcher().fastFieldValues('doc_id', [])).toEqual([]) + }) + + it('test_single_match', () => { + expect(fastIndex.searcher().fastFieldValues('doc_id', addressesFor('alpha'))).toEqual([101]) + }) + + const typed: [string, unknown[]][] = [ + ['rank', [-10, 0, 10]], + ['score', [0.5, 1.5, 2.5]], + ['flag', [true, false]], + ] + for (const [fieldName, expected] of typed) { + it(`test_typed_fields_${fieldName}`, () => { + const values = fastIndex.searcher().fastFieldValues(fieldName, addressesFor('gamma')) + expect(new Set(values)).toEqual(new Set(expected)) + }) + } + + it('test_unknown_field_raises', () => { + expect(() => fastIndex.searcher().fastFieldValues('nonexistent', addressesFor('gamma', 1))).toThrow('Unknown field') + }) + + it('test_non_fast_field_raises', () => { + expect(() => fastIndex.searcher().fastFieldValues('active', addressesFor('gamma', 1))).toThrow('not a fast field') + }) + + it('test_unsupported_type_raises', () => { + // Text fast fields store term ids, not scalar values — unsupported. + expect(() => fastIndex.searcher().fastFieldValues('tag', addressesFor('gamma', 1))).toThrow('unsupported type') + }) +}) + +describe('TestTermsWithPrefix', () => { + let prefixIndex: Index + let multiSegIndex: Index + let filteredIndex: Index + + beforeAll(() => { + // Single-segment index with predictable term frequencies. + const prefixSchema = new SchemaBuilder() + .addTextField('body') + .addUnsignedField('owner_id', { stored: true, indexed: true, fast: true }) + .build() + prefixIndex = new Index(prefixSchema) + const prefixWriter = prefixIndex.writer(15_000_000, 1) + // apple: 2 docs, apricot: 1 doc, banana: 1 doc, cherry: 1 doc, date: 1 doc + for (const [body, owner] of [ + ['apple banana', 1], + ['apple apricot', 2], + ['cherry date', 1], + ] as [string, number][]) { + const doc = new Document() + doc.addText('body', body) + doc.addUnsigned('owner_id', owner) + prefixWriter.addDocument(doc) + } + prefixWriter.commit() + prefixWriter.waitMergingThreads() + prefixIndex.reload() + + // Two-segment index. waitMergingThreads() is intentionally omitted: + // calling it lets the default merge policy collapse the two small + // segments into one, which would defeat the purpose of this fixture. + multiSegIndex = new Index(new SchemaBuilder().addTextField('body').build()) + for (const body of ['apple banana', 'apple apricot']) { + const writer = multiSegIndex.writer(15_000_000, 1) + const doc = new Document() + doc.addText('body', body) + writer.addDocument(doc) + writer.commit() + // Release the directory lock so the next writer can be created; this is + // what tantivy-py's `with index.writer() as writer:` block does on exit. + writer.waitMergingThreads() + multiSegIndex.reload() + } + + // 4-doc index: 2 owned by user 1, 2 owned by user 2. All docs contain + // 'apple'; 'exclusive' only in user-2 docs. + filteredIndex = new Index( + new SchemaBuilder() + .addTextField('body') + .addUnsignedField('owner_id', { stored: true, indexed: true, fast: true }) + .build(), + ) + const filteredWriter = filteredIndex.writer(15_000_000, 1) + for (const [body, owner] of [ + ['apple common', 1], + ['apple common', 1], + ['apple exclusive', 2], + ['apple exclusive', 2], + ] as [string, number][]) { + const doc = new Document() + doc.addText('body', body) + doc.addUnsigned('owner_id', owner) + filteredWriter.addDocument(doc) + } + filteredWriter.commit() + filteredWriter.waitMergingThreads() + filteredIndex.reload() + }) + + const countsOf = (results: { term: string; count: number }[]) => + Object.fromEntries(results.map(({ term, count }) => [term, count])) + + it('test_basic_prefix_match', () => { + const results = prefixIndex.searcher().termsWithPrefix('body', 'ap') + const counts = countsOf(results) + expect(Object.keys(counts).sort()).toEqual(['apple', 'apricot']) + expect(counts.apple).toBe(2) + expect(counts.apricot).toBe(1) + }) + + it('test_sorted_by_count_descending_then_alpha', () => { + const results = prefixIndex.searcher().termsWithPrefix('body', '') + const counts = results.map((r) => r.count) + expect(counts).toEqual([...counts].sort((a, b) => b - a)) + // Ties are broken alphabetically within each count group. + for (let i = 1; i < results.length; i++) { + if (results[i].count === results[i - 1].count) { + expect(results[i - 1].term < results[i].term).toBe(true) + } + } + }) + + it('test_multi_segment_counts_summed', () => { + expect(multiSegIndex.searcher().numSegments).toBeGreaterThanOrEqual(2) + const counts = countsOf(multiSegIndex.searcher().termsWithPrefix('body', 'ap')) + expect(counts.apple).toBe(2) + expect(counts.apricot).toBe(1) + }) + + it('test_filter_query_path', () => { + const searcher = filteredIndex.searcher() + const filterQuery = Query.termQuery(filteredIndex.schema, 'owner_id', 2) + const counts = countsOf(searcher.termsWithPrefix('body', '', filterQuery)) + // user 2 has 2 docs each containing 'apple' and 'exclusive' + expect(counts.apple).toBe(2) + expect(counts.common).toBeUndefined() + expect(counts.exclusive).toBe(2) + }) + + it('test_filter_matches_nothing', () => { + const filterQuery = Query.termQuery(filteredIndex.schema, 'owner_id', 99) + expect(filteredIndex.searcher().termsWithPrefix('body', 'ap', filterQuery)).toEqual([]) + }) + + it('test_limit_truncation', () => { + const searcher = prefixIndex.searcher() + const all = searcher.termsWithPrefix('body', '') + const limited = searcher.termsWithPrefix('body', '', undefined, 2) + expect(limited.length).toBe(2) + expect(limited).toEqual(all.slice(0, 2)) + }) + + it('test_empty_prefix_returns_all_terms', () => { + const terms = prefixIndex + .searcher() + .termsWithPrefix('body', '') + .map((r) => r.term) + expect(terms.sort()).toEqual(['apple', 'apricot', 'banana', 'cherry', 'date']) + }) + + it('test_unknown_field_raises', () => { + expect(() => prefixIndex.searcher().termsWithPrefix('nonexistent', 'ap')).toThrow('is not defined in the schema') + }) + + it('test_non_text_field_raises', () => { + expect(() => prefixIndex.searcher().termsWithPrefix('owner_id', 'ap')).toThrow('not an indexed text field') + }) + + it('test_no_filter_equals_all_docs_filter', () => { + const searcher = filteredIndex.searcher() + const noFilter = searcher.termsWithPrefix('body', '') + const allDocs = filteredIndex.parseQuery('apple OR common OR exclusive', ['body']) + expect(searcher.termsWithPrefix('body', '', allDocs)).toEqual(noFilter) + }) }) diff --git a/__test__/test-document-scoring.spec.ts b/__test__/test-document-scoring.spec.ts new file mode 100644 index 0000000..0accdd8 --- /dev/null +++ b/__test__/test-document-scoring.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' + +import { Document, Index, SchemaBuilder } from '../index' + +const scoringSchema = () => + new SchemaBuilder() + .addIntegerField('id', { stored: true, indexed: true, fast: true }) + .addFloatField('weight_f64', { stored: true, indexed: true, fast: true }) + .addIntegerField('weight_i64', { stored: true, indexed: true, fast: true }) + .addUnsignedField('weight_u64', { stored: true, indexed: true, fast: true }) + .addTextField('body', { stored: true, fast: true }) + .build() + +describe('TestDocumentScoring', () => { + for (const weightByField of ['weight_f64', 'weight_i64', 'weight_u64']) { + it(`test_document_scoring_${weightByField}`, () => { + const index = new Index(scoringSchema()) + const writer = index.writer(15_000_000, 1) + + const doc1 = new Document() + doc1.addInteger('id', 1) + doc1.addFloat('weight_f64', 0.1) + doc1.addInteger('weight_i64', 1) + doc1.addUnsigned('weight_u64', 1) + doc1.addText('body', 'apple banana orange mango') + writer.addDocument(doc1) + + const doc2 = new Document() + doc2.addInteger('id', 2) + doc2.addFloat('weight_f64', 0.9) + doc2.addInteger('weight_i64', 10) + doc2.addUnsigned('weight_u64', 10) + doc2.addText('body', 'pear lemon tomato banana') + writer.addDocument(doc2) + + writer.commit() + writer.waitMergingThreads() + index.reload() + + const searcher = index.searcher() + const query = index.parseQuery('body:banana') + + // Without weighting, the shorter-field document wins on BM25. + let results = searcher.search(query, 1) + expect(results.hits.length).toBe(1) + expect((searcher.doc(results.hits[0].docAddress).toDict() as any).id).toEqual([1]) + + // Weighting by a fast field promotes the heavier document. + results = searcher.search(query, 1, true, undefined, undefined, undefined, weightByField) + expect(results.hits.length).toBe(1) + expect((searcher.doc(results.hits[0].docAddress).toDict() as any).id).toEqual([2]) + }) + } + + it('test_not_fastfield', () => { + const index = new Index( + new SchemaBuilder() + .addIntegerField('id', { stored: true, indexed: true, fast: true }) + .addFloatField('weight_f64', { stored: true, indexed: true, fast: false }) + .addTextField('body', { stored: true, fast: true }) + .build(), + ) + index.reload() + + const searcher = index.searcher() + const query = index.parseQuery('body:banana') + expect(() => searcher.search(query, 1, true, undefined, undefined, undefined, 'weight_f64')).toThrow( + 'not a fast field', + ) + }) +}) diff --git a/docs/tutorials.md b/docs/tutorials.md index c3a522d..019839c 100644 --- a/docs/tutorials.md +++ b/docs/tutorials.md @@ -288,6 +288,245 @@ console.assert(results.hits.length === 1, 'Should find one document with complex console.assert(results.hits[0].score !== undefined && results.hits[0].score > 0, 'Result should have a positive score') ``` +## Combining Queries Fluently + +`Query.booleanQuery()` is explicit but verbose for the common case of "this and that". Every query also carries three helpers — `andMustMatch`, `orShouldMatch` and `andMustNotMatch` — each taking a list of queries. + +Chaining the same operator keeps the result flat rather than nesting one level per call, and mixing operators groups the left-hand side, so `a.andMustMatch([b]).orShouldMatch([c])` means `(a AND b) OR c`. + + + +```typescript +import { SchemaBuilder, Index, Document, Query } from '@oxdev03/node-tantivy-binding' + +// Setup index with three books +const schemaBuilder = new SchemaBuilder() +schemaBuilder.addTextField('title', { stored: true }) +schemaBuilder.addTextField('body', { stored: true }) +const schema = schemaBuilder.build() + +const index = new Index(schema) +const writer = index.writer() + +for (const [title, body] of [ + ['The Old Man and the Sea', 'He was an old man who fished alone in a skiff in the Gulf Stream.'], + ['Of Mice and Men', 'A few miles south of Soledad, the Salinas River drops in close to the hillside bank.'], + ['Frankenstein', 'You will rejoice to hear that no disaster has accompanied the commencement.'], +]) { + const doc = new Document() + doc.addText('title', title) + doc.addText('body', body) + writer.addDocument(doc) +} +writer.commit() +writer.waitMergingThreads() +index.reload() + +const searcher = index.searcher() +const old = Query.termQuery(schema, 'title', 'old') +const man = Query.termQuery(schema, 'title', 'man') +const mice = Query.termQuery(schema, 'title', 'mice') + +// AND: both terms must appear in the title +const both = old.andMustMatch([man]) +console.log('old AND man:', searcher.search(both, 10).hits.length) + +// OR: either query may match. Several queries can be passed at once +const either = old.orShouldMatch([mice]) +console.log('old OR mice:', searcher.search(either, 10).hits.length) + +// AND NOT: exclude documents matching the given queries +const withoutSea = Query.termQuery(schema, 'body', 'the').andMustNotMatch([old]) +console.log('the AND NOT old:', searcher.search(withoutSea, 10).hits.length) + +// Chains stay flat, and mixing AND with OR groups the left-hand side: +// (old AND man) OR mice +const mixed = old.andMustMatch([man]).orShouldMatch([mice]) + +// Assertions +console.assert(searcher.search(both, 10).hits.length === 1, 'old AND man should match one document') +console.assert(searcher.search(either, 10).hits.length === 2, 'old OR mice should match two documents') +console.assert(searcher.search(withoutSea, 10).hits.length === 2, 'excluding "old" should leave two documents') +console.assert(searcher.search(mixed, 10).hits.length === 2, '(old AND man) OR mice should match two documents') +``` + +## Aggregating Results + +`searcher.aggregate()` takes an aggregation spec as a plain object and returns the result as one. Every aggregated field must be declared `fast` in the schema. `searcher.cardinality()` is a shorthand for the distinct-value count of a single field. + + + +```typescript +import { SchemaBuilder, Index, Document, Query } from '@oxdev03/node-tantivy-binding' + +// Aggregations run over fast fields, so every aggregated field must be fast +const schemaBuilder = new SchemaBuilder() +schemaBuilder.addTextField('category', { stored: true, fast: true, tokenizerName: 'raw' }) +schemaBuilder.addFloatField('price', { stored: true, indexed: true, fast: true }) +const schema = schemaBuilder.build() + +const index = new Index(schema) +const writer = index.writer() + +for (const [category, price] of [ + ['books', 12.5], + ['books', 30.0], + ['music', 9.99], + ['music', 9.99], +] as [string, number][]) { + const doc = new Document() + doc.addText('category', category) + doc.addFloat('price', price) + writer.addDocument(doc) +} +writer.commit() +writer.waitMergingThreads() +index.reload() + +const searcher = index.searcher() +const all = Query.allQuery() + +// Aggregation specs are plain objects, and the result comes back as one too +const result = searcher.aggregate(all, { + by_category: { + terms: { field: 'category' }, + aggs: { avg_price: { avg: { field: 'price' } } }, + }, +}) as any + +for (const bucket of result.by_category.buckets) { + console.log(`${bucket.key}: ${bucket.doc_count} items, avg ${bucket.avg_price.value}`) +} + +// cardinality() is a shorthand for the distinct-value count of one field +const distinctPrices = searcher.cardinality(all, 'price') +console.log('distinct prices:', distinctPrices) + +// Assertions +console.assert(result.by_category.buckets.length === 2, 'Should produce one bucket per category') +console.assert(distinctPrices === 3, 'There are three distinct prices') +``` + +## Autocomplete and Fast-field Reads + +`searcher.termsWithPrefix()` walks the term dictionary of a text field and returns each matching term with the number of documents containing it, sorted by count. An optional filter query scopes those counts, which is what you want when results must respect per-user visibility. + +When you only need one numeric field for many hits, `searcher.fastFieldValues()` reads it straight out of the column instead of fetching each stored document. + + + +```typescript +import { SchemaBuilder, Index, Document, Query } from '@oxdev03/node-tantivy-binding' + +// termsWithPrefix walks the term dictionary, so the field only needs indexing. +// fastFieldValues reads a column, so that field must be declared fast. +const schemaBuilder = new SchemaBuilder() +schemaBuilder.addTextField('body') +schemaBuilder.addUnsignedField('owner_id', { stored: true, indexed: true, fast: true }) +const schema = schemaBuilder.build() + +const index = new Index(schema) +const writer = index.writer() + +for (const [body, owner] of [ + ['apple banana', 1], + ['apple apricot', 2], + ['cherry date', 1], +] as [string, number][]) { + const doc = new Document() + doc.addText('body', body) + doc.addUnsigned('owner_id', owner) + writer.addDocument(doc) +} +writer.commit() +writer.waitMergingThreads() +index.reload() + +const searcher = index.searcher() + +// Suggestions for a prefix, sorted by document frequency then alphabetically +const suggestions = searcher.termsWithPrefix('body', 'ap') +for (const { term, count } of suggestions) { + console.log(`${term} (${count})`) +} + +// A filter query scopes the counts, e.g. to documents the user may see +const ownedByUser2 = Query.termQuery(schema, 'owner_id', 2) +const scoped = searcher.termsWithPrefix('body', 'ap', ownedByUser2, 5) + +// Reading one numeric column for many hits is far cheaper than fetching +// each stored document just to pull a single field out of it +const hits = searcher.search(index.parseQuery('apple', ['body']), 10).hits +const owners = searcher.fastFieldValues( + 'owner_id', + hits.map((hit) => hit.docAddress), +) +console.log('owners of matching documents:', owners) + +// Assertions +console.assert(suggestions[0].term === 'apple' && suggestions[0].count === 2, 'apple appears in two documents') +console.assert(scoped.length === 2, 'user 2 sees both apple and apricot') +console.assert(owners.length === 2, 'One value per hit, in hit order') +``` + +## Ordering and Weighting Results + +By default results come back ranked by BM25 relevance, and each hit carries a `score`. Passing `orderByField` replaces that with the value of a fast field — each hit then carries `order` instead, typed after the field (number, boolean or string; dates arrive as milliseconds since the epoch). + +`weightByField` is the middle ground: relevance still decides the ranking, but each score is multiplied by `log2(2 + fieldValue)`, so a popularity signal can nudge results without overriding the text match. + + + +```typescript +import { SchemaBuilder, Index, Document, Order } from '@oxdev03/node-tantivy-binding' + +// Both ordering and weighting read from fast fields +const schemaBuilder = new SchemaBuilder() +schemaBuilder.addTextField('title', { stored: true }) +schemaBuilder.addUnsignedField('views', { stored: true, indexed: true, fast: true }) +const schema = schemaBuilder.build() + +const index = new Index(schema) +const writer = index.writer() + +for (const [title, views] of [ + ['tantivy basics', 10], + ['tantivy advanced', 500], +] as [string, number][]) { + const doc = new Document() + doc.addText('title', title) + doc.addUnsigned('views', views) + writer.addDocument(doc) +} +writer.commit() +writer.waitMergingThreads() +index.reload() + +const searcher = index.searcher() +const query = index.parseQuery('tantivy', ['title']) + +// orderByField replaces the relevance score with the field value. Each hit +// then carries `order` instead of `score`, typed after the field. +const byViews = searcher.search(query, 10, true, 'views') +console.log('most viewed first:', byViews.hits[0].order) + +// Ascending order, and an offset, are available too +const leastViewed = searcher.search(query, 10, true, 'views', 0, Order.Asc) + +// weightByField keeps BM25 relevance but multiplies each score by +// log2(2 + fieldValue), so popular documents float up without ignoring the text +const weighted = searcher.search(query, 10, true, undefined, undefined, undefined, 'views') + +// Assertions +console.assert(byViews.hits[0].order === 500, 'Descending order puts the most viewed first') +console.assert(leastViewed.hits[0].order === 10, 'Ascending order puts the least viewed first') +console.assert(weighted.hits[0].score !== undefined, 'Weighted search still produces a score') +console.assert( + (searcher.doc(weighted.hits[0].docAddress).toDict() as any).title[0] === 'tantivy advanced', + 'The heavier document wins once weighted', +) +``` + ## Debugging Queries with explain() When working with search queries, it's often useful to understand why a particular document matched a query and how its score was calculated. The `explain()` method provides detailed information about the scoring process. diff --git a/examples/aggregations.ts b/examples/aggregations.ts new file mode 100644 index 0000000..bb2ea8f --- /dev/null +++ b/examples/aggregations.ts @@ -0,0 +1,48 @@ +import { SchemaBuilder, Index, Document, Query } from '../index' + +// Aggregations run over fast fields, so every aggregated field must be fast +const schemaBuilder = new SchemaBuilder() +schemaBuilder.addTextField('category', { stored: true, fast: true, tokenizerName: 'raw' }) +schemaBuilder.addFloatField('price', { stored: true, indexed: true, fast: true }) +const schema = schemaBuilder.build() + +const index = new Index(schema) +const writer = index.writer() + +for (const [category, price] of [ + ['books', 12.5], + ['books', 30.0], + ['music', 9.99], + ['music', 9.99], +] as [string, number][]) { + const doc = new Document() + doc.addText('category', category) + doc.addFloat('price', price) + writer.addDocument(doc) +} +writer.commit() +writer.waitMergingThreads() +index.reload() + +const searcher = index.searcher() +const all = Query.allQuery() + +// Aggregation specs are plain objects, and the result comes back as one too +const result = searcher.aggregate(all, { + by_category: { + terms: { field: 'category' }, + aggs: { avg_price: { avg: { field: 'price' } } }, + }, +}) as any + +for (const bucket of result.by_category.buckets) { + console.log(`${bucket.key}: ${bucket.doc_count} items, avg ${bucket.avg_price.value}`) +} + +// cardinality() is a shorthand for the distinct-value count of one field +const distinctPrices = searcher.cardinality(all, 'price') +console.log('distinct prices:', distinctPrices) + +// Assertions +console.assert(result.by_category.buckets.length === 2, 'Should produce one bucket per category') +console.assert(distinctPrices === 3, 'There are three distinct prices') diff --git a/examples/autocomplete.ts b/examples/autocomplete.ts new file mode 100644 index 0000000..f81e827 --- /dev/null +++ b/examples/autocomplete.ts @@ -0,0 +1,51 @@ +import { SchemaBuilder, Index, Document, Query } from '../index' + +// termsWithPrefix walks the term dictionary, so the field only needs indexing. +// fastFieldValues reads a column, so that field must be declared fast. +const schemaBuilder = new SchemaBuilder() +schemaBuilder.addTextField('body') +schemaBuilder.addUnsignedField('owner_id', { stored: true, indexed: true, fast: true }) +const schema = schemaBuilder.build() + +const index = new Index(schema) +const writer = index.writer() + +for (const [body, owner] of [ + ['apple banana', 1], + ['apple apricot', 2], + ['cherry date', 1], +] as [string, number][]) { + const doc = new Document() + doc.addText('body', body) + doc.addUnsigned('owner_id', owner) + writer.addDocument(doc) +} +writer.commit() +writer.waitMergingThreads() +index.reload() + +const searcher = index.searcher() + +// Suggestions for a prefix, sorted by document frequency then alphabetically +const suggestions = searcher.termsWithPrefix('body', 'ap') +for (const { term, count } of suggestions) { + console.log(`${term} (${count})`) +} + +// A filter query scopes the counts, e.g. to documents the user may see +const ownedByUser2 = Query.termQuery(schema, 'owner_id', 2) +const scoped = searcher.termsWithPrefix('body', 'ap', ownedByUser2, 5) + +// Reading one numeric column for many hits is far cheaper than fetching +// each stored document just to pull a single field out of it +const hits = searcher.search(index.parseQuery('apple', ['body']), 10).hits +const owners = searcher.fastFieldValues( + 'owner_id', + hits.map((hit) => hit.docAddress), +) +console.log('owners of matching documents:', owners) + +// Assertions +console.assert(suggestions[0].term === 'apple' && suggestions[0].count === 2, 'apple appears in two documents') +console.assert(scoped.length === 2, 'user 2 sees both apple and apricot') +console.assert(owners.length === 2, 'One value per hit, in hit order') diff --git a/examples/boolean-query-helpers.ts b/examples/boolean-query-helpers.ts new file mode 100644 index 0000000..4afbd8c --- /dev/null +++ b/examples/boolean-query-helpers.ts @@ -0,0 +1,51 @@ +import { SchemaBuilder, Index, Document, Query } from '../index' + +// Setup index with three books +const schemaBuilder = new SchemaBuilder() +schemaBuilder.addTextField('title', { stored: true }) +schemaBuilder.addTextField('body', { stored: true }) +const schema = schemaBuilder.build() + +const index = new Index(schema) +const writer = index.writer() + +for (const [title, body] of [ + ['The Old Man and the Sea', 'He was an old man who fished alone in a skiff in the Gulf Stream.'], + ['Of Mice and Men', 'A few miles south of Soledad, the Salinas River drops in close to the hillside bank.'], + ['Frankenstein', 'You will rejoice to hear that no disaster has accompanied the commencement.'], +]) { + const doc = new Document() + doc.addText('title', title) + doc.addText('body', body) + writer.addDocument(doc) +} +writer.commit() +writer.waitMergingThreads() +index.reload() + +const searcher = index.searcher() +const old = Query.termQuery(schema, 'title', 'old') +const man = Query.termQuery(schema, 'title', 'man') +const mice = Query.termQuery(schema, 'title', 'mice') + +// AND: both terms must appear in the title +const both = old.andMustMatch([man]) +console.log('old AND man:', searcher.search(both, 10).hits.length) + +// OR: either query may match. Several queries can be passed at once +const either = old.orShouldMatch([mice]) +console.log('old OR mice:', searcher.search(either, 10).hits.length) + +// AND NOT: exclude documents matching the given queries +const withoutSea = Query.termQuery(schema, 'body', 'the').andMustNotMatch([old]) +console.log('the AND NOT old:', searcher.search(withoutSea, 10).hits.length) + +// Chains stay flat, and mixing AND with OR groups the left-hand side: +// (old AND man) OR mice +const mixed = old.andMustMatch([man]).orShouldMatch([mice]) + +// Assertions +console.assert(searcher.search(both, 10).hits.length === 1, 'old AND man should match one document') +console.assert(searcher.search(either, 10).hits.length === 2, 'old OR mice should match two documents') +console.assert(searcher.search(withoutSea, 10).hits.length === 2, 'excluding "old" should leave two documents') +console.assert(searcher.search(mixed, 10).hits.length === 2, '(old AND man) OR mice should match two documents') diff --git a/examples/sorted-search.ts b/examples/sorted-search.ts new file mode 100644 index 0000000..20deaed --- /dev/null +++ b/examples/sorted-search.ts @@ -0,0 +1,47 @@ +import { SchemaBuilder, Index, Document, Order } from '../index' + +// Both ordering and weighting read from fast fields +const schemaBuilder = new SchemaBuilder() +schemaBuilder.addTextField('title', { stored: true }) +schemaBuilder.addUnsignedField('views', { stored: true, indexed: true, fast: true }) +const schema = schemaBuilder.build() + +const index = new Index(schema) +const writer = index.writer() + +for (const [title, views] of [ + ['tantivy basics', 10], + ['tantivy advanced', 500], +] as [string, number][]) { + const doc = new Document() + doc.addText('title', title) + doc.addUnsigned('views', views) + writer.addDocument(doc) +} +writer.commit() +writer.waitMergingThreads() +index.reload() + +const searcher = index.searcher() +const query = index.parseQuery('tantivy', ['title']) + +// orderByField replaces the relevance score with the field value. Each hit +// then carries `order` instead of `score`, typed after the field. +const byViews = searcher.search(query, 10, true, 'views') +console.log('most viewed first:', byViews.hits[0].order) + +// Ascending order, and an offset, are available too +const leastViewed = searcher.search(query, 10, true, 'views', 0, Order.Asc) + +// weightByField keeps BM25 relevance but multiplies each score by +// log2(2 + fieldValue), so popular documents float up without ignoring the text +const weighted = searcher.search(query, 10, true, undefined, undefined, undefined, 'views') + +// Assertions +console.assert(byViews.hits[0].order === 500, 'Descending order puts the most viewed first') +console.assert(leastViewed.hits[0].order === 10, 'Ascending order puts the least viewed first') +console.assert(weighted.hits[0].score !== undefined, 'Weighted search still produces a score') +console.assert( + (searcher.doc(weighted.hits[0].docAddress).toDict() as any).title[0] === 'tantivy advanced', + 'The heavier document wins once weighted', +) diff --git a/index.d.ts b/index.d.ts index 70e6ca7..61a8313 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,14 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ + +/** + * Which binding artifact the generated loader actually loaded: `'native'` for + * a native addon, otherwise the `platformArchABI` of the WASI flavor. Every + * flavor napi-rs can build is listed, because `NAPI_RS_NATIVE_LIBRARY_PATH` + * can point the loader at a WASI artifact this package does not build itself. + */ +export declare const __napiBindingTarget: 'native' | 'wasm32-wasi' | 'wasm32-wasip1' + /** It is forbidden queries that are only "excluding". (e.g. -title:pop) */ export declare class AllButQueryForbiddenError { toString(): string @@ -310,9 +319,7 @@ export declare class FieldNotIndexedError { toString(): string } -export declare class Filter { - -} +export declare class Filter {} export declare class FilterStatic { /** AlphaNumOnlyFilter */ @@ -333,10 +340,12 @@ export declare class FilterStatic { * * @param language - Stop words list language. * Valid values: { - * "arabic", "danish", "dutch", "english", "finnish", "french", "german", "greek", - * "hungarian", "italian", "norwegian", "portuguese", "romanian", "russian", - * "spanish", "swedish", "tamil", "turkish" + * "danish", "dutch", "english", "finnish", "french", "german", "hungarian", + * "italian", "norwegian", "portuguese", "russian", "spanish", "swedish" * } + * + * Adding this filter to a builder throws for any other language, including + * stemmer languages without a builtin stop word list. */ static stopword(language: string): Filter /** @@ -427,6 +436,25 @@ export declare class Index { * Raises error if the directory cannot be opened. */ static exists(path: string): boolean + /** + * Check whether the index stored at `path` can be opened by this version + * of tantivy. + * + * Tantivy stores the index format version in each segment file. When that + * version falls outside the range supported by the bundled tantivy, the + * index cannot be opened. This method reports that without throwing, so a + * caller can decide how to handle an incompatible index (for example, by + * rebuilding it). + * + * @param path - The directory containing the index. + * + * @returns True if the index is compatible, false if it was built with an + * unsupported index format version. + * + * @throws if no index could be found at the given path or if it could not + * be read for any other reason. + */ + static isCompatible(path: string): boolean /** The schema of the current index. */ get schema(): Schema /** @@ -454,8 +482,20 @@ export declare class Index { * `prefix` determines if terms which are prefixes of the given term match the query. * `distance` determines the maximum Levenshtein distance between terms matching the query and the given term. * `transpose_cost_one` determines if transpositions of neighbouring characters are counted only once against the Levenshtein distance. + * + * @param conjunctionByDefault - If true, the query will be parsed as a + * conjunction query. Defaults to a disjunction query. + * + * @param allowRegexes - If true, allow regexes in queries. */ - parseQuery(query: string, defaultFieldNames?: Array | undefined | null, fieldBoosts?: Record | undefined | null, fuzzyFields?: Record | undefined | null): Query + parseQuery( + query: string, + defaultFieldNames?: Array | undefined | null, + fieldBoosts?: Record | undefined | null, + fuzzyFields?: Record | undefined | null, + conjunctionByDefault?: boolean | undefined | null, + allowRegexes?: boolean | undefined | null, + ): Query /** * Parse a query leniently. * @@ -479,15 +519,35 @@ export declare class Index { * `distance` determines the maximum Levenshtein distance between terms matching the query and the given term. * `transpose_cost_one` determines if transpositions of neighbouring characters are counted only once against the Levenshtein distance. * - * Returns a tuple containing the parsed query and a list of error messages. + * @param conjunctionByDefault - If true, the query will be parsed as a + * conjunction query. Defaults to a disjunction query. + * + * @param allowRegexes - If true, allow regexes in queries. + * + * Returns a tuple containing the parsed query and a list of errors. Each + * error is an instance of one of the exported query parser error classes, + * so it can be matched with `instanceof`. */ - parseQueryLenient(query: string, defaultFieldNames?: Array | undefined | null, fieldBoosts?: Record | undefined | null, fuzzyFields?: Record | undefined | null): [Query, Array] + parseQueryLenient( + query: string, + defaultFieldNames?: Array | undefined | null, + fieldBoosts?: Record | undefined | null, + fuzzyFields?: Record | undefined | null, + conjunctionByDefault?: boolean | undefined | null, + allowRegexes?: boolean | undefined | null, + ): [Query, Array] /** * Register a custom text analyzer by name. (Confusingly, * this is one of the places where Tantivy uses 'tokenizer' to refer to a * TextAnalyzer instance.) */ registerTokenizer(name: string, analyzer: TextAnalyzer): void + /** + * Register a custom text analyzer for fast fields by name. (Confusingly, + * this is one of the places where Tantivy uses 'tokenizer' to refer to a + * TextAnalyzer instance.) + */ + registerFastFieldTokenizer(name: string, analyzer: TextAnalyzer): void } /** @@ -539,12 +599,7 @@ export declare class IndexWriter { * was after the last commit. */ rollback(): bigint - /** - * Detect and removes the files that are not used by the index anymore. - * - * Note: This is currently a no-op. Tantivy's garbage collection requires - * an async runtime. A future version may implement this properly. - */ + /** Detect and removes the files that are not used by the index anymore. */ garbageCollectFiles(): void /** Deletes all documents from the index. */ deleteAllDocuments(): void @@ -558,6 +613,18 @@ export declare class IndexWriter { * for searchers. */ get commitOpstamp(): bigint + /** + * @deprecated Use `deleteDocumentsByTerm` or `deleteDocumentsByQuery` instead. + * + * Kept as an alias of `deleteDocumentsByTerm` for parity with tantivy-py. + * The Rust `#[deprecated]` attribute is deliberately not used here: it fires + * on napi's own generated glue rather than on the caller, and the JSDoc tag + * is what actually reaches TypeScript users through `index.d.ts`. + * + * @param fieldName - The field name for which we want to filter deleted docs. + * @param fieldValue - JavaScript value with the value we want to filter. + */ + deleteDocuments(fieldName: string, fieldValue: unknown): bigint /** * Delete all documents containing a given term. * @@ -621,7 +688,12 @@ export declare class PhrasePrefixRequiresAtLeastTwoTermsError { export declare class Query { toString(): string /** Construct a Tantivy's TermQuery */ - static termQuery(schema: Schema, fieldName: string, fieldValue: unknown, indexOption?: string | undefined | null): Query + static termQuery( + schema: Schema, + fieldName: string, + fieldValue: unknown, + indexOption?: string | undefined | null, + ): Query /** Construct a Tantivy's TermSetQuery */ static termSetQuery(schema: Schema, fieldName: string, fieldValues: Array): Query /** Construct a Tantivy's AllQuery */ @@ -635,93 +707,178 @@ export declare class Query { /** * Construct a Tantivy's ExistsQuery * - * Matches all documents that have at least one non-null value in the given field. - * Useful for filtering documents that have a specific field populated. + * Matches all documents that have at least one non-null value in the given + * field. Executing a search with this query will fail if the field doesn't + * exist or is not a fast field. * - * # Arguments - * - * * `schema` - Schema of the target index. - * * `field_name` - Field name to check for existence. + * @param fastFieldName - Field name to be searched. + * @param jsonSubpaths - If true, check all the subpaths inside a JSON field. */ - static existsQuery(schema: Schema, fieldName: string): Query + static existsQuery(fastFieldName: string, jsonSubpaths?: boolean | undefined | null): Query /** * Construct a Tantivy's FuzzyTermQuery * - * # Arguments + * @param schema - Schema of the target index. + * @param fieldName - Field name to be searched. + * @param text - String representation of the query term. + * @param distance - (Optional) Edit distance you are going to allow. When not specified, the default is 1. + * @param transpositionCostOne - (Optional) If true, a transposition (swapping) cost will be 1; otherwise it will be 2. When not specified, the default is true. + * @param prefix - (Optional) If true, prefix levenshtein distance is applied. When not specified, the default is false. + */ + static fuzzyTermQuery( + schema: Schema, + fieldName: string, + text: string, + distance?: number | undefined | null, + transpositionCostOne?: boolean | undefined | null, + prefix?: boolean | undefined | null, + ): Query + /** + * Construct a Tantivy's PhraseQuery with custom offsets and slop * - * * `schema` - Schema of the target index. - * * `field_name` - Field name to be searched. - * * `text` - String representation of the query term. - * * `distance` - (Optional) Edit distance you are going to alow. When not specified, the default is 1. - * * `transposition_cost_one` - (Optional) If true, a transposition (swapping) cost will be 1; otherwise it will be 2. When not specified, the default is true. - * * `prefix` - (Optional) If true, prefix levenshtein distance is applied. When not specified, the default is false. + * @param schema - Schema of the target index. + * @param fieldName - Field name to be searched. + * @param words - Word list that constructs the phrase. A word can be a term + * text, or a `[offset, text]` pair giving its offset in the phrase. + * @param slop - (Optional) The number of gaps permitted between the words in the query phrase. Default is 0. */ - static fuzzyTermQuery(schema: Schema, fieldName: string, text: string, distance?: number | undefined | null, transpositionCostOne?: boolean | undefined | null, prefix?: boolean | undefined | null): Query + static phraseQuery(schema: Schema, fieldName: string, words: Array, slop?: number | undefined | null): Query /** - * Construct a Tantivy's PhraseQuery with custom offsets and slop + * Construct a Tantivy's PhrasePrefixQuery with custom offsets * - * # Arguments + * Matches a specific sequence of words followed by a term of which only a + * prefix is known. Requires positions to be indexed on the target field. * - * * `schema` - Schema of the target index. - * * `field_name` - Field name to be searched. - * * `words` - Word list that constructs the phrase. A word can be a term text or a pair of term text and its offset in the phrase. - * * `slop` - (Optional) The number of gaps permitted between the words in the query phrase. Default is 0. + * @param schema - Schema of the target index. + * @param fieldName - Field name to be searched. + * @param words - Word list that constructs the phrase. A word can be a term + * text, or a `[offset, text]` pair giving its offset in the phrase. */ - static phraseQuery(schema: Schema, fieldName: string, words: Array, slop?: number | undefined | null): Query - /** Construct a Tantivy's BooleanQuery */ - static booleanQuery(subqueries: Array): Query - /** Construct a Tantivy's DisjunctionMaxQuery */ - static disjunctionMaxQuery(subqueries: Array, tieBreaker?: number | undefined | null): Query - /** Construct a Tantivy's BoostQuery */ - static boostQuery(query: Query, boost: number): Query - /** Construct a Tantivy's RegexQuery */ - static regexQuery(schema: Schema, fieldName: string, regexPattern: string): Query - static moreLikeThisQuery(docAddress: DocAddress, minDocFrequency?: number | undefined | null, maxDocFrequency?: number | undefined | null, minTermFrequency?: number | undefined | null, maxQueryTerms?: number | undefined | null, minWordLength?: number | undefined | null, maxWordLength?: number | undefined | null, boostFactor?: number | undefined | null, stopWords?: Array | undefined | null): Query - /** Construct a Tantivy's ConstScoreQuery */ - static constScoreQuery(query: Query, score: number): Query - static rangeQuery(schema: Schema, fieldName: string, fieldType: FieldType, lowerBound: unknown, upperBound: unknown, includeLower?: boolean | undefined | null, includeUpper?: boolean | undefined | null): Query + static phrasePrefixQuery(schema: Schema, fieldName: string, words: Array): Query /** - * Construct a Tantivy's PhrasePrefixQuery + * Construct a Tantivy's RegexPhraseQuery * - * Matches a specific sequence of words followed by a term of which only a prefix is known. - * Requires positions to be indexed on the target field. At least two terms are required. + * Matches a specific sequence of regex patterns in positional order, with + * optional slop. Each pattern can match multiple indexed terms via regex + * expansion. * - * # Arguments + * @param schema - Schema of the target index. + * @param fieldName - Field name to be searched. + * @param words - Pattern list forming the phrase. A pattern can be a string, + * or a `[offset, pattern]` pair giving its offset in the phrase. + * @param slop - (Optional) Number of gaps permitted between matched terms. Default is 0. + */ + static regexPhraseQuery( + schema: Schema, + fieldName: string, + words: Array, + slop?: number | undefined | null, + ): Query + /** + * Construct a Tantivy's BooleanQuery * - * * `schema` - Schema of the target index. - * * `field_name` - Field name to be searched. - * * `words` - Word list that constructs the phrase. The last word is treated as a prefix. - * * `max_expansions` - (Optional) Maximum number of terms the prefix can expand to. Default is 50. + * @param subqueries - `{ occur, query }` pairs making up the clauses. + * @param minimumNumberShouldMatch - (Optional) How many Should clauses a + * document must match. Defaults to tantivy's own rule: 1 when there + * is no Must/MustNot clause, 0 otherwise. */ - static phrasePrefixQuery(schema: Schema, fieldName: string, words: Array, maxExpansions?: number | undefined | null): Query + static booleanQuery(subqueries: Array, minimumNumberShouldMatch?: number | undefined | null): Query /** - * Construct a Tantivy's RegexPhraseQuery + * Combine queries with AND (MUST) logic. * - * Matches a specific sequence of regex patterns in positional order, with optional slop. - * Each pattern can match multiple indexed terms via regex expansion. + * Returns a query matching documents that match this query and every + * given query. + */ + andMustMatch(queries: Array): Query + /** + * Combine queries with AND NOT (MUST NOT) logic. * - * # Arguments + * Returns a query matching documents that match this query and none of + * the given queries. + */ + andMustNotMatch(queries: Array): Query + /** + * Combine queries with OR (SHOULD) logic. * - * * `schema` - Schema of the target index. - * * `field_name` - Field name to be searched. - * * `patterns` - List of regex patterns forming the phrase. Each pattern can be a string - * (offset = index) or a [offset, pattern] pair for custom positioning. - * * `slop` - (Optional) Number of gaps permitted between matched terms. Default is 0. - * * `max_expansions` - (Optional) Maximum number of terms each regex can expand to. + * Returns a query matching documents that match this query or any of the + * given queries. */ - static regexPhraseQuery(schema: Schema, fieldName: string, patterns: Array, slop?: number | undefined | null, maxExpansions?: number | undefined | null): Query + orShouldMatch(queries: Array): Query + /** Construct a Tantivy's DisjunctionMaxQuery */ + static disjunctionMaxQuery(subqueries: Array, tieBreaker?: number | undefined | null): Query + /** Construct a Tantivy's BoostQuery */ + static boostQuery(query: Query, boost: number): Query + /** Construct a Tantivy's RegexQuery */ + static regexQuery(schema: Schema, fieldName: string, regexPattern: string): Query + /** Construct a Tantivy's MoreLikeThisQuery from an indexed document. */ + static moreLikeThisQuery( + docAddress: DocAddress, + minDocFrequency?: number | undefined | null, + maxDocFrequency?: number | undefined | null, + minTermFrequency?: number | undefined | null, + maxQueryTerms?: number | undefined | null, + minWordLength?: number | undefined | null, + maxWordLength?: number | undefined | null, + boostFactor?: number | undefined | null, + stopWords?: Array | undefined | null, + ): Query + /** + * Construct a Tantivy's MoreLikeThisQuery from caller-provided field values. + * + * @param schema - Schema of the target index. + * @param documentFields - An object mapping field names to their value(s). + */ + static moreLikeThisDocumentFieldsQuery( + schema: Schema, + documentFields: object, + minDocFrequency?: number | undefined | null, + maxDocFrequency?: number | undefined | null, + minTermFrequency?: number | undefined | null, + maxQueryTerms?: number | undefined | null, + minWordLength?: number | undefined | null, + maxWordLength?: number | undefined | null, + boostFactor?: number | undefined | null, + stopWords?: Array | undefined | null, + ): Query + /** Construct a Tantivy's ConstScoreQuery */ + static constScoreQuery(query: Query, score: number): Query + /** + * Construct a range query over a numeric, date or IP address field. + * + * Pass `null` for `lowerBound` or `upperBound` to leave that side + * unbounded. Both bounds cannot be null; use `Query.allQuery()` to match + * all documents. Setting `includeLower` or `includeUpper` to false while + * the corresponding bound is null is an error — unbounded sides are always + * inclusive by definition. + * + * @param schema - Schema of the target index. + * @param fieldName - Field name to be searched. + * @param fieldType - Type of the field. + * @param lowerBound - Lower bound value, or null for unbounded. + * @param upperBound - Upper bound value, or null for unbounded. + * @param includeLower - Whether the lower bound is inclusive. Defaults to true. + * @param includeUpper - Whether the upper bound is inclusive. Defaults to true. + * @param useInvertedIndex - If true, use an inverted index range query + * instead of a fast-field range query. Defaults to false. + */ + static rangeQuery( + schema: Schema, + fieldName: string, + fieldType: FieldType, + lowerBound?: unknown | undefined | null, + upperBound?: unknown | undefined | null, + includeLower?: boolean | undefined | null, + includeUpper?: boolean | undefined | null, + useInvertedIndex?: boolean | undefined | null, + ): Query /** * Explain how this query matches a given document. * - * This method provides detailed information about how the document matched the query - * and how the score was calculated. - * - * # Arguments - * * `searcher` - The searcher used to perform the search - * * `doc_address` - The address of the document to explain + * This method provides detailed information about how the document matched + * the query and how the score was calculated. * - * # Returns - * * `Explanation` - An object containing detailed scoring information + * @param searcher - The searcher used to perform the search. + * @param docAddress - The address of the document to explain. */ explain(searcher: Searcher, docAddress: DocAddress): Explanation } @@ -836,7 +993,7 @@ export declare class SchemaBuilder { * @param options - JSON field options * @returns Self for method chaining */ - addJsonField(name: string, options?: TextFieldOptions | undefined | null): this + addJsonField(name: string, options?: JsonFieldOptions | undefined | null): this /** * Add a facet field to the schema. * @@ -884,21 +1041,55 @@ export declare class Searcher { * return. Defaults to 10. * @param count - Should the number of documents that match * the query be returned as well. Defaults to true. - * @param orderByField - A schema field that the results - * should be ordered by. The field must be declared as a fast field - * when building the schema. Note, this only works for unsigned - * fields. + * @param orderByField - Name of a field that the results should be ordered + * by. The field must be declared as a fast field when building the + * schema. Supported field types: Text, Unsigned, Integer, Float, + * Boolean and Date. * @param offset - The offset from which the results have * to be returned. * @param order - The order in which the results * should be sorted. If not specified, defaults to descending. + * @param weightByField - Name of a field that the results should be + * weighted by. The field must be declared as a fast field when + * building the schema. Note, this only works for Float, Integer + * and Unsigned fields. The given field value is first transformed + * using the formula `log2(2.0 + value)` and then multiplied with + * the original score. This means that a weight field value of 0.0 + * results in no change to the original score. If the weight value + * is negative, it is treated as 0.0. * - * @returns SearchResult object. + * @returns SearchResult object. Each hit carries either a `score` (no + * `orderByField`) or an `order` key matching the ordered field's + * type; date fields yield milliseconds since the epoch. * - * @throws ValueError if there was an error with the search. + * @throws if there was an error with the search. */ - search(query: Query, limit?: number | undefined | null, count?: boolean | undefined | null, orderByField?: string | undefined | null, offset?: number | undefined | null, order?: Order | undefined | null): SearchResult - aggregate(query: Query, agg: unknown): string + search( + query: Query, + limit?: number | undefined | null, + count?: boolean | undefined | null, + orderByField?: string | undefined | null, + offset?: number | undefined | null, + order?: Order | undefined | null, + weightByField?: string | undefined | null, + ): SearchResult + /** + * Execute an aggregation query and return the results. + * + * @param query - The query that filters the documents to aggregate over. + * @param agg - The aggregation specification. + * + * @returns An object containing the aggregation results. + */ + aggregate(query: Query, agg: any): any + /** + * Returns the cardinality (approximate distinct value count) of a field + * over the documents matching the query. + * + * @param query - The query that will be used for the search. + * @param fieldName - The field for which to compute the cardinality. + */ + cardinality(query: Query, fieldName: string): number /** Returns the overall number of documents in the index. */ get numDocs(): number /** Returns the number of segments in the index. */ @@ -914,9 +1105,54 @@ export declare class Searcher { * @param docAddress - The DocAddress that is associated with * the document that we wish to fetch. * - * @returns The Document, raises ValueError if the document can't be found. + * @returns The Document, throws if the document can't be found. */ doc(docAddress: DocAddress): Document + /** + * Read a numeric fast field for a batch of DocAddresses without fetching + * stored documents. + * + * Fast fields are column-oriented and support O(1) random access by + * segment-local DocId. Use this instead of `doc().toDict()[field]` when + * you only need a single numeric field for many documents. + * + * @param fieldName - Name of a u64, i64, f64 or boolean field declared as fast. + * @param docAddresses - The addresses to read (e.g. from `search().hits`). + * + * @returns The values in the same order as `docAddresses`. `null` is + * returned for any address where the column is absent (e.g. a + * segment written before the field was added to the schema). + * + * @throws if the field does not exist, is not a fast field, or has an + * unsupported type. + */ + fastFieldValues(fieldName: string, docAddresses: Array): Array + /** + * Walk the term dictionary for `fieldName` and return all terms that + * begin with `prefix`, together with their document frequencies. + * + * @param fieldName - Name of an indexed text field in the schema. + * @param prefix - Only terms beginning with this string are returned. + * An empty string returns all terms in the field. + * @param filterQuery - When provided, each term's count reflects only + * documents matched by the query (e.g. for permission filtering). + * Counts are still summed across segments. + * @param limit - If given, only the top-`limit` entries (by count) are returned. + * + * @returns `[{ term, count }, ...]` sorted by count descending, then + * alphabetically. Terms present in multiple segments have their + * counts summed. + * + * @throws if the field does not exist or is not a text field. + */ + termsWithPrefix( + fieldName: string, + prefix: string, + filterQuery?: Query | undefined | null, + limit?: number | undefined | null, + ): Array + /** Convert the searcher to a string representation */ + toString(): string } /** @@ -994,9 +1230,7 @@ export declare class TextAnalyzerBuilder { build(): TextAnalyzer } -export declare class Tokenizer { - -} +export declare class Tokenizer {} export declare class TokenizerStatic { /** SimpleTokenizer */ @@ -1016,7 +1250,11 @@ export declare class TokenizerStatic { * @param maxGram - Maximum character length of each ngram. * @param prefixOnly - If true, ngrams must count from the start of the word. */ - static ngram(minGram?: number | undefined | null, maxGram?: number | undefined | null, prefixOnly?: boolean | undefined | null): Tokenizer + static ngram( + minGram?: number | undefined | null, + maxGram?: number | undefined | null, + prefixOnly?: boolean | undefined | null, + ): Tokenizer } /** The tokenizer for the given field is unknown. */ @@ -1055,20 +1293,28 @@ export interface DocAddress { doc: number } -/** Tantivy's FieldType */ +/** + * Tantivy's Type + * + * The variant names mirror `tantivy.FieldType` in tantivy-py rather than + * tantivy's own Rust `Type` names. + */ export declare const enum FieldType { - Str = 0, - U64 = 1, - I64 = 2, - F64 = 3, - Bool = 4, + Text = 0, + Unsigned = 1, + Integer = 2, + Float = 3, + Boolean = 4, Date = 5, Facet = 6, Bytes = 7, - JsonObject = 8, - IpAddr = 9 + Json = 8, + IpAddr = 9, } +/** Get the version of the underlying tantivy engine. */ +export declare function getTantivyVersion(): string + /** Get the version of the library */ export declare function getVersion(): string @@ -1082,6 +1328,26 @@ export interface IpAddrFieldOptions { fast?: boolean } +/** JSON field options */ +export interface JsonFieldOptions { + /** Store the field value (can be retrieved from search results) */ + stored?: boolean + /** Fast field access (column-oriented storage) */ + fast?: boolean + /** Tokenizer name to use (default: "default") */ + tokenizerName?: string + /** Index record option: "basic", "freq", or "position" (default: "position") */ + indexOption?: string + /** + * If true, a "." in a JSON object key is treated as a path separator, the + * same as a "." between keys in a query string. E.g. `{"a.b": "hello"}` is + * then indexed as if it was `{"a": {"b": "hello"}}`, reachable via + * `attrs.a.b` instead of the default escaped form `attrs.a\.b`. + * Defaults to false. + */ + expandDotsEnabled?: boolean +} + /** Numeric field options (for integers, floats, dates) */ export interface NumericFieldOptions { /** Store the field value (can be retrieved from search results) */ @@ -1096,7 +1362,7 @@ export interface NumericFieldOptions { export declare const enum Occur { Must = 0, Should = 1, - MustNot = 2 + MustNot = 2, } /** Enum representing the direction in which something should be sorted. */ @@ -1104,21 +1370,69 @@ export declare const enum Order { /** Ascending. Smaller values appear first. */ Asc = 0, /** Descending. Larger values appear first. */ - Desc = 1 + Desc = 1, } +/** + * Parse a query string into an abstract syntax tree (AST). + * + * This function parses a query string following Tantivy's query language + * syntax and returns a plain object representing the parsed AST. + * Unlike `Index.parseQuery()`, this function does not require a schema + * and returns the raw syntax tree structure. + * + * @param query - The query string to parse. + * @returns An object representing the parsed query AST. + * + * @throws if the query has invalid syntax. + * + * Example: + * ```javascript + * const ast = parseQuery('title:hello AND body:world') + * ``` + */ +export declare function parseQuery(query: string): any + +/** + * Parse a query string leniently, recovering from syntax errors. + * + * This function attempts to parse a query string even if it contains + * syntax errors. It returns both the parsed AST and a list of errors + * encountered during parsing. Unlike `Index.parseQueryLenient()`, this + * function does not require a schema and returns the raw syntax tree + * structure. + * + * @param query - The query string to parse. + * @returns A tuple of the parsed AST and a list of syntax errors. + * + * Example: + * ```javascript + * const [ast, errors] = parseQueryLenient('title:hello AND invalid:') + * ``` + */ +export declare function parseQueryLenient(query: string): [any, any] + export interface Range { start: number end: number } export interface SearchHit { + /** The relevance score. Only set when the results are not ordered by a field. */ score?: number - order?: number + /** + * The value of the ordered field. Only set when `orderByField` was given. + * + * The variant matches the ordered field's type: numeric fields yield a + * number, boolean fields a boolean and text fields a string. Date fields + * yield milliseconds since the epoch, matching the convention used + * everywhere else in this binding. + */ + order?: number | boolean | string docAddress: DocAddress } -/** Object holding a results successful search. */ +/** Object holding the results of a successful search. */ export interface SearchResult { hits: Array /** @@ -1128,6 +1442,12 @@ export interface SearchResult { count?: number } +/** A term of a field paired with the number of documents containing it. */ +export interface TermCount { + term: string + count: number +} + /** Text field indexing options */ export interface TextFieldOptions { /** Store the field value (can be retrieved from search results) */ diff --git a/index.js b/index.js index eb237ec..2f3120b 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,13 @@ -// prettier-ignore /* eslint-disable */ // @ts-nocheck /* auto-generated by NAPI-RS */ -const { readFileSync } = require('node:fs') +const { readFileSync } = require('fs') let nativeBinding = null +// Which artifact actually loaded. The WASI fallback chain overwrites it with +// the flavor it resolved; the late native retry below leaves it alone because +// it only runs while no WASI candidate has been loaded. +let __napiLoadedBindingTarget = 'native' const loadErrors = [] const isMusl = () => { @@ -33,7 +36,7 @@ const isMuslFromFilesystem = () => { const isMuslFromReport = () => { let report = null - if (typeof process.report?.getReport === 'function') { + if (process.report && typeof process.report.getReport === 'function') { process.report.excludeNetwork = true report = process.report.getReport() } @@ -63,7 +66,16 @@ const isMuslFromChildProcess = () => { function requireNative() { if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) { try { - return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH); + const overrideBinding = require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH) + // The override may be a generated WASI loader, which already reports its + // own flavor. Adopt it: `module.exports` aliases this object, so claiming + // 'native' would both misreport the artifact and overwrite the loader's + // marker through the alias. + __napiLoadedBindingTarget = + overrideBinding && typeof overrideBinding.__napiBindingTarget === 'string' + ? overrideBinding.__napiBindingTarget + : 'native' + return overrideBinding } catch (err) { loadErrors.push(err) } @@ -77,8 +89,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-android-arm64') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-android-arm64/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -93,8 +105,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-android-arm-eabi') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-android-arm-eabi/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -105,38 +117,38 @@ function requireNative() { } } else if (process.platform === 'win32') { if (process.arch === 'x64') { - if (process.config?.variables?.shlib_suffix === 'dll.a' || process.config?.variables?.node_target_type === 'shared_library') { + if ((process.config && process.config.variables && process.config.variables.shlib_suffix === 'dll.a') || (process.config && process.config.variables && process.config.variables.node_target_type === 'shared_library')) { try { - return require('./node-tantivy-binding.win32-x64-gnu.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@oxdev03/node-tantivy-binding-win32-x64-gnu') - const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-win32-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + return require('./node-tantivy-binding.win32-x64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@oxdev03/node-tantivy-binding-win32-x64-gnu') + const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-win32-x64-gnu/package.json').version + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) } - return binding - } catch (e) { - loadErrors.push(e) - } } else { try { - return require('./node-tantivy-binding.win32-x64-msvc.node') - } catch (e) { - loadErrors.push(e) - } - try { - const binding = require('@oxdev03/node-tantivy-binding-win32-x64-msvc') - const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-win32-x64-msvc/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + return require('./node-tantivy-binding.win32-x64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@oxdev03/node-tantivy-binding-win32-x64-msvc') + const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-win32-x64-msvc/package.json').version + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) } - return binding - } catch (e) { - loadErrors.push(e) - } } } else if (process.arch === 'ia32') { try { @@ -147,8 +159,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-win32-ia32-msvc') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-win32-ia32-msvc/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -163,8 +175,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-win32-arm64-msvc') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-win32-arm64-msvc/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -182,8 +194,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-darwin-universal') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-darwin-universal/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -198,8 +210,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-darwin-x64') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-darwin-x64/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -214,8 +226,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-darwin-arm64') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-darwin-arm64/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -234,8 +246,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-freebsd-x64') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-freebsd-x64/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -250,8 +262,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-freebsd-arm64') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-freebsd-arm64/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -271,8 +283,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-linux-x64-musl') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-linux-x64-musl/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -287,8 +299,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-linux-x64-gnu') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -305,8 +317,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-linux-arm64-musl') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -321,8 +333,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-linux-arm64-gnu') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -339,8 +351,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-linux-arm-musleabihf') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -355,8 +367,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-linux-arm-gnueabihf') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -373,8 +385,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-linux-loong64-musl') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -389,8 +401,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-linux-loong64-gnu') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -407,8 +419,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-linux-riscv64-musl') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -423,8 +435,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-linux-riscv64-gnu') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -440,8 +452,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-linux-ppc64-gnu') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -456,8 +468,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-linux-s390x-gnu') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -476,8 +488,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-openharmony-arm64') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-openharmony-arm64/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -492,8 +504,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-openharmony-x64') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-openharmony-x64/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -508,8 +520,8 @@ function requireNative() { try { const binding = require('@oxdev03/node-tantivy-binding-openharmony-arm') const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-openharmony-arm/package.json').version - if (bindingPackageVersion !== '0.2.1' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 0.2.1 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '0.3.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -523,68 +535,248 @@ function requireNative() { } } -nativeBinding = requireNative() +function createLoadErrorChain(errors) { + return errors.reduce((previous, current) => { + let message + try { + message = + current && typeof current.message === 'string' + ? current.message + : String(current) + } catch { + message = 'Unknown error' + } + const error = new Error(message) + error.cause = previous + return error + }, null) +} // NAPI_RS_FORCE_WASI is a tri-state flag: // unset / any other value → native binding preferred, WASI is only a fallback -// 'true' → force WASI fallback even if native loaded -// 'error' → force WASI and throw if no WASI binding is found +// 'true' → prefer WASI, but retain native as a lazy fallback +// 'error' → require WASI without initializing a native fallback // Treating any non-empty string as truthy (the historical behavior) meant // NAPI_RS_FORCE_WASI=false, NAPI_RS_FORCE_WASI=0, etc. inadvertently triggered // the WASI path, causing ENOENT for packages shipped without a .wasi.cjs file. +// +// NAPI_RS_WASI_FLAVOR selects one exact generated flavor and implies strict +// WASI loading. It never crosses into another flavor or falls back to native. +const __napiWasiFlavors = ['wasm32-wasi'] +const __napiWasiFlavor = process.env.NAPI_RS_WASI_FLAVOR +const __napiWasiFlavorRequested = + typeof __napiWasiFlavor === 'string' && __napiWasiFlavor.length > 0 +if ( + __napiWasiFlavorRequested && + __napiWasiFlavors.indexOf(__napiWasiFlavor) === -1 +) { + throw new Error( + 'Unsupported WASI flavor "' + + __napiWasiFlavor + + '". Available flavors: ' + + __napiWasiFlavors.join(', '), + ) +} +const forceWasiError = process.env.NAPI_RS_FORCE_WASI === 'error' const forceWasi = - process.env.NAPI_RS_FORCE_WASI === 'true' || process.env.NAPI_RS_FORCE_WASI === 'error' + process.env.NAPI_RS_FORCE_WASI === 'true' || + forceWasiError || + __napiWasiFlavorRequested + +if (!forceWasi) { + nativeBinding = requireNative() +} if (!nativeBinding || forceWasi) { let wasiBinding = null - let wasiBindingError = null - try { - wasiBinding = require('./node-tantivy-binding.wasi.cjs') - nativeBinding = wasiBinding - } catch (err) { - if (forceWasi) { - wasiBindingError = err + let wasiBindingLoaded = false + const wasiBindingErrors = [] + const __napiWasiResolveCandidate = (specifier, isPackage, localArtifacts) => { + try { + require.resolve(specifier) + } catch (resolveError) { + if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') { + throw resolveError + } + if (isPackage) { + try { + require.resolve(specifier + '/package.json') + } catch (packageError) { + if (packageError && packageError.code === 'MODULE_NOT_FOUND') { + return resolveError + } + // An exports restriction proves the package exists even when its + // package.json is not public. Preserve the root resolution failure. + throw resolveError + } + // The package exists but its main/export target is broken. + throw resolveError + } + return resolveError } + if (localArtifacts) { + let artifactError = null + for (let i = 0; i < localArtifacts.length; i++) { + try { + require.resolve(localArtifacts[i]) + return null + } catch (resolveError) { + if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') { + throw resolveError + } + artifactError = resolveError + } + } + return artifactError + } + return null } - if (!nativeBinding || forceWasi) { + if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === 'wasm32-wasi')) { + let candidateError = null + let candidateFailed = false try { - wasiBinding = require('@oxdev03/node-tantivy-binding-wasm32-wasi') - nativeBinding = wasiBinding + candidateError = __napiWasiResolveCandidate('./node-tantivy-binding.wasi.cjs', false, ['./node-tantivy-binding.wasm32-wasi.debug.wasm', './node-tantivy-binding.wasm32-wasi.wasm']) + candidateFailed = candidateError !== null + if (!candidateFailed) { + wasiBinding = require('./node-tantivy-binding.wasi.cjs') + nativeBinding = wasiBinding + __napiLoadedBindingTarget = 'wasm32-wasi' + wasiBindingLoaded = true + } } catch (err) { - if (forceWasi) { - if (!wasiBindingError) { - wasiBindingError = err - } else { - wasiBindingError.cause = err + candidateError = err + candidateFailed = true + } + if (candidateFailed) { + wasiBindingErrors.push(candidateError) + loadErrors.push(candidateError) + } + } + if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === 'wasm32-wasi')) { + let candidateError = null + let candidateFailed = false + try { + candidateError = __napiWasiResolveCandidate('@oxdev03/node-tantivy-binding-wasm32-wasi', true, undefined) + candidateFailed = candidateError !== null + if (!candidateFailed) { + if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + const bindingPackageVersion = require('@oxdev03/node-tantivy-binding-wasm32-wasi/package.json').version + if (bindingPackageVersion !== '0.3.3') { + throw new Error(`WASI binding package version mismatch, expected 0.3.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } } - loadErrors.push(err) + wasiBinding = require('@oxdev03/node-tantivy-binding-wasm32-wasi') + nativeBinding = wasiBinding + __napiLoadedBindingTarget = 'wasm32-wasi' + wasiBindingLoaded = true } + } catch (err) { + candidateError = err + candidateFailed = true + } + if (candidateFailed) { + wasiBindingErrors.push(candidateError) + loadErrors.push(candidateError) } } - if (process.env.NAPI_RS_FORCE_WASI === 'error' && !wasiBinding) { - const error = new Error('WASI binding not found and NAPI_RS_FORCE_WASI is set to error') - error.cause = wasiBindingError + if ( + !wasiBindingLoaded && + forceWasi && + !forceWasiError && + !__napiWasiFlavorRequested + ) { + nativeBinding = requireNative() + } + if ((forceWasiError || __napiWasiFlavorRequested) && !wasiBindingLoaded) { + const error = new Error( + __napiWasiFlavorRequested + ? 'WASI binding for flavor "' + __napiWasiFlavor + '" not found' + : 'WASI binding not found and NAPI_RS_FORCE_WASI is set to error', + ) + error.cause = createLoadErrorChain(wasiBindingErrors) throw error } } if (!nativeBinding) { if (loadErrors.length > 0) { - throw new Error( + const error = new Error( `Cannot find native binding. ` + `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` + 'Please try `npm i` again after removing both package-lock.json and node_modules directory.', - { - cause: loadErrors.reduce((err, cur) => { - cur.cause = err - return cur - }), - }, ) + // assign instead of the `new Error(message, { cause })` options form, + // which Node < 16.9 silently ignores + error.cause = createLoadErrorChain(loadErrors) + throw error } throw new Error(`Failed to load native binding`) } +function __napiStampBindingTarget(exportsObject, target) { + if ( + Object.prototype.hasOwnProperty.call(exportsObject, '__napiBindingTarget') + ) { + if (exportsObject.__napiBindingTarget === target) { + // Already ours: the root entry aliases the object it loaded, so a WASI + // fallback candidate — or a `NAPI_RS_NATIVE_LIBRARY_PATH` override that + // is a generated loader — arrives already stamped with this same value. + return target + } + const error = new Error( + '`__napiBindingTarget` is reserved by the generated binding loader, but the loaded binding already exports it. Rename the export, e.g. #[napi(js_name = "...")].', + ) + error.code = 'ERR_NAPI_BINDING_TARGET_CONFLICT' + throw error + } + if (!Object.isExtensible(exportsObject)) { + // A `#[napi(module_exports)]` hook may seal or freeze this object + // (`Object::seal` / `Object::freeze`). Reporting the artifact is metadata, + // never a reason to fail an otherwise successful load, so the stamp is + // skipped. What a consumer still sees then follows the entry point: the + // browser and deferred loaders declare `__napiBindingTarget` at module + // level and go on reporting it, while the CommonJS entries hand back this + // very object as `module.exports`, so there the value is absent. + return target + } + try { + // [[Define]], not [[Set]]: an ordinary assignment walks the prototype + // chain, so an inherited accessor could swallow the value or throw and + // fail an otherwise successful load. The descriptor is what a successful + // assignment would have produced. + Object.defineProperty(exportsObject, '__napiBindingTarget', { + configurable: true, + enumerable: true, + value: target, + writable: true, + }) + } catch { + // Same rule as the non-extensible skip above: reporting the artifact is + // metadata, never a reason to fail an otherwise successful load. An exotic + // object (a Proxy whose defineProperty trap refuses) is skipped, not + // thrown over. + } + // The CommonJS loaders assign this return value so `cjs-module-lexer` — and + // therefore Node's CJS -> ESM named export detection — can see + // `__napiBindingTarget` statically. + return target +} +// Stamp before the alias, not after. The guard only reads `nativeBinding` +// (`hasOwnProperty` plus a comparison), which is safe against any addon +// accessor; an assignment is not, because a `#[napi(module_exports)]` hook can +// expose a getter reporting this very value and a setter that throws. So the +// assignment lands on the loader's own `module.exports`, still the original +// object here, and the alias below replaces it. +// +// The assignment is what keeps the marker a statically visible CommonJS export: +// `cjs-module-lexer` is Node's CJS -> ESM named export detection, it cannot see +// a bare call, and the later `module.exports = nativeBinding` does not undo the +// detection. The assignment itself always succeeds — its target is this +// loader's own, still extensible `module.exports` — and the alias below then +// discards the value it wrote. What a consumer reads is whatever the guard put +// on `nativeBinding`, so on a frozen binding, where the guard skips, the +// linked import resolves to `undefined`. +module.exports.__napiBindingTarget = __napiStampBindingTarget(nativeBinding, __napiLoadedBindingTarget) module.exports = nativeBinding module.exports.AllButQueryForbiddenError = nativeBinding.AllButQueryForbiddenError module.exports.DateFormatError = nativeBinding.DateFormatError @@ -621,6 +813,9 @@ module.exports.TokenizerStatic = nativeBinding.TokenizerStatic module.exports.UnknownTokenizerError = nativeBinding.UnknownTokenizerError module.exports.UnsupportedQueryError = nativeBinding.UnsupportedQueryError module.exports.FieldType = nativeBinding.FieldType +module.exports.getTantivyVersion = nativeBinding.getTantivyVersion module.exports.getVersion = nativeBinding.getVersion module.exports.Occur = nativeBinding.Occur module.exports.Order = nativeBinding.Order +module.exports.parseQuery = nativeBinding.parseQuery +module.exports.parseQueryLenient = nativeBinding.parseQueryLenient diff --git a/package.json b/package.json index 6f83a3a..64e193a 100644 --- a/package.json +++ b/package.json @@ -64,16 +64,16 @@ "prepare": "husky" }, "devDependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", - "@napi-rs/cli": "^3.2.0", - "@napi-rs/wasm-runtime": "^1.0.4", + "@emnapi/core": "^1.11.3", + "@emnapi/runtime": "^1.11.3", + "@napi-rs/cli": "^3.10.4", + "@napi-rs/wasm-runtime": "^1.2.4", "@oxc-node/core": "^0.1.0", "@taplo/cli": "^0.7.0", - "@tybys/wasm-util": "^0.10.0", + "@tybys/wasm-util": "^0.10.4", "@types/node": "^24.0.0", "chalk": "^6.0.0", - "emnapi": "^1.5.0", + "emnapi": "^1.11.3", "husky": "^9.1.7", "lint-staged": "^17.0.0", "marked": "^17.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d89540..6fcebbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: devDependencies: '@emnapi/core': - specifier: ^1.5.0 - version: 1.10.0 + specifier: ^1.11.3 + version: 1.11.3 '@emnapi/runtime': - specifier: ^1.5.0 - version: 1.10.0 + specifier: ^1.11.3 + version: 1.11.3 '@napi-rs/cli': - specifier: ^3.2.0 - version: 3.7.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.1) + specifier: ^3.10.4 + version: 3.10.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.1)(emnapi@1.11.3) '@napi-rs/wasm-runtime': - specifier: ^1.0.4 - version: 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + specifier: ^1.2.4 + version: 1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) '@oxc-node/core': specifier: ^0.1.0 version: 0.1.0 @@ -27,8 +27,8 @@ importers: specifier: ^0.7.0 version: 0.7.0 '@tybys/wasm-util': - specifier: ^0.10.0 - version: 0.10.2 + specifier: ^0.10.4 + version: 0.10.4 '@types/node': specifier: ^24.0.0 version: 24.13.1 @@ -36,8 +36,8 @@ importers: specifier: ^6.0.0 version: 6.0.0 emnapi: - specifier: ^1.5.0 - version: 1.10.0 + specifier: ^1.11.3 + version: 1.11.3 husky: specifier: ^9.1.7 version: 9.1.7 @@ -71,27 +71,51 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/runtime@1.9.1': resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + '@emnapi/wasi-threads@1.2.0': resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@inquirer/ansi@2.0.7': - resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + + '@inquirer/ansi@2.0.8': + resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/checkbox@5.2.1': - resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + '@inquirer/checkbox@5.2.5': + resolution: {integrity: sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -99,8 +123,8 @@ packages: '@types/node': optional: true - '@inquirer/confirm@6.1.1': - resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + '@inquirer/confirm@6.3.2': + resolution: {integrity: sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -108,8 +132,8 @@ packages: '@types/node': optional: true - '@inquirer/core@11.2.1': - resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + '@inquirer/core@12.0.3': + resolution: {integrity: sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -117,8 +141,8 @@ packages: '@types/node': optional: true - '@inquirer/editor@5.2.2': - resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + '@inquirer/editor@5.3.3': + resolution: {integrity: sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -126,8 +150,8 @@ packages: '@types/node': optional: true - '@inquirer/expand@5.1.1': - resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + '@inquirer/expand@5.1.5': + resolution: {integrity: sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -135,8 +159,8 @@ packages: '@types/node': optional: true - '@inquirer/external-editor@3.0.3': - resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + '@inquirer/external-editor@3.0.5': + resolution: {integrity: sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -144,12 +168,12 @@ packages: '@types/node': optional: true - '@inquirer/figures@2.0.7': - resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + '@inquirer/figures@2.0.9': + resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/input@5.1.2': - resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + '@inquirer/input@5.1.6': + resolution: {integrity: sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -157,8 +181,8 @@ packages: '@types/node': optional: true - '@inquirer/number@4.1.1': - resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + '@inquirer/number@4.2.3': + resolution: {integrity: sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -166,8 +190,8 @@ packages: '@types/node': optional: true - '@inquirer/password@5.1.1': - resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + '@inquirer/password@5.2.2': + resolution: {integrity: sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -175,8 +199,8 @@ packages: '@types/node': optional: true - '@inquirer/prompts@8.5.2': - resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + '@inquirer/prompts@8.7.2': + resolution: {integrity: sha512-QoRB4wFIjgH5iOhSjoIKMkTvSHDuV+O3OITlIqAYO0oK5x364GJILXiMBvlPiE+klg7Xx9tq5XVqQHcGUDYYPA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -184,8 +208,8 @@ packages: '@types/node': optional: true - '@inquirer/rawlist@5.3.1': - resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + '@inquirer/rawlist@5.3.5': + resolution: {integrity: sha512-1oHky1ONfCOwNrnkQGDE1oaSij/3fI6HFMSf2H/WsGO2lEyDX9My82iggITSy9ddSZ8yk8j9v41OI0fVoSIoaA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -193,8 +217,8 @@ packages: '@types/node': optional: true - '@inquirer/search@4.2.1': - resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + '@inquirer/search@4.3.3': + resolution: {integrity: sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -202,8 +226,8 @@ packages: '@types/node': optional: true - '@inquirer/select@5.2.1': - resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + '@inquirer/select@5.2.5': + resolution: {integrity: sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -211,8 +235,8 @@ packages: '@types/node': optional: true - '@inquirer/type@4.0.7': - resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + '@inquirer/type@4.1.1': + resolution: {integrity: sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -223,15 +247,21 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@napi-rs/cli@3.7.0': - resolution: {integrity: sha512-3d3+rmxlOIV/G1zPWeX4PCxuYnhcCQM2BvY9rtimC8RO0dFR9gtYP+Grov+WoduZtfWRj5N1XvytWeRxxCk5zw==} - engines: {node: '>= 16'} + '@napi-rs/cli@3.10.4': + resolution: {integrity: sha512-7yvXpu/m4p1Re1/DViAooSYAyivYEhqDrjuq66a6haXLEAKrZSzndG7B850g1irngI1pAdZS5xL4tsu0xQ8P9Q==} + engines: {node: ^20.17.0 || ^22.13.0 || >= 23.5.0} hasBin: true peerDependencies: - '@emnapi/runtime': ^1.7.1 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + emnapi: ^1.7.1 || ^2.0.0-alpha.4 peerDependenciesMeta: + '@emnapi/core': + optional: true '@emnapi/runtime': optional: true + emnapi: + optional: true '@napi-rs/cross-toolchain@1.0.3': resolution: {integrity: sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg==} @@ -268,221 +298,221 @@ packages: '@napi-rs/cross-toolchain-x64-target-x86_64': optional: true - '@napi-rs/lzma-android-arm-eabi@1.4.5': - resolution: {integrity: sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q==} - engines: {node: '>= 10'} + '@napi-rs/lzma-android-arm-eabi@1.5.1': + resolution: {integrity: sha512-sahBe4ko2Z69NPTddaX6ZgbQZu9SDoITxw1S3dWl1gAGynZG34qHHCT8UaUMFxf3h3zMhCJjEzz4basaBxiTuQ==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [arm] os: [android] - '@napi-rs/lzma-android-arm64@1.4.5': - resolution: {integrity: sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ==} - engines: {node: '>= 10'} + '@napi-rs/lzma-android-arm64@1.5.1': + resolution: {integrity: sha512-7tkQAJJuBHxAxiEBNFgSTpvrtGpbwZYYJUSOmGEK3OfbdbNeoT2rdBxpM/gY1s+itEVbtOSlpaRPPG19MnwOzA==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [arm64] os: [android] - '@napi-rs/lzma-darwin-arm64@1.4.5': - resolution: {integrity: sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA==} - engines: {node: '>= 10'} + '@napi-rs/lzma-darwin-arm64@1.5.1': + resolution: {integrity: sha512-XWX8gtF+GHGk3nH3Wm3QUZNcxw9QHsFVZz3MzVLhWWHhceede1J4/vD+3dj3E1iKB9G6mualaZxOoD08R3E+7g==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [arm64] os: [darwin] - '@napi-rs/lzma-darwin-x64@1.4.5': - resolution: {integrity: sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw==} - engines: {node: '>= 10'} + '@napi-rs/lzma-darwin-x64@1.5.1': + resolution: {integrity: sha512-CfsqUpMTI1z8enrA/b+GcHM6YDI8D0kqCiqPYEnst4rbOABQ9KZ92ybTTNnlnZ7A017WoMZKUEWc36KXDwi0xg==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [x64] os: [darwin] - '@napi-rs/lzma-freebsd-x64@1.4.5': - resolution: {integrity: sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw==} - engines: {node: '>= 10'} + '@napi-rs/lzma-freebsd-x64@1.5.1': + resolution: {integrity: sha512-bTyNfg90FXIgE61U7l14aMmVOqRQ6AyP5JMT3jmCStaZI18apLNPdzZ8i7yqxZfKvRMVfPjE2brXIw27c+RRgA==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [x64] os: [freebsd] - '@napi-rs/lzma-linux-arm-gnueabihf@1.4.5': - resolution: {integrity: sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw==} - engines: {node: '>= 10'} + '@napi-rs/lzma-linux-arm-gnueabihf@1.5.1': + resolution: {integrity: sha512-vNE+D8nrw+eOkBsdKCsmDhowDV3pIMKXEhedvXfbgrWbrO7GlZJH+RXL+X+RYLxGwi8Ym61ZMt15sIOnNmh9Sw==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [arm] os: [linux] - '@napi-rs/lzma-linux-arm64-gnu@1.4.5': - resolution: {integrity: sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg==} - engines: {node: '>= 10'} + '@napi-rs/lzma-linux-arm64-gnu@1.5.1': + resolution: {integrity: sha512-csUem4WgoKGTprv/pOPm9UIWbb+hrfUwYXefpTHPAEGVFLl5behEFabisJ7FtihCa3yG2Efcl+yw25rlhhrIYw==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [arm64] os: [linux] libc: [glibc] - '@napi-rs/lzma-linux-arm64-musl@1.4.5': - resolution: {integrity: sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w==} - engines: {node: '>= 10'} + '@napi-rs/lzma-linux-arm64-musl@1.5.1': + resolution: {integrity: sha512-kB/xhlVN1eLvVmDJSKZEjp5Gg2xDYexNrB5jwpSMbOkeGS6N9AasByPBg5VqCpMYC+zZi7DM458DRhtWYhqXTQ==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [arm64] os: [linux] libc: [musl] - '@napi-rs/lzma-linux-ppc64-gnu@1.4.5': - resolution: {integrity: sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ==} - engines: {node: '>= 10'} + '@napi-rs/lzma-linux-ppc64-gnu@1.5.1': + resolution: {integrity: sha512-s28RW0W1yBWQc1nbPdF7tp14koqslY3ZWLVI8uaanX292Dc6ezd4NPVwxEoCNBVON/oD7BmUbWGtyFvmm7dQ5A==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [ppc64] os: [linux] libc: [glibc] - '@napi-rs/lzma-linux-riscv64-gnu@1.4.5': - resolution: {integrity: sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA==} - engines: {node: '>= 10'} + '@napi-rs/lzma-linux-riscv64-gnu@1.5.1': + resolution: {integrity: sha512-+lGNwYlIN14YPMTNvYtIJJqHFevDTd6Juw/1NmXbWx/iRd/LLrjhlM/yluMX6pxs6NkOGsuuEXJJrbbEUS59OQ==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [riscv64] os: [linux] libc: [glibc] - '@napi-rs/lzma-linux-s390x-gnu@1.4.5': - resolution: {integrity: sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA==} - engines: {node: '>= 10'} + '@napi-rs/lzma-linux-s390x-gnu@1.5.1': + resolution: {integrity: sha512-PB44FFWWFrLeQowhcep1hPD1YcLqKlnnY60RMU74qrxTlr4YGEyzeMItJqh2uivBfv9kQScOF/B0J9+Vab/oyw==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [s390x] os: [linux] libc: [glibc] - '@napi-rs/lzma-linux-x64-gnu@1.4.5': - resolution: {integrity: sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw==} - engines: {node: '>= 10'} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [x64] os: [linux] libc: [glibc] - '@napi-rs/lzma-linux-x64-musl@1.4.5': - resolution: {integrity: sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ==} - engines: {node: '>= 10'} + '@napi-rs/lzma-linux-x64-musl@1.5.1': + resolution: {integrity: sha512-I3nsYrWtrW9JpeCr+mkJIVDt0HY3m6qVUBs5vTtoIvJQxwqf1PBXSy5IS7T53ksQFH2kd2UX8rLxJ7B4WISpZg==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [x64] os: [linux] libc: [musl] - '@napi-rs/lzma-wasm32-wasi@1.4.5': - resolution: {integrity: sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA==} - engines: {node: '>=14.0.0'} + '@napi-rs/lzma-wasm32-wasi@1.5.1': + resolution: {integrity: sha512-gy3wwPBa6+XEyA4fUzq6CClrXA1ajXjuVf5zbnHytJRgoHznj+mvpU3+co2fxXwqTCmIpn6KrzqH5bRDztBPhA==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [wasm32] - '@napi-rs/lzma-win32-arm64-msvc@1.4.5': - resolution: {integrity: sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg==} - engines: {node: '>= 10'} + '@napi-rs/lzma-win32-arm64-msvc@1.5.1': + resolution: {integrity: sha512-dK+huOsHiyH6oJjij+cnjqFCakk2HgWmpI12Xm4pLUyPphe4ebYoJBgehaNAxprmjFqBQ7nL95YPVz9BHyqmPg==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [arm64] os: [win32] - '@napi-rs/lzma-win32-ia32-msvc@1.4.5': - resolution: {integrity: sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg==} - engines: {node: '>= 10'} + '@napi-rs/lzma-win32-ia32-msvc@1.5.1': + resolution: {integrity: sha512-dGE8L+0EQ+GyU9ap9InqB/t/PmPG/bLj918q7OsJ29FuTdn8fK4OX3U4IQZhylHIA+/dQ/SXJk5n4yfah2XVvA==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [ia32] os: [win32] - '@napi-rs/lzma-win32-x64-msvc@1.4.5': - resolution: {integrity: sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q==} - engines: {node: '>= 10'} + '@napi-rs/lzma-win32-x64-msvc@1.5.1': + resolution: {integrity: sha512-EKW4t/iqdCT/xnd5t9oXLvVER/PMNAWXKqUAl3fgvUcOILeZIIht77/dVnfFcc9htA/DCBXC/6YQWdW+LusjFA==} + engines: {node: ^22.20 || ^24.12 || >=25} cpu: [x64] os: [win32] - '@napi-rs/lzma@1.4.5': - resolution: {integrity: sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg==} - engines: {node: '>= 10'} + '@napi-rs/lzma@1.5.1': + resolution: {integrity: sha512-sgOZ89+y8cDbY+3WbzR8CtIhCuFRWotZ9/2PjPVDJHz6np5KFTAev0DrwiyTJTgFsCRDhfGlbmhMgyhHbWdZ6g==} + engines: {node: ^22.20 || ^24.12 || >=25} - '@napi-rs/tar-android-arm-eabi@1.1.0': - resolution: {integrity: sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA==} + '@napi-rs/tar-android-arm-eabi@1.1.1': + resolution: {integrity: sha512-cAhnA10cSusAUbcE9HtjQY/tZ9BH/0w2sKtRcQc94TzIlnm7QSr1htJSd/PPrbWNPtrv1orXb2CkrHlVlbnlHA==} engines: {node: '>= 10'} cpu: [arm] os: [android] - '@napi-rs/tar-android-arm64@1.1.0': - resolution: {integrity: sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw==} + '@napi-rs/tar-android-arm64@1.1.1': + resolution: {integrity: sha512-EslUWHCDBY/g5abTPBiHLsMaML4GagV0TXLm5WL9hAjx/DDtlxz9fegMb77RJ+f7nFLOIsUxF/3QWFvgOT0sMQ==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@napi-rs/tar-darwin-arm64@1.1.0': - resolution: {integrity: sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw==} + '@napi-rs/tar-darwin-arm64@1.1.1': + resolution: {integrity: sha512-+A42/6ES5G9CQ35BOwzwA+WBjLID28r2jNPgc0dteD2hhClIhng0mva7D2ujUlXBNmgNOsr1LHn3stA4uTf4NQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@napi-rs/tar-darwin-x64@1.1.0': - resolution: {integrity: sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA==} + '@napi-rs/tar-darwin-x64@1.1.1': + resolution: {integrity: sha512-RYtE8w1dkEvj8hSJCDV5Jw0Rz2i13fsM7u893zv5O9n/4Ad5GNsw/f4RQ7/0YGSFaenkVxqPFrjmEvUHlKzsrg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@napi-rs/tar-freebsd-x64@1.1.0': - resolution: {integrity: sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g==} + '@napi-rs/tar-freebsd-x64@1.1.1': + resolution: {integrity: sha512-rEepBvCJUwcuvUYkY83e8aot8RsR5Jcnal4PsG3tbWGKW1yAvcXhyMXf0fN6ZGpVRZFnB+FJqDyBxvsCPEXKhw==} engines: {node: '>= 10'} cpu: [x64] os: [freebsd] - '@napi-rs/tar-linux-arm-gnueabihf@1.1.0': - resolution: {integrity: sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg==} + '@napi-rs/tar-linux-arm-gnueabihf@1.1.1': + resolution: {integrity: sha512-an1bJdfyhI5FpZYyTQ20mrqwR+a676i8GkaYc4Uy12dH/a7TJIfrK6Qa2Gm46arZvxUvx56qxoRKXbpOjUPvwA==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@napi-rs/tar-linux-arm64-gnu@1.1.0': - resolution: {integrity: sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA==} + '@napi-rs/tar-linux-arm64-gnu@1.1.1': + resolution: {integrity: sha512-w++Vtx36T2yHTKws7GVnmHHcUT1ybB59xLWSh9A8bwEpJVG4dG7Qub9mFe5cpcbfrJ+XP2mKKxC3oUJSunK3iQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@napi-rs/tar-linux-arm64-musl@1.1.0': - resolution: {integrity: sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ==} + '@napi-rs/tar-linux-arm64-musl@1.1.1': + resolution: {integrity: sha512-Rh6UFhNtj3i4deJHOBINFIeRL0072mgbeyuK5rl1HokKnNoMKx8qKIZNEzBTTqpogMfDHWGvzyTQdnVxes5dpA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@napi-rs/tar-linux-ppc64-gnu@1.1.0': - resolution: {integrity: sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ==} + '@napi-rs/tar-linux-ppc64-gnu@1.1.1': + resolution: {integrity: sha512-Cp+AxFbv9zcyAXtnzQi0OzmgDnQgy2w9D4Ubr+iwzMtVgJcztzcEoCcCrN1k2ATdEB01LX2Vb49IaocGOZhC9Q==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] libc: [glibc] - '@napi-rs/tar-linux-s390x-gnu@1.1.0': - resolution: {integrity: sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ==} + '@napi-rs/tar-linux-s390x-gnu@1.1.1': + resolution: {integrity: sha512-ZyscC3SYKTBWyDRYjLOKAd5TyJ7q0KACRdQ8bWrb3rgrra1CCIJD66CsGTH6Dh0AVSdfLwZ8MfIIXU6+14BMjQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] libc: [glibc] - '@napi-rs/tar-linux-x64-gnu@1.1.0': - resolution: {integrity: sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww==} + '@napi-rs/tar-linux-x64-gnu@1.1.1': + resolution: {integrity: sha512-LlIv+zg4fiOQge9LQX/ieBdRWE2fhVDjCTHxnunZkbugNmdhdelxWf1RpZb/6ZujWpNF4LPu4N/MW7ygg2oYAQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@napi-rs/tar-linux-x64-musl@1.1.0': - resolution: {integrity: sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ==} + '@napi-rs/tar-linux-x64-musl@1.1.1': + resolution: {integrity: sha512-gZBeoKLjanOVj55qk4EMu13P2i9M0SuINmlGQkOxm1niIJofexzddHUYtqO5o/5QqtyL8lADmAcZplLILMLhHA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@napi-rs/tar-wasm32-wasi@1.1.0': - resolution: {integrity: sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw==} + '@napi-rs/tar-wasm32-wasi@1.1.1': + resolution: {integrity: sha512-rwtQ1Mdt/ft6g6I54fJzbUeLspl4yTwj6I3UJ6mitKnrN42soJkcDrdh3Y/FGvlpqZTad2YMQ96fGJl3EtAm2Q==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@napi-rs/tar-win32-arm64-msvc@1.1.0': - resolution: {integrity: sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg==} + '@napi-rs/tar-win32-arm64-msvc@1.1.1': + resolution: {integrity: sha512-30PVp1AehRpfwxmv5wI4cg0yj3WmWBsZ+1QnLGnvEELu7Eu/+dhNU0nrmhI7VfPgLwSRK2eg9DQTB3tP7Wv9bA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@napi-rs/tar-win32-ia32-msvc@1.1.0': - resolution: {integrity: sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ==} + '@napi-rs/tar-win32-ia32-msvc@1.1.1': + resolution: {integrity: sha512-aI3/rmz+izUChiSeaPxcasAOxhf3FpJNuIHMXlxS/vpW+HIxUsSDR5+XV61PEG5DL4L/75iENVUxmSGM5l2yaw==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@napi-rs/tar-win32-x64-msvc@1.1.0': - resolution: {integrity: sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw==} + '@napi-rs/tar-win32-x64-msvc@1.1.1': + resolution: {integrity: sha512-yJsB2IsrODQVLKbm2Fg1nHiVRbEj49mSPbj4x7JPZWJI0jGVPjohE2Sif0FBbx8OxsVoUODvS0BwksZZ8jl/OA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@napi-rs/tar@1.1.0': - resolution: {integrity: sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ==} + '@napi-rs/tar@1.1.1': + resolution: {integrity: sha512-p6q2HhUc5vwH1CNwfOcrhLoxfgn8ust8Sqlfx+sA4VzAcp1cMbvbkl99tZZlDqOjCHgQNSiTfk/yWPjl/D42qA==} engines: {node: '>= 10'} '@napi-rs/wasm-runtime@1.1.4': @@ -491,110 +521,120 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-tools-android-arm-eabi@1.0.1': - resolution: {integrity: sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw==} - engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@napi-rs/wasm-tools-android-arm-eabi@1.1.0': + resolution: {integrity: sha512-p6J8PB59I8d/XItXB/go5JH6nKW+xIbpzaL43EBTV0hi7mrS/Z4gs+MsB04ZrlqZN29BdZV8fChRyasuXLhRaA==} + engines: {node: '>= 12.22.0'} cpu: [arm] os: [android] - '@napi-rs/wasm-tools-android-arm64@1.0.1': - resolution: {integrity: sha512-WDR7S+aRLV6LtBJAg5fmjKkTZIdrEnnQxgdsb7Cf8pYiMWBHLU+LC49OUVppQ2YSPY0+GeYm9yuZWW3kLjJ7Bg==} - engines: {node: '>= 10'} + '@napi-rs/wasm-tools-android-arm64@1.1.0': + resolution: {integrity: sha512-lWoKN3suypeBSCIRPIw+++sH9V2K6nQkhtdt1opu7XY3v9JwLs6Gw063HWRqkNjphlYpkd/Qy8XcfSPGbJj7nQ==} + engines: {node: '>= 12.22.0'} cpu: [arm64] os: [android] - '@napi-rs/wasm-tools-darwin-arm64@1.0.1': - resolution: {integrity: sha512-qWTI+EEkiN0oIn/N2gQo7+TVYil+AJ20jjuzD2vATS6uIjVz+Updeqmszi7zq7rdFTLp6Ea3/z4kDKIfZwmR9g==} - engines: {node: '>= 10'} + '@napi-rs/wasm-tools-darwin-arm64@1.1.0': + resolution: {integrity: sha512-jfw5vyNDUf6oe0kP8lMveFN9U7cLk1cUosS7uMIfw/xmqmopYfKQ198DAx2g/6aEF7Tm+CqER2gpMpYKui30LA==} + engines: {node: '>= 12.22.0'} cpu: [arm64] os: [darwin] - '@napi-rs/wasm-tools-darwin-x64@1.0.1': - resolution: {integrity: sha512-bA6hubqtHROR5UI3tToAF/c6TDmaAgF0SWgo4rADHtQ4wdn0JeogvOk50gs2TYVhKPE2ZD2+qqt7oBKB+sxW3A==} - engines: {node: '>= 10'} + '@napi-rs/wasm-tools-darwin-x64@1.1.0': + resolution: {integrity: sha512-R+pjeudAB7BYdH1vKkOJM61Tfv5jB6uXkxmFscYd+KKpdUpWBlNG+s4hr0w4i1rMBM91VhIAETZn2pz+MDHK9A==} + engines: {node: '>= 12.22.0'} cpu: [x64] os: [darwin] - '@napi-rs/wasm-tools-freebsd-x64@1.0.1': - resolution: {integrity: sha512-90+KLBkD9hZEjPQW1MDfwSt5J1L46EUKacpCZWyRuL6iIEO5CgWU0V/JnEgFsDOGyyYtiTvHc5bUdUTWd4I9Vg==} - engines: {node: '>= 10'} + '@napi-rs/wasm-tools-freebsd-x64@1.1.0': + resolution: {integrity: sha512-hQJTe+aazrT++Vgm6I4lUd9099ItUCFYdd+aKg6Ys6nax6d/cZ1barDLTwA2lwOoVDsXMekJI/FOL6ZvVlIYBg==} + engines: {node: '>= 12.22.0'} cpu: [x64] os: [freebsd] - '@napi-rs/wasm-tools-linux-arm64-gnu@1.0.1': - resolution: {integrity: sha512-rG0QlS65x9K/u3HrKafDf8cFKj5wV2JHGfl8abWgKew0GVPyp6vfsDweOwHbWAjcHtp2LHi6JHoW80/MTHm52Q==} - engines: {node: '>= 10'} + '@napi-rs/wasm-tools-linux-arm64-gnu@1.1.0': + resolution: {integrity: sha512-1TAXJxUHsWGar90k3W/MknavvBMwOWzjh7Q6Spxo8twRcWJbBD5Kow/Q2KhhDq5hxh2sKGDXn3uLc1tdtz4WUg==} + engines: {node: '>= 12.22.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@napi-rs/wasm-tools-linux-arm64-musl@1.0.1': - resolution: {integrity: sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ==} - engines: {node: '>= 10'} + '@napi-rs/wasm-tools-linux-arm64-musl@1.1.0': + resolution: {integrity: sha512-7rw3nlubTjNAVRH2LwphCxHy1b/N2/TerXocQ6XRn4Q+buaY1Z7P/hbdALy1i1ex2yfOU2Xcij7ib7ZLi/lKfw==} + engines: {node: '>= 12.22.0'} cpu: [arm64] os: [linux] libc: [musl] - '@napi-rs/wasm-tools-linux-x64-gnu@1.0.1': - resolution: {integrity: sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw==} - engines: {node: '>= 10'} + '@napi-rs/wasm-tools-linux-x64-gnu@1.1.0': + resolution: {integrity: sha512-1sel0t9MRjI/tdT89M8Dd6gPfANeeFP24Xa46R11WeHNwhjsXXZh+xUk50uWCRTSGcaCy3ugm3AMK/lmHYQJkg==} + engines: {node: '>= 12.22.0'} cpu: [x64] os: [linux] libc: [glibc] - '@napi-rs/wasm-tools-linux-x64-musl@1.0.1': - resolution: {integrity: sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ==} - engines: {node: '>= 10'} + '@napi-rs/wasm-tools-linux-x64-musl@1.1.0': + resolution: {integrity: sha512-o2jH5AMfor4EKF2HII1LBnMQxoWu7+usPifTEY8Zk6e9OiSi4EJkAXf9v3ANlX7TI2V/cUEV34OEW7r10GiVIA==} + engines: {node: '>= 12.22.0'} cpu: [x64] os: [linux] libc: [musl] - '@napi-rs/wasm-tools-wasm32-wasi@1.0.1': - resolution: {integrity: sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA==} + '@napi-rs/wasm-tools-wasm32-wasi@1.1.0': + resolution: {integrity: sha512-s6YDtDR1UWrsqJPtaxf+JLYLceWVyn3l8OpQYElHkDhf3Qfz9R6Ba3S0OgznTBv38L5/TIHysQ9Q4yO73Z0csg==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@napi-rs/wasm-tools-win32-arm64-msvc@1.0.1': - resolution: {integrity: sha512-PFi7oJIBu5w7Qzh3dwFea3sHRO3pojMsaEnUIy22QvsW+UJfNQwJCryVrpoUt8m4QyZXI+saEq/0r4GwdoHYFQ==} - engines: {node: '>= 10'} + '@napi-rs/wasm-tools-win32-arm64-msvc@1.1.0': + resolution: {integrity: sha512-x+NuxbG84VxU68tU8w7Rf5lSyq0l584M6dVlke5DTweHYFZoMyeqkpbwEq+qsyAX6ivfipK8xRsmFwamb5uDnA==} + engines: {node: '>= 12.22.0'} cpu: [arm64] os: [win32] - '@napi-rs/wasm-tools-win32-ia32-msvc@1.0.1': - resolution: {integrity: sha512-gXkuYzxQsgkj05Zaq+KQTkHIN83dFAwMcTKa2aQcpYPRImFm2AQzEyLtpXmyCWzJ0F9ZYAOmbSyrNew8/us6bw==} - engines: {node: '>= 10'} + '@napi-rs/wasm-tools-win32-ia32-msvc@1.1.0': + resolution: {integrity: sha512-mdD96QDEp70SX67rXFTY6c725nVYeqEEjyDqzzbNh6u1APj7CI7IMNpMmvE75XbCRl4C2MHZVU4U6AWdAzvyQQ==} + engines: {node: '>= 12.22.0'} cpu: [ia32] os: [win32] - '@napi-rs/wasm-tools-win32-x64-msvc@1.0.1': - resolution: {integrity: sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ==} - engines: {node: '>= 10'} + '@napi-rs/wasm-tools-win32-x64-msvc@1.1.0': + resolution: {integrity: sha512-bVVjuvhlyVX++3eJXfDR63cXdw1ay5QYac6iq0MKQw8wZARInTM+bXCtByDT4fzVFI3+7ZthYb/ERWRdBNIqgQ==} + engines: {node: '>= 12.22.0'} cpu: [x64] os: [win32] - '@napi-rs/wasm-tools@1.0.1': - resolution: {integrity: sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ==} - engines: {node: '>= 10'} + '@napi-rs/wasm-tools@1.1.0': + resolution: {integrity: sha512-VjHyKEqXAwYZK+HY7iJctYvRm3TFEbaQxeZwvAG1QRkoo1a39phMY8J6x9tUEqJI03W6MysB8F2jacI6wvcx+w==} + engines: {node: '>= 12.22.0'} '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} - '@octokit/core@7.0.6': - resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + '@octokit/core@7.0.8': + resolution: {integrity: sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==} engines: {node: '>= 20'} - '@octokit/endpoint@11.0.3': - resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} + '@octokit/endpoint@11.0.5': + resolution: {integrity: sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==} engines: {node: '>= 20'} - '@octokit/graphql@9.0.3': - resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + '@octokit/graphql@9.0.5': + resolution: {integrity: sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==} engines: {node: '>= 20'} '@octokit/openapi-types@27.0.0': resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + '@octokit/openapi-types@29.0.1': + resolution: {integrity: sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==} + '@octokit/plugin-paginate-rest@14.0.0': resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} engines: {node: '>= 20'} @@ -613,12 +653,12 @@ packages: peerDependencies: '@octokit/core': '>=6' - '@octokit/request-error@7.1.0': - resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + '@octokit/request-error@7.1.2': + resolution: {integrity: sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==} engines: {node: '>= 20'} - '@octokit/request@10.0.10': - resolution: {integrity: sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w==} + '@octokit/request@10.0.16': + resolution: {integrity: sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==} engines: {node: '>= 20'} '@octokit/rest@22.0.1': @@ -628,6 +668,9 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@octokit/types@18.0.0': + resolution: {integrity: sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==} + '@oxc-node/core-android-arm-eabi@0.1.0': resolution: {integrity: sha512-+ycNqMBKBz3EWpQKm7HgUMRLGKfFZsZ/JxN9ctx12CwGy0PTtjX3TB+1WEbiJrgWiZM0axBjuwe4MEqS6j1kgQ==} cpu: [arm] @@ -955,6 +998,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1026,8 +1072,8 @@ packages: resolution: {integrity: sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==} engines: {node: '>=22'} - chardet@2.1.1: - resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} @@ -1049,9 +1095,9 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} - engines: {node: '>=18'} + content-type@3.1.1: + resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==} + engines: {node: '>=22'} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1073,8 +1119,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - emnapi@1.10.0: - resolution: {integrity: sha512-swoyZjupDvLoe/KC3HZ4SY1JUN+tviT6eOZ3Px28TZAYdBHtRIiMWWrIUUH+2/9CYY4fNTID1YhYZ+kdFHszHg==} + emnapi@1.11.3: + resolution: {integrity: sha512-+/ZS90YK/rYfVOHtGLHkGffVsnmD/MAKaBHio+Y4XAtg75RLr4cveV/w0jTkUdLM1CcAlaRgG76mpIemWAlk0A==} peerDependencies: node-addon-api: '>= 6.1.0' peerDependenciesMeta: @@ -1091,8 +1137,8 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - es-toolkit@1.47.0: - resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + es-toolkit@1.52.0: + resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1136,8 +1182,8 @@ packages: engines: {node: '>=18'} hasBin: true - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} is-fullwidth-code-point@5.1.0: @@ -1151,16 +1197,16 @@ packages: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@5.4.2: + resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} hasBin: true json-parse-even-better-errors@6.0.0: resolution: {integrity: sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - json-with-bigint@3.5.8: - resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} + json-with-bigint@3.5.12: + resolution: {integrity: sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==} lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} @@ -1290,6 +1336,10 @@ packages: resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} engines: {node: '>=12.20.0'} + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -1358,8 +1408,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - semver@7.8.2: - resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -1570,6 +1620,18 @@ snapshots: dependencies: '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 '@emnapi/core@1.9.1': dependencies: @@ -1577,15 +1639,36 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 '@emnapi/runtime@1.9.1': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.0': dependencies: tslib: 2.8.1 @@ -1594,30 +1677,40 @@ snapshots: '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 + optional: true - '@inquirer/ansi@2.0.7': {} + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true - '@inquirer/checkbox@5.2.1(@types/node@24.13.1)': + '@emnapi/wasi-threads@1.2.3': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@24.13.1) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@24.13.1) + tslib: 2.8.1 + + '@inquirer/ansi@2.0.8': {} + + '@inquirer/checkbox@5.2.5(@types/node@24.13.1)': + dependencies: + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.3(@types/node@24.13.1) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@24.13.1) optionalDependencies: '@types/node': 24.13.1 - '@inquirer/confirm@6.1.1(@types/node@24.13.1)': + '@inquirer/confirm@6.3.2(@types/node@24.13.1)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.1) - '@inquirer/type': 4.0.7(@types/node@24.13.1) + '@inquirer/core': 12.0.3(@types/node@24.13.1) + '@inquirer/type': 4.1.1(@types/node@24.13.1) optionalDependencies: '@types/node': 24.13.1 - '@inquirer/core@11.2.1(@types/node@24.13.1)': + '@inquirer/core@12.0.3(@types/node@24.13.1)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@24.13.1) + '@inquirer/ansi': 2.0.8 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@24.13.1) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 @@ -1625,115 +1718,116 @@ snapshots: optionalDependencies: '@types/node': 24.13.1 - '@inquirer/editor@5.2.2(@types/node@24.13.1)': + '@inquirer/editor@5.3.3(@types/node@24.13.1)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.1) - '@inquirer/external-editor': 3.0.3(@types/node@24.13.1) - '@inquirer/type': 4.0.7(@types/node@24.13.1) + '@inquirer/core': 12.0.3(@types/node@24.13.1) + '@inquirer/external-editor': 3.0.5(@types/node@24.13.1) + '@inquirer/type': 4.1.1(@types/node@24.13.1) optionalDependencies: '@types/node': 24.13.1 - '@inquirer/expand@5.1.1(@types/node@24.13.1)': + '@inquirer/expand@5.1.5(@types/node@24.13.1)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.1) - '@inquirer/type': 4.0.7(@types/node@24.13.1) + '@inquirer/core': 12.0.3(@types/node@24.13.1) + '@inquirer/type': 4.1.1(@types/node@24.13.1) optionalDependencies: '@types/node': 24.13.1 - '@inquirer/external-editor@3.0.3(@types/node@24.13.1)': + '@inquirer/external-editor@3.0.5(@types/node@24.13.1)': dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.2 + chardet: 2.2.0 + iconv-lite: 0.7.3 optionalDependencies: '@types/node': 24.13.1 - '@inquirer/figures@2.0.7': {} + '@inquirer/figures@2.0.9': {} - '@inquirer/input@5.1.2(@types/node@24.13.1)': + '@inquirer/input@5.1.6(@types/node@24.13.1)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.1) - '@inquirer/type': 4.0.7(@types/node@24.13.1) + '@inquirer/core': 12.0.3(@types/node@24.13.1) + '@inquirer/type': 4.1.1(@types/node@24.13.1) optionalDependencies: '@types/node': 24.13.1 - '@inquirer/number@4.1.1(@types/node@24.13.1)': + '@inquirer/number@4.2.3(@types/node@24.13.1)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.1) - '@inquirer/type': 4.0.7(@types/node@24.13.1) + '@inquirer/core': 12.0.3(@types/node@24.13.1) + '@inquirer/type': 4.1.1(@types/node@24.13.1) optionalDependencies: '@types/node': 24.13.1 - '@inquirer/password@5.1.1(@types/node@24.13.1)': + '@inquirer/password@5.2.2(@types/node@24.13.1)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@24.13.1) - '@inquirer/type': 4.0.7(@types/node@24.13.1) + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.3(@types/node@24.13.1) + '@inquirer/type': 4.1.1(@types/node@24.13.1) optionalDependencies: '@types/node': 24.13.1 - '@inquirer/prompts@8.5.2(@types/node@24.13.1)': - dependencies: - '@inquirer/checkbox': 5.2.1(@types/node@24.13.1) - '@inquirer/confirm': 6.1.1(@types/node@24.13.1) - '@inquirer/editor': 5.2.2(@types/node@24.13.1) - '@inquirer/expand': 5.1.1(@types/node@24.13.1) - '@inquirer/input': 5.1.2(@types/node@24.13.1) - '@inquirer/number': 4.1.1(@types/node@24.13.1) - '@inquirer/password': 5.1.1(@types/node@24.13.1) - '@inquirer/rawlist': 5.3.1(@types/node@24.13.1) - '@inquirer/search': 4.2.1(@types/node@24.13.1) - '@inquirer/select': 5.2.1(@types/node@24.13.1) + '@inquirer/prompts@8.7.2(@types/node@24.13.1)': + dependencies: + '@inquirer/checkbox': 5.2.5(@types/node@24.13.1) + '@inquirer/confirm': 6.3.2(@types/node@24.13.1) + '@inquirer/editor': 5.3.3(@types/node@24.13.1) + '@inquirer/expand': 5.1.5(@types/node@24.13.1) + '@inquirer/input': 5.1.6(@types/node@24.13.1) + '@inquirer/number': 4.2.3(@types/node@24.13.1) + '@inquirer/password': 5.2.2(@types/node@24.13.1) + '@inquirer/rawlist': 5.3.5(@types/node@24.13.1) + '@inquirer/search': 4.3.3(@types/node@24.13.1) + '@inquirer/select': 5.2.5(@types/node@24.13.1) optionalDependencies: '@types/node': 24.13.1 - '@inquirer/rawlist@5.3.1(@types/node@24.13.1)': + '@inquirer/rawlist@5.3.5(@types/node@24.13.1)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.1) - '@inquirer/type': 4.0.7(@types/node@24.13.1) + '@inquirer/core': 12.0.3(@types/node@24.13.1) + '@inquirer/type': 4.1.1(@types/node@24.13.1) optionalDependencies: '@types/node': 24.13.1 - '@inquirer/search@4.2.1(@types/node@24.13.1)': + '@inquirer/search@4.3.3(@types/node@24.13.1)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.1) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@24.13.1) + '@inquirer/core': 12.0.3(@types/node@24.13.1) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@24.13.1) optionalDependencies: '@types/node': 24.13.1 - '@inquirer/select@5.2.1(@types/node@24.13.1)': + '@inquirer/select@5.2.5(@types/node@24.13.1)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@24.13.1) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@24.13.1) + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.3(@types/node@24.13.1) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@24.13.1) optionalDependencies: '@types/node': 24.13.1 - '@inquirer/type@4.0.7(@types/node@24.13.1)': + '@inquirer/type@4.1.1(@types/node@24.13.1)': optionalDependencies: '@types/node': 24.13.1 '@jridgewell/sourcemap-codec@1.5.5': {} - '@napi-rs/cli@3.7.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.13.1)': + '@napi-rs/cli@3.10.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.1)(emnapi@1.11.3)': dependencies: - '@inquirer/prompts': 8.5.2(@types/node@24.13.1) - '@napi-rs/cross-toolchain': 1.0.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@napi-rs/wasm-tools': 1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@inquirer/prompts': 8.7.2(@types/node@24.13.1) + '@napi-rs/cross-toolchain': 1.0.3 + '@napi-rs/wasm-tools': 1.1.0 '@octokit/rest': 22.0.1 clipanion: 4.0.0-rc.4(typanion@3.14.0) colorette: 2.0.20 - emnapi: 1.10.0 - es-toolkit: 1.47.0 - js-yaml: 4.2.0 - obug: 2.1.2 - semver: 7.8.2 + es-toolkit: 1.52.0 + js-yaml: 5.4.2 + obug: 3.0.0 + semver: 7.8.5 typanion: 3.14.0 + typescript: 6.0.3 optionalDependencies: - '@emnapi/runtime': 1.10.0 + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + emnapi: 1.11.3 transitivePeerDependencies: - - '@emnapi/core' - '@napi-rs/cross-toolchain-arm64-target-aarch64' - '@napi-rs/cross-toolchain-arm64-target-armv7' - '@napi-rs/cross-toolchain-arm64-target-ppc64le' @@ -1745,312 +1839,324 @@ snapshots: - '@napi-rs/cross-toolchain-x64-target-s390x' - '@napi-rs/cross-toolchain-x64-target-x86_64' - '@types/node' - - node-addon-api - supports-color - '@napi-rs/cross-toolchain@1.0.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/cross-toolchain@1.0.3': dependencies: - '@napi-rs/lzma': 1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@napi-rs/tar': 1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/lzma': 1.5.1 + '@napi-rs/tar': 1.1.1 debug: 4.4.3 transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - supports-color - '@napi-rs/lzma-android-arm-eabi@1.4.5': + '@napi-rs/lzma-android-arm-eabi@1.5.1': optional: true - '@napi-rs/lzma-android-arm64@1.4.5': + '@napi-rs/lzma-android-arm64@1.5.1': optional: true - '@napi-rs/lzma-darwin-arm64@1.4.5': + '@napi-rs/lzma-darwin-arm64@1.5.1': optional: true - '@napi-rs/lzma-darwin-x64@1.4.5': + '@napi-rs/lzma-darwin-x64@1.5.1': optional: true - '@napi-rs/lzma-freebsd-x64@1.4.5': + '@napi-rs/lzma-freebsd-x64@1.5.1': optional: true - '@napi-rs/lzma-linux-arm-gnueabihf@1.4.5': + '@napi-rs/lzma-linux-arm-gnueabihf@1.5.1': optional: true - '@napi-rs/lzma-linux-arm64-gnu@1.4.5': + '@napi-rs/lzma-linux-arm64-gnu@1.5.1': optional: true - '@napi-rs/lzma-linux-arm64-musl@1.4.5': + '@napi-rs/lzma-linux-arm64-musl@1.5.1': optional: true - '@napi-rs/lzma-linux-ppc64-gnu@1.4.5': + '@napi-rs/lzma-linux-ppc64-gnu@1.5.1': optional: true - '@napi-rs/lzma-linux-riscv64-gnu@1.4.5': + '@napi-rs/lzma-linux-riscv64-gnu@1.5.1': optional: true - '@napi-rs/lzma-linux-s390x-gnu@1.4.5': + '@napi-rs/lzma-linux-s390x-gnu@1.5.1': optional: true - '@napi-rs/lzma-linux-x64-gnu@1.4.5': + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@napi-rs/lzma-linux-x64-musl@1.4.5': + '@napi-rs/lzma-linux-x64-musl@1.5.1': optional: true - '@napi-rs/lzma-wasm32-wasi@1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/lzma-wasm32-wasi@1.5.1': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true - '@napi-rs/lzma-win32-arm64-msvc@1.4.5': + '@napi-rs/lzma-win32-arm64-msvc@1.5.1': optional: true - '@napi-rs/lzma-win32-ia32-msvc@1.4.5': + '@napi-rs/lzma-win32-ia32-msvc@1.5.1': optional: true - '@napi-rs/lzma-win32-x64-msvc@1.4.5': + '@napi-rs/lzma-win32-x64-msvc@1.5.1': optional: true - '@napi-rs/lzma@1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/lzma@1.5.1': optionalDependencies: - '@napi-rs/lzma-android-arm-eabi': 1.4.5 - '@napi-rs/lzma-android-arm64': 1.4.5 - '@napi-rs/lzma-darwin-arm64': 1.4.5 - '@napi-rs/lzma-darwin-x64': 1.4.5 - '@napi-rs/lzma-freebsd-x64': 1.4.5 - '@napi-rs/lzma-linux-arm-gnueabihf': 1.4.5 - '@napi-rs/lzma-linux-arm64-gnu': 1.4.5 - '@napi-rs/lzma-linux-arm64-musl': 1.4.5 - '@napi-rs/lzma-linux-ppc64-gnu': 1.4.5 - '@napi-rs/lzma-linux-riscv64-gnu': 1.4.5 - '@napi-rs/lzma-linux-s390x-gnu': 1.4.5 - '@napi-rs/lzma-linux-x64-gnu': 1.4.5 - '@napi-rs/lzma-linux-x64-musl': 1.4.5 - '@napi-rs/lzma-wasm32-wasi': 1.4.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@napi-rs/lzma-win32-arm64-msvc': 1.4.5 - '@napi-rs/lzma-win32-ia32-msvc': 1.4.5 - '@napi-rs/lzma-win32-x64-msvc': 1.4.5 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@napi-rs/lzma-android-arm-eabi': 1.5.1 + '@napi-rs/lzma-android-arm64': 1.5.1 + '@napi-rs/lzma-darwin-arm64': 1.5.1 + '@napi-rs/lzma-darwin-x64': 1.5.1 + '@napi-rs/lzma-freebsd-x64': 1.5.1 + '@napi-rs/lzma-linux-arm-gnueabihf': 1.5.1 + '@napi-rs/lzma-linux-arm64-gnu': 1.5.1 + '@napi-rs/lzma-linux-arm64-musl': 1.5.1 + '@napi-rs/lzma-linux-ppc64-gnu': 1.5.1 + '@napi-rs/lzma-linux-riscv64-gnu': 1.5.1 + '@napi-rs/lzma-linux-s390x-gnu': 1.5.1 + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@napi-rs/lzma-linux-x64-musl': 1.5.1 + '@napi-rs/lzma-wasm32-wasi': 1.5.1 + '@napi-rs/lzma-win32-arm64-msvc': 1.5.1 + '@napi-rs/lzma-win32-ia32-msvc': 1.5.1 + '@napi-rs/lzma-win32-x64-msvc': 1.5.1 - '@napi-rs/tar-android-arm-eabi@1.1.0': + '@napi-rs/tar-android-arm-eabi@1.1.1': optional: true - '@napi-rs/tar-android-arm64@1.1.0': + '@napi-rs/tar-android-arm64@1.1.1': optional: true - '@napi-rs/tar-darwin-arm64@1.1.0': + '@napi-rs/tar-darwin-arm64@1.1.1': optional: true - '@napi-rs/tar-darwin-x64@1.1.0': + '@napi-rs/tar-darwin-x64@1.1.1': optional: true - '@napi-rs/tar-freebsd-x64@1.1.0': + '@napi-rs/tar-freebsd-x64@1.1.1': optional: true - '@napi-rs/tar-linux-arm-gnueabihf@1.1.0': + '@napi-rs/tar-linux-arm-gnueabihf@1.1.1': optional: true - '@napi-rs/tar-linux-arm64-gnu@1.1.0': + '@napi-rs/tar-linux-arm64-gnu@1.1.1': optional: true - '@napi-rs/tar-linux-arm64-musl@1.1.0': + '@napi-rs/tar-linux-arm64-musl@1.1.1': optional: true - '@napi-rs/tar-linux-ppc64-gnu@1.1.0': + '@napi-rs/tar-linux-ppc64-gnu@1.1.1': optional: true - '@napi-rs/tar-linux-s390x-gnu@1.1.0': + '@napi-rs/tar-linux-s390x-gnu@1.1.1': optional: true - '@napi-rs/tar-linux-x64-gnu@1.1.0': + '@napi-rs/tar-linux-x64-gnu@1.1.1': optional: true - '@napi-rs/tar-linux-x64-musl@1.1.0': + '@napi-rs/tar-linux-x64-musl@1.1.1': optional: true - '@napi-rs/tar-wasm32-wasi@1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/tar-wasm32-wasi@1.1.1': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true - '@napi-rs/tar-win32-arm64-msvc@1.1.0': + '@napi-rs/tar-win32-arm64-msvc@1.1.1': optional: true - '@napi-rs/tar-win32-ia32-msvc@1.1.0': + '@napi-rs/tar-win32-ia32-msvc@1.1.1': optional: true - '@napi-rs/tar-win32-x64-msvc@1.1.0': + '@napi-rs/tar-win32-x64-msvc@1.1.1': optional: true - '@napi-rs/tar@1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/tar@1.1.1': optionalDependencies: - '@napi-rs/tar-android-arm-eabi': 1.1.0 - '@napi-rs/tar-android-arm64': 1.1.0 - '@napi-rs/tar-darwin-arm64': 1.1.0 - '@napi-rs/tar-darwin-x64': 1.1.0 - '@napi-rs/tar-freebsd-x64': 1.1.0 - '@napi-rs/tar-linux-arm-gnueabihf': 1.1.0 - '@napi-rs/tar-linux-arm64-gnu': 1.1.0 - '@napi-rs/tar-linux-arm64-musl': 1.1.0 - '@napi-rs/tar-linux-ppc64-gnu': 1.1.0 - '@napi-rs/tar-linux-s390x-gnu': 1.1.0 - '@napi-rs/tar-linux-x64-gnu': 1.1.0 - '@napi-rs/tar-linux-x64-musl': 1.1.0 - '@napi-rs/tar-wasm32-wasi': 1.1.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@napi-rs/tar-win32-arm64-msvc': 1.1.0 - '@napi-rs/tar-win32-ia32-msvc': 1.1.0 - '@napi-rs/tar-win32-x64-msvc': 1.1.0 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@napi-rs/tar-android-arm-eabi': 1.1.1 + '@napi-rs/tar-android-arm64': 1.1.1 + '@napi-rs/tar-darwin-arm64': 1.1.1 + '@napi-rs/tar-darwin-x64': 1.1.1 + '@napi-rs/tar-freebsd-x64': 1.1.1 + '@napi-rs/tar-linux-arm-gnueabihf': 1.1.1 + '@napi-rs/tar-linux-arm64-gnu': 1.1.1 + '@napi-rs/tar-linux-arm64-musl': 1.1.1 + '@napi-rs/tar-linux-ppc64-gnu': 1.1.1 + '@napi-rs/tar-linux-s390x-gnu': 1.1.1 + '@napi-rs/tar-linux-x64-gnu': 1.1.1 + '@napi-rs/tar-linux-x64-musl': 1.1.1 + '@napi-rs/tar-wasm32-wasi': 1.1.1 + '@napi-rs/tar-win32-arm64-msvc': 1.1.1 + '@napi-rs/tar-win32-ia32-msvc': 1.1.1 + '@napi-rs/tar-win32-x64-msvc': 1.1.1 '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.2 + optional: true + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.4 + optional: true - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: '@emnapi/core': 1.9.1 '@emnapi/runtime': 1.9.1 - '@tybys/wasm-util': 0.10.2 + '@tybys/wasm-util': 0.10.4 optional: true - '@napi-rs/wasm-tools-android-arm-eabi@1.0.1': + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.4 optional: true - '@napi-rs/wasm-tools-android-arm64@1.0.1': + '@napi-rs/wasm-tools-android-arm-eabi@1.1.0': optional: true - '@napi-rs/wasm-tools-darwin-arm64@1.0.1': + '@napi-rs/wasm-tools-android-arm64@1.1.0': optional: true - '@napi-rs/wasm-tools-darwin-x64@1.0.1': + '@napi-rs/wasm-tools-darwin-arm64@1.1.0': optional: true - '@napi-rs/wasm-tools-freebsd-x64@1.0.1': + '@napi-rs/wasm-tools-darwin-x64@1.1.0': optional: true - '@napi-rs/wasm-tools-linux-arm64-gnu@1.0.1': + '@napi-rs/wasm-tools-freebsd-x64@1.1.0': optional: true - '@napi-rs/wasm-tools-linux-arm64-musl@1.0.1': + '@napi-rs/wasm-tools-linux-arm64-gnu@1.1.0': optional: true - '@napi-rs/wasm-tools-linux-x64-gnu@1.0.1': + '@napi-rs/wasm-tools-linux-arm64-musl@1.1.0': optional: true - '@napi-rs/wasm-tools-linux-x64-musl@1.0.1': + '@napi-rs/wasm-tools-linux-x64-gnu@1.1.0': optional: true - '@napi-rs/wasm-tools-wasm32-wasi@1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-tools-linux-x64-musl@1.1.0': + optional: true + + '@napi-rs/wasm-tools-wasm32-wasi@1.1.0': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) optional: true - '@napi-rs/wasm-tools-win32-arm64-msvc@1.0.1': + '@napi-rs/wasm-tools-win32-arm64-msvc@1.1.0': optional: true - '@napi-rs/wasm-tools-win32-ia32-msvc@1.0.1': + '@napi-rs/wasm-tools-win32-ia32-msvc@1.1.0': optional: true - '@napi-rs/wasm-tools-win32-x64-msvc@1.0.1': + '@napi-rs/wasm-tools-win32-x64-msvc@1.1.0': optional: true - '@napi-rs/wasm-tools@1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-tools@1.1.0': optionalDependencies: - '@napi-rs/wasm-tools-android-arm-eabi': 1.0.1 - '@napi-rs/wasm-tools-android-arm64': 1.0.1 - '@napi-rs/wasm-tools-darwin-arm64': 1.0.1 - '@napi-rs/wasm-tools-darwin-x64': 1.0.1 - '@napi-rs/wasm-tools-freebsd-x64': 1.0.1 - '@napi-rs/wasm-tools-linux-arm64-gnu': 1.0.1 - '@napi-rs/wasm-tools-linux-arm64-musl': 1.0.1 - '@napi-rs/wasm-tools-linux-x64-gnu': 1.0.1 - '@napi-rs/wasm-tools-linux-x64-musl': 1.0.1 - '@napi-rs/wasm-tools-wasm32-wasi': 1.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@napi-rs/wasm-tools-win32-arm64-msvc': 1.0.1 - '@napi-rs/wasm-tools-win32-ia32-msvc': 1.0.1 - '@napi-rs/wasm-tools-win32-x64-msvc': 1.0.1 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@napi-rs/wasm-tools-android-arm-eabi': 1.1.0 + '@napi-rs/wasm-tools-android-arm64': 1.1.0 + '@napi-rs/wasm-tools-darwin-arm64': 1.1.0 + '@napi-rs/wasm-tools-darwin-x64': 1.1.0 + '@napi-rs/wasm-tools-freebsd-x64': 1.1.0 + '@napi-rs/wasm-tools-linux-arm64-gnu': 1.1.0 + '@napi-rs/wasm-tools-linux-arm64-musl': 1.1.0 + '@napi-rs/wasm-tools-linux-x64-gnu': 1.1.0 + '@napi-rs/wasm-tools-linux-x64-musl': 1.1.0 + '@napi-rs/wasm-tools-wasm32-wasi': 1.1.0 + '@napi-rs/wasm-tools-win32-arm64-msvc': 1.1.0 + '@napi-rs/wasm-tools-win32-ia32-msvc': 1.1.0 + '@napi-rs/wasm-tools-win32-x64-msvc': 1.1.0 '@octokit/auth-token@6.0.0': {} - '@octokit/core@7.0.6': + '@octokit/core@7.0.8': dependencies: '@octokit/auth-token': 6.0.0 - '@octokit/graphql': 9.0.3 - '@octokit/request': 10.0.10 - '@octokit/request-error': 7.1.0 - '@octokit/types': 16.0.0 + '@octokit/graphql': 9.0.5 + '@octokit/request': 10.0.16 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 before-after-hook: 4.0.0 universal-user-agent: 7.0.3 - '@octokit/endpoint@11.0.3': + '@octokit/endpoint@11.0.5': dependencies: - '@octokit/types': 16.0.0 + '@octokit/types': 18.0.0 universal-user-agent: 7.0.3 - '@octokit/graphql@9.0.3': + '@octokit/graphql@9.0.5': dependencies: - '@octokit/request': 10.0.10 - '@octokit/types': 16.0.0 + '@octokit/request': 10.0.16 + '@octokit/types': 18.0.0 universal-user-agent: 7.0.3 '@octokit/openapi-types@27.0.0': {} - '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': + '@octokit/openapi-types@29.0.1': {} + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.8)': dependencies: - '@octokit/core': 7.0.6 + '@octokit/core': 7.0.8 '@octokit/types': 16.0.0 - '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.8)': dependencies: - '@octokit/core': 7.0.6 + '@octokit/core': 7.0.8 - '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.8)': dependencies: - '@octokit/core': 7.0.6 + '@octokit/core': 7.0.8 '@octokit/types': 16.0.0 - '@octokit/request-error@7.1.0': + '@octokit/request-error@7.1.2': dependencies: - '@octokit/types': 16.0.0 + '@octokit/types': 18.0.0 - '@octokit/request@10.0.10': + '@octokit/request@10.0.16': dependencies: - '@octokit/endpoint': 11.0.3 - '@octokit/request-error': 7.1.0 - '@octokit/types': 16.0.0 - content-type: 2.0.0 - json-with-bigint: 3.5.8 + '@octokit/endpoint': 11.0.5 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + content-type: 3.1.1 + json-with-bigint: 3.5.12 universal-user-agent: 7.0.3 '@octokit/rest@22.0.1': dependencies: - '@octokit/core': 7.0.6 - '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) - '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) - '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) + '@octokit/core': 7.0.8 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.8) + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.8) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.8) '@octokit/types@16.0.0': dependencies: '@octokit/openapi-types': 27.0.0 + '@octokit/types@18.0.0': + dependencies: + '@octokit/openapi-types': 29.0.1 + '@oxc-node/core-android-arm-eabi@0.1.0': optional: true @@ -2094,7 +2200,7 @@ snapshots: dependencies: '@emnapi/core': 1.9.1 '@emnapi/runtime': 1.9.1 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) optional: true '@oxc-node/core-win32-arm64-msvc@0.1.0': @@ -2245,6 +2351,11 @@ snapshots: '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 + optional: true + + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 '@types/chai@5.2.3': dependencies: @@ -2318,7 +2429,7 @@ snapshots: chalk@6.0.0: {} - chardet@2.1.1: {} + chardet@2.2.0: {} cli-cursor@5.0.0: dependencies: @@ -2337,7 +2448,7 @@ snapshots: colorette@2.0.20: {} - content-type@2.0.0: {} + content-type@3.1.1: {} convert-source-map@2.0.0: {} @@ -2353,7 +2464,7 @@ snapshots: detect-libc@2.1.2: {} - emnapi@1.10.0: {} + emnapi@1.11.3: {} emoji-regex@10.6.0: {} @@ -2361,7 +2472,7 @@ snapshots: es-module-lexer@2.1.0: {} - es-toolkit@1.47.0: {} + es-toolkit@1.52.0: {} estree-walker@3.0.3: dependencies: @@ -2392,7 +2503,7 @@ snapshots: husky@9.1.7: {} - iconv-lite@0.7.2: + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -2404,13 +2515,13 @@ snapshots: isexe@4.0.0: {} - js-yaml@4.2.0: + js-yaml@5.4.2: dependencies: argparse: 2.0.1 json-parse-even-better-errors@6.0.0: {} - json-with-bigint@3.5.8: {} + json-with-bigint@3.5.12: {} lightningcss-android-arm64@1.32.0: optional: true @@ -2517,6 +2628,8 @@ snapshots: obug@2.1.2: {} + obug@3.0.0: {} + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -2598,7 +2711,7 @@ snapshots: safer-buffer@2.1.2: {} - semver@7.8.2: {} + semver@7.8.5: {} shebang-command@2.0.0: dependencies: diff --git a/src/document.rs b/src/document.rs index f55ef81..62dc83f 100644 --- a/src/document.rs +++ b/src/document.rs @@ -1,4 +1,4 @@ -use napi::{bindgen_prelude::*, Error, JsNumber, Result, Status, ValueType}; +use napi::{bindgen_prelude::*, Error, JsDate, JsNumber, Result, Status, ValueType}; use napi_derive::napi; use tantivy::{self as tv, schema::document::OwnedValue as Value}; @@ -165,6 +165,12 @@ pub(crate) fn extract_value(value: &Unknown) -> Result { } } +/// True for a finite JavaScript number with no fractional part. Integer fields +/// reject anything else rather than silently truncating. +fn is_integral(n: f64) -> bool { + n.is_finite() && n.fract() == 0.0 +} + // Simplified schema-aware value extraction (similar to Python) pub(crate) fn extract_value_for_type( value: &Unknown, @@ -183,58 +189,80 @@ pub(crate) fn extract_value_for_type( let s = value.coerce_to_string()?.into_utf8()?.into_owned()?; Ok(Value::Str(s)) } - tv::schema::Type::U64 => { - // Reject strings but allow number coercion - if matches!(value.get_type()?, ValueType::String) { - return Err(Error::new(Status::InvalidArg, error_msg("U64"))); + tv::schema::Type::U64 => match value.get_type()? { + ValueType::BigInt => { + let big: BigInt = unsafe { value.cast()? }; + let (signed, n, lossless) = big.get_u64(); + if signed || !lossless { + return Err(Error::new(Status::InvalidArg, error_msg("U64"))); + } + Ok(Value::U64(n)) } - let n = value.coerce_to_number()?.get_double()?; - Ok(Value::U64(n.abs() as u64)) - } - tv::schema::Type::I64 => { - if matches!(value.get_type()?, ValueType::String) { - return Err(Error::new(Status::InvalidArg, error_msg("I64"))); + ValueType::Number => { + let n = value.coerce_to_number()?.get_double()?; + if !is_integral(n) || n < 0.0 || n > u64::MAX as f64 { + return Err(Error::new(Status::InvalidArg, error_msg("U64"))); + } + Ok(Value::U64(n as u64)) } - let n = value.coerce_to_number()?.get_double()?; - Ok(Value::I64(n as i64)) - } + _ => Err(Error::new(Status::InvalidArg, error_msg("U64"))), + }, + tv::schema::Type::I64 => match value.get_type()? { + ValueType::BigInt => { + let big: BigInt = unsafe { value.cast()? }; + let (n, lossless) = big.get_i64(); + if !lossless { + return Err(Error::new(Status::InvalidArg, error_msg("I64"))); + } + Ok(Value::I64(n)) + } + ValueType::Number => { + let n = value.coerce_to_number()?.get_double()?; + if !is_integral(n) || n < i64::MIN as f64 || n > i64::MAX as f64 { + return Err(Error::new(Status::InvalidArg, error_msg("I64"))); + } + Ok(Value::I64(n as i64)) + } + _ => Err(Error::new(Status::InvalidArg, error_msg("I64"))), + }, tv::schema::Type::F64 => { - if matches!(value.get_type()?, ValueType::String) { + if !matches!(value.get_type()?, ValueType::Number) { return Err(Error::new(Status::InvalidArg, error_msg("F64"))); } - let n = value.coerce_to_number()?.get_double()?; - Ok(Value::F64(n)) + Ok(Value::F64(value.coerce_to_number()?.get_double()?)) } tv::schema::Type::Bool => { let b = value.coerce_to_bool()?; Ok(Value::Bool(b)) } - tv::schema::Type::Date => { - match value.get_type()? { - ValueType::Number => { - let timestamp = value.coerce_to_number()?.get_int64()?; - // JavaScript timestamps are in milliseconds - Ok(Value::Date(tv::DateTime::from_timestamp_secs( - timestamp / 1000, - ))) - } - ValueType::String => { - // Handle ISO date strings - let date_str = value.coerce_to_string()?.into_utf8()?.into_owned()?; - if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) { - Ok(Value::Date(tv::DateTime::from_timestamp_secs( - dt.timestamp(), - ))) - } else { - Err(Error::new( - Status::InvalidArg, - format!("Invalid ISO date string: {}", date_str), - )) - } - } - _ => Err(Error::new(Status::InvalidArg, error_msg("DateTime"))), + tv::schema::Type::Date => match value.get_type()? { + // JavaScript timestamps are in milliseconds. + ValueType::Number => Ok(Value::Date(tv::DateTime::from_timestamp_millis( + value.coerce_to_number()?.get_int64()?, + ))), + ValueType::String => { + // Handle ISO date strings + let date_str = value.coerce_to_string()?.into_utf8()?.into_owned()?; + let dt = chrono::DateTime::parse_from_rfc3339(&date_str).map_err(|_| { + Error::new( + Status::InvalidArg, + format!("Invalid ISO date string: {}", date_str), + ) + })?; + Ok(Value::Date(tv::DateTime::from_timestamp_millis( + dt.timestamp_millis(), + ))) } - } + // A JS `Date` instance carries its time in UTC milliseconds, which is + // exactly what tantivy stores. + ValueType::Object if value.is_date()? => { + let date: JsDate = unsafe { value.cast()? }; + Ok(Value::Date(tv::DateTime::from_timestamp_millis( + date.value_of()? as i64, + ))) + } + _ => Err(Error::new(Status::InvalidArg, error_msg("DateTime"))), + }, tv::schema::Type::Facet => { let facet_str = value.coerce_to_string()?.into_utf8()?.into_owned()?; let facet = tv::schema::Facet::from_text(&facet_str) @@ -344,10 +372,7 @@ fn value_to_js(env: Env, value: &Value) -> Result> { Value::F64(num) => env.to_js_value(num)?, Value::Bytes(b) => env.to_js_value(&b.as_slice())?, Value::PreTokStr(_pretoken) => env.to_js_value(&())?, - Value::Date(d) => { - let timestamp = d.into_timestamp_secs(); - env.to_js_value(&(timestamp as f64 * 1000.0))? - } + Value::Date(d) => env.to_js_value(&(d.into_timestamp_millis() as f64))?, Value::Facet(f) => env.to_js_value(&f.to_string())?, Value::Array(arr) => { let vec: Vec = arr.iter().map(value_to_serde_json).collect(); @@ -384,8 +409,7 @@ fn value_to_serde_json(value: &Value) -> serde_json::Value { ), Value::Bool(b) => serde_json::Value::Bool(*b), Value::Date(d) => { - let timestamp = d.into_timestamp_secs(); - serde_json::Value::Number(serde_json::Number::from(timestamp)) + serde_json::Value::Number(serde_json::Number::from(d.into_timestamp_millis())) } Value::Facet(f) => serde_json::Value::String(f.to_string()), Value::Bytes(b) => serde_json::Value::Array( @@ -829,7 +853,7 @@ impl Document { pub fn add_date(&mut self, field_name: String, timestamp_millis: i64) { self.add_value( field_name, - tv::DateTime::from_timestamp_secs(timestamp_millis / 1000), + tv::DateTime::from_timestamp_millis(timestamp_millis), ); } @@ -978,6 +1002,31 @@ impl Document { Ok(()) } + /// Extract the field values of a JavaScript object against a schema. + /// + /// Unlike `extract_js_values_from_object`, a key that is not a field of the + /// schema is an error rather than being skipped: callers use this to build + /// a synthetic document, where a silently dropped field would change the + /// meaning of the request. + pub(crate) fn field_values_from_dict( + js_object: &Object, + schema: &Schema, + ) -> Result>> { + let mut field_values = BTreeMap::new(); + let keys = js_object.get_property_names()?; + for i in 0..keys.get_array_length()? { + let key: String = keys.get_element(i)?; + let js_value: Unknown = js_object.get_named_property(&key)?; + let field = crate::get_field(&schema.inner, &key)?; + let field_type = schema.inner.get_field_entry(field).field_type(); + field_values.insert( + key.clone(), + extract_value_single_or_list_for_type(&js_value, field_type, &key)?, + ); + } + Ok(field_values) + } + pub fn iter_values_for_field<'a>(&'a self, field: &str) -> impl Iterator + 'a { self .field_values diff --git a/src/index.rs b/src/index.rs index 3531d1f..ad67ded 100644 --- a/src/index.rs +++ b/src/index.rs @@ -63,10 +63,9 @@ impl IndexWriter { /// since the creation of the index. #[napi] pub fn add_document(&mut self, doc: &Document) -> Result { - let named_doc = tantivy::schema::NamedFieldDocument(doc.field_values.clone()); + let named_doc = tv::schema::NamedFieldDocument(doc.field_values.clone()); let doc = - tantivy::schema::document::TantivyDocument::convert_named_doc(&self.schema, named_doc) - .map_err(to_napi_error)?; + tv::TantivyDocument::convert_named_doc(&self.schema, named_doc).map_err(to_napi_error)?; self.inner()?.add_document(doc).map_err(to_napi_error) } @@ -80,8 +79,7 @@ impl IndexWriter { /// since the creation of the index. #[napi] pub fn add_json(&mut self, json: String) -> Result { - let doc = tantivy::schema::document::TantivyDocument::parse_json(&self.schema, &json) - .map_err(to_napi_error)?; + let doc = tv::TantivyDocument::parse_json(&self.schema, &json).map_err(to_napi_error)?; let opstamp = self.inner()?.add_document(doc); opstamp.map_err(to_napi_error) } @@ -111,12 +109,9 @@ impl IndexWriter { } /// Detect and removes the files that are not used by the index anymore. - /// - /// Note: This is currently a no-op. Tantivy's garbage collection requires - /// an async runtime. A future version may implement this properly. #[napi] pub fn garbage_collect_files(&mut self) -> Result<()> { - // TODO: Implement using futures::executor::block_on(writer.garbage_collect_files()) + futures::executor::block_on(self.inner()?.garbage_collect_files()).map_err(to_napi_error)?; Ok(()) } @@ -142,6 +137,20 @@ impl IndexWriter { Ok(self.inner()?.commit_opstamp()) } + /// @deprecated Use `deleteDocumentsByTerm` or `deleteDocumentsByQuery` instead. + /// + /// Kept as an alias of `deleteDocumentsByTerm` for parity with tantivy-py. + /// The Rust `#[deprecated]` attribute is deliberately not used here: it fires + /// on napi's own generated glue rather than on the caller, and the JSDoc tag + /// is what actually reaches TypeScript users through `index.d.ts`. + /// + /// @param fieldName - The field name for which we want to filter deleted docs. + /// @param fieldValue - JavaScript value with the value we want to filter. + #[napi] + pub fn delete_documents(&mut self, field_name: String, field_value: Unknown) -> Result { + self.delete_documents_by_term(field_name, field_value) + } + /// Delete all documents containing a given term. /// /// This method does not parse the given term and it expects the term to be @@ -168,7 +177,7 @@ impl IndexWriter { field_name: String, field_value: Unknown, ) -> Result { - let term = crate::make_term(&self.schema, &field_name, field_value)?; + let term = crate::make_term(&self.schema, &field_name, &field_value)?; Ok(self.inner()?.delete_term(term)) } @@ -233,7 +242,7 @@ impl Index { let reuse = reuse.unwrap_or(true); let index = match path { Some(p) => { - let directory = tantivy::directory::MmapDirectory::open(&p).map_err(to_napi_error)?; + let directory = tv::directory::MmapDirectory::open(&p).map_err(to_napi_error)?; if reuse { tv::Index::open_or_create(directory, schema.inner.clone()) } else { @@ -346,10 +355,35 @@ impl Index { /// Raises error if the directory cannot be opened. #[napi] pub fn exists(path: String) -> Result { - let directory = tantivy::directory::MmapDirectory::open(&path).map_err(to_napi_error)?; + let directory = tv::directory::MmapDirectory::open(&path).map_err(to_napi_error)?; tv::Index::exists(&directory).map_err(to_napi_error) } + /// Check whether the index stored at `path` can be opened by this version + /// of tantivy. + /// + /// Tantivy stores the index format version in each segment file. When that + /// version falls outside the range supported by the bundled tantivy, the + /// index cannot be opened. This method reports that without throwing, so a + /// caller can decide how to handle an incompatible index (for example, by + /// rebuilding it). + /// + /// @param path - The directory containing the index. + /// + /// @returns True if the index is compatible, false if it was built with an + /// unsupported index format version. + /// + /// @throws if no index could be found at the given path or if it could not + /// be read for any other reason. + #[napi] + pub fn is_compatible(path: String) -> Result { + match tv::Index::open_in_dir(&path).and_then(|index| index.reader().map(|_| ())) { + Ok(()) => Ok(true), + Err(tv::TantivyError::IncompatibleIndex(_)) => Ok(false), + Err(e) => Err(to_napi_error(e)), + } + } + /// The schema of the current index. #[napi(getter)] pub fn schema(&self) -> Schema { @@ -383,15 +417,29 @@ impl Index { /// `prefix` determines if terms which are prefixes of the given term match the query. /// `distance` determines the maximum Levenshtein distance between terms matching the query and the given term. /// `transpose_cost_one` determines if transpositions of neighbouring characters are counted only once against the Levenshtein distance. + /// + /// @param conjunctionByDefault - If true, the query will be parsed as a + /// conjunction query. Defaults to a disjunction query. + /// + /// @param allowRegexes - If true, allow regexes in queries. #[napi] + #[allow(clippy::too_many_arguments)] pub fn parse_query( &self, query: String, default_field_names: Option>, field_boosts: Option>, fuzzy_fields: Option>, + conjunction_by_default: Option, + allow_regexes: Option, ) -> Result { - let parser = self.prepare_query_parser(default_field_names, field_boosts, fuzzy_fields)?; + let parser = self.prepare_query_parser( + default_field_names, + field_boosts, + fuzzy_fields, + conjunction_by_default, + allow_regexes, + )?; let query = parser.parse_query(&query).map_err(to_napi_error)?; @@ -420,21 +468,41 @@ impl Index { /// `distance` determines the maximum Levenshtein distance between terms matching the query and the given term. /// `transpose_cost_one` determines if transpositions of neighbouring characters are counted only once against the Levenshtein distance. /// - /// Returns a tuple containing the parsed query and a list of error messages. + /// @param conjunctionByDefault - If true, the query will be parsed as a + /// conjunction query. Defaults to a disjunction query. + /// + /// @param allowRegexes - If true, allow regexes in queries. + /// + /// Returns a tuple containing the parsed query and a list of errors. Each + /// error is an instance of one of the exported query parser error classes, + /// so it can be matched with `instanceof`. #[napi] - pub fn parse_query_lenient( + #[allow(clippy::too_many_arguments)] + pub fn parse_query_lenient<'env>( &self, + env: &'env Env, query: String, default_field_names: Option>, field_boosts: Option>, fuzzy_fields: Option>, - ) -> Result<(Query, Vec)> { - let parser = self.prepare_query_parser(default_field_names, field_boosts, fuzzy_fields)?; + conjunction_by_default: Option, + allow_regexes: Option, + ) -> Result<(Query, Vec>)> { + let parser = self.prepare_query_parser( + default_field_names, + field_boosts, + fuzzy_fields, + conjunction_by_default, + allow_regexes, + )?; let (query, errors) = parser.parse_query_lenient(&query); - let error_messages: Vec = errors.into_iter().map(|err| format!("{:?}", err)).collect(); + let errors = errors + .into_iter() + .map(|err| crate::parser_error::to_js(env, err)) + .collect::>>()?; - Ok((Query { inner: query }, error_messages)) + Ok((Query { inner: query }, errors)) } /// Register a custom text analyzer by name. (Confusingly, @@ -449,6 +517,19 @@ impl Index { .tokenizers() .register(&name, analyzer.analyzer.clone()); } + + /// Register a custom text analyzer for fast fields by name. (Confusingly, + /// this is one of the places where Tantivy uses 'tokenizer' to refer to a + /// TextAnalyzer instance.) + /// + // Implementation notes: Skipped indirection of TokenizerManager. + #[napi] + pub fn register_fast_field_tokenizer(&self, name: String, analyzer: &TextAnalyzer) { + self + .index + .fast_field_tokenizer() + .register(&name, analyzer.analyzer.clone()); + } } impl Index { @@ -457,6 +538,8 @@ impl Index { default_field_names: Option>, field_boosts: Option>, fuzzy_fields: Option>, + conjunction_by_default: Option, + allow_regexes: Option, ) -> Result { let schema = self.index.schema(); @@ -464,21 +547,13 @@ impl Index { default_field_names .iter() .map(|field_name| { - let field = schema.get_field(field_name).map_err(|_err| { - Error::new( - Status::InvalidArg, - format!("Field `{field_name}` is not defined in the schema."), - ) - })?; - - let field_entry = schema.get_field_entry(field); - if !field_entry.is_indexed() { + let field = crate::get_field(&schema, field_name)?; + if !schema.get_field_entry(field).is_indexed() { return Err(Error::new( Status::InvalidArg, format!("Field `{field_name}` is not set as indexed in the schema."), )); } - Ok(field) }) .collect::>()? @@ -492,30 +567,22 @@ impl Index { let mut parser = tv::query::QueryParser::for_index(&self.index, default_fields); - // Set field boosts if provided - if let Some(field_boosts) = field_boosts { - for (field_name, boost) in field_boosts { - let field = schema.get_field(&field_name).map_err(|_err| { - Error::new( - Status::InvalidArg, - format!("Field `{field_name}` is not defined in the schema."), - ) - })?; - parser.set_field_boost(field, boost as tv::Score); - } + if conjunction_by_default.unwrap_or(false) { + parser.set_conjunction_by_default(); } - // Set fuzzy fields if provided - if let Some(fuzzy_fields) = fuzzy_fields { - for (field_name, (prefix, distance, transpose_cost_one)) in fuzzy_fields { - let field = schema.get_field(&field_name).map_err(|_err| { - Error::new( - Status::InvalidArg, - format!("Field `{field_name}` is not defined in the schema."), - ) - })?; - parser.set_field_fuzzy(field, prefix, distance, transpose_cost_one); - } + if allow_regexes.unwrap_or(false) { + parser.allow_regexes(); + } + + for (field_name, boost) in field_boosts.unwrap_or_default() { + let field = crate::get_field(&schema, &field_name)?; + parser.set_field_boost(field, boost as tv::Score); + } + + for (field_name, (prefix, distance, transpose_cost_one)) in fuzzy_fields.unwrap_or_default() { + let field = crate::get_field(&schema, &field_name)?; + parser.set_field_fuzzy(field, prefix, distance, transpose_cost_one); } Ok(parser) @@ -523,32 +590,31 @@ impl Index { fn register_custom_text_analyzers(index: &tv::Index) { let analyzers = [ - ("ar_stem", tantivy::tokenizer::Language::Arabic), - ("da_stem", tantivy::tokenizer::Language::Danish), - ("nl_stem", tantivy::tokenizer::Language::Dutch), - ("fi_stem", tantivy::tokenizer::Language::Finnish), - ("fr_stem", tantivy::tokenizer::Language::French), - ("de_stem", tantivy::tokenizer::Language::German), - ("el_stem", tantivy::tokenizer::Language::Greek), - ("hu_stem", tantivy::tokenizer::Language::Hungarian), - ("it_stem", tantivy::tokenizer::Language::Italian), - ("no_stem", tantivy::tokenizer::Language::Norwegian), - ("pt_stem", tantivy::tokenizer::Language::Portuguese), - ("ro_stem", tantivy::tokenizer::Language::Romanian), - ("ru_stem", tantivy::tokenizer::Language::Russian), - ("es_stem", tantivy::tokenizer::Language::Spanish), - ("sv_stem", tantivy::tokenizer::Language::Swedish), - ("ta_stem", tantivy::tokenizer::Language::Tamil), - ("tr_stem", tantivy::tokenizer::Language::Turkish), + ("ar_stem", tv::tokenizer::Language::Arabic), + ("da_stem", tv::tokenizer::Language::Danish), + ("nl_stem", tv::tokenizer::Language::Dutch), + ("fi_stem", tv::tokenizer::Language::Finnish), + ("fr_stem", tv::tokenizer::Language::French), + ("de_stem", tv::tokenizer::Language::German), + ("el_stem", tv::tokenizer::Language::Greek), + ("hu_stem", tv::tokenizer::Language::Hungarian), + ("it_stem", tv::tokenizer::Language::Italian), + ("no_stem", tv::tokenizer::Language::Norwegian), + ("pt_stem", tv::tokenizer::Language::Portuguese), + ("ro_stem", tv::tokenizer::Language::Romanian), + ("ru_stem", tv::tokenizer::Language::Russian), + ("es_stem", tv::tokenizer::Language::Spanish), + ("sv_stem", tv::tokenizer::Language::Swedish), + ("ta_stem", tv::tokenizer::Language::Tamil), + ("tr_stem", tv::tokenizer::Language::Turkish), ]; for (name, lang) in &analyzers { - let an = - tantivy::tokenizer::TextAnalyzer::builder(tantivy::tokenizer::SimpleTokenizer::default()) - .filter(tantivy::tokenizer::RemoveLongFilter::limit(40)) - .filter(tantivy::tokenizer::LowerCaser) - .filter(tantivy::tokenizer::Stemmer::new(*lang)) - .build(); + let an = tv::tokenizer::TextAnalyzer::builder(tv::tokenizer::SimpleTokenizer::default()) + .filter(tv::tokenizer::RemoveLongFilter::limit(40)) + .filter(tv::tokenizer::LowerCaser) + .filter(tv::tokenizer::Stemmer::new(*lang)) + .build(); index.tokenizers().register(name, an); } } diff --git a/src/lib.rs b/src/lib.rs index a38681f..988cdba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ use napi::bindgen_prelude::*; use napi::{Error, Result, Status}; use napi_derive::napi; -use tantivy as tv; +use tantivy::{self as tv, schema::OwnedValue as Value}; /// Get the version of the library #[napi] @@ -9,6 +9,12 @@ pub fn get_version() -> String { env!("CARGO_PKG_VERSION").to_string() } +/// Get the version of the underlying tantivy engine. +#[napi] +pub fn get_tantivy_version() -> String { + tv::version_string().to_string() +} + // Helper functions for query operations pub(crate) fn to_napi_error(e: impl std::error::Error) -> Error { Error::new(Status::GenericFailure, format!("{}", e)) @@ -21,7 +27,7 @@ pub(crate) fn get_field( schema.get_field(field_name).map_err(|_| { Error::new( Status::InvalidArg, - format!("Field '{}' is not defined in the schema.", field_name), + format!("Field `{field_name}` is not defined in the schema."), ) }) } @@ -29,78 +35,47 @@ pub(crate) fn get_field( pub(crate) fn make_term( schema: &tv::schema::Schema, field_name: &str, - field_value: Unknown, + field_value: &Unknown, ) -> Result { let field = get_field(schema, field_name)?; - let field_entry = schema.get_field_entry(field); - let field_type = - crate::schema::FieldType::from_tantivy_type(&field_entry.field_type().value_type()); - - make_term_for_type(schema, field_name, field_type, field_value) + // Look up the actual field type from the schema so that JavaScript numbers + // are extracted as the correct numeric type (u64 vs i64). The generic + // `extract_value()` path infers integers from the runtime value alone, which + // silently produces wrong terms for u64 fields. + let field_type = schema.get_field_entry(field).field_type().value_type(); + let value = crate::document::extract_value_for_type(field_value, field_type, field_name)?; + term_from_value(field, field_name, value) } pub(crate) fn make_term_for_type( schema: &tv::schema::Schema, field_name: &str, field_type: crate::schema::FieldType, - field_value: Unknown, + field_value: &Unknown, ) -> Result { let field = get_field(schema, field_name)?; + let value = crate::document::extract_value_for_type(field_value, field_type.into(), field_name)?; + term_from_value(field, field_name, value) +} - match field_type { - crate::schema::FieldType::Str => { - let str_val = field_value.coerce_to_string()?.into_utf8()?.into_owned()?; - Ok(tv::Term::from_field_text(field, &str_val)) - } - crate::schema::FieldType::U64 => { - let num_val = field_value.coerce_to_number()?.get_uint32()? as u64; - Ok(tv::Term::from_field_u64(field, num_val)) - } - crate::schema::FieldType::I64 => { - let num_val = field_value.coerce_to_number()?.get_int64()?; - Ok(tv::Term::from_field_i64(field, num_val)) - } - crate::schema::FieldType::F64 => { - let num_val = field_value.coerce_to_number()?.get_double()?; - Ok(tv::Term::from_field_f64(field, num_val)) - } - crate::schema::FieldType::Date => { - let num_val = field_value.coerce_to_number()?.get_int64()?; - Ok(tv::Term::from_field_date( - field, - tv::DateTime::from_timestamp_secs(num_val), +fn term_from_value(field: tv::schema::Field, field_name: &str, value: Value) -> Result { + Ok(match value { + Value::Str(text) => tv::Term::from_field_text(field, &text), + Value::U64(num) => tv::Term::from_field_u64(field, num), + Value::I64(num) => tv::Term::from_field_i64(field, num), + Value::F64(num) => tv::Term::from_field_f64(field, num), + Value::Date(d) => tv::Term::from_field_date(field, d), + Value::Facet(facet) => tv::Term::from_facet(field, &facet), + Value::Bool(b) => tv::Term::from_field_bool(field, b), + Value::IpAddr(i) => tv::Term::from_field_ip_addr(field, i), + Value::Bytes(ref bytes) => tv::Term::from_field_bytes(field, bytes), + _ => { + return Err(Error::new( + Status::InvalidArg, + format!("Can't create a term for Field `{field_name}` with the given value."), )) } - crate::schema::FieldType::Facet => { - let str_val = field_value.coerce_to_string()?.into_utf8()?.into_owned()?; - let facet = tv::schema::Facet::from(&str_val); - Ok(tv::Term::from_facet(field, &facet)) - } - crate::schema::FieldType::Bytes => { - let str_val = field_value.coerce_to_string()?.into_utf8()?.into_owned()?; - Ok(tv::Term::from_field_bytes(field, str_val.as_bytes())) - } - crate::schema::FieldType::Bool => { - let bool_val = field_value.coerce_to_bool()?; - Ok(tv::Term::from_field_bool(field, bool_val)) - } - crate::schema::FieldType::IpAddr => { - let str_val = field_value.coerce_to_string()?.into_utf8()?.into_owned()?; - let ip_addr: std::net::IpAddr = str_val - .parse() - .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid IP address: {}", e)))?; - // Convert IpAddr to Ipv6Addr for Tantivy - let ipv6_addr = match ip_addr { - std::net::IpAddr::V6(v6) => v6, - std::net::IpAddr::V4(v4) => v4.to_ipv6_mapped(), - }; - Ok(tv::Term::from_field_ip_addr(field, ipv6_addr)) - } - crate::schema::FieldType::JsonObject => { - let str_val = field_value.coerce_to_string()?.into_utf8()?.into_owned()?; - Ok(tv::Term::from_field_json_path(field, &str_val, false)) - } - } + }) } pub mod document; @@ -109,6 +84,7 @@ pub mod facet; pub mod index; pub mod parser_error; pub mod query; +pub mod query_grammar; pub mod schema; pub mod schemabuilder; pub mod searcher; @@ -125,6 +101,7 @@ pub use parser_error::{ RangeMustNotHavePhraseError, SyntaxError, UnknownTokenizerError, UnsupportedQueryError, }; pub use query::{Occur, Query}; +pub use query_grammar::{parse_query, parse_query_lenient}; pub use schema::{FieldType, Schema}; pub use schemabuilder::SchemaBuilder; pub use searcher::Searcher; diff --git a/src/parser_error.rs b/src/parser_error.rs index e1edcf0..4c91c1c 100644 --- a/src/parser_error.rs +++ b/src/parser_error.rs @@ -5,6 +5,8 @@ use std::{ str::ParseBoolError, }; +use napi::bindgen_prelude::*; +use napi::{Error, Result, Status}; use napi_derive::napi; use tantivy::{self as tv}; @@ -13,6 +15,50 @@ pub(crate) trait QueryParserError { fn full_message(&self) -> String; } +/// Convert a tantivy query parser error into an instance of the matching +/// JavaScript error class, mirroring tantivy-py's `query_parser_error` +/// submodule. Used by `Index.parseQueryLenient`, so callers can branch on +/// `err instanceof FieldDoesNotExistError` instead of parsing a message. +pub(crate) fn to_js<'env>( + env: &'env Env, + error: tv::query::QueryParserError, +) -> Result> { + use tv::query::QueryParserError as E; + + // Each arm only inspects the discriminant; `error` itself is still owned + // here, so the matching `TryFrom` impl can consume it. + macro_rules! as_js { + ($ty:ty) => { + <$ty>::try_from(error) + .map_err(|message| Error::new(Status::GenericFailure, message))? + .into_instance(env)? + .to_unknown() + }; + } + + Ok(match error { + E::SyntaxError(..) => as_js!(SyntaxError), + E::UnsupportedQuery(..) => as_js!(UnsupportedQueryError), + E::FieldDoesNotExist(..) => as_js!(FieldDoesNotExistError), + E::ExpectedInt(..) => as_js!(ExpectedIntError), + E::ExpectedBase64(..) => as_js!(ExpectedBase64Error), + E::ExpectedFloat(..) => as_js!(ExpectedFloatError), + E::ExpectedBool(..) => as_js!(ExpectedBoolError), + E::AllButQueryForbidden => as_js!(AllButQueryForbiddenError), + E::NoDefaultFieldDeclared => as_js!(NoDefaultFieldDeclaredError), + E::FieldNotIndexed(..) => as_js!(FieldNotIndexedError), + E::FieldDoesNotHavePositionsIndexed(..) => as_js!(FieldDoesNotHavePositionsIndexedError), + E::PhrasePrefixRequiresAtLeastTwoTerms { .. } => { + as_js!(PhrasePrefixRequiresAtLeastTwoTermsError) + } + E::UnknownTokenizer { .. } => as_js!(UnknownTokenizerError), + E::RangeMustNotHavePhrase => as_js!(RangeMustNotHavePhraseError), + E::DateFormatError(..) => as_js!(DateFormatError), + E::FacetFormatError(..) => as_js!(FacetFormatError), + E::IpFormatError(..) => as_js!(IpFormatError), + }) +} + /// Error in the query syntax. #[napi] #[derive(Clone)] diff --git a/src/query.rs b/src/query.rs index e578718..1e74f50 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,6 +1,6 @@ use crate::{ - explanation::Explanation, get_field, make_term, make_term_for_type, schema::FieldType, - searcher::DocAddress, to_napi_error, Schema, + document::Document, explanation::Explanation, get_field, make_term, make_term_for_type, + schema::FieldType, searcher::DocAddress, to_napi_error, Schema, }; use core::ops::Bound as OpsBound; use napi::bindgen_prelude::*; @@ -44,6 +44,133 @@ impl Query { pub(crate) fn get(&self) -> &dyn tv::query::Query { &self.inner } + + // Arguments mirror tantivy's MoreLikeThisQuery builder options one-to-one; + // a config struct here would only duplicate that builder. + #[allow(clippy::too_many_arguments)] + fn more_like_this_builder( + min_doc_frequency: Option, + max_doc_frequency: Option, + min_term_frequency: Option, + max_query_terms: Option, + min_word_length: Option, + max_word_length: Option, + boost_factor: Option, + stop_words: Option>, + ) -> tv::query::MoreLikeThisQueryBuilder { + let mut builder = tv::query::MoreLikeThisQuery::builder(); + if let Some(value) = min_doc_frequency { + builder = builder.with_min_doc_frequency(value as u64); + } + if let Some(value) = max_doc_frequency { + builder = builder.with_max_doc_frequency(value as u64); + } + if let Some(value) = min_term_frequency { + builder = builder.with_min_term_frequency(value as usize); + } + if let Some(value) = max_query_terms { + builder = builder.with_max_query_terms(value as usize); + } + if let Some(value) = min_word_length { + builder = builder.with_min_word_length(value as usize); + } + if let Some(value) = max_word_length { + builder = builder.with_max_word_length(value as usize); + } + if let Some(value) = boost_factor { + builder = builder.with_boost_factor(value as f32); + } + builder.with_stop_words(stop_words.unwrap_or_default()) + } + + /// This is an internal helper for the BooleanQuery convenience methods + /// (`andMustMatch`, `orShouldMatch`, `andMustNotMatch`). It builds a new + /// query in which each query in `others` is added as an `other_occur` + /// clause alongside `self`. + /// + /// When `self` is already a BooleanQuery, the new clauses are appended to + /// its clause list where doing so preserves matching semantics, so that + /// fluent chains stay flat instead of nesting one level per call: + /// + /// - Must/MustNot clauses can always be appended, provided the existing + /// `minimum_number_should_match` is carried over. (Tantivy recomputes + /// the minimum to 0 when a Must/MustNot clause is present, which would + /// silently turn existing Should clauses from required-disjunction into + /// optional scoring hints.) + /// - Should clauses can only be appended when the existing query is a + /// plain disjunction: all clauses Should, with the default minimum of 1. + /// In any other case, e.g. `a.andMustMatch(b).orShouldMatch(c)`, + /// appending would change which documents match. + /// + /// In all other cases `self` is nested as a single `self_occur` clause of + /// a new BooleanQuery. + fn combine_with( + &self, + others: Vec<&Query>, + self_occur: tv::query::Occur, + other_occur: tv::query::Occur, + ) -> Query { + use tv::query::BooleanQuery; + use tv::query::Occur; + + if others.is_empty() { + return self.clone(); + } + let new_clauses = others + .into_iter() + .map(|query| (other_occur, query.inner.box_clone())); + + if let Some(boolean_query) = self.inner.downcast_ref::() { + let minimum = boolean_query.get_minimum_number_should_match(); + let appendable = match other_occur { + Occur::Must | Occur::MustNot => true, + Occur::Should => { + minimum == 1 + && boolean_query + .clauses() + .iter() + .all(|(occur, _)| *occur == Occur::Should) + } + }; + if appendable { + let mut subqueries = boolean_query + .clauses() + .iter() + .map(|(occur, subquery)| (*occur, subquery.box_clone())) + .collect::>(); + subqueries.extend(new_clauses); + return Query { + inner: Box::new(BooleanQuery::with_minimum_required_clauses( + subqueries, minimum, + )), + }; + } + } + + let mut subqueries = vec![(self_occur, self.inner.box_clone())]; + subqueries.extend(new_clauses); + Query { + inner: Box::new(BooleanQuery::new(subqueries)), + } + } +} + +fn index_record_option(index_option: &str) -> Result { + match index_option { + "position" => Ok(tv::schema::IndexRecordOption::WithFreqsAndPositions), + "freq" => Ok(tv::schema::IndexRecordOption::WithFreqs), + "basic" => Ok(tv::schema::IndexRecordOption::Basic), + _ => Err(Error::new( + Status::InvalidArg, + "Invalid index option, valid choices are: 'basic', 'freq' and 'position'", + )), + } +} + +fn boxed(inner: impl tv::query::Query + 'static) -> Query { + Query { + inner: Box::new(inner), + } } #[napi] @@ -62,23 +189,9 @@ impl Query { field_value: Unknown, index_option: Option, ) -> Result { - let index_option = index_option.unwrap_or_else(|| "position".to_string()); - let term = make_term(&schema.inner, &field_name, field_value)?; - let index_option = match index_option.as_str() { - "position" => tv::schema::IndexRecordOption::WithFreqsAndPositions, - "freq" => tv::schema::IndexRecordOption::WithFreqs, - "basic" => tv::schema::IndexRecordOption::Basic, - _ => { - return Err(Error::new( - Status::InvalidArg, - "Invalid index option, valid choices are: 'basic', 'freq' and 'position'".to_string(), - )) - } - }; - let inner = tv::query::TermQuery::new(term, index_option); - Ok(Query { - inner: Box::new(inner), - }) + let term = make_term(&schema.inner, &field_name, &field_value)?; + let index_option = index_record_option(index_option.as_deref().unwrap_or("position"))?; + Ok(boxed(tv::query::TermQuery::new(term, index_option))) } /// Construct a Tantivy's TermSetQuery @@ -89,22 +202,16 @@ impl Query { field_values: Vec, ) -> Result { let terms = field_values - .into_iter() + .iter() .map(|field_value| make_term(&schema.inner, &field_name, field_value)) .collect::>>()?; - let inner = tv::query::TermSetQuery::new(terms); - Ok(Query { - inner: Box::new(inner), - }) + Ok(boxed(tv::query::TermSetQuery::new(terms))) } /// Construct a Tantivy's AllQuery #[napi(factory)] pub fn all_query() -> Result { - let inner = tv::query::AllQuery {}; - Ok(Query { - inner: Box::new(inner), - }) + Ok(boxed(tv::query::AllQuery)) } /// Construct a Tantivy's EmptyQuery @@ -112,41 +219,33 @@ impl Query { /// A query that matches no documents. Useful as a placeholder or default. #[napi(factory)] pub fn empty_query() -> Result { - let inner = tv::query::EmptyQuery {}; - Ok(Query { - inner: Box::new(inner), - }) + Ok(boxed(tv::query::EmptyQuery)) } /// Construct a Tantivy's ExistsQuery /// - /// Matches all documents that have at least one non-null value in the given field. - /// Useful for filtering documents that have a specific field populated. + /// Matches all documents that have at least one non-null value in the given + /// field. Executing a search with this query will fail if the field doesn't + /// exist or is not a fast field. /// - /// # Arguments - /// - /// * `schema` - Schema of the target index. - /// * `field_name` - Field name to check for existence. + /// @param fastFieldName - Field name to be searched. + /// @param jsonSubpaths - If true, check all the subpaths inside a JSON field. #[napi(factory)] - pub fn exists_query(schema: &Schema, field_name: String) -> Result { - // Validate the field exists in the schema - let _field = get_field(&schema.inner, &field_name)?; - let inner = tv::query::ExistsQuery::new(field_name, false); - Ok(Query { - inner: Box::new(inner), - }) + pub fn exists_query(fast_field_name: String, json_subpaths: Option) -> Result { + Ok(boxed(tv::query::ExistsQuery::new( + fast_field_name, + json_subpaths.unwrap_or(false), + ))) } /// Construct a Tantivy's FuzzyTermQuery /// - /// # Arguments - /// - /// * `schema` - Schema of the target index. - /// * `field_name` - Field name to be searched. - /// * `text` - String representation of the query term. - /// * `distance` - (Optional) Edit distance you are going to alow. When not specified, the default is 1. - /// * `transposition_cost_one` - (Optional) If true, a transposition (swapping) cost will be 1; otherwise it will be 2. When not specified, the default is true. - /// * `prefix` - (Optional) If true, prefix levenshtein distance is applied. When not specified, the default is false. + /// @param schema - Schema of the target index. + /// @param fieldName - Field name to be searched. + /// @param text - String representation of the query term. + /// @param distance - (Optional) Edit distance you are going to allow. When not specified, the default is 1. + /// @param transpositionCostOne - (Optional) If true, a transposition (swapping) cost will be 1; otherwise it will be 2. When not specified, the default is true. + /// @param prefix - (Optional) If true, prefix levenshtein distance is applied. When not specified, the default is false. #[napi(factory)] pub fn fuzzy_term_query( schema: &Schema, @@ -158,28 +257,31 @@ impl Query { ) -> Result { let distance = distance.unwrap_or(1); let transposition_cost_one = transposition_cost_one.unwrap_or(true); - let prefix = prefix.unwrap_or(false); - let field = crate::get_field(&schema.inner, &field_name)?; + let field = get_field(&schema.inner, &field_name)?; let term = tv::Term::from_field_text(field, &text); - let inner = if prefix { - tv::query::FuzzyTermQuery::new_prefix(term, distance, transposition_cost_one) + Ok(if prefix.unwrap_or(false) { + boxed(tv::query::FuzzyTermQuery::new_prefix( + term, + distance, + transposition_cost_one, + )) } else { - tv::query::FuzzyTermQuery::new(term, distance, transposition_cost_one) - }; - Ok(Query { - inner: Box::new(inner), + boxed(tv::query::FuzzyTermQuery::new( + term, + distance, + transposition_cost_one, + )) }) } /// Construct a Tantivy's PhraseQuery with custom offsets and slop /// - /// # Arguments - /// - /// * `schema` - Schema of the target index. - /// * `field_name` - Field name to be searched. - /// * `words` - Word list that constructs the phrase. A word can be a term text or a pair of term text and its offset in the phrase. - /// * `slop` - (Optional) The number of gaps permitted between the words in the query phrase. Default is 0. + /// @param schema - Schema of the target index. + /// @param fieldName - Field name to be searched. + /// @param words - Word list that constructs the phrase. A word can be a term + /// text, or a `[offset, text]` pair giving its offset in the phrase. + /// @param slop - (Optional) The number of gaps permitted between the words in the query phrase. Default is 0. #[napi(factory)] pub fn phrase_query( schema: &Schema, @@ -187,65 +289,124 @@ impl Query { words: Vec, slop: Option, ) -> Result { - let slop = slop.unwrap_or(0); - let mut terms_with_offset = Vec::with_capacity(words.len()); - for (idx, word) in words.into_iter().enumerate() { - // For now, we'll use the list index as the offset since napi-rs - // doesn't have a direct equivalent to PyO3's tuple extraction - let term = make_term(&schema.inner, &field_name, word)?; - terms_with_offset.push((idx, term)); - } - if terms_with_offset.is_empty() { - return Err(Error::new( - Status::InvalidArg, - "words must not be empty.".to_string(), - )); - } - let inner = tv::query::PhraseQuery::new_with_offset_and_slop(terms_with_offset, slop); - Ok(Query { - inner: Box::new(inner), - }) + let terms_with_offset = phrase_words(&words, |w| make_term(&schema.inner, &field_name, w))?; + Ok(boxed(tv::query::PhraseQuery::new_with_offset_and_slop( + terms_with_offset, + slop.unwrap_or(0), + ))) + } + + /// Construct a Tantivy's PhrasePrefixQuery with custom offsets + /// + /// Matches a specific sequence of words followed by a term of which only a + /// prefix is known. Requires positions to be indexed on the target field. + /// + /// @param schema - Schema of the target index. + /// @param fieldName - Field name to be searched. + /// @param words - Word list that constructs the phrase. A word can be a term + /// text, or a `[offset, text]` pair giving its offset in the phrase. + #[napi(factory)] + pub fn phrase_prefix_query( + schema: &Schema, + field_name: String, + words: Vec, + ) -> Result { + let terms_with_offset = phrase_words(&words, |w| make_term(&schema.inner, &field_name, w))?; + Ok(boxed(tv::query::PhrasePrefixQuery::new_with_offset( + terms_with_offset, + ))) + } + + /// Construct a Tantivy's RegexPhraseQuery + /// + /// Matches a specific sequence of regex patterns in positional order, with + /// optional slop. Each pattern can match multiple indexed terms via regex + /// expansion. + /// + /// @param schema - Schema of the target index. + /// @param fieldName - Field name to be searched. + /// @param words - Pattern list forming the phrase. A pattern can be a string, + /// or a `[offset, pattern]` pair giving its offset in the phrase. + /// @param slop - (Optional) Number of gaps permitted between matched terms. Default is 0. + #[napi(factory)] + pub fn regex_phrase_query( + schema: &Schema, + field_name: String, + words: Vec, + slop: Option, + ) -> Result { + let field = get_field(&schema.inner, &field_name)?; + let patterns_with_offset = + phrase_words(&words, |w| w.coerce_to_string()?.into_utf8()?.into_owned())?; + Ok(boxed( + tv::query::RegexPhraseQuery::new_with_offset_and_slop( + field, + patterns_with_offset, + slop.unwrap_or(0), + ), + )) } /// Construct a Tantivy's BooleanQuery + /// + /// @param subqueries - `{ occur, query }` pairs making up the clauses. + /// @param minimumNumberShouldMatch - (Optional) How many Should clauses a + /// document must match. Defaults to tantivy's own rule: 1 when there + /// is no Must/MustNot clause, 0 otherwise. #[napi(factory)] - pub fn boolean_query(subqueries: Vec) -> Result { - let mut dyn_subqueries = Vec::new(); + pub fn boolean_query( + subqueries: Vec, + minimum_number_should_match: Option, + ) -> Result { + let mut dyn_subqueries = Vec::with_capacity(subqueries.len()); for subquery_obj in subqueries { - // Extract the occur and query from the object - // Expected format: { occur: Occur, query: Query } - let occur_value: Unknown = subquery_obj + let occur: Occur = subquery_obj .get("occur")? .ok_or_else(|| Error::new(Status::InvalidArg, "Missing 'occur' field in subquery"))?; - let query_value: ClassInstance = subquery_obj + let query: ClassInstance = subquery_obj .get("query")? .ok_or_else(|| Error::new(Status::InvalidArg, "Missing 'query' field in subquery"))?; - // Convert the occur value to our Occur enum - let occur_num: u32 = occur_value.coerce_to_number()?.get_uint32()?; - let occur = match occur_num { - 0 => tv::query::Occur::Must, - 1 => tv::query::Occur::Should, - 2 => tv::query::Occur::MustNot, - _ => { - return Err(Error::new( - Status::InvalidArg, - "Invalid occur value, must be 0 (Must), 1 (Should), or 2 (MustNot)", - )) - } - }; - - dyn_subqueries.push((occur, query_value.inner.box_clone())); + dyn_subqueries.push((occur.into(), query.inner.box_clone())); } - let inner = tv::query::BooleanQuery::from(dyn_subqueries); - - Ok(Query { - inner: Box::new(inner), + Ok(match minimum_number_should_match { + None => boxed(tv::query::BooleanQuery::from(dyn_subqueries)), + Some(n) => boxed(tv::query::BooleanQuery::with_minimum_required_clauses( + dyn_subqueries, + n as usize, + )), }) } + /// Combine queries with AND (MUST) logic. + /// + /// Returns a query matching documents that match this query and every + /// given query. + #[napi] + pub fn and_must_match(&self, queries: Vec<&Query>) -> Query { + self.combine_with(queries, tv::query::Occur::Must, tv::query::Occur::Must) + } + + /// Combine queries with AND NOT (MUST NOT) logic. + /// + /// Returns a query matching documents that match this query and none of + /// the given queries. + #[napi] + pub fn and_must_not_match(&self, queries: Vec<&Query>) -> Query { + self.combine_with(queries, tv::query::Occur::Must, tv::query::Occur::MustNot) + } + + /// Combine queries with OR (SHOULD) logic. + /// + /// Returns a query matching documents that match this query or any of the + /// given queries. + #[napi] + pub fn or_should_match(&self, queries: Vec<&Query>) -> Query { + self.combine_with(queries, tv::query::Occur::Should, tv::query::Occur::Should) + } + /// Construct a Tantivy's DisjunctionMaxQuery #[napi(factory)] pub fn disjunction_max_query(subqueries: Vec<&Query>, tie_breaker: Option) -> Result { @@ -254,40 +415,34 @@ impl Query { .map(|query| query.inner.box_clone()) .collect(); - let dismax_query = if let Some(tie_breaker) = tie_breaker { - tv::query::DisjunctionMaxQuery::with_tie_breaker(inner_queries, tie_breaker as f32) - } else { - tv::query::DisjunctionMaxQuery::new(inner_queries) - }; - - Ok(Query { - inner: Box::new(dismax_query), + Ok(match tie_breaker { + Some(tie_breaker) => boxed(tv::query::DisjunctionMaxQuery::with_tie_breaker( + inner_queries, + tie_breaker as f32, + )), + None => boxed(tv::query::DisjunctionMaxQuery::new(inner_queries)), }) } /// Construct a Tantivy's BoostQuery #[napi(factory)] pub fn boost_query(query: &Query, boost: f64) -> Result { - let inner = tv::query::BoostQuery::new(query.inner.box_clone(), boost as f32); - Ok(Query { - inner: Box::new(inner), - }) + Ok(boxed(tv::query::BoostQuery::new( + query.inner.box_clone(), + boost as f32, + ))) } /// Construct a Tantivy's RegexQuery #[napi(factory)] pub fn regex_query(schema: &Schema, field_name: String, regex_pattern: String) -> Result { let field = get_field(&schema.inner, &field_name)?; - - let inner_result = tv::query::RegexQuery::from_pattern(®ex_pattern, field); - match inner_result { - Ok(inner) => Ok(Query { - inner: Box::new(inner), - }), - Err(e) => Err(to_napi_error(e)), - } + tv::query::RegexQuery::from_pattern(®ex_pattern, field) + .map(boxed) + .map_err(to_napi_error) } + /// Construct a Tantivy's MoreLikeThisQuery from an indexed document. #[napi(factory)] #[allow(clippy::too_many_arguments)] pub fn more_like_this_query( @@ -301,100 +456,125 @@ impl Query { boost_factor: Option, stop_words: Option>, ) -> Result { - let mut builder = tv::query::MoreLikeThisQuery::builder(); - if let Some(value) = min_doc_frequency { - builder = builder.with_min_doc_frequency(value as u64); - } - if let Some(value) = max_doc_frequency { - builder = builder.with_max_doc_frequency(value as u64); - } - if let Some(value) = min_term_frequency { - builder = builder.with_min_term_frequency(value as usize); - } - if let Some(value) = max_query_terms { - builder = builder.with_max_query_terms(value as usize); - } - if let Some(value) = min_word_length { - builder = builder.with_min_word_length(value as usize); - } - if let Some(value) = max_word_length { - builder = builder.with_max_word_length(value as usize); - } - if let Some(value) = boost_factor { - builder = builder.with_boost_factor(value as f32); - } - if let Some(stop_words) = stop_words { - builder = builder.with_stop_words(stop_words); - } + let builder = Query::more_like_this_builder( + min_doc_frequency, + max_doc_frequency, + min_term_frequency, + max_query_terms, + min_word_length, + max_word_length, + boost_factor, + stop_words, + ); + Ok(boxed( + builder.with_document(tv::DocAddress::from(&doc_address)), + )) + } - let inner = builder.with_document(tv::DocAddress::from(&doc_address)); - Ok(Query { - inner: Box::new(inner), - }) + /// Construct a Tantivy's MoreLikeThisQuery from caller-provided field values. + /// + /// @param schema - Schema of the target index. + /// @param documentFields - An object mapping field names to their value(s). + #[napi(factory)] + #[allow(clippy::too_many_arguments)] + pub fn more_like_this_document_fields_query( + schema: &Schema, + document_fields: Object, + min_doc_frequency: Option, + max_doc_frequency: Option, + min_term_frequency: Option, + max_query_terms: Option, + min_word_length: Option, + max_word_length: Option, + boost_factor: Option, + stop_words: Option>, + ) -> Result { + // Tantivy's provided-fields MLT path operates on field ids, so the + // binding must resolve caller-provided field values against the target + // schema before constructing the query object. + let doc_fields = Document::field_values_from_dict(&document_fields, schema)? + .into_iter() + .map(|(field_name, values)| Ok((get_field(&schema.inner, &field_name)?, values))) + .collect::>>()?; + + let builder = Query::more_like_this_builder( + min_doc_frequency.or(Some(5.0)), + max_doc_frequency, + min_term_frequency.or(Some(2)), + max_query_terms.or(Some(25)), + min_word_length, + max_word_length, + boost_factor.or(Some(1.0)), + stop_words, + ); + Ok(boxed(builder.with_document_fields(doc_fields))) } /// Construct a Tantivy's ConstScoreQuery #[napi(factory)] pub fn const_score_query(query: &Query, score: f64) -> Result { - let inner = tv::query::ConstScoreQuery::new(query.inner.box_clone(), score as f32); - Ok(Query { - inner: Box::new(inner), - }) + Ok(boxed(tv::query::ConstScoreQuery::new( + query.inner.box_clone(), + score as f32, + ))) } + /// Construct a range query over a numeric, date or IP address field. + /// + /// Pass `null` for `lowerBound` or `upperBound` to leave that side + /// unbounded. Both bounds cannot be null; use `Query.allQuery()` to match + /// all documents. Setting `includeLower` or `includeUpper` to false while + /// the corresponding bound is null is an error — unbounded sides are always + /// inclusive by definition. + /// + /// @param schema - Schema of the target index. + /// @param fieldName - Field name to be searched. + /// @param fieldType - Type of the field. + /// @param lowerBound - Lower bound value, or null for unbounded. + /// @param upperBound - Upper bound value, or null for unbounded. + /// @param includeLower - Whether the lower bound is inclusive. Defaults to true. + /// @param includeUpper - Whether the upper bound is inclusive. Defaults to true. + /// @param useInvertedIndex - If true, use an inverted index range query + /// instead of a fast-field range query. Defaults to false. #[napi(factory)] + #[allow(clippy::too_many_arguments)] pub fn range_query( schema: &Schema, field_name: String, field_type: FieldType, - lower_bound: Unknown, - upper_bound: Unknown, + lower_bound: Option, + upper_bound: Option, include_lower: Option, include_upper: Option, + use_inverted_index: Option, ) -> Result { let include_lower = include_lower.unwrap_or(true); let include_upper = include_upper.unwrap_or(true); - match field_type { - FieldType::Str => { - return Err(Error::new( - Status::InvalidArg, - "Text fields are not supported for range queries.".to_string(), - )) - } - FieldType::Bool => { - return Err(Error::new( - Status::InvalidArg, - "Boolean fields are not supported for range queries.".to_string(), - )) - } - FieldType::Facet => { - return Err(Error::new( - Status::InvalidArg, - "Facet fields are not supported for range queries.".to_string(), - )) - } - FieldType::Bytes => { - return Err(Error::new( - Status::InvalidArg, - "Bytes fields are not supported for range queries.".to_string(), - )) - } - FieldType::JsonObject => { - return Err(Error::new( - Status::InvalidArg, - "Json fields are not supported for range queries.".to_string(), - )) - } - _ => {} + let unsupported = match field_type { + FieldType::Text => Some("Text"), + FieldType::Boolean => Some("Boolean"), + FieldType::Facet => Some("Facet"), + FieldType::Bytes => Some("Bytes"), + FieldType::Json => Some("Json"), + _ => None, + }; + if let Some(name) = unsupported { + return Err(Error::new( + Status::InvalidArg, + format!("{name} fields are not supported for range queries."), + )); } // Look up the field in the schema. The given type must match the // field type in the schema. let field = get_field(&schema.inner, &field_name)?; - let actual_field_entry = schema.inner.get_field_entry(field); - let actual_field_type = actual_field_entry.field_type().value_type(); // Convert tv::schema::FieldType to local FieldType - let given_field_type: tv::schema::Type = field_type.clone().into(); // Convert local FieldType to tv::schema::FieldType + let actual_field_type = schema + .inner + .get_field_entry(field) + .field_type() + .value_type(); + let given_field_type: tv::schema::Type = field_type.clone().into(); if actual_field_type != given_field_type { return Err(Error::new( @@ -406,131 +586,102 @@ impl Query { )); } - let lower_bound_term = - make_term_for_type(&schema.inner, &field_name, field_type.clone(), lower_bound)?; - let upper_bound_term = - make_term_for_type(&schema.inner, &field_name, field_type.clone(), upper_bound)?; - - let lower_bound = if include_lower { - OpsBound::Included(lower_bound_term) - } else { - OpsBound::Excluded(lower_bound_term) - }; - - let upper_bound = if include_upper { - OpsBound::Included(upper_bound_term) - } else { - OpsBound::Excluded(upper_bound_term) - }; - - let inner = tv::query::RangeQuery::new(lower_bound, upper_bound); - - Ok(Query { - inner: Box::new(inner), - }) - } - - /// Construct a Tantivy's PhrasePrefixQuery - /// - /// Matches a specific sequence of words followed by a term of which only a prefix is known. - /// Requires positions to be indexed on the target field. At least two terms are required. - /// - /// # Arguments - /// - /// * `schema` - Schema of the target index. - /// * `field_name` - Field name to be searched. - /// * `words` - Word list that constructs the phrase. The last word is treated as a prefix. - /// * `max_expansions` - (Optional) Maximum number of terms the prefix can expand to. Default is 50. - #[napi(factory)] - pub fn phrase_prefix_query( - schema: &Schema, - field_name: String, - words: Vec, - max_expansions: Option, - ) -> Result { - if words.len() < 2 { + if lower_bound.is_none() && upper_bound.is_none() { + // tv::query::RangeQuery panics if both bounds are Unbounded, so this + // combination has to be rejected before constructing the query. return Err(Error::new( Status::InvalidArg, - "PhrasePrefixQuery requires at least two terms.".to_string(), + "At least one of lowerBound or upperBound must be provided. \ + To match all documents, use Query.allQuery() instead.", )); } - let field = get_field(&schema.inner, &field_name)?; - let terms: Vec = words - .iter() - .map(|w| tv::Term::from_field_text(field, w)) - .collect(); - let mut inner = tv::query::PhrasePrefixQuery::new(terms); - if let Some(max_exp) = max_expansions { - inner.set_max_expansions(max_exp); - } - Ok(Query { - inner: Box::new(inner), - }) - } - - /// Construct a Tantivy's RegexPhraseQuery - /// - /// Matches a specific sequence of regex patterns in positional order, with optional slop. - /// Each pattern can match multiple indexed terms via regex expansion. - /// - /// # Arguments - /// - /// * `schema` - Schema of the target index. - /// * `field_name` - Field name to be searched. - /// * `patterns` - List of regex patterns forming the phrase. Each pattern can be a string - /// (offset = index) or a [offset, pattern] pair for custom positioning. - /// * `slop` - (Optional) Number of gaps permitted between matched terms. Default is 0. - /// * `max_expansions` - (Optional) Maximum number of terms each regex can expand to. - #[napi(factory)] - pub fn regex_phrase_query( - schema: &Schema, - field_name: String, - patterns: Vec, - slop: Option, - max_expansions: Option, - ) -> Result { - if patterns.is_empty() { + if lower_bound.is_none() && !include_lower { return Err(Error::new( Status::InvalidArg, - "patterns must not be empty.".to_string(), + "includeLower=false is invalid when lowerBound is null: \ + an unbounded side is always inclusive.", )); } - let field = get_field(&schema.inner, &field_name)?; - let terms_with_offset: Vec<(usize, String)> = patterns.into_iter().enumerate().collect(); - let mut inner = tv::query::RegexPhraseQuery::new_with_offset(field, terms_with_offset); - if let Some(s) = slop { - inner.set_slop(s); - } - if let Some(max_exp) = max_expansions { - inner.set_max_expansions(max_exp); + if upper_bound.is_none() && !include_upper { + return Err(Error::new( + Status::InvalidArg, + "includeUpper=false is invalid when upperBound is null: \ + an unbounded side is always inclusive.", + )); } - Ok(Query { - inner: Box::new(inner), + + let make_bound = |value: Option, include: bool| -> Result> { + Ok(match value { + None => OpsBound::Unbounded, + Some(value) => { + let term = make_term_for_type(&schema.inner, &field_name, field_type.clone(), &value)?; + if include { + OpsBound::Included(term) + } else { + OpsBound::Excluded(term) + } + } + }) + }; + + let lower_bound = make_bound(lower_bound, include_lower)?; + let upper_bound = make_bound(upper_bound, include_upper)?; + + Ok(if use_inverted_index.unwrap_or(false) { + boxed(tv::query::InvertedIndexRangeQuery::new( + lower_bound, + upper_bound, + )) + } else { + boxed(tv::query::RangeQuery::new(lower_bound, upper_bound)) }) } /// Explain how this query matches a given document. /// - /// This method provides detailed information about how the document matched the query - /// and how the score was calculated. + /// This method provides detailed information about how the document matched + /// the query and how the score was calculated. /// - /// # Arguments - /// * `searcher` - The searcher used to perform the search - /// * `doc_address` - The address of the document to explain - /// - /// # Returns - /// * `Explanation` - An object containing detailed scoring information + /// @param searcher - The searcher used to perform the search. + /// @param docAddress - The address of the document to explain. #[napi] pub fn explain( &self, searcher: &crate::searcher::Searcher, doc_address: DocAddress, ) -> Result { - let tantivy_doc_address = tv::DocAddress::from(&doc_address); let explanation = self .inner - .explain(&searcher.inner, tantivy_doc_address) + .explain(&searcher.inner, tv::DocAddress::from(&doc_address)) .map_err(to_napi_error)?; Ok(Explanation::new(explanation)) } } + +/// Resolve a phrase word list to `(offset, value)` pairs. A word is either +/// the term itself — offset defaults to its index in the list — or a +/// `[offset, term]` pair placing it at an explicit position in the phrase. +fn phrase_words<'a, T>( + words: &'a [Unknown<'a>], + mut convert: impl FnMut(&Unknown<'a>) -> Result, +) -> Result> { + let mut out = Vec::with_capacity(words.len()); + for (idx, word) in words.iter().enumerate() { + if word.is_array()? { + let pair: Object = unsafe { word.cast()? }; + if pair.get_array_length()? == 2 { + let offset: u32 = pair.get_element(0)?; + out.push((offset as usize, convert(&pair.get_element(1)?)?)); + continue; + } + } + out.push((idx, convert(word)?)); + } + if out.is_empty() { + return Err(Error::new( + Status::InvalidArg, + "words must not be empty.".to_string(), + )); + } + Ok(out) +} diff --git a/src/query_grammar.rs b/src/query_grammar.rs new file mode 100644 index 0000000..9a71cf0 --- /dev/null +++ b/src/query_grammar.rs @@ -0,0 +1,53 @@ +use napi::{Error, Result, Status}; +use napi_derive::napi; +use tantivy as tv; + +use crate::to_napi_error; + +fn to_json(value: &impl serde::Serialize) -> Result { + serde_json::to_value(value).map_err(to_napi_error) +} + +/// Parse a query string into an abstract syntax tree (AST). +/// +/// This function parses a query string following Tantivy's query language +/// syntax and returns a plain object representing the parsed AST. +/// Unlike `Index.parseQuery()`, this function does not require a schema +/// and returns the raw syntax tree structure. +/// +/// @param query - The query string to parse. +/// @returns An object representing the parsed query AST. +/// +/// @throws if the query has invalid syntax. +/// +/// Example: +/// ```javascript +/// const ast = parseQuery('title:hello AND body:world') +/// ``` +#[napi] +pub fn parse_query(query: String) -> Result { + let ast = tv::query_grammar::parse_query(&query) + .map_err(|e| Error::new(Status::InvalidArg, format!("Query parsing error: {:?}", e)))?; + to_json(&ast) +} + +/// Parse a query string leniently, recovering from syntax errors. +/// +/// This function attempts to parse a query string even if it contains +/// syntax errors. It returns both the parsed AST and a list of errors +/// encountered during parsing. Unlike `Index.parseQueryLenient()`, this +/// function does not require a schema and returns the raw syntax tree +/// structure. +/// +/// @param query - The query string to parse. +/// @returns A tuple of the parsed AST and a list of syntax errors. +/// +/// Example: +/// ```javascript +/// const [ast, errors] = parseQueryLenient('title:hello AND invalid:') +/// ``` +#[napi] +pub fn parse_query_lenient(query: String) -> Result<(serde_json::Value, serde_json::Value)> { + let (ast, errors) = tv::query_grammar::parse_query_lenient(&query); + Ok((to_json(&ast)?, to_json(&errors)?)) +} diff --git a/src/schema.rs b/src/schema.rs index a63c29e..d9aff8c 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -3,34 +3,37 @@ use serde_json; use tantivy as tv; use tantivy::schema::Schema as TantivySchema; -/// Tantivy's FieldType +/// Tantivy's Type +/// +/// The variant names mirror `tantivy.FieldType` in tantivy-py rather than +/// tantivy's own Rust `Type` names. #[napi] #[derive(PartialEq, Clone)] pub enum FieldType { - Str, - U64, - I64, - F64, - Bool, + Text, + Unsigned, + Integer, + Float, + Boolean, Date, Facet, Bytes, - JsonObject, + Json, IpAddr, } impl From for tv::schema::Type { fn from(field_type: FieldType) -> tv::schema::Type { match field_type { - FieldType::Str => tv::schema::Type::Str, - FieldType::U64 => tv::schema::Type::U64, - FieldType::I64 => tv::schema::Type::I64, - FieldType::F64 => tv::schema::Type::F64, - FieldType::Bool => tv::schema::Type::Bool, + FieldType::Text => tv::schema::Type::Str, + FieldType::Unsigned => tv::schema::Type::U64, + FieldType::Integer => tv::schema::Type::I64, + FieldType::Float => tv::schema::Type::F64, + FieldType::Boolean => tv::schema::Type::Bool, FieldType::Date => tv::schema::Type::Date, FieldType::Facet => tv::schema::Type::Facet, FieldType::Bytes => tv::schema::Type::Bytes, - FieldType::JsonObject => tv::schema::Type::Json, + FieldType::Json => tv::schema::Type::Json, FieldType::IpAddr => tv::schema::Type::IpAddr, } } @@ -39,15 +42,15 @@ impl From for tv::schema::Type { impl FieldType { pub fn from_tantivy_type(field_type: &tv::schema::Type) -> Self { match field_type { - tv::schema::Type::Str => FieldType::Str, - tv::schema::Type::U64 => FieldType::U64, - tv::schema::Type::I64 => FieldType::I64, - tv::schema::Type::F64 => FieldType::F64, - tv::schema::Type::Bool => FieldType::Bool, + tv::schema::Type::Str => FieldType::Text, + tv::schema::Type::U64 => FieldType::Unsigned, + tv::schema::Type::I64 => FieldType::Integer, + tv::schema::Type::F64 => FieldType::Float, + tv::schema::Type::Bool => FieldType::Boolean, tv::schema::Type::Date => FieldType::Date, tv::schema::Type::Facet => FieldType::Facet, tv::schema::Type::Bytes => FieldType::Bytes, - tv::schema::Type::Json => FieldType::JsonObject, + tv::schema::Type::Json => FieldType::Json, tv::schema::Type::IpAddr => FieldType::IpAddr, } } diff --git a/src/schemabuilder.rs b/src/schemabuilder.rs index 6a035bf..44f1dd0 100644 --- a/src/schemabuilder.rs +++ b/src/schemabuilder.rs @@ -2,7 +2,7 @@ use crate::schema::Schema; use napi::{Error, Result, Status}; use napi_derive::napi; use tantivy::schema::{ - BytesOptions, DateOptions, IndexRecordOption, IpAddrOptions, NumericOptions, + BytesOptions, DateOptions, IndexRecordOption, IpAddrOptions, JsonObjectOptions, NumericOptions, Schema as TantivySchema, SchemaBuilder as TantivySchemaBuilder, TextFieldIndexing, TextOptions, INDEXED, }; @@ -37,6 +37,25 @@ pub struct TextFieldOptions { pub index_option: Option, } +/// JSON field options +#[napi(object)] +pub struct JsonFieldOptions { + /// Store the field value (can be retrieved from search results) + pub stored: Option, + /// Fast field access (column-oriented storage) + pub fast: Option, + /// Tokenizer name to use (default: "default") + pub tokenizer_name: Option, + /// Index record option: "basic", "freq", or "position" (default: "position") + pub index_option: Option, + /// If true, a "." in a JSON object key is treated as a path separator, the + /// same as a "." between keys in a query string. E.g. `{"a.b": "hello"}` is + /// then indexed as if it was `{"a": {"b": "hello"}}`, reachable via + /// `attrs.a.b` instead of the default escaped form `attrs.a\.b`. + /// Defaults to false. + pub expand_dots_enabled: Option, +} + /// Numeric field options (for integers, floats, dates) #[napi(object)] pub struct NumericFieldOptions { @@ -230,14 +249,28 @@ impl SchemaBuilder { pub fn add_json_field( &mut self, name: String, - options: Option, + options: Option, ) -> Result<&Self> { + let expand_dots = options + .as_ref() + .and_then(|o| o.expand_dots_enabled) + .unwrap_or(false); + let text_options = Self::build_text_options(options.map(|o| TextFieldOptions { + stored: o.stored, + fast: o.fast, + tokenizer_name: o.tokenizer_name, + index_option: o.index_option, + }))?; + + let mut opts: JsonObjectOptions = text_options.into(); + if expand_dots { + opts = opts.set_expand_dots_enabled(); + } + let builder = self .inner .as_mut() .ok_or_else(|| Error::new(Status::InvalidArg, "Schema builder is no longer valid"))?; - - let opts = Self::build_text_options(options)?; builder.add_json_field(&name, opts); Ok(self) } diff --git a/src/searcher.rs b/src/searcher.rs index 5533777..3cae16f 100644 --- a/src/searcher.rs +++ b/src/searcher.rs @@ -1,17 +1,96 @@ -use crate::{document::Document, query::Query}; +use crate::{document::Document, query::Query, to_napi_error}; use napi::bindgen_prelude::*; use napi::{Error, Result, Status}; use napi_derive::napi; -use serde::{Deserialize, Serialize}; +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashMap}; use tantivy as tv; use tantivy::aggregation::AggregationCollector; -use tantivy::collector::{Count, MultiCollector, TopDocs}; +use tantivy::collector::{Collector, Count, MultiCollector, SegmentCollector, TopDocs}; +use tantivy::schema::{IndexRecordOption, Type}; use tantivy::TantivyDocument; +use tantivy::{DocId, DocSet, Score, SegmentOrdinal, TERMINATED}; +use tantivy_common::BitSet; // Bring the trait into scope. This is required for the `to_named_doc` method. // However, node-tantivy declares its own `Document` class, so we need to avoid // introduce the `Document` trait into the namespace. use tantivy::Document as _; +/// Returns the smallest byte string strictly greater than `prefix`. +/// +/// Increments the last non-0xFF byte in place and truncates. Returns None +/// if every byte is 0xFF (caller must fall back to a manual prefix check). +fn next_prefix_bound(prefix: &[u8]) -> Option> { + let mut bound = prefix.to_vec(); + for i in (0..bound.len()).rev() { + if bound[i] < 0xFF { + bound[i] += 1; + bound.truncate(i + 1); + return Some(bound); + } + } + None +} + +/// Private collector that gathers matching DocIds per segment as a BitSet. +/// +/// Each segment's BitSet is sized to that segment's `max_doc()`, so total +/// memory is ~1 bit per indexed document regardless of how many docs the +/// query matches. `merge_fruits` places each segment's BitSet at index +/// `segment_ord` so `terms_with_prefix` can look up visibility by segment +/// index. Slots for segments that produced no fruit remain `None`. +struct PerSegmentBitSetCollector { + num_segments: usize, +} + +struct PerSegmentBitSetSegmentCollector { + segment_ord: u32, + docs: BitSet, +} + +impl SegmentCollector for PerSegmentBitSetSegmentCollector { + type Fruit = (u32, BitSet); + + fn collect(&mut self, doc: DocId, _score: Score) { + self.docs.insert(doc); + } + + fn harvest(self) -> Self::Fruit { + (self.segment_ord, self.docs) + } +} + +impl Collector for PerSegmentBitSetCollector { + type Fruit = Vec>; + type Child = PerSegmentBitSetSegmentCollector; + + fn for_segment( + &self, + segment_local_id: SegmentOrdinal, + reader: &tv::SegmentReader, + ) -> tv::Result { + Ok(PerSegmentBitSetSegmentCollector { + segment_ord: segment_local_id, + docs: BitSet::with_max_value(reader.max_doc()), + }) + } + + fn requires_scoring(&self) -> bool { + false + } + + fn merge_fruits(&self, segment_fruits: Vec<(u32, BitSet)>) -> tv::Result>> { + // None marks "no fruit for this segment". In practice tantivy calls + // for_segment for every segment, so every slot ends up as Some(_) — + // but a None default keeps merge_fruits robust to any future change. + let mut result: Vec> = (0..self.num_segments).map(|_| None).collect(); + for (seg_ord, docs) in segment_fruits { + result[seg_ord as usize] = Some(docs); + } + Ok(result) + } +} + /// Tantivy's Searcher class /// /// A Searcher is used to search the index given a prepared Query. @@ -21,7 +100,6 @@ pub struct Searcher { } #[napi] -#[derive(Deserialize, PartialEq, Serialize)] /// Enum representing the direction in which something should be sorted. pub enum Order { /// Ascending. Smaller values appear first. @@ -41,8 +119,7 @@ impl From for tv::Order { } #[napi(object)] -#[derive(Clone, Default, Deserialize, PartialEq, Serialize)] -/// Object holding a results successful search. +/// Object holding the results of a successful search. pub struct SearchResult { pub hits: Vec, /// How many documents matched the query. Only available if `count` was set @@ -51,13 +128,67 @@ pub struct SearchResult { } #[napi(object)] -#[derive(Clone, Deserialize, PartialEq, Serialize)] pub struct SearchHit { + /// The relevance score. Only set when the results are not ordered by a field. pub score: Option, - pub order: Option, + /// The value of the ordered field. Only set when `orderByField` was given. + /// + /// The variant matches the ordered field's type: numeric fields yield a + /// number, boolean fields a boolean and text fields a string. Date fields + /// yield milliseconds since the epoch, matching the convention used + /// everywhere else in this binding. + pub order: Option>, pub doc_address: DocAddress, } +#[napi(object)] +/// A term of a field paired with the number of documents containing it. +pub struct TermCount { + pub term: String, + pub count: u32, +} + +/// Open one column per segment, map each DocAddress to its value, and wrap it +/// in the given `FastFieldValue` variant. Used by `fastFieldValues()` to avoid +/// repeating the same iterator chain for each numeric type. +macro_rules! read_fast_field_column_values { + ($readers:expr, $field:expr, $addrs:expr, $method:ident, $variant:expr) => {{ + let columns: Vec> = $readers + .iter() + .map(|reader| reader.fast_fields().$method($field).ok()) + .collect(); + Ok( + $addrs + .iter() + .map(|addr| { + columns[addr.segment_ord as usize] + .as_ref() + .and_then(|col| col.first(addr.doc)) + .map($variant) + }) + .collect(), + ) + }}; +} + +impl Searcher { + /// Execute an aggregation from an already-deserialized spec. Shared by + /// `aggregate()` and `cardinality()` so neither needs to round-trip + /// through JSON when the spec is already a `serde_json::Value`. + fn aggregate_value( + &self, + query: &Query, + aggs: tv::aggregation::agg_req::Aggregations, + ) -> Result { + let agg_collector = AggregationCollector::from_aggs(aggs, Default::default()); + let agg_res = self + .inner + .search(query.get(), &agg_collector) + .map_err(to_napi_error)?; + serde_json::to_value(agg_res).map_err(to_napi_error) + } +} + #[napi] impl Searcher { /// Search the index with the given query and collect results. @@ -67,18 +198,28 @@ impl Searcher { /// return. Defaults to 10. /// @param count - Should the number of documents that match /// the query be returned as well. Defaults to true. - /// @param orderByField - A schema field that the results - /// should be ordered by. The field must be declared as a fast field - /// when building the schema. Note, this only works for unsigned - /// fields. + /// @param orderByField - Name of a field that the results should be ordered + /// by. The field must be declared as a fast field when building the + /// schema. Supported field types: Text, Unsigned, Integer, Float, + /// Boolean and Date. /// @param offset - The offset from which the results have /// to be returned. /// @param order - The order in which the results /// should be sorted. If not specified, defaults to descending. + /// @param weightByField - Name of a field that the results should be + /// weighted by. The field must be declared as a fast field when + /// building the schema. Note, this only works for Float, Integer + /// and Unsigned fields. The given field value is first transformed + /// using the formula `log2(2.0 + value)` and then multiplied with + /// the original score. This means that a weight field value of 0.0 + /// results in no change to the original score. If the weight value + /// is negative, it is treated as 0.0. /// - /// @returns SearchResult object. + /// @returns SearchResult object. Each hit carries either a `score` (no + /// `orderByField`) or an `order` key matching the ordered field's + /// type; date fields yield milliseconds since the epoch. /// - /// @throws ValueError if there was an error with the search. + /// @throws if there was an error with the search. #[napi] #[allow(clippy::too_many_arguments)] pub fn search( @@ -89,64 +230,112 @@ impl Searcher { order_by_field: Option, offset: Option, order: Option, + weight_by_field: Option, ) -> Result { let limit = limit.unwrap_or(10) as usize; let count = count.unwrap_or(true); let offset = offset.unwrap_or(0) as usize; let order = order.unwrap_or(Order::Desc); - if let Some(order_by_field) = order_by_field { - // Order by field search - let mut multicollector = MultiCollector::new(); - - let count_handle = if count { - Some(multicollector.add_collector(Count)) - } else { - None - }; + let mut multicollector = MultiCollector::new(); + let count_handle = if count { + Some(multicollector.add_collector(Count)) + } else { + None + }; - let collector = TopDocs::with_limit(limit) - .and_offset(offset) - .order_by_u64_field(&order_by_field, order.into()); - let top_docs_handle = multicollector.add_collector(collector); + let collector = TopDocs::with_limit(limit).and_offset(offset); - let mut multifruit = self + let (mut multifruit, hits) = if let Some(weight_by_field) = weight_by_field { + let collector = self.weighted_collector(collector, weight_by_field)?; + let handle = multicollector.add_collector(collector); + let mut fruit = self .inner - .search(&query.inner, &multicollector) - .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?; - - let top_docs = top_docs_handle.extract(&mut multifruit); - let hits: Vec = top_docs + .search(query.get(), &multicollector) + .map_err(to_napi_error)?; + let hits = handle + .extract(&mut fruit) .iter() .map(|(f, d)| SearchHit { - score: None, - order: Some(*f as f64), + score: Some(*f as f64), + order: None, doc_address: DocAddress::from(d), }) .collect(); + (fruit, hits) + } else if let Some(order_by) = order_by_field.as_deref() { + let schema = self.inner.schema(); + let field = crate::get_field(schema, order_by)?; + let field_type = schema.get_field_entry(field).field_type().value_type(); - let count = count_handle.map(|h| h.extract(&mut multifruit) as u32); - Ok(SearchResult { hits, count }) - } else { - // Score-based search - let mut multicollector = MultiCollector::new(); - - let count_handle = if count { - Some(multicollector.add_collector(Count)) - } else { - None - }; - - let collector = TopDocs::with_limit(limit).and_offset(offset); - let top_docs_handle = multicollector.add_collector(collector); + // Each arm builds a differently-typed collector, so the search has to + // run inside the macro where that type is still concrete. + macro_rules! run_order_by_fast { + ($t:ty, $to_key:expr) => {{ + let handle = multicollector + .add_collector(collector.order_by_fast_field::<$t>(order_by, order.into())); + let mut fruit = self + .inner + .search(query.get(), &multicollector) + .map_err(to_napi_error)?; + let hits = handle + .extract(&mut fruit) + .into_iter() + .map(|(f, d)| SearchHit { + score: None, + order: f.map($to_key), + doc_address: DocAddress::from(&d), + }) + .collect(); + (fruit, hits) + }}; + } - let mut multifruit = self + match field_type { + Type::U64 => run_order_by_fast!(u64, |v: u64| Either3::A(v as f64)), + Type::I64 => run_order_by_fast!(i64, |v: i64| Either3::A(v as f64)), + Type::F64 => run_order_by_fast!(f64, Either3::A), + Type::Bool => run_order_by_fast!(bool, Either3::B), + Type::Date => run_order_by_fast!(tv::DateTime, |v: tv::DateTime| { + Either3::A(v.into_timestamp_millis() as f64) + }), + Type::Str => { + let handle = multicollector + .add_collector(collector.order_by_string_fast_field(order_by, order.into())); + let mut fruit = self + .inner + .search(query.get(), &multicollector) + .map_err(to_napi_error)?; + let hits = handle + .extract(&mut fruit) + .into_iter() + .map(|(f, d)| SearchHit { + score: None, + order: f.map(Either3::C), + doc_address: DocAddress::from(&d), + }) + .collect(); + (fruit, hits) + } + other => { + return Err(Error::new( + Status::InvalidArg, + format!( + "Field '{}' has type {:?}; orderByField only supports \ + Text, Unsigned, Integer, Float, Boolean and Date fast fields.", + order_by, other + ), + )) + } + } + } else { + let handle = multicollector.add_collector(collector.order_by_score()); + let mut fruit = self .inner - .search(&query.inner, &multicollector) - .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?; - - let top_docs = top_docs_handle.extract(&mut multifruit); - let hits: Vec = top_docs + .search(query.get(), &multicollector) + .map_err(to_napi_error)?; + let hits = handle + .extract(&mut fruit) .iter() .map(|(f, d)| SearchHit { score: Some(*f as f64), @@ -154,36 +343,41 @@ impl Searcher { doc_address: DocAddress::from(d), }) .collect(); + (fruit, hits) + }; - let count = count_handle.map(|h| h.extract(&mut multifruit) as u32); - Ok(SearchResult { hits, count }) - } + let count = count_handle.map(|h| h.extract(&mut multifruit) as u32); + Ok(SearchResult { hits, count }) } + /// Execute an aggregation query and return the results. + /// + /// @param query - The query that filters the documents to aggregate over. + /// @param agg - The aggregation specification. + /// + /// @returns An object containing the aggregation results. #[napi] - pub fn aggregate(&self, query: &Query, agg: Unknown) -> Result { - // Convert the JS object to JSON string first - let agg_str = agg.coerce_to_string()?.into_utf8()?.into_owned()?; - - let agg_collector = AggregationCollector::from_aggs( - serde_json::from_str(&agg_str).map_err(|e| { - Error::new( - Status::InvalidArg, - format!("Invalid aggregation JSON: {}", e), - ) - })?, - Default::default(), - ); - - let agg_res = self - .inner - .search(&query.inner, &agg_collector) - .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?; + pub fn aggregate(&self, query: &Query, agg: serde_json::Value) -> Result { + let aggs = serde_json::from_value(agg) + .map_err(|e| Error::new(Status::InvalidArg, format!("Invalid aggregation: {e}")))?; + self.aggregate_value(query, aggs) + } - let result_str = serde_json::to_string(&agg_res) - .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?; + /// Returns the cardinality (approximate distinct value count) of a field + /// over the documents matching the query. + /// + /// @param query - The query that will be used for the search. + /// @param fieldName - The field for which to compute the cardinality. + #[napi] + pub fn cardinality(&self, query: &Query, field_name: String) -> Result { + let aggs = serde_json::from_value(serde_json::json!({ + "cardinality": { "cardinality": { "field": field_name } } + })) + .map_err(to_napi_error)?; - Ok(result_str) + self.aggregate_value(query, aggs)?["cardinality"]["value"] + .as_f64() + .ok_or_else(|| Error::new(Status::GenericFailure, "Unexpected aggregation result")) } /// Returns the overall number of documents in the index. @@ -202,14 +396,13 @@ impl Searcher { /// the given term. #[napi] pub fn doc_freq(&self, field_name: String, field_value: Unknown) -> Result { - // Wrap the tantivy Searcher `doc_freq` method to return a Result. let schema = self.inner.schema(); - let term = crate::make_term(schema, &field_name, field_value)?; + let term = crate::make_term(schema, &field_name, &field_value)?; self .inner .doc_freq(&term) .map(|count| count as u32) - .map_err(|e| Error::new(Status::GenericFailure, e.to_string())) + .map_err(to_napi_error) } /// Fetches a document from Tantivy's store given a DocAddress. @@ -217,18 +410,347 @@ impl Searcher { /// @param docAddress - The DocAddress that is associated with /// the document that we wish to fetch. /// - /// @returns The Document, raises ValueError if the document can't be found. + /// @returns The Document, throws if the document can't be found. #[napi] pub fn doc(&self, doc_address: DocAddress) -> Result { let doc: TantivyDocument = self .inner .doc((&doc_address).into()) - .map_err(|e| Error::new(Status::GenericFailure, e.to_string()))?; + .map_err(to_napi_error)?; let named_doc = doc.to_named_doc(self.inner.schema()); Ok(crate::document::Document { field_values: named_doc.0, }) } + + /// Read a numeric fast field for a batch of DocAddresses without fetching + /// stored documents. + /// + /// Fast fields are column-oriented and support O(1) random access by + /// segment-local DocId. Use this instead of `doc().toDict()[field]` when + /// you only need a single numeric field for many documents. + /// + /// @param fieldName - Name of a u64, i64, f64 or boolean field declared as fast. + /// @param docAddresses - The addresses to read (e.g. from `search().hits`). + /// + /// @returns The values in the same order as `docAddresses`. `null` is + /// returned for any address where the column is absent (e.g. a + /// segment written before the field was added to the schema). + /// + /// @throws if the field does not exist, is not a fast field, or has an + /// unsupported type. + #[napi] + pub fn fast_field_values( + &self, + field_name: String, + doc_addresses: Vec, + ) -> Result>>> { + let schema = self.inner.schema(); + let field = schema + .get_field(&field_name) + .map_err(|_| Error::new(Status::InvalidArg, format!("Unknown field: '{field_name}'")))?; + let field_entry = schema.get_field_entry(field); + if !field_entry.is_fast() { + return Err(Error::new( + Status::InvalidArg, + format!("Field '{field_name}' is not a fast field."), + )); + } + + let segment_readers = self.inner.segment_readers(); + let num_segments = segment_readers.len(); + + // Validate all segment_ords before reading so we don't produce a + // partial result on error. + for doc_address in &doc_addresses { + if doc_address.segment_ord as usize >= num_segments { + return Err(Error::new( + Status::InvalidArg, + format!("Invalid segmentOrd: {}", doc_address.segment_ord), + )); + } + } + + // Pre-open one Column per segment so it is not reopened per document. + // Column::first() returns Option, so no sentinel value is needed. + let field_name = field_name.as_str(); + match field_entry.field_type().value_type() { + Type::U64 => { + read_fast_field_column_values!(segment_readers, field_name, doc_addresses, u64, |v: u64| { + Either::A(v as f64) + }) + } + Type::I64 => { + read_fast_field_column_values!(segment_readers, field_name, doc_addresses, i64, |v: i64| { + Either::A(v as f64) + }) + } + Type::F64 => { + read_fast_field_column_values!(segment_readers, field_name, doc_addresses, f64, Either::A) + } + Type::Bool => { + read_fast_field_column_values!(segment_readers, field_name, doc_addresses, bool, Either::B) + } + _ => Err(Error::new( + Status::InvalidArg, + format!( + "Field '{field_name}' has unsupported type for fast field access. \ + Only u64, i64, f64 and boolean fast fields are supported." + ), + )), + } + } + + /// Walk the term dictionary for `fieldName` and return all terms that + /// begin with `prefix`, together with their document frequencies. + /// + /// @param fieldName - Name of an indexed text field in the schema. + /// @param prefix - Only terms beginning with this string are returned. + /// An empty string returns all terms in the field. + /// @param filterQuery - When provided, each term's count reflects only + /// documents matched by the query (e.g. for permission filtering). + /// Counts are still summed across segments. + /// @param limit - If given, only the top-`limit` entries (by count) are returned. + /// + /// @returns `[{ term, count }, ...]` sorted by count descending, then + /// alphabetically. Terms present in multiple segments have their + /// counts summed. + /// + /// @throws if the field does not exist or is not a text field. + #[napi] + pub fn terms_with_prefix( + &self, + field_name: String, + prefix: String, + filter_query: Option<&Query>, + limit: Option, + ) -> Result> { + let schema = self.inner.schema(); + let field = crate::get_field(schema, &field_name)?; + if !matches!( + schema.get_field_entry(field).field_type().value_type(), + Type::Str + ) { + return Err(Error::new( + Status::InvalidArg, + format!("Field '{field_name}' is not an indexed text field."), + )); + } + + let prefix_bytes = prefix.as_bytes(); + let upper_bound = next_prefix_bound(prefix_bytes); + // When every byte of prefix is 0xFF no FST upper bound can be expressed; + // the inner loop falls back to a manual starts_with check. + let open_ended = upper_bound.is_none() && !prefix_bytes.is_empty(); + let num_segments = self.inner.segment_readers().len(); + + let filter_sets: Option>> = filter_query + .map(|fq| { + self + .inner + .search(fq.get(), &PerSegmentBitSetCollector { num_segments }) + .map_err(to_napi_error) + }) + .transpose()?; + + if let Some(ref sets) = filter_sets { + if sets + .iter() + .all(|s| s.as_ref().is_none_or(|bs| bs.len() == 0)) + { + return Ok(vec![]); + } + } + + let mut counts: HashMap = HashMap::new(); + + for (seg_ord, segment_reader) in self.inner.segment_readers().iter().enumerate() { + // Resolve this segment's filter once per segment, not per term. + // - None outer → no filter at all; use term doc_freq below. + // - Some(None) → segment produced no fruit; skip it entirely. + // - Some(Some(bs)) with len() == 0 → no docs match here; skip. + // - Some(Some(bs)) with len() > 0 → intersect postings against bs. + let segment_filter: Option<&BitSet> = match &filter_sets { + None => None, + Some(sets) => match sets[seg_ord].as_ref() { + Some(bs) if bs.len() > 0 => Some(bs), + _ => continue, + }, + }; + + let inv_index = segment_reader + .inverted_index(field) + .map_err(to_napi_error)?; + + let mut stream = { + let mut builder = inv_index.terms().range().ge(prefix_bytes); + if let Some(ref ub) = upper_bound { + builder = builder.lt(ub.as_slice()); + } + builder.into_stream().map_err(to_napi_error)? + }; + + while stream.advance() { + let key = stream.key(); + if open_ended && !key.starts_with(prefix_bytes) { + break; + } + let Ok(term_str) = std::str::from_utf8(key) else { + continue; + }; + + let count = match segment_filter { + None => stream.value().doc_freq, + Some(filter_set) => { + let mut postings = inv_index + .read_postings_from_terminfo(stream.value(), IndexRecordOption::Basic) + .map_err(to_napi_error)?; + let mut c = 0u32; + // SegmentPostings initialises at doc 0; read doc() before the + // first advance(). + loop { + let doc = postings.doc(); + if doc == TERMINATED { + break; + } + if filter_set.contains(doc) { + c += 1; + } + postings.advance(); + } + c + } + }; + + if count > 0 { + *counts.entry(term_str.to_owned()).or_insert(0) += count; + } + } + } + + let pairs: Vec<(String, u32)> = match limit.map(|l| l as usize) { + None => { + let mut pairs: Vec<(String, u32)> = counts.into_iter().collect(); + pairs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + pairs + } + Some(0) => Vec::new(), + Some(n) => { + // Bounded min-heap of size n. The key (count, Reverse(term)) is + // constructed so "larger" means "more deserving" — higher count, or + // on ties, lexicographically smaller term. BinaryHeap is a max-heap, + // so wrapping in an outer Reverse flips it to a min-heap whose peek() + // is the worst currently kept entry — the candidate for eviction when + // a new term outranks it. + let mut heap: BinaryHeap)>> = BinaryHeap::with_capacity(n); + for (term, count) in counts { + let key = (count, Reverse(term)); + if heap.len() < n { + heap.push(Reverse(key)); + } else if heap.peek().is_some_and(|Reverse(worst)| &key > worst) { + heap.pop(); + heap.push(Reverse(key)); + } + } + let mut top: Vec<(u32, Reverse)> = heap.into_iter().map(|Reverse(k)| k).collect(); + // Heap order is unspecified; sort the survivors descending (largest + // key first) for the documented output order. + top.sort_by(|a, b| b.cmp(a)); + top + .into_iter() + .map(|(count, Reverse(term))| (term, count)) + .collect() + } + }; + + Ok( + pairs + .into_iter() + .map(|(term, count)| TermCount { term, count }) + .collect(), + ) + } + + /// Convert the searcher to a string representation + #[napi] + #[allow(clippy::inherent_to_string)] + pub fn to_string(&self) -> String { + format!( + "Searcher(numDocs={}, numSegments={})", + self.inner.num_docs(), + self.inner.segment_readers().len() + ) + } +} + +impl Searcher { + /// Wrap `collector` so each score is multiplied by `log2(2 + fieldValue)`. + fn weighted_collector( + &self, + collector: TopDocs, + weight_by_field: String, + ) -> Result>> { + let schema = self.inner.schema(); + let field = crate::get_field(schema, &weight_by_field)?; + let field_entry = schema.get_field_entry(field); + let field_type = field_entry.field_type().value_type(); + + if !field_entry.is_fast() { + return Err(Error::new( + Status::InvalidArg, + format!( + "Field '{weight_by_field}' is not a fast field. The field must be declared as fast in the schema." + ), + )); + } + + if !matches!(field_type, Type::F64 | Type::I64 | Type::U64) { + return Err(Error::new( + Status::InvalidArg, + format!( + "Unsupported field type for weighting: {field_type:?}. Only f64, i64 and u64 fast fields are supported." + ), + )); + } + + Ok( + collector.tweak_score(move |segment_reader: &tv::SegmentReader| { + // All three readers are created upfront even though only one matches + // the field type: a Rust closure has a single concrete type, so the + // arms cannot return different closures, and Box would add a + // heap allocation per segment plus virtual dispatch per document. + let f64_reader = segment_reader + .fast_fields() + .f64(&weight_by_field) + .ok() + .map(|r| r.first_or_default_col(0.0)); + let i64_reader = segment_reader + .fast_fields() + .i64(&weight_by_field) + .ok() + .map(|r| r.first_or_default_col(0)); + let u64_reader = segment_reader + .fast_fields() + .u64(&weight_by_field) + .ok() + .map(|r| r.first_or_default_col(0)); + + move |doc: tv::DocId, original_score: tv::Score| { + // map_or(0.0, ...) rather than unwrap(): segments created before a + // schema change may lack this fast field. A default of 0.0 is + // neutral, since log2(2.0 + 0.0) == 1.0. + let value: f64 = match field_type { + Type::F64 => f64_reader.as_ref().map_or(0.0, |r| r.get_val(doc)), + Type::I64 => i64_reader.as_ref().map_or(0.0, |r| r.get_val(doc) as f64), + Type::U64 => u64_reader.as_ref().map_or(0.0, |r| r.get_val(doc) as f64), + _ => unreachable!("field type validated above"), + }; + let value = value.max(0.0); // Negative values are not allowed + ((2f64 + value) as tv::Score).log2() * original_score + } + }), + ) + } } /// DocAddress contains all the necessary information to identify a document @@ -238,7 +760,7 @@ impl Searcher { /// The id used for the segment is actually an ordinal in the list of segment /// hold by a Searcher. #[napi(object)] -#[derive(Clone, Debug, Deserialize, PartialEq, PartialOrd, Eq, Ord, Serialize)] +#[derive(Clone, Debug, PartialEq, PartialOrd, Eq, Ord)] pub struct DocAddress { pub segment_ord: u32, pub doc: u32, diff --git a/src/tokenizer.rs b/src/tokenizer.rs index cc215fc..05e8e8f 100644 --- a/src/tokenizer.rs +++ b/src/tokenizer.rs @@ -192,10 +192,12 @@ impl FilterStatic { /// /// @param language - Stop words list language. /// Valid values: { - /// "arabic", "danish", "dutch", "english", "finnish", "french", "german", "greek", - /// "hungarian", "italian", "norwegian", "portuguese", "romanian", "russian", - /// "spanish", "swedish", "tamil", "turkish" + /// "danish", "dutch", "english", "finnish", "french", "german", "hungarian", + /// "italian", "norwegian", "portuguese", "russian", "spanish", "swedish" /// } + /// + /// Adding this filter to a builder throws for any other language, including + /// stemmer languages without a builtin stop word list. #[napi] pub fn stopword(language: String) -> Filter { Filter { @@ -363,15 +365,17 @@ impl TextAnalyzerBuilder { Ok(lang) => builder.filter_dynamic(tvt::Stemmer::new(lang)), Err(e) => return Err(e), }, - FilterType::StopWord { language } => match parse_language(language) { - Ok(lang) => builder.filter_dynamic(tvt::StopWordFilter::new(lang).ok_or_else(|| { + FilterType::StopWord { language } => { + let lang = parse_language(language)?; + // Not every stemmer language has a builtin stop word list + let stop_words = tvt::StopWordFilter::new(lang).ok_or_else(|| { Error::from_reason(format!( - "Failed to create stop word filter for language: {:?}", + "No builtin stop word list for language: {}", language )) - })?), - Err(e) => return Err(e), - }, + })?; + builder.filter_dynamic(stop_words) + } FilterType::CustomStopWord { stopwords } => { builder.filter_dynamic(tvt::StopWordFilter::remove(stopwords.clone())) } diff --git a/tantivy-py b/tantivy-py index 3acbf34..dcd5019 160000 --- a/tantivy-py +++ b/tantivy-py @@ -1 +1 @@ -Subproject commit 3acbf341f6ed80c70cef53572a386892784fdc58 +Subproject commit dcd5019b128ddcf2226a1cc0f43e8de8f79b4574 diff --git a/wasi-worker-browser.mjs b/wasi-worker-browser.mjs deleted file mode 100644 index 0c189dc..0000000 --- a/wasi-worker-browser.mjs +++ /dev/null @@ -1,34 +0,0 @@ -import { instantiateNapiModuleSync, MessageHandler, WASI } from '@napi-rs/wasm-runtime' - -const handler = new MessageHandler({ - onLoad({ wasmModule, wasmMemory }) { - const wasi = new WASI({ - print: function () { - // eslint-disable-next-line no-console - console.log.apply(console, arguments) - }, - printErr: function() { - // eslint-disable-next-line no-console - console.error.apply(console, arguments) - - }, - }) - return instantiateNapiModuleSync(wasmModule, { - childThread: true, - wasi, - overwriteImports(importObject) { - importObject.env = { - ...importObject.env, - ...importObject.napi, - ...importObject.emnapi, - memory: wasmMemory, - } - }, - }) - }, - -}) - -globalThis.onmessage = function (e) { - handler.handle(e) -}