For AI agents: the complete documentation index is available at https://silt-db.dev/llms.txt, the full documentation bundle is available at https://silt-db.dev/llms-full.txt, and this page is available as Markdown at https://silt-db.dev/advanced/server.md.

Concurrent Node servers

Configure MongoClient with the worker-backed server engine when multiple requests should query SQLite while other requests write. The engine opens one writer worker and a bounded pool of read-only workers against the same WAL file. Application code uses the MongoDB-compatible JavaScript API.

import { MongoClient } from '@silt-db/mongodb';
import { openDatabase } from '@silt-db/sqlite/server';

const database = await openDatabase('./app.sqlite', {
  adapter: 'better-sqlite3',
  readPoolSize: 2,
});
const client = new MongoClient({ database, dbName: 'app' });
const db = client.db();

// Serve application requests using db.collection(...).

await client.close();
await database.close();

The caller owns the supplied engine: closing the client releases its cursors and sessions, and closing database releases the workers. Set closeDatabase: true on the client only when it should also own engine shutdown. Drain application requests before closing either handle.

One SQLite file is one database. dbName labels the client's database handle (default test); it does not partition the file. Other database names reject. Use separate files for separate databases.

Install the optional better-sqlite3 peer for this example. Omit adapter to use Node's built-in SQLite. Both choices execute SQL in workers with this entry point. The ordinary /node and /better-sqlite3 entries retain their lighter, single connection behavior and execute SQL on the calling thread.

Ownership and routing

The server opens its own file-backed connections, requests WAL and synchronous=FULL, and verifies the actual journal mode. :memory:, native connection injection, and wal:false are rejected here. Existing single-connection drivers remain usable through { driver }; supplying one does not automatically create a reader pool. Closing a supplied driver is opt-in with closeDriver:true.

Document queries, aggregations, counts, and normal ShareDB reads use the read pool. Schema creation, document mutations, and commits use the writer. Planning index metadata and executing a query share a reader snapshot, so an index change cannot make the compiled query refer to a different catalog version.

For application transactions, pass a Mongo session to every participating operation:

await client.withSession(async (session) => {
  await session.withTransaction(async () => {
    await db.collection('notes').updateOne({ _id: 'one' }, { $set: { saved: true } }, { session });
    const note = await db.collection('notes').findOne({ _id: 'one' }, { session });
    console.log(note);
  });
});

Session operations use the writer's transaction, including reads, and see their own writes. Other reads can see the last committed state; unrelated writes wait. Do not await an operation without its session inside the transaction or run parallel operations with one session. Transaction cursors must be consumed inside the transaction. See sessions and transactions for failure handling and supported semantics.

SQLite still permits only one writer at a time. Workers improve reader concurrency and keep SQL execution off Node's event loop; they do not distribute writes across hosts. Multiple ShareDB backend processes also need a shared PubSub adapter.

Share the coordinator with SQL and file storage

await database.sql.exec('CREATE TABLE IF NOT EXISTS files (id TEXT PRIMARY KEY, data BLOB)');
await database.sql.run('INSERT OR REPLACE INTO files VALUES (?, ?)', [
  'file-1',
  new Uint8Array([1, 2, 3]),
]);
const size = await database.sql.read.get('SELECT length(data) AS bytes FROM files WHERE id=?', [
  'file-1',
]);

await database.sql.transaction(async (sql) => {
  await sql.run('UPDATE files SET data=? WHERE id=?', [new Uint8Array([4]), 'file-1']);
  console.log(await sql.read.get('SELECT length(data) AS bytes FROM files WHERE id=?', ['file-1']));
});

database.sql.get/all/run/exec use the writer queue. database.sql.read.get/all/iterate accepts a single SELECT query (including SELECT CTEs and VALUES) and can use the reader pool. Every facade checks the engine's lifetime. sql.transaction() supplies a scoped SQL facade; manual transaction-control SQL is rejected. Use parameter binding for values. Raw driver and native sqlite handles are low-level interfaces and must not introduce a competing transaction manager.

The core engine's database.transaction(async tx => ...) also supplies a scoped handle: all tx operations, including tx.sql.read, stay on its writer connection. Use that supplied handle for every operation in a core transaction. This low-level API is described in the engine reference.

Mongo and core APIs share ordinary collection tables and indexes. ShareDB and custom SQL/BLOB tables are separate; they survive Mongo dropDatabase(), which removes ordinary document collections and their indexes. These integrations can share the same owner and file. Keep ShareDB document changes in its operation protocol. Coordinating a BLOB write does not by itself make a later ShareDB metadata update part of that transaction. The framework patches add an explicit provider using this API.

Cursor resources

Ordinary toArray() reads finish their statements before returning the requested array. A server iterator leases one reader and keeps its query snapshot while rows are delivered in bounded batches. Writing through the database from its loop body works because the writer is a different connection. A new iteration of a core Silt cursor executes a new query; Mongo cursors maintain one position until rewound.

Breaking iteration, closing the cursor/client/database, and errors release the reader. A queued cursor can be cancelled without waiting for another cursor's lease. An abandoned pooled cursor expires after cursorIdleTimeoutMs; subsequent consumption rejects explicitly. Active long-lived snapshots can retain WAL pages, so close unused cursors and inspect workload-specific storage growth. An explicit read transaction's lifetime follows its callback and has no automatic deadline.

Expo and single-connection iterators instead materialize the result once into SQLite TEMP storage, then deliver bounded pages without keeping a source statement open between application awaits. This costs temporary storage and initial work. Actual Expo web tests verify disk-backed OPFS spooling; native runtime evidence is listed separately in Expo.

Settings and measurements

Server optionDefaultMeaning
readPoolSize2Reader workers, from 1 to 32
readBatchSize128Rows per reader transport batch, from 1 to 10,000
cursorIdleTimeoutMs30,000Positive idle deadline for pooled cursors
maxPendingReads1,024Maximum queued reader requests; excess requests reject
timeout5,000SQLite busy timeout in milliseconds
cacheSizeKiB8,192Suggested page cache per connection
cursorBatchSize128Rows per writer/portable result-spool page

database.driver.stats() reports writer/reader thread IDs and active, queued, and cursor counts. These are diagnostics, not application data. Cache settings are targets, not a hard process memory limit; every worker has its own runtime and SQLite cache.

The September 2026 measured fixture improved grouping/lookup throughput about 1.6–2× with two readers and reduced event-loop stalls. Tiny primary-key SQL and document point reads were slower; workers added startup and RSS costs. See the performance results for the reproducible workloads, exact figures, and limits. Choose settings using your own request mix.