Operations

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.

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.

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.

support-desk.ts
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.

OptionDefaultCovered in
durability.directoryNonePersistence and durability
durability.tier'wal' with a directory or a filesystem adapter, and 'snapshot' with any other adapterPersistence and durability
durability.mode'sync'Persistence and durability
durability.flushIntervalMs1,000Persistence and durability
durability.checkpointIntervalMs300,000Persistence and durability
durability.checkpointMutationThreshold100,000Persistence and durability
durability.segmentMaxBytes64 MiBPersistence and durability
durability.compactionThreshold12Persistence and durability
persistenceNonePersistence and durability
invalidationNonePersistence and durability
lifecycle.idleTimeoutMs, lifecycle.maxOpenIndexes, and lifecycle.maxOpenBytesNo limitPersistence and durability
lifecycle.maxReopenWaiters64This page
workers.enabledtrue, except on a browser pagePartitions and workers
workers.countThe host's cores minus one, from 2 to 8Partitions and workers
workers.promotionThreshold1,000 documentsPartitions and workers
workers.idleTimeoutMsFive minutes, or lifecycle.idleTimeoutMs where that is shorterPartitions and workers
workers.mainCopyQueries'lone', and 'none' behind createServerPartitions and workers
workers.bootstrapModuleNonePartitions and workers
embedding and embeddingAdaptersNoneEmbedding adapters
analysis.rebuild'auto'Language support
pluginsNoneThis page
idGeneratorA UUID v7 for each document inserted without an idIndexes 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.

OptionDefaultCovered in
schemaRequiredIndexes and documents
requiredNoneIndexes and documents
strictfalseIndexes and documents
surfaceFormstrueIndexes and documents
bm25k1 of 1.2 and b of 0.75Indexes and documents
language'english'Language support
stopWords and tokenizerThe language's ownLanguage support
defaultScoring'local'Full-text search
partitions.maxPartitions1Partitions and workers
partitions.maxDocsPerPartitionNo limitPartitions and workers
partitions.watermarkNone, so the engine emits no partitionWatermark eventPartitions and workers
vectorPromotionA graph build from 1,024 vectorsVector search
embeddingNoneEmbedding adapters
trackPositionstrueThis 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