createNarsil takes the settings for the whole engine, while createIndex takes the settings for one index. Build the engine once, in a module of its own, and import it wherever you need it, because one engine serves every index in the process. You build support-desk.ts here, in which you configure an engine for a support desk and then add a plugin that checks every ticket before the engine stores it. Each code block below holds the whole file, with green lines marking the lines that you add at each step and struck lines marking the lines that you remove. The two tables at the end list every option with its default and the page that covers it.
Configure the engine in one module
The engine below writes its indexes to a directory on the write-ahead-log tier, where it forces every write to disk before the call resolves. With workers.count: 4, the engine has a budget of four threads, which it splits into two copies of each large index and two threads for vector search. Under mainCopyQueries: 'none', the engine sends every search on an index that holds copies to one of them, which suits a process whose main thread also answers HTTP requests. Under lifecycle, the engine releases an index from memory once the index goes ten minutes without a read or a write, and keeps at most 20 indexes open.
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createNarsil } from '@delali/narsil'
const narsil = await createNarsil({
durability: { directory: mkdtempSync(join(tmpdir(), 'narsil-support-')) },
workers: { count: 4, mainCopyQueries: 'none' },
lifecycle: { idleTimeoutMs: 600_000, maxOpenIndexes: 20 },
})
await narsil.createIndex('tickets', {
schema: { subject: 'string', body: 'string', priority: 'enum' },
required: ['subject'],
strict: true,
})
await narsil.shutdown()createNarsil checks each value before the engine starts and fails with CONFIG_INVALID and a message that holds the option's name, such as durability.mode must be "sync" or "async", got "fast". It also fails for lifecycle on an engine with no durability, for a workers.idleTimeoutMs longer than lifecycle.idleTimeoutMs, and for a relative workers.bootstrapModule. Beyond the values, it refuses a key that NarsilConfig does not declare, at the top level or inside a block such as workers, so a misspelt durabilty fails with a message that lists every setting that the engine accepts at that level. TypeScript rejects the same key at compile time, where you pass the object straight to createNarsil, as the program does, or where you declare it as NarsilConfig.
Check every ticket with a plugin
A plugin is an object of hooks that the engine calls around its own work. createNarsil fails with CONFIG_INVALID for a plugin without a name, because the engine names the plugin in its errors and in its event payloads. A before hook fires first and can reject the operation by throwing. The engine then stores whatever the hook leaves in context.document, so the same hook can clean a ticket before the engine indexes it. An after hook fires once the operation succeeds, which suits an audit log.
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createNarsil } from '@delali/narsil'
import { createNarsil, ErrorCodes, isNarsilError, NarsilError, type NarsilPlugin } from '@delali/narsil'
const searches: Array<{ term: string | undefined; count: number | undefined }> = []
const ticketRules: NarsilPlugin = {
name: 'ticket-rules',
beforeInsert: (context) => {
const subject = String(context.document.subject ?? '').trim()
if (subject === '') {
throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, 'A ticket needs a subject', { field: 'subject' })
}
context.document.subject = subject
},
afterSearch: (context) => {
searches.push({ term: context.params.term, count: context.results?.count })
},
}
const narsil = await createNarsil({
durability: { directory: mkdtempSync(join(tmpdir(), 'narsil-support-')) },
workers: { count: 4, mainCopyQueries: 'none' },
lifecycle: { idleTimeoutMs: 600_000, maxOpenIndexes: 20 },
plugins: [ticketRules],
})
await narsil.createIndex('tickets', {
schema: { subject: 'string', body: 'string', priority: 'enum' },
required: ['subject'],
strict: true,
})
await narsil.insert('tickets', {
id: 'ticket-1001',
subject: ' Refund not received ',
body: 'My refund for order 5521 has not reached my card.',
priority: 'high',
})
let rejected = ''
try {
await narsil.insert('tickets', {
id: 'ticket-1002',
subject: ' ',
body: 'The parcel for order 5530 arrived damaged.',
priority: 'low',
})
} catch (error) {
if (!isNarsilError(error)) throw error
rejected = `${error.code}: ${error.message}`
}
await narsil.query('tickets', { term: 'refund' })
const stored = await narsil.get('tickets', 'ticket-1001')
console.log(JSON.stringify({ storedSubject: stored?.subject, rejected, searches }, null, 2))
await narsil.shutdown(){
"storedSubject": "Refund not received",
"rejected": "DOC_VALIDATION_FAILED: A ticket needs a subject",
"searches": [
{
"term": "refund",
"count": 1
}
]
}The engine stores the first ticket with its subject trimmed but refuses the second before indexing it. The hook throws a NarsilError under a code from ErrorCodes, so the caller's isNarsilError guard works as it does for the engine's own failures. Where a hook throws a plain Error, the engine turns it into a NarsilError under DOC_VALIDATION_FAILED that keeps the hook's own message, both from a single insert and in the failed list of insertBatch. Where an afterSearch hook throws, the caller still receives the results, because the engine logs the error and answers the query all the same.
The engine awaits a hook that returns a promise, so slow work inside a hook delays each write or search that waits on it. The same before and after pairs exist for updates, removals, and searches. The engine calls four more hooks when it creates or drops an index, when a rebalance changes an index's partition count, and when it loads worker copies. It calls no hook for suggest, because it answers that call from the term dictionary and searches no document.
Every engine option
These options go to createNarsil, and the engine uses the default in the second column for each option that you leave out.
| Option | Default | Covered in |
|---|---|---|
durability.directory | None | Persistence and durability |
durability.tier | 'wal' with a directory or a filesystem adapter, and 'snapshot' with any other adapter | Persistence and durability |
durability.mode | 'sync' | Persistence and durability |
durability.flushIntervalMs | 1,000 | Persistence and durability |
durability.checkpointIntervalMs | 300,000 | Persistence and durability |
durability.checkpointMutationThreshold | 100,000 | Persistence and durability |
durability.segmentMaxBytes | 64 MiB | Persistence and durability |
durability.compactionThreshold | 12 | Persistence and durability |
persistence | None | Persistence and durability |
invalidation | None | Persistence and durability |
lifecycle.idleTimeoutMs, lifecycle.maxOpenIndexes, and lifecycle.maxOpenBytes | No limit | Persistence and durability |
lifecycle.maxReopenWaiters | 64 | This page |
workers.enabled | true, except on a browser page | Partitions and workers |
workers.count | The host's cores minus one, from 2 to 8 | Partitions and workers |
workers.promotionThreshold | 1,000 documents | Partitions and workers |
workers.idleTimeoutMs | Five minutes, or lifecycle.idleTimeoutMs where that is shorter | Partitions and workers |
workers.mainCopyQueries | 'lone', and 'none' behind createServer | Partitions and workers |
workers.bootstrapModule | None | Partitions and workers |
embedding and embeddingAdapters | None | Embedding adapters |
analysis.rebuild | 'auto' | Language support |
plugins | None | This page |
idGenerator | A UUID v7 for each document inserted without an id | Indexes and documents |
lifecycle.maxReopenWaiters is the number of calls that can wait while the engine loads a closed index back into memory. The engine fails each call beyond that number with INDEX_REOPEN_CAPACITY_EXHAUSTED, so the queue behind a closed index stays at that length or shorter.
Every index option
These options go to createIndex, and only schema is required.
| Option | Default | Covered in |
|---|---|---|
schema | Required | Indexes and documents |
required | None | Indexes and documents |
strict | false | Indexes and documents |
surfaceForms | true | Indexes and documents |
bm25 | k1 of 1.2 and b of 0.75 | Indexes and documents |
language | 'english' | Language support |
stopWords and tokenizer | The language's own | Language support |
defaultScoring | 'local' | Full-text search |
partitions.maxPartitions | 1 | Partitions and workers |
partitions.maxDocsPerPartition | No limit | Partitions and workers |
partitions.watermark | None, so the engine emits no partitionWatermark event | Partitions and workers |
vectorPromotion | A graph build from 1,024 vectors | Vector search |
embedding | None | Embedding adapters |
trackPositions | true | This page |
With trackPositions, the engine records the position of each term in a field and writes those positions into the .nrsl format for other programs that read the format and match phrases. The engine's own highlighter works from the stored text, so the engine gives the same answers on an index with trackPositions: false, which takes less space.
Where to go next
- Persistence and durability covers the tiers, the checkpoints, and the lifecycle limits.
- Partitions and workers covers the partition limits and the worker copies.