Typed local documents, SQL-style queries, automatic indexing, and a browser Studio—in one npm package.
node-idb is an embedded, server-side document store for Node.js built on
SQLite. It stores JavaScript-shaped documents locally, preserves useful native
types, and provides a compact document API with a practical SQL-style query
language.
It is designed for applications that want local, durable, queryable document storage without operating a separate database server. It is not intended to replace PostgreSQL, MongoDB, or another client/server database when data must be shared across many application servers or written heavily by many processes.
Short recommendation: choose
node-idbfor one Node.js application on one machine, low-to-moderate write concurrency, stable document shapes, and small or medium documents. Do not choose it for distributed deployments, unbounded dynamic field names, very large media documents, or workloads that need many simultaneous writers.
| Question | Answer |
|---|---|
| What is it? | A typed document layer over embedded SQLite. |
| Where does it run? | In a Node.js server process, on the same machine as its database files. |
| What does it store? | Plain objects, nested objects, atomic arrays, strings, finite numbers, booleans, null, BigInt, Date, and binary data. |
| How is it queried? | SELECT * reconstructs complete documents; explicit SELECT fields support structured object/array projections, scalar expressions, grouping, and aggregates. |
| Is it transactional? | Yes. Each mutation is atomic, including values split between the main and blob databases. |
| Is it serverless? | Yes in the database sense: no database daemon is required. Your Node.js application remains the server for remote clients. |
| What is the best workload? | Local application data, caches, catalogs, configuration, metadata, offline-capable services, and low-to-medium traffic single-host applications. |
| What is the main scaling boundary? | SQLite serializes writers to each collection database, and every distinct field path still requires storage structures even when optional query indexes are disabled. |
| What document size is preferred? | Usually below 100 KB. Documents up to about 1 MB can be reasonable when measured on the target system. Larger documents require deliberate testing. |
| Is there a hard document-size setting? | No single node-idb document limit. SQLite, Node.js memory, value encoding, disk capacity, and transaction duration impose the real limits. |
| Can it open storage without writing? | The 0.2 API has a genuine mode: "readonly" for current, complete disk storage and verified backups. |
| Does it create online backups? | backup() creates integrity-checked, manifested snapshots with per-collection consistency. |
| Can indexing and open handles be tuned? | Yes. fieldIndexes controls optional predicate indexes, and maxOpenCollections bounds retained disk collection connections. |
| Can an application inspect document shape? | Yes. structure() returns an immutable observed type tree for a collection or exact nested field path. |
| Is there a visual management tool? | The included node-idb/studio entry point starts a token-protected, loopback-only browser Studio that is read-only by default. |
| How mature is it? | It is a young 0.x package with a comprehensive automated test suite, but it should be evaluated and load-tested before critical production use. |
- In 30 seconds
- Start here: five minutes
- Beginner tutorials
- Who should use it?
- Who should not use it?
- Advantages
- Tradeoffs, sizing, and limits
- Examples
- Local browser Studio
- Public API
- Statements and results
- Paths, types, storage, and migration
- Performance and production checklist
- Project status and support
Which path should I follow?
- I want to try it now: continue to Start here.
- I am new to document databases: run the two beginner tutorials.
- I want the visual interface: jump to Studio or its complete guide.
- I learn from real projects: open the Phonebook tutorial.
- I am evaluating production use: read tradeoffs, limits, and the production checklist.
- I need exact contracts: jump to the public API and statement reference.
Install the single package. Node.js 20.19 or newer and ESM are required.
npm install node-idbCreate a database, insert one JavaScript-shaped document, and read it back:
import { createIdb } from "node-idb";
const database = createIdb({ storagePath: "./data/my-app" });
try {
await database.execute("INSERT INTO people", {
name: "Ada",
contact: { email: "ada@example.test" },
tags: ["friend", "engineer"],
createdAt: new Date(),
});
const people = await database.execute(
"SELECT * FROM people WHERE contact.email = ?",
["ada@example.test"],
);
console.log(people);
} finally {
await database.close();
}Open the same data in the included local Studio:
import { startStudio } from "node-idb/studio";
const studio = await startStudio({ rootPath: "./data", port: 0 });
console.log(studio.url); // Open the complete token-bearing URL.Core and Studio have the same package version and release lifecycle. Studio is
a subpath export, not a second dependency: do not install a separate Studio
package. It binds only to 127.0.0.1 and starts read-only unless you explicitly
set writable: true.
From this repository or an unpacked npm package:
node examples/00-beginner.jsRead examples/00-beginner.js first. It contains
only factory creation, one insert, one select, and safe shutdown.
node examples/00-beginner-studio.jsOpen the complete printed URL, select greetings, and try the Browse,
Structure, Query, and Diagnostics tabs. Press Ctrl+C in the terminal to
stop the server. This example is intentionally read-only.
node examples/phonebook-studio/index.js --port=0The Phonebook Studio tutorial builds five related
collections and 12,000+ synthetic documents. It teaches nested references,
two-step relation lookup, indexes, structure inspection, querying, editing, and
safe reseeding. For the complete Studio manual, see
docs/STUDIO.md.
We recommend node-idb when most of these statements are true:
- Your Node.js process and the data files live on the same physical machine.
- You want an embedded database with no database server to install or manage.
- Your data is naturally document-shaped but SQL-style filtering, projection, grouping, ordering, and aggregation are still useful.
- Document shapes are reasonably stable across a collection.
- Reads are more common than writes, or writes are short and can queue.
- A single application process, or a small number of cooperating processes, owns the data.
- You value local files, simple deployment, transactional durability, and easy backup over horizontal database scaling.
- You need to retain
Date,BigInt, arrays, nested objects, or binary values without manually mapping every value to JSON. - Your application is a desktop/local service, edge application, small website, internal tool, test fixture store, build cache, content catalog, job metadata store, or single-host API.
Typical good fits include:
| Use case | Why it fits |
|---|---|
| Desktop or local-first application backend | Data stays beside the application and needs no administrator. |
| Embedded device or edge service | SQLite is self-contained and works without a database daemon. |
| Small-to-medium single-server API | The application server serializes short writes and serves remote clients through its own API. |
| Metadata or content catalog | Documents are easy to model and scalar fields remain queryable. |
| Configuration, templates, and application resources | Typed nested data and transactional replacement are useful. |
| Local cache of a remote system | Low latency, offline reads, and simple invalidation/replacement. |
| Tests, prototypes, and development tools | Isolated storage paths and :memory: databases are convenient. |
| Migration from the original HIS/EV3 IDB module | Legacy v0/v2 files and callback behavior are supported. |
We do not recommend node-idb when any of these are central requirements:
- Multiple application servers must directly share the same files.
- The database files are on NFS, SMB, a synchronized cloud-drive folder, or another network filesystem.
- Many processes must commit writes to the same collection at the same time.
- You need horizontal scaling, replicas, automatic failover, sharding, change streams, or multi-region operation.
- You need cross-collection joins, foreign keys between documents, arbitrary SQL/DDL, stored procedures, or a complete relational database interface.
- You need a MongoDB-compatible query language or ecosystem.
- You need built-in authentication, authorization, encryption at rest, compression, auditing, replication, or remote administration.
- Your documents use arbitrary IDs, timestamps, URLs, or user-provided strings
as object keys. Use an array of
{ key, value }entries or a dedicated collection instead. - Individual documents are normally many megabytes or contain videos, large archives, or other stream-oriented payloads.
- Large arrays must be filtered or updated by individual element. Arrays are
atomic values in
node-idb. - You need transactions spanning multiple public API calls or multiple collections.
- You require a mature
1.xAPI and a long public production track record today.
For those workloads, consider PostgreSQL, MongoDB, another client/server database, or object storage combined with a database for metadata. SQLite's own appropriate-use guide makes the same fundamental distinction: SQLite is strongest when the data is local and writer concurrency is modest.
- Zero database-server administration. Install the npm package and choose a storage directory.
- Document-shaped data.
SELECT *reconstructs complete nested plain objects, while direct object paths remain structured in mixed projections. - Native values.
Date,BigInt, arrays, buffers, typed arrays, andArrayBuffervalues round-trip without application-side JSON conventions. - Useful SQL concepts. Predicates, projections, aliases, grouping, aggregates, ordering, limits, offsets, expressions, and parameters compose.
- Transactional writes. Document replacement and its external blob values commit as one crash-atomic SQLite transaction.
- Predictable alias handling. Exact paths, leaf-name ambiguity, wildcards, and explicit aliases follow documented deterministic rules.
- Adaptive scalar indexes. Common equality, range,
IN,BETWEEN,LIKE, andGLOBpredicates can use per-field indexes; the default manager learns a bounded set automatically, while applications can still pin all, none, or a focused collection/path policy. - No JSON-only compromise. Binary data and integers larger than JavaScript's safe integer range are preserved.
- Explicit isolation. Each
createIdb({ storagePath })instance owns one database directory, making deployment and test boundaries visible. - Zero-copy storage migration. An existing HIS/EV3 project directory can
become the new instance's
storagePath; its collection files do not need to be copied or renamed. - Inspectability. The physical files remain SQLite databases that standard SQLite tools can inspect for diagnostics.
- Bounded open-handle cache. Disk engines retain at most 16 collection connections by default and transparently reopen least-recently-used collections when necessary.
- Read-only and backup workflows. A genuine read-only mode and a manifested, integrity-checked backup API support safer reporting, validation, and recovery procedures.
- Optional local Studio. A dependency-free browser interface can discover
nearby database directories, browse typed documents, build bounded
SELECTqueries, inspect diagnostics, and perform explicitly enabled maintenance.
- One writer per collection database at a time. Writers wait rather than running concurrently. Different collections use different files, but a hot collection can still become a write bottleneck.
- A table-and-structural-index cost per distinct field path. Every new path
found in a collection creates a value table and a required structural index.
Optional query/type indexes are controlled by
fieldIndexes, but disabling them does not remove the underlying per-path schema cost. Stable schemas work well; unbounded dynamic keys cause schema growth, slower initialization, more disk usage, and more write work. - Whole-document mutation cost. Payload-style updates read, merge, encode, and rewrite matched documents. Updating a tiny property in a huge document is therefore not cheap.
- Arrays are atomic. Their contents are serialized as one value and cannot be individually indexed or addressed by the query language.
- Large text and binary values use fallback query paths. They are preserved, but they do not have the same compact scalar-index behavior as short values.
- Two files per disk-backed collection. Backups must preserve the matching main and blob files together.
- No public multi-operation transaction API. Each
execute()mutation is a transaction; an application cannot currently group several calls into one atomic unit. - SQL-style, not full SQL. Statements are compiled against one document collection. Cross-collection joins and arbitrary write SQL are intentionally outside the public API.
- Memory is part of the limit. Documents and atomic arrays are encoded and decoded in process. The API does not stream document properties or BLOBs.
- Predicate indexing increases write amplification. Adaptive indexing
deliberately waits for evidence and limits its index budget, but every
optional index still adds pages and mutation work. Manual
"all"maximizes that cost; focused policies require workload knowledge and benchmarking. - Backups are not cross-collection transactions. Each collection's main and blob files are captured consistently, but a backup containing several collections can represent slightly different moments in time.
- Young package. Version
0.xmeans APIs and storage behavior must be reviewed carefully before upgrades.
The following values are conservative design guidance, not enforced limits or performance guarantees. Hardware, filesystem, cache behavior, query patterns, write frequency, and Node.js memory settings matter. Benchmark representative data on the target system before committing to a design.
| Area | Preferred starting point | Caution zone | Usually choose another representation |
|---|---|---|---|
| Complete document | Up to roughly 100 KB | 100 KB to 1 MB; measure reads and rewrites | Regularly above 1 MB, especially write-heavy documents |
| One text or binary property | Up to roughly 100 KB | Hundreds of KB to a few MB | Large media, archives, backups, or stream-oriented files |
| Leaf/path count per document | Tens; preferably below 100 | Low hundreds; benchmark write and open time | Many hundreds or thousands of distinct paths |
| Distinct field paths across a collection | Stable and shared by documents | Growing into the hundreds | Unbounded keys unique to each document |
| Atomic array | Small or moderate bounded lists | Large arrays read or replaced as a unit | Arrays whose elements need queries, indexes, or partial updates |
| Insert batch | Small bounded chunks | Hundreds of small documents per call | Huge batches that create long write locks or high memory pressure |
| Collection database | Comfortably fits local disk and backup windows | Multi-GB stores should be load- and recovery-tested | Data that must span machines or approach local operational limits |
SQLite reports that small BLOBs can be competitive with, and often faster than, separate filesystem files. Its historical measurements found a crossover around 100 KB on the tested setup, while emphasizing that results vary by system. See Internal Versus External BLOBs and 35% Faster Than The Filesystem.
For node-idb, 100 KB is also a useful application-design boundary because
documents are materialized in JavaScript and payload-style mutations rewrite
the matched document. It is not a cliff: a 300 KB document may work perfectly
for an infrequently updated catalog, while a frequently rewritten 50 KB
document may deserve profiling.
For large images, videos, PDFs, archives, or generated artifacts, prefer:
- Store the bytes in the filesystem or object storage.
- Store the URI/path, checksum, MIME type, size, ownership, and searchable
metadata in
node-idb. - Use atomic file-replacement and application-level cleanup rules so metadata and files do not drift apart.
Small thumbnails, compact attachments, and other bounded binary properties can
remain convenient inside node-idb. Always test on the actual operating system
and storage device.
Avoid this shape when keys grow without a fixed bound:
{
readings: {
'2026-07-19T10:00:00Z': 12,
'2026-07-19T10:01:00Z': 13,
},
}Each timestamp becomes a distinct field path. Prefer a bounded array when its contents are always read as one unit, or separate documents when readings must be queried:
{ sensorId: 'alpha', timestamp: new Date(), value: 12 }The same rule applies to user IDs, UUIDs, URLs, filenames, and other values that might otherwise become property names.
node-idb applies these explicit limits:
| Limit | Current behavior |
|---|---|
| Collection name | 1-128 characters |
| Collection-name characters | Letters, numbers, underscores, and hyphens; at least one letter, number, or underscore |
| Storage path | Any non-empty filesystem path without a null byte; the operating system supplies practical limits |
| Busy timeout | Integer from 0 through 2,147,483,647 milliseconds; defaults to 10,000 |
| Open disk collection cache | Positive safe integer; defaults to 16; an explicit cap is rejected for :memory: storage |
| Document/array nesting | Maximum 128 levels |
| Field-name dots | Rejected because . separates nested paths |
| Field-name null bytes | Rejected |
| Prototype-sensitive field names | __proto__, prototype, and constructor are rejected |
| Numbers | Must be finite JavaScript numbers; use BigInt for larger exact integers |
| Dates | Must contain a valid timestamp |
| Object types | Only plain objects or objects with a null prototype are accepted |
| Circular references | Rejected |
There is no separate node-idb maximum for the length of an ordinary field
name or for the number of properties in one document. Those values are bounded
indirectly by SQLite storage, memory, and the 128-level nesting rule. In
practice, very long names waste schema and index space, and large property
counts are expensive because each distinct path creates persistent schema
objects. Keep names concise and field sets bounded even though no small hard
limit is enforced.
SQLite supplies additional upper bounds. node-idb does not promise that
values close to these theoretical maxima are practical:
| SQLite boundary | Typical/current build value | Effect on node-idb |
|---|---|---|
| One string, BLOB, or encoded SQLite row | 1,000,000,000 bytes | A single long text, binary value, or serialized array must remain below the active SQLite length limit and fit process memory. |
| Result columns / selected terms | 2,000 | Extremely wide flat SELECT projections can hit the SQLite column limit. Document reconstruction is internally chunked and is not equivalent to one column per property. |
| Bound SQL variables | 32,766 | Extremely large application-supplied parameter lists, especially IN (...), can hit the build's variable limit. |
Compound SELECT terms |
500 | Very complex generated or raw diagnostic reads can hit this boundary. |
| Expression depth | 1,000 | Deeply nested SQL expressions can fail even when document nesting is valid. |
| Pages per database file | 4,294,967,294 | With a 4 KiB page size this is about 17.6 TB per SQLite file; filesystem and operational limits normally arrive much earlier. |
These values depend on the SQLite build delivered by the installed sqlite3
dependency and may change. SQLite documents its defaults and compile-time
options in Limits In SQLite. At the maximum
64 KiB page size, SQLite's theoretical file-size ceiling is about 281 TB, but
that is not a sensible capacity target for this package.
There is no small fixed maximum number of documents or collections in
node-idb. Real limits include disk space, filesystem file-size limits, schema
size, open file descriptors, available memory, query latency, backup duration,
and recovery objectives. Each disk-backed collection creates two database
files and may hold an open connection while active.
- Writes are serialized within an engine and by SQLite's file locks.
- SQLite permits only one active writer per collection database file pair.
- A writer waits for
busyTimeoutMs(10 seconds by default) before a busy error is returned. - Readers use stable SQLite snapshots and do not observe half-written local documents.
- Separate collections use separate file pairs, so partitioning unrelated hot data into sensible collections can reduce lock contention.
- Long batches and large document rewrites keep the write lock longer and reduce effective concurrency.
- Database files should be stored on a local filesystem. Do not place live files on a network share or a consumer file-sync folder.
- Remote clients should call your Node.js application API; they should not open the SQLite files themselves.
If many writers cannot wait their turn, use a client/server database. SQLite's appropriate-use guide explains this boundary in detail.
node-idb currently does not provide built-in:
- Point-in-time recovery, scheduled retention, or remote backup transport
- Replication, clustering, leader election, or failover
- Schema validation or application-level migrations
- Encryption at rest or field-level encryption
- Compression
- User authentication, roles, or row-level permissions
- Change streams, subscriptions, triggers exposed as an API, or reactive events
- Time-to-live indexes or automatic expiry
- Full-text search or vector search
- Geospatial indexes
- Cross-collection joins or foreign-key relationships
- Streaming reads/writes for large properties
- User-controlled transactions spanning multiple calls
- Automatic collection compaction, archival, or retention policies
- A browser IndexedDB implementation; despite its historical name, this is a Node.js package backed by SQLite
These features can be implemented at the application layer where appropriate, but applications that fundamentally depend on several of them should usually select a database designed around those requirements.
Every disk-backed collection has a matching pair:
db-collection-<collection>.sqlite
db-blobs-<collection>.sqlite
Treat the pair as one logical database. The 0.2 API can create a
live, verified backup without exposing a partially populated destination:
const result = await database.backup({
destinationPath: "./backups/portal-latest",
integrityCheck: "full",
overwrite: true,
});
console.log(result.createdAt, result.collections, result.files);The engine stages each backup in a private sibling directory, runs SQLite
integrity checks, calculates sizes and SHA-256 digests, writes the recognition
manifest last, and then promotes the completed directory. See
backup(options) for the full contract and consistency
boundary.
For a simple offline filesystem backup instead:
- Stop writes and call
await database.close(). - Copy both files for every collection in the database directory.
- Preserve filenames and directory structure.
- Test restoration periodically rather than assuming a copied backup works.
Do not copy only the main file: long text, arrays, and binary payloads may live in the matching blob file. A filesystem snapshot remains useful for storage-level disaster recovery, especially when the application needs one coordinated snapshot across several independent databases. Always back up before opening legacy data with a newer package version, and regularly test a restore rather than treating backup creation as proof of recoverability.
The package includes a progression from first use to backups and performance
controls. Run any example from the package root with
node examples/<file>.js.
See examples/README.md for the complete guide.
- 00 Beginner: one insert and one select
- 00 Beginner Studio: seed and browse
- 01 Quick start
- 02 CRUD and mutations
- 03 SQL queries and aliases
- 04 Typed and binary data
- 05 Callback compatibility
- 06 Separate database instances
- 07 Raw diagnostic reads
- 08 Concurrent writers
- 09 Backup and read-only mode
- 10 Field-index policy and collection cache
- 11 Streaming and operational controls
- 12 Verify, restore, and inspect
- 13 Automatic indexing
- 14 Local browser Studio
- 15 Observed collection structure
- Multi-collection Phonebook Studio project
node-idb/studio is the optional, dependency-free management interface shipped
inside the same node-idb package as the database API. It is designed for
development, diagnosis, and deliberate local maintenance. It starts a small
pure-Node HTTP server and serves a vanilla browser application; it does not add
Express, Vite, a browser framework, or another runtime dependency.
New to Studio? Run
node examples/00-beginner-studio.js, then follow the complete Studio guide. For a realistic guided project, use the Phonebook Studio tutorial.
import { startStudio } from "node-idb/studio";
const studio = await startStudio({
rootPath: "./idbs",
port: 4177,
});
console.log(studio.url);
// Close the server and every database engine opened by this Studio.
await studio.close();The printed URL contains a cryptographically random access token in its
fragment. A browser fragment is not sent in the HTTP request: the application
stores it for the current tab in sessionStorage, removes it from the visible
address, and uses it as a bearer token only for same-origin API calls. Treat
the complete launch URL like a short-lived password. Restart Studio to rotate
it.
The returned handle exposes url, host, the actual port, resolved
rootPath, writable, a closed getter, refresh(), and an idempotent async
close(). Use port: 0 when the operating system should choose a free port.
| Option | Default | Meaning |
|---|---|---|
rootPath |
Required | Trusted top-level directory. Relative paths resolve when startStudio() runs. |
port |
4177 |
Local port from 0 through 65535; 0 selects a currently free port. |
writable |
false |
Enables dedicated insert, update, replace, delete, ANALYZE, and index-optimization endpoints. |
maxRows |
500 |
Hard maximum for one query response or document page; configurable through 10_000. |
bodyLimitBytes |
2 MiB |
Maximum JSON request body; configurable through 64 MiB. |
queryTimeoutMs |
10_000 |
Deadline applied to database work; configurable through ten minutes. |
Studio always binds to 127.0.0.1. There is intentionally no remote bind
option. Unknown options and unsafe limits are rejected before listening.
Successful JSON responses also have a derived 32 MiB to 128 MiB ceiling,
reported as state.limits.maxResponseBytes. Query rows and document-page
chunks are encoded incrementally and stop with response_too_large before an
unbounded result can accumulate in memory.
Read-only mode can discover storage, browse documents, inspect schemas and
diagnostics, and run bounded canonical SELECT queries. Mutation and
maintenance endpoints return 403 even if someone bypasses the browser UI.
writable: true must be chosen when the server starts; it cannot be enabled
from the page.
const studio = await startStudio({
rootPath: "./idbs",
port: 4177,
writable: true,
});The query editor remains SELECT-only in writable mode. Writes use narrow,
validated forms; delete additionally requires an explicit confirmation. This
separates exploration from mutation and prevents the query editor from
becoming an arbitrary command console. A query must also name exactly one
collection already present in the current Studio catalog; a misspelled or new
collection is rejected without creating files. Refresh the catalog before
querying a collection created by another process.
Studio inspects the root itself and each immediate child directory. It does not scan recursively:
idbs/
db-collection-shared.sqlite # root database, when present
db-blobs-shared.sqlite
development/ # immediate child database
db-collection-users.sqlite
db-blobs-users.sqlite
production/ # immediate child database
db-collection-users.sqlite
db-blobs-users.sqlite
archive/
old/ # ignored grandchild
Only directories recognized by inspectStorage() are included. Unrelated and
empty directories are ignored. Child symbolic links and junctions are not
followed, resolved paths must remain within the trusted root, and browser
requests select opaque catalog IDs rather than filesystem paths. A refresh
finds added or renamed child folders and closes engines for entries that
disappeared.
Studio does not create a database merely because an empty child folder exists.
Create at least one collection through createIdb(), then refresh:
const database = createIdb({ storagePath: "./idbs/development" });
await database.execute("INSERT INTO settings", {
key: "theme",
value: "dark",
});
await database.close();The interface includes:
- database and collection navigation;
- bounded document pages with expandable, type-aware trees;
- a canonical
SELECTeditor with separate parameters; - a schema-driven query builder for projections, filters, ordering, and limits;
- insert, deep-merge update, complete replacement, and confirmed deletion when write mode is enabled;
- schema, adaptive-index, cache, file-size, and reclaimable-space diagnostics;
- explicit
ANALYZEand adaptive-index optimization controls.
For a larger interface walkthrough, the Phonebook Studio project deterministically creates five related collections and more than 12,000 synthetic documents. It includes bounded insert batches, application-level relationship references, pinned lookup indexes, rerun protection, and a ready-to-run Studio launcher.
FIND is intentionally neither generated nor accepted by Studio. New queries
use the canonical form:
SELECT person.contact.details, person.name, person.tags
FROM persons AS person
WHERE person.active = ?
ORDER BY person.name
LIMIT 50Query results stop at maxRows and report when they were truncated. This is a
safety bound, not a substitute for stable pagination; use ORDER BY, LIMIT,
and OFFSET when page boundaries matter.
Ordinary JSON cannot preserve every node-idb value. Studio therefore uses a collision-proof tagged transport internally and presents a friendlier Extended JSON editor. Native values can be written as:
{
"createdAt": {
"$nodeIdb": {
"type": "date",
"value": "2026-07-21T10:00:00.000Z"
}
},
"counter": {
"$nodeIdb": {
"type": "bigint",
"value": "9007199254740993"
}
},
"bytes": {
"$nodeIdb": {
"type": "binary",
"value": "AAECAw=="
}
},
"items": [1, true, { "nested": "value" }]
}undefined uses { "$nodeIdb": { "type": "undefined" } }. It is preserved
inside arrays; like the main API, an object property whose value is
undefined is normalized to null. The editor can escape a real user object
that would otherwise look like a type marker, so no document key is reserved.
The lower-level encodeStudioValue() and decodeStudioValue() helpers are
also exported from node-idb/studio for tests or custom local clients.
The server combines loopback binding, a new 256-bit token per launch, strict
Host and browser Origin validation, same-origin fetches, no-store API
responses, a restrictive Content Security Policy, bounded request bodies,
server-side path selection, sanitized errors, and read-only-by-default engines.
The UI builds DOM nodes with text content rather than injecting result HTML.
These controls make Studio a safer local tool, not a multi-user administration service. Do not place it behind a reverse proxy, port forward, public hostname, tunnel, or container port publication. It has no accounts, roles, TLS termination, tenant isolation, user-level audit identities, or defense against a malicious process already running as the same operating-system user.
Additional operational boundaries:
- one Studio instance manages one trusted root;
- discovery includes only the root and immediate children;
- opening old storage in writable mode may perform the same supported schema
migration as
createIdb(); read-only mode never migrates it; - pages and request bodies are intentionally bounded, so Studio is not a bulk import/export pipeline;
- root arrays can be inserted, browsed, and queried, but the current public
command API treats an array payload for matched
REPLACE INTOas ambiguous; Studio therefore rejects root-array replacement instead of guessing. Delete and insert a new array only when changing its object ID is acceptable; - replace is atomic and must still find the requested object ID, but Studio does not yet attach document revisions or hashes. If another process edits the same existing document after it was loaded, the last completed write can win; reload before editing data that has concurrent writers;
- write mode is intended for deliberate local corrections, not unattended production mutations;
- closing a browser tab does not stop the Node server; call
studio.close()or stop its owning process.
Prefer backups, migrations, application-specific tooling, or direct node-idb APIs for bulk changes, shared administration, production automation, durable audit requirements, or secrets that must never be displayed locally.
The main package exports createIdb() plus the offline verifyBackup(),
restoreBackup(), and inspectStorage() tools. The optional node-idb/studio
subpath exports startStudio() and its typed transport helpers. Importing
either entry point never creates storage or starts a server, and there is no
global default database.
Creates one engine bound to exactly one database directory.
| Option | Default | Meaning |
|---|---|---|
storagePath |
Required | Database directory. A relative value is resolved against process.cwd() immediately when createIdb() runs. Use exactly ":memory:" for a non-persistent database. |
busyTimeoutMs |
10_000 |
How long SQLite waits for a conflicting lock. Must be an integer from 0 through 2_147_483_647. |
durability |
"strict" |
"strict" uses SQLite synchronous=FULL; "balanced" uses synchronous=NORMAL for fewer synchronization operations and weaker power-loss durability. |
mode |
"readwrite" |
"readonly" opens existing current-format disk storage through SQLite's read-only mode and permits only reads and backups. |
maxOpenCollections |
16 |
Positive safe integer limiting disk collection connections retained by this engine. It is intentionally unavailable for :memory: engines. |
fieldIndexes |
"auto" for new storage |
Adaptive "auto", deterministic "all"/"none", or an automatic/manual policy. Existing storage keeps its persisted policy when this option is omitted. It cannot be supplied in read-only mode. |
Unknown options are rejected so misspellings do not silently change storage or durability behavior.
TypeScript callers normally get the strictest option checks by letting
createIdb() infer the path literal. For an explicit reusable annotation, pass
that path type to IdbOptions, for example
IdbOptions<"./idbs/main"> or IdbOptions<":memory:">. IdbOptions<string>
represents a path known only at runtime and therefore exposes only options safe
for both disk and memory engines; disk-only read-only/cache configuration
requires a narrowed non-memory path. Runtime validation remains authoritative
for JavaScript and dynamically constructed values.
import { createIdb } from "node-idb";
const mainDatabase = createIdb({
storagePath: "./idbs/main",
busyTimeoutMs: 15_000,
durability: "strict",
maxOpenCollections: 16,
fieldIndexes: "auto",
});
const auditDatabase = createIdb({
storagePath: "./idbs/audit",
durability: "balanced",
});Each engine owns every collection below its resolved storagePath. Create
separate instances with separate paths when an application needs separate
databases. Within one process, prefer sharing one engine for each resolved
path; separate processes may open the same path and coordinate through SQLite
locking.
For tests or temporary work:
const database = createIdb({ storagePath: ":memory:" });Each in-memory engine is isolated and disappears when closed.
Do not pass maxOpenCollections with :memory:. A memory collection exists
inside its open SQLite connection, so evicting that connection would erase the
collection. Memory engines therefore retain all collections until close().
Use mode: "readonly" for reporting, verification, backup sources, or other
workflows that must not alter storage:
const snapshot = createIdb({
storagePath: "./backups/portal-latest",
mode: "readonly",
maxOpenCollections: 4,
});
try {
const users = await snapshot.execute("SELECT * FROM users WHERE active = ?", [true]);
const plan = await snapshot.execute(
"QUERY ON users EXPLAIN QUERY PLAN SELECT * FROM tbl_record",
);
console.log(users, plan);
} finally {
await snapshot.close();
}Read-only mode is deliberately strict:
- It uses SQLite read-only file access in addition to
query_onlyprotection. SELECT, theFINDcompatibility spelling, and read-onlyQUERY ON ... SELECT|EXPLAINwork; all mutation statements reject before execution.backup()is also allowed.- The storage directory and both files for a requested collection must already exist. A miss never creates a directory, collection, or partner file.
- Every opened collection must already use the current schema version (
v5), include its persisted field-index policy, and use the required rollback journal mode. Read-only mode will not migrate an old schema, convert WAL, reconcile indexes, or normalize legacy metadata. Open such storage once in read/write mode after taking an offline filesystem backup, close it, and only then open it read-only. mode: "readonly"cannot be combined with:memory:. Do not passdurabilityorfieldIndexes, because either could require a write.busyTimeoutMsandmaxOpenCollectionsremain valid.
Read-only mode prevents this engine from changing the database; it does not
make the directory an immutable historical snapshot while another engine or
process is writing. Use backup() when a durable snapshot copy is needed, and
observe its per-collection consistency boundary.
Each disk collection uses one SQLite connection with its blob database
attached. maxOpenCollections bounds the number of such collection stores
retained by one engine; it does not limit how many collections can exist on
disk. At the default 16, accessing a seventeenth collection closes the least
recently used idle store and transparently reopens it when needed. An active
store is never evicted; if every retained store is active, a new acquisition
waits until one is released.
Use a lower value for applications that touch many collections infrequently or have tight file-descriptor limits. Use a higher value when a stable hot set is larger than 16 and reopen latency is measurable. Every engine has its own cache, so several engines for the same path multiply retained connections. Measure both cache churn and operating-system resource use instead of setting a very large value by default.
Every field path always has a value table and a structural index required for
document reconstruction and mutation. fieldIndexes controls two additional
indexes used by common predicates and type-aware lookups. It changes query
plans and write/storage cost, not query correctness: an unindexed predicate
still works by scanning the relevant value table.
The complete option contract is:
type FieldIndexes =
| "auto"
| "all"
| "none"
| {
mode: "auto";
preset?: "conservative" | "balanced" | "aggressive";
maxIndexesPerCollection?: number;
minDocuments?: number;
minQueryCount?: number;
slowQueryMs?: number;
maxResultRatio?: number;
evaluationInterval?: number;
cooldownMs?: number;
allowDrop?: boolean;
dropUnusedAfterMs?: number;
minIndexAgeMs?: number;
rules?: FieldIndexRule[];
}
| {
default?: "all" | "none";
rules?: FieldIndexRule[];
};"auto"is the default for newly created storage. It begins with structural indexes and learns which optional indexes are justified by observed filters."all"creates optional indexes for every field path."none"omits every optional field index while retaining required structural indexes.- A policy object's
defaultis"all"when omitted. Its rules are evaluated in array order, and the last matching rule wins. collectionis either one valid collection name or"*". Collection matching is case-insensitive; document path matching is case-sensitive.- A
pathrule is literal. Even a path value of"*"means a property literally named*; usepatternfor wildcards. - A
patternis a deterministic dot-segment glob: a complete*segment matches exactly one path segment, while a complete**segment matches zero or more. Embedded stars,?, brackets, and other regular-expression or shell syntax are literal. For example,profile.*matchesprofile.namebut notprofile.address.city;audit.**matchesauditand all descendants. - Each rule must provide exactly one of
pathorpattern, andenabledmust be boolean. Unknown policy/rule keys, empty path segments, and invalid collection names are rejected. - The internal root row has an empty path. It follows
defaultand cannot be targeted by a document-field rule. - The policy is normalized and snapshotted when
createIdb()runs. Mutating a caller-owned option object or rules array afterward has no effect.
Automatic mode observes canonical field identities after collection aliases and quoted paths have been resolved. It persists only bounded aggregates: query counts, predicate categories, elapsed time, result counts, and last-use times. SQL source, parameter values, aliases, and document contents are never stored as telemetry.
The balanced default waits for repeated selective filters on a sufficiently large collection. Evaluation uses frequency, average result ratio, and query time, creates at most one index per maintenance cycle, enforces a collection budget and cooldown, and serializes schema changes through the same SQLite write lock used by document mutations. Several processes can therefore observe the same storage without independently creating duplicate indexes. Statistics decay after evaluation so old workloads gradually lose influence and the telemetry tables remain bounded to one row per field.
// The simple and recommended form for new storage.
const database = createIdb({ storagePath: "./data/application" });
// Optional tuning. Rules are hard overrides and are never auto-managed.
const tuned = createIdb({
storagePath: "./data/reporting",
fieldIndexes: {
mode: "auto",
preset: "balanced",
maxIndexesPerCollection: 20,
rules: [
{ collection: "users", path: "email", enabled: true },
{ collection: "events", pattern: "payload.**", enabled: false },
],
},
});The presets are deliberately different:
conservativewaits for larger collections and more evidence, keeps a smaller budget, and never removes indexes automatically.balanced, the default, reacts sooner but also leaves automatic removal off.aggressiveis intended for measured workloads; it uses lower thresholds and permits removal after long inactivity and a minimum index age.
allowDrop affects only indexes created by the automatic manager. Explicitly
enabled rules cannot be removed, explicitly disabled fields cannot become
candidates, and manual "all", "none", or policy objects never learn or
change themselves. An omitted option preserves the policy already stored in an
existing collection, which prevents package upgrades from silently rebuilding
indexes.
Automatic indexing currently manages the existing per-field value/type index
pair. It does not synthesize compound indexes such as (tenantId, createdAt):
document paths live in separate physical value tables, so true compound
indexing would require a future materialized-projection feature. ORDER BY
usage is recorded for diagnostics but does not by itself justify the current
per-field predicate index.
A focused policy normally starts with default: "none" and enables only fields
used by selective filters or ordered/range query plans confirmed by
measurement:
const database = createIdb({
storagePath: "./data/application",
fieldIndexes: {
default: "none",
rules: [
{ collection: "*", path: "tenantId", enabled: true },
{ collection: "users", path: "email", enabled: true },
{ collection: "events", pattern: "context.**", enabled: true },
// Later and more specific: exclude private event context again.
{ collection: "events", pattern: "context.private.**", enabled: false },
],
},
});The normalized policy and adaptive metadata are persisted inside every collection as schema-v5
metadata. On read/write open, the requested policy becomes that collection's
active persisted policy and existing optional indexes are created or dropped to
match it. Data is not rewritten, but reconciliation is schema work: on a large
or wide collection it can take locks, consume time and temporary disk space,
and make the next open slower. Back up first and roll out policy changes as an
operational migration. Dropping indexes makes their pages reusable inside
SQLite but does not necessarily shrink the database file; node-idb does not run
VACUUM automatically.
New field paths follow the currently persisted policy. Engines that share a storage path also share that policy; do not keep concurrent engines configured with different policies. The latest read/write initialization can replace the persisted policy, cause index churn, and change which indexes later writers create. Use one policy per storage path across every process.
Read-only engines load and validate the persisted policy instead of accepting a new one. Legacy schema versions must first be upgraded by a read/write engine. Backups preserve the schema version, policy metadata, and current indexes.
Choose "all" for convenience, smaller/stable schemas, or query-heavy data;
choose "none" for write-heavy data with few selective predicates; choose a
focused policy for established workloads. Do not assume fewer indexes are
faster overall: scans can dominate. Use the packaged benchmark and production-
shaped load tests before changing the policy.
Returns the direct operation result and rejects on an error.
const controller = new AbortController();
const files = await database.execute("SELECT * FROM files WHERE key = $key", {
$key: "main.js",
}, {
timeoutMs: 2_000,
signal: controller.signal,
});options.signal cancels queued or running work and rejects with an
AbortError. options.timeoutMs creates an execution deadline and rejects
with a TimeoutError whose code is IDB_TIMEOUT. Cancellation interrupts the
active SQLite statement. A mutating command is rolled back; the collection
connection remains reusable. Both options are cooperative around filesystem
and JavaScript work, so an already-running synchronous callback cannot be
preempted until control returns to node-idb.
For UPSERT INTO and REPLACE INTO, options.requireMatch: true disables the
normal insert-on-miss behavior. Selection and the decision not to insert occur
inside the same mutation transaction; a miss returns []. This is useful for
edit screens that must never create a document after a concurrent deletion:
const replaced = await database.execute(
"REPLACE INTO users WHERE object_id = 42",
{ name: "Updated user", active: true },
{ requireMatch: true },
);
if (!replaced.length) console.log("The document no longer exists");requireMatch is rejected for other statements so a misplaced safety option
cannot be silently ignored. It does not change the existing array-payload
ambiguity: matched upsert/replace payloads must still be one non-array document.
Returns an AsyncIterable for projected rows or complete documents from
SELECT. Results are fetched in bounded pages and only one unread page is
buffered, so a slow consumer applies backpressure instead of accumulating the
complete result in memory. The query keeps one stable SQLite snapshot until
iteration finishes.
for await (const document of database.stream(
"SELECT * FROM events WHERE tenantId = ? ORDER BY createdAt",
[tenantId],
{ batchSize: 250, timeoutMs: 30_000, signal: controller.signal },
)) {
await sendToArchive(document);
}batchSize defaults to 100 and accepts 1 through 10_000. Breaking a
for await loop releases the snapshot and collection lease. Always finish,
break, or explicitly call return() on a manually consumed iterator; leaving
an iterator open also leaves its snapshot active, and close() correctly waits
for that active operation.
The Promise form is a compatibility API that always resolves an envelope. The two Node-style callback overloads still work, but are deprecated:
const outcome = await database.run("SELECT * FROM files");
// { error: null, result: [...] } or { error, result: undefined }
database.run("SELECT * FROM files", (error, result) => {});
database.run(
"SELECT * FROM files WHERE key=?",
["main.js"],
(error, result) => {},
);The first callback use in a process emits a DeprecationWarning with code
NODE_IDB_RUN_CALLBACK. Callback support is retained throughout the 0.x
release line so existing applications can migrate deliberately; it will not be
removed in a 0.x update. New code should use execute(). Callers that need
the non-throwing envelope can keep using the Promise form of run(), which is
not deprecated.
Returns an immutable observed structure, not a declared or enforced schema. It reads the current collection metadata and stored values in one stable snapshot, reconstructs the object-field hierarchy, and reports exact logical type counts, coverage, optionality, nesting depth, and current physical predicate-index status.
const complete = await database.structure("people");
console.dir(complete, { depth: null });
const contact = await database.structure("people", {
path: "contact.details",
timeoutMs: 10_000,
signal: controller.signal,
});
console.log(contact.root.path); // contact.details
console.log(contact.root.types); // [{ type: "object", count: 80 }]
console.log(contact.root.children[0].coverage); // fraction of all documentsThe result has this shape:
CollectionStructure
collection, path, documentCount, fieldCount, maxDepth
root
name, path, depth
types: [{ type, count }]
presentInDocuments, coverage, optional
coverageWithinParent, optionalWithinParent
indexed
children: [...same node shape]
path is an exact, case-sensitive canonical document path such as
"contact.details". It is not a SQL alias and must not include the collection
name. Supplying a path restricts type counting and the returned tree to that
field and its descendants, which is especially useful for large or wide
collections. A missing collection or path rejects and never creates storage.
Important interpretation details:
coverageis the fraction of all collection documents containing the field.coverageWithinParentuses only parent values observed as objects. This distinguishes a child that is always present inside an optional object from a child that is itself optional inside that object.optionalis collection-wide;optionalWithinParentis relative to the parent objects.- Mixed document shapes produce several entries in
types; booleans and short or long strings are combined into their logicalbooleanandstringtypes. - Stored
undefinedandnullare both reported asnullbecause node-idb's document storage intentionally represents them with the same value type. - Arrays are atomic values. Their elements are preserved and returned by document reads, but array-internal object keys are not collection field paths and therefore do not appear as structure children.
- Field metadata is historical. If every document stops using a previously observed path, that node remains visible with no types and zero coverage.
indexedreports the index that physically exists now, including pinned or automatically managed indexes; it is not merely the configured policy.
Exact type and coverage counts scan the value table for every returned field.
Use the focused path option, a suitable timeout, and a quiet operational
window when inspecting extremely large or very wide collections. The method is
available in read/write and genuine read-only engines and never mutates data.
Creates a complete staged backup for all collections, or a selected non-empty
set, and resolves only after its SQLite files, integrity checks, hashes,
manifest, and destination promotion succeed. It works from read/write and
read-only engines backed by the filesystem; :memory: engines are rejected.
An engine whose storage contains no complete collection/blob file pair also
rejects instead of publishing an empty backup.
| Option | Default | Meaning |
|---|---|---|
destinationPath |
Required | New backup directory. Relative paths resolve against process.cwd() when backup() runs. It must not equal, contain, or be contained by the source path; physical/symlink overlap is also rejected. A filesystem root is never accepted. |
overwrite |
false |
When false, any existing destination rejects. When true, replacement is allowed only if the existing real directory contains a valid node-idb recognition manifest. A destination without that recognition data is never overwritten. |
integrityCheck |
"quick" |
"quick" runs SQLite quick_check; "full" runs the more thorough and usually slower integrity_check on every copied main and blob file. |
collections |
All discovered collections | Optional non-empty collection-name array. Names are case-insensitive, must exist, and must not contain duplicate identities. Each selected collection includes both files. |
signal |
None | Optional AbortSignal, checked cooperatively while files are copied, verified, and hashed, and immediately before manifest/promotion. A detected cancellation rejects, attempts to discard the private stage, and leaves the destination unchanged. If stage removal itself fails, an AggregateError reports both failures and identifies the retained stage. Once destination promotion begins, cancellation does not roll it back. |
const controller = new AbortController();
const backup = await database.backup({
destinationPath: "./backups/reporting",
collections: ["users", "orders"],
integrityCheck: "full",
overwrite: true,
signal: controller.signal,
});
// Absolute path, ISO timestamp, sorted collection names, and two entries per
// collection (main plus blobs), each with byte length and SHA-256 digest.
console.log(backup.destinationPath);
console.log(backup.createdAt);
console.table(backup.files);The frozen result has this shape:
interface BackupResult {
readonly destinationPath: string;
readonly createdAt: string;
readonly collections: readonly string[];
readonly files: readonly {
readonly collection: string;
readonly kind: "collection" | "blobs";
readonly filename: string;
readonly bytes: number;
readonly sha256: string;
}[];
}The destination also contains .node-idb-backup.json. Its recognition data
includes format, formatVersion, createdAt, the node-idb and SQLite
versions, consistency: "per-collection", sorted collection identities, and
the same file metadata. The manifest is written last inside the private stage.
It makes guarded overwrite: true possible and gives restore tooling sizes and
digests to verify; it is not a signature and does not provide authenticity.
For a guarded overwrite, node-idb requires the existing directory to contain exactly the manifest and every file it declares. It verifies each file's type, size, SHA-256 digest, canonical main/blob pairing, and SQLite integrity before staging and again while atomically claiming the old directory; missing, renamed, modified, malformed, symlinked, or untracked entries make promotion reject. The newly published directory is verified again after its rename. If that last check fails, the rejected copy is quarantined at a reported sibling path and a displaced old backup is restored. The old recognized backup is moved aside only after the new stage is complete and is also restored if the promotion itself fails; in the rare event that both promotion and rollback fail, the thrown error reports the paths that require inspection. If promotion succeeds but cleanup of the displaced backup fails, the call rejects even though the new destination is complete; that error identifies the cleanup sibling, which may still contain all or part of the previous backup.
A backup is exclusive relative to calls made through the same engine: it waits
for earlier operations, later operations wait for it, and close() waits for
it. For each collection, the main and attached blob databases are held on one
stable read transaction while both files are copied, so that pair represents a
consistent collection snapshot. SQLite integrity checking is then applied to
each output file.
That stable snapshot can temporarily block external writers to the collection
while its pair is being copied. Backup lock retries honor the engine's
busyTimeoutMs; heavy concurrent writes can therefore delay the backup or make
it reject with a busy/locked error after the configured deadline. Schedule
large backups for quieter periods and test their effect on write latency.
There is intentionally no cross-collection transaction. Collections are copied
one after another, and writers using another engine or process may commit
between them. Consequently, consistency: "per-collection" is not a promise
that every collection represents the same instant. If the application requires
a global business-level checkpoint, pause all writers or coordinate them at the
application layer before calling backup().
The completed backup directory can be opened directly with
createIdb({ storagePath: backup.destinationPath, mode: "readonly" }). For a
production restore, stop every reader/writer and use the verification and
restore APIs below. The API does not schedule backups, enforce retention,
upload snapshots, authenticate manifests, encrypt files, or provide
point-in-time log replay.
These standalone functions do not require an open engine. verifyBackup()
validates the manifest, exact directory contents, canonical file pairs, sizes,
SHA-256 digests, and SQLite integrity. restoreBackup() verifies first, copies
into a private sibling stage, verifies that stage, and atomically promotes it.
import { restoreBackup, verifyBackup } from "node-idb";
await verifyBackup({ backupPath: "./backups/main", integrityCheck: "full" });
await restoreBackup({
backupPath: "./backups/main",
destinationPath: "./data/restored-main",
integrityCheck: "full",
});Restore is intentionally conservative. The destination must be absent unless
overwrite: true; even then, only a directory with a valid node-idb backup
manifest can be replaced. Arbitrary directories and unmanifested live storage
are never overwritten. Restore to a new path, validate the application against
it, then change the application path while writers are stopped. Both functions
accept an AbortSignal; integrityCheck defaults to "quick" and can be
"full".
diagnostics() reports the engine state, schema version, configured policy,
active operation count, discovered collections, open collection leases, cache
limit/waiters, cumulative LRU evictions, and per-collection automatic-index
candidates, managed indexes, scores, reasons, and errors. storageStats() reports actual
file sizes, SQLite page allocation, free pages, and estimated reclaimable
bytes per main/blob database.
console.dir(await database.diagnostics(), { depth: null });
console.dir(await database.storageStats({ collections: ["events"] }), {
depth: null,
});
await database.analyze({ collections: ["events"] });
await database.vacuum({ collections: ["events"], timeoutMs: 60_000 });
// Inspect the next eligible action without changing the schema.
console.dir(await database.optimizeIndexes({
collections: ["events"],
dryRun: true,
}), { depth: null });
// Evaluate immediately and apply at most one safe action per collection.
await database.optimizeIndexes({ collections: ["events"] });optimizeIndexes() flushes pending observations and evaluates automatic
policies immediately. dryRun: true may persist the observations already
produced by completed queries, but it never changes indexes. Manual policies
return mode: "manual" with no action.
node-idb automatically invokes SQLite PRAGMA optimize when a writable
collection opens, after an automatic schema change, periodically for active
long-lived stores, and before close. This bounded planner maintenance replaces
the need to routinely call full analyze(); that method remains available for
explicit operational work. vacuum() rewrites both files
and can require substantial temporary disk space and exclusive locks; it is
never automatic. Both reject on read-only engines and accept optional
collections, signal, and timeoutMs. Run maintenance during a quiet period
after a verified backup.
inspectStorage() discovers and validates file pairs without migrating them.
It reports schema versions, persisted index policies, paths, and sizes;
integrityCheck is "quick" by default and also accepts "none" or "full".
The installed node-idb command exposes the same offline workflow:
node-idb inspect ./data/main --integrity full --json
node-idb verify-backup ./backups/main --full --json
node-idb restore ./backups/main ./data/restored-main --full
node-idb migrate ./data/main --yes --field-indexes automigrate first performs a read-only integrity inspection, opens each discovered
collection through the current writable engine to apply supported migrations,
closes it, and inspects again. It requires --yes; this is a guard, not an
automatic backup. Stop all processes and create a verified backup yourself
before running it. Inspection and verification never migrate.
Waits for active operations, closes every collection handle, and permanently
closes the engine. Repeated close() calls return the same closing result.
Later execute() calls reject and run() calls resolve an error envelope;
create a new instance to reopen the database.
| Statement | Behavior | Result |
|---|---|---|
INSERT INTO collection |
Inserts one payload, or a batch when the payload is an array | object ID, or an array of IDs |
SELECT * FROM collection ... |
Reconstructs complete typed documents | document array |
SELECT fields FROM collection ... |
Projects structured objects/arrays, scalar fields, expressions, or aggregates | row array |
FIND collection ... |
Compatibility spelling for SELECT * FROM collection ... during 0.x |
document array |
UPDATE collection ... + payload |
Deep-merges the payload into every match | { object_id }[] |
UPDATE collection SET ... |
Evaluates SQLite expressions, then replaces assigned paths | { object_id }[] |
UPSERT INTO collection WHERE ... |
Deep-merges matches or inserts a miss | { object_id, inserted? }[] |
REPLACE INTO collection WHERE ... |
Replaces matches completely or inserts a miss | { object_id, inserted? }[] |
UNSET fields FROM collection ... |
Removes selected paths and rewrites each match | { object_id }[] |
DELETE FROM collection ... |
Deletes complete matching documents | { object_id }[] |
QUERY ON collection ... |
Runs a physical read-only SELECT or EXPLAIN for diagnostics |
physical row array |
UPSERT INTO and REPLACE INTO require a WHERE clause. This prevents an
accidental operation from merging into or replacing every document when the
caller intended an insert. Use INSERT INTO for unconditional insertion.
An array is a batch payload on an insert or on an upsert/replace miss. It is
rejected when a payload-style UPDATE, UPSERT INTO, or REPLACE INTO
already has matches, because treating the same array as both SQL bindings and
one mutation payload would be ambiguous and potentially destructive.
SELECT *, its FIND compatibility spelling, mutation selectors, and UNSET
support filtering, ordering, and pagination. Complete-document selection
rejects DISTINCT, GROUP BY, and HAVING; select explicit scalar fields for
grouped or aggregate results. UPDATE, UNSET, and DELETE without WHERE
intentionally affect every document, so build those statements carefully.
SELECT is the canonical read command. A lone bare *, or a lone real table
alias such as person.*, returns complete documents without exposing the
internal object_id:
SELECT person.*
FROM persons AS person
WHERE person.active = ?
ORDER BY person.nameAn exact object path is reconstructed instead of returning its physical child count. It composes with scalar and atomic-array projections:
SELECT person.contact.details AS details, person.name, person.tags
FROM persons AS personThat query can return { object_id, details: { ... }, name, tags: [...] }.
Nested values inside details, including Date, BigInt, arrays, and binary
data, retain their document types. A path may hold an object in one document,
an array or scalar in another, and be null or absent elsewhere; each row is
decoded according to its own stored type, with an absent projection reported
as null.
FIND persons ... remains a thin compatibility rewrite to
SELECT * FROM persons ... throughout 0.x; it has no separate query or
reconstruction implementation. New code and all packaged examples use
SELECT.
WHERE, logical/comparison operators, IN, BETWEEN, LIKE, GLOB,
ESCAPE, IS NULL, grouping, HAVING, aggregates, DISTINCT, ordering,
limits, offsets, CASE, and safe SQLite scalar functions compose through the
SQL compiler. SQL keywords used as collection or field names must be quoted:
SELECT entry.`order`, entry.`shipping-address.zip-code`
FROM `order-log` AS entry
WHERE entry.`group` = ?Non-aggregate, non-DISTINCT explicit field/expression projections include
object_id unless it is explicitly selected. Complete-document SELECT *
never exposes the internal ID. Aggregates and DISTINCT do not receive an
implicit ID. An explicit ID alias is honored:
SELECT item.object_id AS "DocumentID", item.name FROM items AS itemPositional placeholders receive an array. Named $name placeholders receive
an object whose key may be $name or name; the dollar-prefixed key wins even
when its value is null.
await database.execute("SELECT * FROM users WHERE age >= ? AND active = ?", [
18,
true,
]);
await database.execute("SELECT * FROM users WHERE email = $email", {
$email: "user@example.test",
});Direct SET parameters preserve native document types, including Date,
BigInt, buffers, arrays, and plain objects. Assigning one field directly to
another also preserves its logical type. Formula results use SQLite result
types:
await database.execute(
"UPDATE users SET profile=$profile, visits=visits+1 WHERE id=$id",
{ $id: 7, $profile: { theme: "dark" } },
);Dots represent object nesting and are therefore rejected inside actual field names. Quoting a dotted name refers to that exact stored path; it does not turn the dot into a literal key.
- A lone
*, or a lone real table-qualifiedalias.*, reconstructs complete documents. ?selects immediate top-level fields as a flat projection.profile.?selects immediate children ofprofile.profile.*selects every descendant of the storedprofilepath as flat columns. It is a complete-document selector only whenprofileis the real table alias in that statement.
The same path wildcards work in UNSET field lists, except a bare * is
rejected because complete documents must be removed with DELETE FROM.
Alias resolution is deterministic:
- An explicit
ASalias wins and its source spelling is preserved. - An exact dotted path wins over leaf matching.
- A unique bare leaf resolves wherever it is nested.
- An ambiguous bare leaf projects every match under full-path aliases.
- Ambiguous positive predicates match any path; negative predicates require every matching path to satisfy the negative condition.
- Exact matching is attempted first, followed by a case-insensitive fallback.
Output aliases must be unique case-insensitively. Duplicate explicit or implicit aliases are rejected instead of allowing the SQLite driver to silently overwrite one projected value.
For example, if both home.city and work.city exist, SELECT city returns
both keys. Use SELECT home.city AS residence for one stable output name.
SELECT * reconstructs the logical document. Explicit scalar projections keep
SQLite-compatible behavior, while direct objects and blob-backed payloads are
decoded structurally.
| Input | SELECT * / inside projected object |
Direct explicit projection |
|---|---|---|
null / property undefined |
null |
null |
| boolean | boolean | 0 or 1 |
| finite number | number | number |
BigInt |
BigInt |
decimal string |
valid Date |
Date |
epoch milliseconds |
| string / long text | string | string |
| array | array, including nested typed values | array, including nested typed values |
| plain object | nested object | complete reconstructed object |
Buffer, typed array, ArrayBuffer, or Blob |
Buffer |
Buffer |
Direct object result terms do not support DISTINCT, GROUP BY, HAVING, or
mixed aggregate results because deep object equality is not represented by one
SQLite scalar. Select scalar descendants for those operations. Atomic arrays
remain single values and can participate in ordinary DISTINCT or grouping.
Object reconstruction applies to direct result paths; use scalar descendants
inside SQL functions, predicates, and ordering expressions.
Arrays are atomic values rather than queryable child collections. undefined
inside an array is preserved. Binary values inside arrays are base64-encoded as
part of the array's JSON representation, which increases their encoded size.
Circular references, non-finite numbers, invalid dates, unsupported class
instances, prototype-sensitive keys, literal dots, and excessive nesting are
rejected before commit.
Blob-backed rows whose external payload is missing are reported as an integrity error rather than silently decoded as empty data.
storagePath is the database directory itself:
<storagePath>/
db-collection-<collection>.sqlite
db-blobs-<collection>.sqlite
The directory is created lazily on the first collection access. Relative paths
are resolved once, when createIdb() is called, so a later
process.chdir() cannot redirect an existing instance. Pass exactly
":memory:" to avoid filesystem storage.
Collection identities are case-insensitive on every platform: Users,
users, and USERS address the same collection. New database filenames use
the lowercase identity. Existing legacy filenames with other casing are found
and opened in place; if several files differ only by collection-name casing,
opening is rejected instead of choosing an ambiguous file.
Earlier versions stored each project's files in a child of the configured
root. For example, the portal database beneath a former ./idbs root already
lives in ./idbs/portal. Point the new instance directly at that existing
child directory:
const database = createIdb({ storagePath: "./idbs/portal" });
await database.execute("SELECT * FROM users");Repeat this for every old project directory that the application uses. Legacy populated v0/v2 collection and blob files are opened and upgraded in place; document IDs and filenames are retained. Take a complete backup before the first open with the new version.
For application code, import the named factory, construct one engine per old
project directory, remove the former project argument from each method call,
and use only the canonical statements in Statements and results.
Treat close() as terminal and construct a new engine when reopening is
required.
Main and blob databases are attached to one SQLite connection. Disk-backed
stores use rollback journals, allowing SQLite's multi-file transaction
mechanism to coordinate the collection and blob files. durability: "strict"
is the default and uses synchronous=FULL. durability: "balanced" uses
synchronous=NORMAL, reducing synchronization work while accepting a greater
chance of losing the latest committed transaction after an operating-system or
power failure.
Writes use BEGIN IMMEDIATE, the configured busy timeout, an in-process queue,
and a catalog refresh after acquiring the write lock. Read/modify/write
operations are one transaction, and multi-query document reads use a stable
snapshot.
Common scalar equality, range, IN, BETWEEN, LIKE, and GLOB predicates
can use optional per-field indexes when the persisted fieldIndexes policy
enables them. Disabled indexes and blob-backed long text, array, and binary
values retain semantically equivalent scan/fallback paths. Wide result reads
are internally chunked below common SQLite bind and compound-query limits.
As with every in-place database upgrade, take a filesystem backup before the first production deployment.
fieldIndexes, maxOpenCollections, and durability trade read latency,
write cost, retained resources, and failure guarantees against one another.
There is no universally fastest combination. The repository includes a
deterministic harness covering batched inserts, indexed point reads, ordered
range reads, updates, and collection-cache churn.
From a development checkout:
# Small smoke run
npm run benchmark:quick
# Standard comparison workload
npm run benchmark
# Larger workload
node benchmarks/run.js --preset stressCompare policies directly on the same idle machine and storage class:
node benchmarks/run.js --field-indexes all --max-open-collections 32
node benchmarks/run.js --field-indexes focused --max-open-collections 8
node benchmarks/run.js --field-indexes none --max-open-collections 4
node benchmarks/run.js --preset standard --json > benchmark.jsonRun node benchmarks/run.js --help for workload, seed, durability, storage,
and report controls. The focused benchmark policy indexes exactly the fields
used by its predicates; application policies must reflect application queries.
See the benchmark guide for safe storage behavior,
repeatable comparisons, report contents, and interpretation. These synthetic
results are a regression and trade-off tool, not a capacity claim or a
substitute for production-shaped load, contention, backup, and recovery tests.
QUERY ON runs a physical read against one collection connection:
QUERY ON files SELECT id, name FROM tbl_fields ORDER BY idOnly SELECT and EXPLAIN are accepted. Raw reads share the engine's snapshot
queue, so they cannot observe a local document halfway through replacement.
These statements expose implementation tables whose schema can change and
should be limited to diagnostics, migrations, and carefully versioned tooling.
Before adopting node-idb for production:
- Model dynamic keys as values or separate documents.
- Generate representative documents and measure insert, query, update, and startup time at expected and 2-5x expected volume.
- Test the largest document, array, binary value, and batch you will accept.
- Test concurrent writers and choose an appropriate
busyTimeoutMs. - Compare representative
fieldIndexespolicies and setmaxOpenCollectionsfrom the measured hot set and process resource limits. - Set application-level maximum sizes for documents, arrays, strings, binary values, query limits, and batches. Do not expose SQLite's theoretical limits to untrusted input.
- Put storage on a local durable filesystem with sufficient free-space monitoring.
- Define and test backup, manifest/hash verification, restore, retention, and corruption-response procedures for both files in every collection pair.
- Decide how encryption, authentication, authorization, and audit logging are handled outside the package.
- Pin and review package upgrades; back up before opening production files with a new version.
- Reconsider a client/server database if write concurrency, dataset size, or multi-host requirements are likely to grow materially.
node-idb is currently a 0.x package. Its test suite covers the public API,
SQL behavior, typed values, legacy migration, concurrency, corruption
detection, aliasing, wide reads, field-index reconciliation, collection-cache
eviction, genuine read-only access, and manifested backups. Tests reduce risk
but do not replace application-specific load, failure, and recovery testing.
Report reproducible defects and documentation gaps through the repository's GitHub issues.
- See CONTRIBUTING.md before proposing a change.
- Report security issues according to SECURITY.md, not in a public issue.
- Version history and release notes are available in CHANGELOG.md and GitHub Releases.
MIT