> Complete content index: https://narsil.sondelali.com/llms.txt

# Configuration

> Set up one engine for production, check every write and search with a plugin, and look up each option that createNarsil and createIndex take, with its default.

URL: https://narsil.sondelali.com/docs/configuration
Section: Operations
Version: 0.3 (@delali/narsil@0.3.0, latest)

`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.

```ts title="support-desk.ts"
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.

```ts title="support-desk.ts"
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createNarsil } from '@delali/narsil' // [!code --]
import { createNarsil, ErrorCodes, isNarsilError, NarsilError, type NarsilPlugin } from '@delali/narsil' // [!code ++]

const searches: Array<{ term: string | undefined; count: number | undefined }> = [] // [!code ++:16]

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], // [!code ++]
})

await narsil.createIndex('tickets', {
  schema: { subject: 'string', body: 'string', priority: 'enum' },
  required: ['subject'],
  strict: true,
})

await narsil.insert('tickets', { // [!code ++:25]
  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()
```

```json result open
{
  "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](/docs/persistence-and-durability) |
| `durability.tier` | `'wal'` with a directory or a filesystem adapter, and `'snapshot'` with any other adapter | [Persistence and durability](/docs/persistence-and-durability) |
| `durability.mode` | `'sync'` | [Persistence and durability](/docs/persistence-and-durability) |
| `durability.flushIntervalMs` | 1,000 | [Persistence and durability](/docs/persistence-and-durability) |
| `durability.checkpointIntervalMs` | 300,000 | [Persistence and durability](/docs/persistence-and-durability) |
| `durability.checkpointMutationThreshold` | 100,000 | [Persistence and durability](/docs/persistence-and-durability) |
| `durability.segmentMaxBytes` | 64 MiB | [Persistence and durability](/docs/persistence-and-durability) |
| `durability.compactionThreshold` | 12 | [Persistence and durability](/docs/persistence-and-durability) |
| `persistence` | None | [Persistence and durability](/docs/persistence-and-durability) |
| `invalidation` | None | [Persistence and durability](/docs/persistence-and-durability) |
| `lifecycle.idleTimeoutMs`, `lifecycle.maxOpenIndexes`, and `lifecycle.maxOpenBytes` | No limit | [Persistence and durability](/docs/persistence-and-durability) |
| `lifecycle.maxReopenWaiters` | 64 | This page |
| `workers.enabled` | `true`, except on a browser page | [Partitions and workers](/docs/partitions-and-workers) |
| `workers.count` | The host's cores minus one, from 2 to 8 | [Partitions and workers](/docs/partitions-and-workers) |
| `workers.promotionThreshold` | 1,000 documents | [Partitions and workers](/docs/partitions-and-workers) |
| `workers.idleTimeoutMs` | Five minutes, or `lifecycle.idleTimeoutMs` where that is shorter | [Partitions and workers](/docs/partitions-and-workers) |
| `workers.mainCopyQueries` | `'lone'`, and `'none'` behind `createServer` | [Partitions and workers](/docs/partitions-and-workers) |
| `workers.bootstrapModule` | None | [Partitions and workers](/docs/partitions-and-workers) |
| `embedding` and `embeddingAdapters` | None | [Embedding adapters](/docs/embedding-adapters) |
| `analysis.rebuild` | `'auto'` | [Language support](/docs/language-support) |
| `plugins` | None | This page |
| `idGenerator` | A UUID v7 for each document inserted without an id | [Indexes and documents](/docs/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](/docs/indexes-and-documents) |
| `required` | None | [Indexes and documents](/docs/indexes-and-documents) |
| `strict` | `false` | [Indexes and documents](/docs/indexes-and-documents) |
| `surfaceForms` | `true` | [Indexes and documents](/docs/indexes-and-documents) |
| `bm25` | `k1` of 1.2 and `b` of 0.75 | [Indexes and documents](/docs/indexes-and-documents) |
| `language` | `'english'` | [Language support](/docs/language-support) |
| `stopWords` and `tokenizer` | The language's own | [Language support](/docs/language-support) |
| `defaultScoring` | `'local'` | [Full-text search](/docs/full-text-search) |
| `partitions.maxPartitions` | 1 | [Partitions and workers](/docs/partitions-and-workers) |
| `partitions.maxDocsPerPartition` | No limit | [Partitions and workers](/docs/partitions-and-workers) |
| `partitions.watermark` | None, so the engine emits no `partitionWatermark` event | [Partitions and workers](/docs/partitions-and-workers) |
| `vectorPromotion` | A graph build from 1,024 vectors | [Vector search](/docs/vector-search) |
| `embedding` | None | [Embedding adapters](/docs/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](/docs/persistence-and-durability) covers the tiers, the checkpoints, and the lifecycle limits.
- [Partitions and workers](/docs/partitions-and-workers) covers the partition limits and the worker copies.
