@cqlite/node
v0.17.0
Published
Node.js bindings for CQLite - read Apache Cassandra 5.0 SSTables without cluster dependencies
Maintainers
Readme
@cqlite/node
Node.js bindings for CQLite - a high-performance library for reading Apache Cassandra 5.0 SSTable files locally, without requiring a running Cassandra cluster.
Installation
npm install @cqlite/nodeQuick Start
import { Database } from '@cqlite/node';
// Open a database with schema
const db = await Database.open('path/to/sstables', { schema: 'schema.cql' });
// Execute queries (executeNative() returns native JS types with full precision)
const result = await db.executeNative('SELECT * FROM keyspace.table LIMIT 10');
for (const row of result.rows) {
console.log(row.name);
}
await db.close();⚠️ Warning:
execute()is deprecated and will be removed in the next major. PreferexecuteNative().execute()returns lossy legacy JSON encodings:
blob→ base64 string (not aBuffer)timestamp→ ISO-8601 string (not aDate)varint→"0x{hex}"stringdecimal→"decimal:{scale}:0x{hex}"stringdate/time→ number (days-since-epoch / nanoseconds-since-midnight)It is also slower (JSON off-loop, then JS on-loop — a double conversion). Calling
execute()emits a one-timeDeprecationWarning. UseexecuteNative()for native types (BigInt,Buffer,Date,Set,Map) with full fidelity. (bigint/countercurrently come back as an exactBigInton this napi build, so they are not presently rounded — butexecute()is unsupported regardless.)
Features
- Zero cluster dependency - Read SSTable files directly from disk
- Full CQL type support - All primitive types, collections, UDTs, and frozen types
- Native JavaScript types - BigInt, Date, Buffer, Set, Map via
executeNative() - Memory-efficient streaming - Configure buffer sizes for large datasets
- Thread-safe - Safe concurrent access from multiple workers
- Cross-platform - Linux (x86_64, ARM64), macOS (Intel, Apple Silicon), Windows
Supported Platforms
| Platform | Architecture | Status | |----------|--------------|--------| | Linux | x86_64 | ✅ | | Linux | ARM64 | ✅ | | macOS | Intel (x86_64) | ✅ | | macOS | Apple Silicon | ✅ | | Windows | x64 | ✅ |
Requirements
Node.js
^18.17.0 || >= 20.3.0— the boundaries are CI-tested, not merely advertised. CI loads the prebuilt native module on exactly 18.17.0, exactly 20.3.0, and the current maintained major (24), running one real query against the canonical corpus on each (Floor smoke (Node …)in.github/workflows/node-ci.yml, issue #1459).That check is a binding matrix tier, so per repo CI-cost policy it runs on every push to
main, on the nightly schedule, and on PRs labeledci:bindings-full— not on a routine unlabeled PR.mainis therefore continuously floor-tested and releases are cut from it, but a floor break is caught just after merge rather than before it.The range is unbounded above 20.3.0, and Node-API ABI stability does not cover regressions in the JS loader — hence the current-major leg.
The range is discontinuous because the module is built against Node-API 9 (
napi9), which ships in Node 18.17.0+ and 20.3.0+ but never in 19.x or 20.0–20.2 — those releases satisfy a naive>= 18or>= 18.17.0constraint yet cannot load the module at all.Cassandra 5.0 SSTable files
API Reference
Opening a Database
import { Database } from '@cqlite/node';
// With schema file
const db = await Database.open('/path/to/sstables', {
schema: '/path/to/schema.cql',
});
// Always close when done
await db.close();The close() method is idempotent - safe to call multiple times.
Executing Queries
// Simple query - executeNative() returns native JS types with full precision
const result = await db.executeNative('SELECT * FROM keyspace.table');
for (const row of result.rows) {
console.log(row);
}
// With LIMIT
const limited = await db.executeNative('SELECT name, age FROM users LIMIT 100');
// Access query metadata
console.log(`Rows returned: ${result.rowCount}`);
console.log(`Execution time: ${result.executionTimeMs}ms`);
console.log(`Columns: ${result.columns.map(c => c.name).join(', ')}`);Prefer
executeNative()over the deprecatedexecute()— see the warning at the top of this README for the precision/encoding hazards ofexecute().
Native Types with executeNative()
Use executeNative() to get native JavaScript types instead of JSON-serializable values:
const result = await db.executeNative('SELECT * FROM keyspace.table');
for (const row of result.rows) {
// BigInt for CQL bigint/varint
const balance: bigint = row.balance;
// Date for CQL timestamp
const created: Date = row.created;
// Buffer for CQL blob
const data: Buffer = row.blob_data;
// Set for CQL set
const tags: Set<string> = row.tags;
// Map for CQL map
const metadata: Map<string, string> = row.metadata;
}JSON Encoding (deprecated execute() method)
⚠️ Deprecated — removed in the next major.
execute()returns lossy legacy JSON encodings and is slower thanexecuteNative(). In particular blob comes back as a base64 string, timestamp as an ISO-8601 string, and varint/decimal as bespoke non-round-trippable strings. This section documents the encoding for the few callers that still depend on it; new code should useexecuteNative().
The execute() method returns JSON-serializable values. For most types this works intuitively,
but varint and decimal types use a hex-based encoding to preserve arbitrary precision:
// Using execute() - hex encoding (deprecated)
const result = await db.execute('SELECT amount FROM transactions');
console.log(result.rows[0].amount);
// Varint: "0x7f" (127), "0xff" (-1), "0x0100" (256)
// Decimal: "decimal:2:0x7b" (1.23), "decimal:2:0xee29" (-45.67)
// Using executeNative() - proper types (recommended)
const native = await db.executeNative('SELECT amount FROM transactions');
console.log(native.rows[0].amount);
// Varint: 127n (BigInt)
// Decimal: "1.23" (human-readable string)Hex Format Details:
varint:"0x{hex}"- Two's complement big-endian hex encodingdecimal:"decimal:{scale}:0x{hex}"- Scale (decimal places) + hex-encoded unscaled value
Recommendation: Use executeNative(). The execute() method is deprecated
(removed in the next major); its JSON encoding is lossy (blob/timestamp/varint/
decimal come back as bespoke strings) and it is slower than executeNative().
Column Metadata
Each query result includes column information:
const result = await db.executeNative('SELECT * FROM keyspace.table');
for (const col of result.columns) {
console.log(`${col.name}: ${col.dataType}`);
console.log(` nullable: ${col.nullable}`);
console.log(` position: ${col.position}`);
}Database Statistics
const stats = await db.getStats();
console.log(`SSTables: ${stats.totalSstables}`);
console.log(`Total rows: ${stats.totalRows}`);
console.log(`Memory: ${stats.memoryUsedBytes} bytes`);Refreshing SSTables (v0.13)
If Cassandra (or another process) writes new SSTables while your Database handle
is open, call refresh() to re-discover them. Refresh is explicit-only (CQLite
never rescans behind your back) and atomic / fail-closed: if any newly found
generation fails to open, the swap is rolled back and the handle keeps serving the
prior, consistent set of readers.
// ... time passes; Cassandra flushes/compacts new SSTables to disk ...
const report = await db.refresh();
console.log(`Tables scanned: ${report.tablesScanned}`);
console.log(`Readers added: ${report.readersAdded}`);
console.log(`Readers removed: ${report.readersRemoved}`);
// Subsequent queries see the newly discovered data
const result = await db.executeNative('SELECT * FROM keyspace.table');refresh(): Promise<RefreshReport> resolves to a RefreshReport with the numeric
fields tablesScanned, readersAdded, and readersRemoved.
Result Byte Budget (v0.13)
Non-streaming queries are bounded by a result-size budget of 64 MiB by default.
When the materialized result's running byte estimate exceeds the budget, the query
rejects with a CqliteError whose code === 'QUERY', directing you to add a
LIMIT clause or use executeStreaming(). Streaming queries are not subject
to this budget.
try {
const result = await db.executeNative('SELECT * FROM keyspace.big_table');
for (const row of result.rows) {
process(row);
}
} catch (e) {
if (e.code === 'QUERY') {
// Result exceeded the 64 MiB byte budget — add a LIMIT or stream instead
for await (const row of db.executeStreaming('SELECT * FROM keyspace.big_table')) {
process(row);
}
} else {
throw e;
}
}OpenTelemetry Tracing (v0.13)
CQLite can emit OpenTelemetry traces when built with the observability Cargo
feature; without that feature the configuration is accepted but is a no-op. Pass an
otel option to Database.open():
const db = await Database.open('path/to/sstables', {
schema: 'schema.cql',
otel: {
enabled: true, // default false
endpoint: 'http://localhost:4317', // default 'http://localhost:4317'
protocol: 'grpc', // 'grpc' (default) or 'http'
serviceName: 'cqlite', // default 'cqlite'
serviceVersion: '0.13.0', // default: package version
samplingRatio: 1.0, // default 1.0
timeoutMs: 10000, // default 10000
},
});Options are layered over the CQLITE_OTEL_* environment variables.
Error Handling
All errors include structured metadata for programmatic handling:
import { Database } from '@cqlite/node';
try {
const db = await Database.open('/path/to/data');
const result = await db.executeNative('SELECT * FROM keyspace.table');
} catch (e) {
// Error code for programmatic handling
console.log(`Code: ${e.code}`); // 'IO', 'SCHEMA', 'QUERY', 'PARSE', 'TIMEOUT', etc.
// Error category
console.log(`Category: ${e.category}`); // 'System', 'Schema', 'Query', etc.
// Whether the operation can be retried
console.log(`Recoverable: ${e.isRecoverable}`);
// Original error message
console.log(`Message: ${e.message}`);
}Error Codes:
Codes come from the shared FFI error contract (cqlite_ffi_common::error_contract),
keyed by the core error VARIANT — the same table the Python binding reads, so a
given failure has the same identity in both bindings (issue #1451). A code is
therefore finer-grained than the category it reports (a timeout is TIMEOUT
with category System).
| Code | Category | Description | Recoverable |
|------|----------|-------------|-------------|
| IO | System | File system errors, invalid path | Yes (IO) |
| SCHEMA | Schema | Schema parsing/validation, table errors | No |
| QUERY | Query | Query execution failures, result-set budget | No |
| PARSE | Data / Query | CQL syntax errors; corrupt or undecodable data | No |
| CONFIG | Configuration | Invalid configuration or read-path knob | No |
| STORAGE | Storage | Storage engine, index, compaction errors | Yes |
| NOT_FOUND | NotFound | Table/resource not found | No |
| INVALID_INPUT | Data / Logic | Invalid input, operation, or state (e.g. closed db) | No |
| CONCURRENCY | Concurrency | Lock contention, write_dir already locked | Varies |
| CONFLICT | Conflict | Resource already exists | No |
| CONSTRAINT | Constraint | Constraint violation | No |
| TRANSACTION | Transaction | Transaction errors | Yes |
| TIMEOUT | System | Operation exceeded its deadline (never IO) | No |
| MEMORY | System | Memory/allocation failure (never IO) | Yes |
| PLATFORM | Platform | Platform-specific (WASM) errors | No |
| INTERNAL | Internal | Internal errors | No |
| CANCELLED | Cancelled | Cooperative scan cancellation (never IO) | No |
Type Conversions
CQL types are automatically converted to JavaScript types:
| CQL Type | JavaScript Type | Notes |
|----------|-----------------|-------|
| text, varchar, ascii | string | |
| int, smallint, tinyint | number | |
| bigint, varint, counter | bigint | via executeNative() |
| float, double | number | |
| decimal | string | Preserves precision |
| boolean | boolean | |
| blob | Buffer | via executeNative() |
| timestamp | Date | via executeNative() |
| date | Date | via executeNative() |
| time | bigint | Nanoseconds since midnight, via executeNative() |
| duration | object | { months, days, nanos } |
| uuid, timeuuid | string | Lowercase formatted |
| inet | string | IP address string |
| list<T> | T[] | |
| set<T> | Set<T> | via executeNative() |
| map<K,V> | Map<K,V> | via executeNative() |
| tuple<...> | [...] | Array |
| frozen<T> | Inner type | Unwrapped |
| UDT | object | { typeName, keyspace, fields } via executeNative() (see below) |
* Note: With execute(), varint returns "0x{hex}" and decimal returns "decimal:{scale}:0x{hex}".
Use executeNative() for human-readable formats.
UDT type identity is carried out of band
A CQL user-defined type is returned as { typeName, keyspace, fields } by
executeNative(). execute() shapes rows through the JSON writer, which emits a UDT as a bare
object of its declared fields — mirroring the CLI's JSON — with no typeName/keyspace/fields
wrapper, so the identity is not available on that path at all:
const { rows } = await db.executeNative('SELECT address FROM ks.t LIMIT 1');
const udt = rows[0].address;
udt.typeName; // 'address_type' — the declared UDT type
udt.keyspace; // 'test_collections'
udt.fields.street; // '1 Main St' — declared fields live here, and ONLY here
Object.keys(udt); // ['typeName', 'keyspace', 'fields']Breaking change (issue #3504). _type and _keyspace used to be set on the same object as the
UDT's own field names — so a UDT declaring a field named _type or _keyspace (legal CQL via a
quoted identifier) silently overwrote the marker and the type name became unrecoverable.
interface UdtValue also no longer declares a [field: string]: Value index signature: that
signature is what permitted the collision. Migration:
| Before | Now |
|---|---|
| result._type | result.typeName |
| result._keyspace | result.keyspace |
| result.street | result.fields.street |
| '_type' in value to spot a UDT | 'typeName' in value && 'fields' in value |
Fields are deliberately NOT also mirrored at the top level — that would re-flatten them beside
typeName and reintroduce the defect. The Python binding keeps udt["street"] via a dedicated
cqlite.Udt type, so the two bindings differ in ergonomics and agree on semantics.
fields has a null prototype. A field name is data, and a plain object's property assignment
consults the prototype chain — so a UDT field named __proto__ (legal CQL via a quoted identifier,
exactly like _type) would call Object.prototype's inherited accessor rather than become a field:
a string value vanished, a null value replaced the object's prototype. fields is therefore built
with Object.create(null), which inherits nothing, so every field name — including any that a
future JavaScript adds to Object.prototype — is an ordinary own data property:
udt.fields.__proto__; // the declared field's value
Object.getPrototypeOf(udt.fields); // null
Object.hasOwn(udt.fields, '__proto__'); // true
udt.fields.hasOwnProperty('x'); // TypeError — not inherited
Object.hasOwn(udt.fields, 'x'); // use this insteadIndexing, in, Object.keys/entries, spread, destructuring and JSON.stringify are all
unaffected. The outer object keeps a normal prototype: its keys (typeName, keyspace, fields)
are chosen by the binding, never by data.
CQL decimal rendering policy
A CQL decimal is unscaled x 10^(-scale) where unscaled is an
arbitrary-precision two's-complement integer. Both CQLite language bindings share
one implementation and one policy (issue #1452), so a value can never
render in one binding and be refused by the other:
| Condition | Outcome |
|---|---|
| Unscaled magnitude > 32 KiB | Refused as corrupt: a typed error naming the scale, the unscaled length and the ceiling |
| Magnitude > 1024 bytes, or abs(scale) > 1_000_000 | Precision-preserving exponent form, <digits>e<-scale> — every digit exact |
| scale < 0 — a legal and common Cassandra encoding | Exponent form at any magnitude, independent of the thresholds above: e.g. unscaled = 123, scale = -2 renders 123e2 |
| Otherwise (scale >= 0) | Positional form, e.g. 1.23, 0.00123, -0.123 |
A negative scale multiplies by a power of ten, so there is no positional form
for it and none of the size thresholds apply. A consumer that parses these
strings must therefore accept exponent form at any magnitude:
^-?[0-9]+(\.[0-9]+)?$ alone is not sufficient.
Below the 32 KiB ceiling the render is infallible: a well-formed value always renders, whatever its scale. Above it the refusal is a typed, catchable error — a corrupt SSTable never aborts the host process.
Write Operations
CQLite v0.9.0 adds write support to the Node.js bindings. Open the database with
writable: true and a writeDir to enable write operations.
const { Database } = require('@cqlite/node');
const db = await Database.open('path/to/sstables', {
schema: 'schema.cql',
writable: true,
writeDir: '/tmp/my-writes',
});
// Write rows via CQL INSERT, UPDATE, or DELETE
await db.executeNative(
"INSERT INTO test_basic.simple_table (id, name, age) " +
"VALUES (22222222-2222-2222-2222-222222222222, 'Bob', 25)"
);
await db.executeNative(
"UPDATE test_basic.simple_table SET age = 26 " +
"WHERE id = 22222222-2222-2222-2222-222222222222"
);
// Flush the in-memory write buffer (memtable) to an SSTable on disk.
// Returns the path to the flushed Data.db file, or "" if memtable was empty.
const path = await db.flushRun();
console.log('Flushed to:', path);
// Run background compaction within a time budget
const report = await db.maintenanceStep({ budgetMs: 100 });
console.log(`Merged ${report.rowsMerged} rows in ${report.timeSpentMs}ms`);
if (report.pendingCompaction) {
console.log('More compaction work available');
}
// Inspect write statistics (synchronous getter)
const stats = db.writeStats;
console.log('Memtable size:', stats.memtableSizeBytes, 'bytes');
console.log('Total flushed:', stats.totalWrittenBytes, 'bytes');
await db.close();Write API
| Method / Property | Description |
|-------------------|-------------|
| db.executeNative(cql) | Execute a CQL INSERT, UPDATE, or DELETE statement (recommended) |
| db.execute(cql) | Deprecated (removed next major; emits a DeprecationWarning). Same DML behavior as executeNative(), but lossy for SELECT — use executeNative() |
| db.flushRun() | Flush memtable to SSTable; returns the Data.db path or "" if memtable was empty |
| db.maintenanceStep(options?) | Run STCS compaction for up to options.budgetMs ms (default: 100); returns MaintenanceReport |
| db.writeStats | Synchronous getter: memtableSizeBytes, memtableRowCount, totalWrittenBytes, l0SstableCount |
Known Limitations
- Counter columns cannot be written —
execute()throwsCqliteErrorfor counter mutations. - BTI-format index files are not produced; the writer emits BIG format.
See docs/write-support-limitations.md for the full limitations reference.
Examples
See the examples/ directory for complete working examples:
- basic-query.ts - Simple SELECT queries
- type-handling.ts - Working with CQL types
- error-handling.ts - Error handling patterns
- streaming.ts - Large result handling
- performance.ts - Memory-optimized usage
Streaming concurrency caveat:
executeStreaming()fetches each batch ofK = bufferSizerows on a libuv threadpool thread, so N concurrent streams can each occupy a libuv threadpool thread for the duration of a batch fetch. Heavy concurrentfs/cryptowork in the same process may see added latency until the follow-up (#1901) moves streaming off the libuv pool onto the tokio runtime. If an error occurs mid-stream, rows already read in the in-flight batch (up tobufferSize) are not delivered — the iterator rejects with the error (errors are terminal).
Resources
- TypeScript Definitions - Complete API type hints
- Main Project README - CQLite project overview
- Write Support Guide - Detailed write documentation
- Issue Tracker - Report bugs or request features
License
MIT OR Apache-2.0
