@cqlite/node 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,8 +16,8 @@ import { Database } from '@cqlite/node';
16
16
  // Open a database with schema
17
17
  const db = await Database.open('path/to/sstables', { schema: 'schema.cql' });
18
18
 
19
- // Execute queries
20
- const result = await db.execute('SELECT * FROM keyspace.table LIMIT 10');
19
+ // Execute queries (executeNative() returns native JS types with full precision)
20
+ const result = await db.executeNative('SELECT * FROM keyspace.table LIMIT 10');
21
21
  for (const row of result.rows) {
22
22
  console.log(row.name);
23
23
  }
@@ -25,6 +25,20 @@ for (const row of result.rows) {
25
25
  await db.close();
26
26
  ```
27
27
 
28
+ > **⚠️ Warning: `execute()` is deprecated and will be removed in the next major.**
29
+ > Prefer `executeNative()`. `execute()` returns **lossy** legacy JSON encodings:
30
+ > - `blob` → base64 **string** (not a `Buffer`)
31
+ > - `timestamp` → ISO-8601 **string** (not a `Date`)
32
+ > - `varint` → `"0x{hex}"` **string**
33
+ > - `decimal` → `"decimal:{scale}:0x{hex}"` **string**
34
+ > - `date`/`time` → **number** (days-since-epoch / nanoseconds-since-midnight)
35
+ >
36
+ > It is also slower (JSON off-loop, then JS on-loop — a double conversion).
37
+ > Calling `execute()` emits a one-time `DeprecationWarning`. Use `executeNative()`
38
+ > for native types (`BigInt`, `Buffer`, `Date`, `Set`, `Map`) with full fidelity.
39
+ > (`bigint`/`counter` currently come back as an exact `BigInt` on this napi build,
40
+ > so they are not presently rounded — but `execute()` is unsupported regardless.)
41
+
28
42
  ## Features
29
43
 
30
44
  - **Zero cluster dependency** - Read SSTable files directly from disk
@@ -70,14 +84,14 @@ The `close()` method is idempotent - safe to call multiple times.
70
84
  ### Executing Queries
71
85
 
72
86
  ```typescript
73
- // Simple query - returns JSON-serializable values
74
- const result = await db.execute('SELECT * FROM keyspace.table');
87
+ // Simple query - executeNative() returns native JS types with full precision
88
+ const result = await db.executeNative('SELECT * FROM keyspace.table');
75
89
  for (const row of result.rows) {
76
90
  console.log(row);
77
91
  }
78
92
 
79
93
  // With LIMIT
80
- const limited = await db.execute('SELECT name, age FROM users LIMIT 100');
94
+ const limited = await db.executeNative('SELECT name, age FROM users LIMIT 100');
81
95
 
82
96
  // Access query metadata
83
97
  console.log(`Rows returned: ${result.rowCount}`);
@@ -85,6 +99,9 @@ console.log(`Execution time: ${result.executionTimeMs}ms`);
85
99
  console.log(`Columns: ${result.columns.map(c => c.name).join(', ')}`);
86
100
  ```
87
101
 
102
+ > Prefer `executeNative()` over the deprecated `execute()` — see the warning at
103
+ > the top of this README for the precision/encoding hazards of `execute()`.
104
+
88
105
  ### Native Types with executeNative()
89
106
 
90
107
  Use `executeNative()` to get native JavaScript types instead of JSON-serializable values:
@@ -109,7 +126,13 @@ for (const row of result.rows) {
109
126
  }
110
127
  ```
111
128
 
112
- ### JSON Encoding (execute method)
129
+ ### JSON Encoding (deprecated `execute()` method)
130
+
131
+ > **⚠️ Deprecated — removed in the next major.** `execute()` returns lossy legacy
132
+ > JSON encodings and is slower than `executeNative()`. In particular blob comes
133
+ > back as a base64 string, timestamp as an ISO-8601 string, and varint/decimal
134
+ > as bespoke non-round-trippable strings. This section documents the encoding for
135
+ > the few callers that still depend on it; new code should use `executeNative()`.
113
136
 
114
137
  The `execute()` method returns JSON-serializable values. For most types this works intuitively,
115
138
  but `varint` and `decimal` types use a hex-based encoding to preserve arbitrary precision:
@@ -132,15 +155,16 @@ console.log(native.rows[0].amount);
132
155
  - `varint`: `"0x{hex}"` - Two's complement big-endian hex encoding
133
156
  - `decimal`: `"decimal:{scale}:0x{hex}"` - Scale (decimal places) + hex-encoded unscaled value
134
157
 
135
- **Recommendation:** Use `executeNative()` for most applications. The `execute()` method is
136
- primarily for JSON serialization scenarios where you need raw, reversible encoding.
158
+ **Recommendation:** Use `executeNative()`. The `execute()` method is deprecated
159
+ (removed in the next major); its JSON encoding is lossy (blob/timestamp/varint/
160
+ decimal come back as bespoke strings) and it is slower than `executeNative()`.
137
161
 
138
162
  ### Column Metadata
139
163
 
140
164
  Each query result includes column information:
141
165
 
142
166
  ```typescript
143
- const result = await db.execute('SELECT * FROM keyspace.table');
167
+ const result = await db.executeNative('SELECT * FROM keyspace.table');
144
168
 
145
169
  for (const col of result.columns) {
146
170
  console.log(`${col.name}: ${col.dataType}`);
@@ -158,6 +182,78 @@ console.log(`Total rows: ${stats.totalRows}`);
158
182
  console.log(`Memory: ${stats.memoryUsedBytes} bytes`);
159
183
  ```
160
184
 
185
+ ### Refreshing SSTables (v0.13)
186
+
187
+ If Cassandra (or another process) writes new SSTables while your `Database` handle
188
+ is open, call `refresh()` to re-discover them. Refresh is **explicit-only** (CQLite
189
+ never rescans behind your back) and **atomic / fail-closed**: if any newly found
190
+ generation fails to open, the swap is rolled back and the handle keeps serving the
191
+ prior, consistent set of readers.
192
+
193
+ ```typescript
194
+ // ... time passes; Cassandra flushes/compacts new SSTables to disk ...
195
+
196
+ const report = await db.refresh();
197
+ console.log(`Tables scanned: ${report.tablesScanned}`);
198
+ console.log(`Readers added: ${report.readersAdded}`);
199
+ console.log(`Readers removed: ${report.readersRemoved}`);
200
+
201
+ // Subsequent queries see the newly discovered data
202
+ const result = await db.executeNative('SELECT * FROM keyspace.table');
203
+ ```
204
+
205
+ `refresh(): Promise<RefreshReport>` resolves to a `RefreshReport` with the numeric
206
+ fields `tablesScanned`, `readersAdded`, and `readersRemoved`.
207
+
208
+ ### Result Byte Budget (v0.13)
209
+
210
+ Non-streaming queries are bounded by a result-size budget of **64 MiB** by default.
211
+ When the materialized result's running byte estimate exceeds the budget, the query
212
+ rejects with a `CqliteError` whose `code === 'QUERY'`, directing you to add a
213
+ `LIMIT` clause or use `executeStreaming()`. Streaming queries are **not** subject
214
+ to this budget.
215
+
216
+ ```typescript
217
+ try {
218
+ const result = await db.executeNative('SELECT * FROM keyspace.big_table');
219
+ for (const row of result.rows) {
220
+ process(row);
221
+ }
222
+ } catch (e) {
223
+ if (e.code === 'QUERY') {
224
+ // Result exceeded the 64 MiB byte budget — add a LIMIT or stream instead
225
+ for await (const row of db.executeStreaming('SELECT * FROM keyspace.big_table')) {
226
+ process(row);
227
+ }
228
+ } else {
229
+ throw e;
230
+ }
231
+ }
232
+ ```
233
+
234
+ ### OpenTelemetry Tracing (v0.13)
235
+
236
+ CQLite can emit OpenTelemetry traces when built with the `observability` Cargo
237
+ feature; without that feature the configuration is accepted but is a no-op. Pass an
238
+ `otel` option to `Database.open()`:
239
+
240
+ ```typescript
241
+ const db = await Database.open('path/to/sstables', {
242
+ schema: 'schema.cql',
243
+ otel: {
244
+ enabled: true, // default false
245
+ endpoint: 'http://localhost:4317', // default 'http://localhost:4317'
246
+ protocol: 'grpc', // 'grpc' (default) or 'http'
247
+ serviceName: 'cqlite', // default 'cqlite'
248
+ serviceVersion: '0.13.0', // default: package version
249
+ samplingRatio: 1.0, // default 1.0
250
+ timeoutMs: 10000, // default 10000
251
+ },
252
+ });
253
+ ```
254
+
255
+ Options are layered over the `CQLITE_OTEL_*` environment variables.
256
+
161
257
  ### Error Handling
162
258
 
163
259
  All errors include structured metadata for programmatic handling:
@@ -167,7 +263,7 @@ import { Database } from '@cqlite/node';
167
263
 
168
264
  try {
169
265
  const db = await Database.open('/path/to/data');
170
- const result = await db.execute('SELECT * FROM keyspace.table');
266
+ const result = await db.executeNative('SELECT * FROM keyspace.table');
171
267
  } catch (e) {
172
268
  // Error code for programmatic handling
173
269
  console.log(`Code: ${e.code}`); // 'IO', 'SCHEMA', 'QUERY', 'PARSE', etc.
@@ -240,11 +336,11 @@ const db = await Database.open('path/to/sstables', {
240
336
  });
241
337
 
242
338
  // Write rows via CQL INSERT, UPDATE, or DELETE
243
- await db.execute(
339
+ await db.executeNative(
244
340
  "INSERT INTO test_basic.simple_table (id, name, age) " +
245
341
  "VALUES (22222222-2222-2222-2222-222222222222, 'Bob', 25)"
246
342
  );
247
- await db.execute(
343
+ await db.executeNative(
248
344
  "UPDATE test_basic.simple_table SET age = 26 " +
249
345
  "WHERE id = 22222222-2222-2222-2222-222222222222"
250
346
  );
@@ -273,7 +369,8 @@ await db.close();
273
369
 
274
370
  | Method / Property | Description |
275
371
  |-------------------|-------------|
276
- | `db.execute(cql)` | Execute a CQL INSERT, UPDATE, or DELETE statement |
372
+ | `db.executeNative(cql)` | Execute a CQL INSERT, UPDATE, or DELETE statement (recommended) |
373
+ | `db.execute(cql)` | **Deprecated** (removed next major; emits a `DeprecationWarning`). Same DML behavior as `executeNative()`, but lossy for SELECT — use `executeNative()` |
277
374
  | `db.flushRun()` | Flush memtable to SSTable; returns the Data.db path or `""` if memtable was empty |
278
375
  | `db.maintenanceStep(options?)` | Run STCS compaction for up to `options.budgetMs` ms (default: 100); returns `MaintenanceReport` |
279
376
  | `db.writeStats` | Synchronous getter: `memtableSizeBytes`, `memtableRowCount`, `totalWrittenBytes`, `l0SstableCount` |
@@ -297,6 +394,15 @@ See the [examples/](examples/) directory for complete working examples:
297
394
  - [streaming.ts](examples/streaming.ts) - Large result handling
298
395
  - [performance.ts](examples/performance.ts) - Memory-optimized usage
299
396
 
397
+ > **Streaming concurrency caveat:** `executeStreaming()` fetches each batch of
398
+ > `K = bufferSize` rows on a libuv threadpool thread, so N concurrent streams can
399
+ > each occupy a libuv threadpool thread for the duration of a batch fetch. Heavy
400
+ > concurrent `fs`/`crypto` work in the same process may see added latency until
401
+ > the follow-up ([#1901](https://github.com/pmcfadin/cqlite/issues/1901)) moves
402
+ > streaming off the libuv pool onto the tokio runtime. If an error occurs
403
+ > mid-stream, rows already read in the in-flight batch (up to `bufferSize`) are
404
+ > not delivered — the iterator rejects with the error (errors are terminal).
405
+
300
406
  ## Resources
301
407
 
302
408
  - [TypeScript Definitions](lib/index.d.ts) - Complete API type hints
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -95,6 +95,89 @@ function wrapAsync(fn) {
95
95
  };
96
96
  }
97
97
 
98
+ /**
99
+ * Build an AsyncIterator that yields ONE row per `next()` while fetching rows
100
+ * from the native stream in BATCHES (issue #1443).
101
+ *
102
+ * The native `StreamingResult.next()` returns `{ rows: Array<Row>, done }` — a
103
+ * whole batch per AsyncTask/`block_on` (K == the stream's `bufferSize`), instead
104
+ * of one row per task. This wrapper buffers that batch and drains it one row at
105
+ * a time, so the public per-row `for await ... of` contract is UNCHANGED
106
+ * (consumers still see exactly one row per iteration; batching is invisible).
107
+ * Amortising dispatch over K rows both raises throughput and stops a busy stream
108
+ * from monopolising libuv's small (default 4) threadpool and starving concurrent
109
+ * `fs`/`crypto` work.
110
+ *
111
+ * @param {() => Promise<{rows: Array<Object>, done: boolean}>} refill
112
+ * Fetches the next batch from the native stream. May throw (already enhanced).
113
+ * @param {(yielded: number) => (void|Promise<void>)} onReturn
114
+ * Invoked on early termination (`return()`/`break`) to close the native
115
+ * stream. Receives the exact number of rows this iterator YIELDED to the
116
+ * consumer, so the native span records rows-yielded rather than the whole
117
+ * fetched batch (the un-yielded tail of the last batch is discarded here on
118
+ * early break). See issue #1443.
119
+ * @param {() => boolean} [isCancelled]
120
+ * Optional predicate checked before each `next()`; when it returns true the
121
+ * iterator discards any buffered rows and reports `done` immediately. This is
122
+ * how an external `close()` on the stream object takes effect even though the
123
+ * batch buffer lives in this iterator's closure.
124
+ * @returns {AsyncIterator} Iterator yielding one row per `next()`.
125
+ */
126
+ function batchedAsyncIterator(refill, onReturn, isCancelled) {
127
+ let buffer = [];
128
+ let bufIdx = 0;
129
+ let exhausted = false;
130
+ // Exact count of rows YIELDED to the consumer (one per `next()` that returns
131
+ // a value). Passed to `onReturn` on early break so the native span records
132
+ // rows-yielded, not the whole fetched batch (issue #1443).
133
+ let yielded = 0;
134
+ return {
135
+ async next() {
136
+ // Honour an external close(): discard buffered rows and end immediately.
137
+ if (isCancelled && isCancelled()) {
138
+ buffer = [];
139
+ bufIdx = 0;
140
+ exhausted = true;
141
+ return { value: undefined, done: true };
142
+ }
143
+ // Drain the buffered batch first — no native call, no threadpool dispatch.
144
+ if (bufIdx < buffer.length) {
145
+ yielded++;
146
+ return { value: buffer[bufIdx++], done: false };
147
+ }
148
+ if (exhausted) {
149
+ return { value: undefined, done: true };
150
+ }
151
+ // Refill: exactly ONE AsyncTask/block_on fetches a whole batch.
152
+ const batch = await refill();
153
+ buffer = (batch && batch.rows) || [];
154
+ bufIdx = 0;
155
+ // An empty batch always signals exhaustion (native `Done`); a `done` flag
156
+ // is honoured defensively too. A non-empty batch may still be the final
157
+ // one — the next refill then observes the empty batch and ends the stream.
158
+ if (!batch || batch.done || buffer.length === 0) {
159
+ exhausted = true;
160
+ }
161
+ if (bufIdx < buffer.length) {
162
+ yielded++;
163
+ return { value: buffer[bufIdx++], done: false };
164
+ }
165
+ return { value: undefined, done: true };
166
+ },
167
+ async return() {
168
+ // Early `break`/`return()`: discard any un-yielded buffered rows and close
169
+ // the native stream (which terminates the producer and finalises the
170
+ // span). Pass the exact rows yielded so the span is not over-counted by
171
+ // the discarded buffer tail (issue #1443).
172
+ buffer = [];
173
+ bufIdx = 0;
174
+ exhausted = true;
175
+ await onReturn(yielded);
176
+ return { value: undefined, done: true };
177
+ },
178
+ };
179
+ }
180
+
98
181
  /**
99
182
  * Create an async iterable wrapper around a native StreamingResult.
100
183
  *
@@ -105,6 +188,7 @@ function wrapAsync(fn) {
105
188
  * @returns {Object} An async iterable object with streaming functionality
106
189
  */
107
190
  function createAsyncIterator(nativeStream) {
191
+ let closed = false;
108
192
  return {
109
193
  /**
110
194
  * Number of rows received so far.
@@ -127,42 +211,70 @@ function createAsyncIterator(nativeStream) {
127
211
  * Safe to call multiple times.
128
212
  */
129
213
  close() {
214
+ closed = true;
130
215
  return nativeStream.close();
131
216
  },
132
217
 
133
218
  /**
134
219
  * Implement Symbol.asyncIterator for `for await...of` support.
220
+ *
221
+ * Yields one row per iteration while fetching in batches (issue #1443).
135
222
  * @returns {AsyncIterator}
136
223
  */
137
224
  [Symbol.asyncIterator]() {
138
- return {
139
- /**
140
- * Get the next row from the stream.
141
- * @returns {Promise<{value: Object|undefined, done: boolean}>}
142
- */
143
- async next() {
225
+ return batchedAsyncIterator(
226
+ async () => {
144
227
  try {
145
- const result = await nativeStream.next();
146
- return result;
228
+ return await nativeStream.next();
147
229
  } catch (error) {
148
230
  throw enhanceError(error);
149
231
  }
150
232
  },
151
-
152
- /**
153
- * Handle early termination (e.g., break from loop).
154
- * Called automatically by JavaScript runtime.
155
- * @returns {Promise<{value: undefined, done: true}>}
156
- */
157
- async return() {
158
- nativeStream.close();
159
- return { value: undefined, done: true };
233
+ (yielded) => {
234
+ closed = true;
235
+ nativeStream.close(yielded);
160
236
  },
161
- };
237
+ () => closed
238
+ );
162
239
  },
163
240
  };
164
241
  }
165
242
 
243
+ /**
244
+ * Emit a one-time deprecation warning for the legacy `execute()` method.
245
+ *
246
+ * `execute()` returns lossy legacy JSON encodings — blob comes back as a base64
247
+ * string (not a Buffer), timestamp as an ISO-8601 string (not a Date), and
248
+ * varint/decimal in bespoke `"0x…"` / `"decimal:…"` strings no caller can
249
+ * round-trip (see index.d.ts). It also double-converts (JSON off-loop, then JS
250
+ * on-loop) so it is slower than `executeNative()`. It is deprecated and will be
251
+ * removed in the next major; `executeNative()` returns native types with full
252
+ * fidelity. We emit via `process.emitWarning(..., 'DeprecationWarning')` guarded
253
+ * by a module-level flag so it fires at most once per process, matching Node's
254
+ * own deprecation convention (issue #1457).
255
+ *
256
+ * @private
257
+ */
258
+ let executeDeprecationWarned = false;
259
+ function warnExecuteDeprecated() {
260
+ if (executeDeprecationWarned) {
261
+ return;
262
+ }
263
+ executeDeprecationWarned = true;
264
+ process.emitWarning(
265
+ "Database.execute() is deprecated and will be removed in the next major. " +
266
+ "It returns lossy legacy JSON encodings: blob becomes a base64 string " +
267
+ "(not a Buffer), timestamp an ISO-8601 string (not a Date), and " +
268
+ "varint/decimal bespoke non-round-trippable strings; it is also slower " +
269
+ "(double conversion). Use executeNative() for native types with full " +
270
+ "fidelity (BigInt, Buffer, Date, Set, Map).",
271
+ {
272
+ type: "DeprecationWarning",
273
+ code: "CQLITE_DEP0001",
274
+ }
275
+ );
276
+ }
277
+
166
278
  /**
167
279
  * Create a wrapped Database class with enhanced error handling.
168
280
  *
@@ -187,6 +299,7 @@ function createWrappedDatabase(NativeDatabase, wrapPreparedStatement) {
187
299
  }
188
300
 
189
301
  async execute(query) {
302
+ warnExecuteDeprecated();
190
303
  try {
191
304
  const result = await this._native.execute(query);
192
305
  // For SELECT: rowsAffected = rowCount (alias, Issue #348).
@@ -229,6 +342,25 @@ function createWrappedDatabase(NativeDatabase, wrapPreparedStatement) {
229
342
  }
230
343
  }
231
344
 
345
+ /**
346
+ * Re-discover the data directory and apply changes to the held reader set.
347
+ *
348
+ * Newly present SSTable generations become queryable, removed generations
349
+ * stop being queried, and unchanged generations keep their warm parsed
350
+ * state. In-flight queries are unaffected; the refresh is atomic and
351
+ * fail-closed. See index.d.ts for the full contract (issue #1749).
352
+ *
353
+ * @returns {Promise<Object>} RefreshReport with tablesScanned, readersAdded, readersRemoved
354
+ * @throws {CqliteError} If the database is closed or a new generation fails to open
355
+ */
356
+ async refresh() {
357
+ try {
358
+ return await this._native.refresh();
359
+ } catch (error) {
360
+ throw enhanceError(error);
361
+ }
362
+ }
363
+
232
364
  async close() {
233
365
  try {
234
366
  return await this._native.close();
@@ -328,11 +460,16 @@ function createWrappedDatabase(NativeDatabase, wrapPreparedStatement) {
328
460
 
329
461
  /**
330
462
  * Implement Symbol.asyncIterator for `for await...of` support.
463
+ *
464
+ * Yields one row per iteration while the native stream is fetched in
465
+ * batches (issue #1443): each refill is a single AsyncTask/block_on that
466
+ * returns up to `bufferSize` rows, which are then drained one at a time.
467
+ * The per-row consumer contract is unchanged; batching is invisible.
331
468
  * @returns {AsyncIterator}
332
469
  */
333
470
  [Symbol.asyncIterator]() {
334
- return {
335
- async next() {
471
+ return batchedAsyncIterator(
472
+ async () => {
336
473
  try {
337
474
  const stream = await ensureInitialized();
338
475
  return await stream.next();
@@ -340,13 +477,13 @@ function createWrappedDatabase(NativeDatabase, wrapPreparedStatement) {
340
477
  throw enhanceError(error);
341
478
  }
342
479
  },
343
- async return() {
480
+ (yielded) => {
344
481
  closed = true;
345
482
  nativeStreamPromise = null;
346
- nativeStream?.close();
347
- return { value: undefined, done: true };
483
+ nativeStream?.close(yielded);
348
484
  },
349
- };
485
+ () => closed
486
+ );
350
487
  },
351
488
  };
352
489
  }
package/lib/index.d.ts CHANGED
@@ -200,19 +200,24 @@ export interface ColumnInfo {
200
200
  */
201
201
  export interface QueryResult {
202
202
  /**
203
- * Result rows as JSON-serializable objects.
203
+ * Result rows as legacy JSON-serialized objects (returned by `execute()`).
204
204
  *
205
- * Values are JSON-serialized versions of CQL types:
206
- * - BigInt/Counter: number (may lose precision for values > 2^53)
207
- * - Blob: base64 string
208
- * - Timestamp: ISO 8601 string
209
- * - Set/Map: Array representations
210
- * - Varint: Hex string `"0x{hex}"` (e.g., `"0x7f"` for 127)
211
- * - Decimal: String `"decimal:{scale}:0x{hex}"` (e.g., `"decimal:2:0x7b"` for 1.23)
205
+ * ⚠️ These values are lossy legacy JSON encodings of CQL types — see the
206
+ * hazards on {@link Database.execute}:
207
+ * - **Blob base64 `string`** (not a `Buffer`).
208
+ * - **Timestamp ISO-8601 `string`** (not a `Date`).
209
+ * - **Varint hex `string` `"0x{hex}"`** (e.g., `"0x7f"` for 127).
210
+ * - **Decimal `string` `"decimal:{scale}:0x{hex}"`** (e.g., `"decimal:2:0x7b"`).
211
+ * - **Date/Time `number`** (days-since-epoch / nanoseconds-since-midnight).
212
+ * - Set/Map → Array representations.
212
213
  *
213
- * For native types with full precision, use `executeNative()`.
214
+ * (BigInt/Counter are currently returned as an exact `BigInt` on this napi
215
+ * build, but `execute()` is deprecated and unsupported regardless.)
214
216
  *
215
- * @deprecated The execute() method uses legacy JSON encoding. Use executeNative() for proper type fidelity.
217
+ * For native types with full fidelity, use `executeNative()`.
218
+ *
219
+ * @deprecated The execute() method uses lossy legacy JSON encoding and is
220
+ * removed in the next major. Use executeNative() for proper type fidelity.
216
221
  */
217
222
  rows: Record<string, unknown>[];
218
223
 
@@ -374,6 +379,115 @@ export interface DatabaseOptions {
374
379
  * ```
375
380
  */
376
381
  writeDir?: string;
382
+
383
+ /**
384
+ * Enable automatic (STCS) size-tiered compaction for the write engine.
385
+ * Default: true. Set false to disable compaction — `maintenanceStep`
386
+ * then performs no merges (issue #1619).
387
+ */
388
+ autoCompaction?: boolean;
389
+
390
+ /**
391
+ * Memtable flush threshold in bytes for the write engine (issue #1620).
392
+ * When the in-memory memtable grows past this size, the write path
393
+ * (`execute`) awaits a real async flush to a new SSTable generation.
394
+ * Only meaningful when `writable` is true. Default: 64 MB (67108864 bytes).
395
+ */
396
+ flushThreshold?: number;
397
+
398
+ /**
399
+ * OpenTelemetry export options (epic #1031, issue #1040).
400
+ *
401
+ * When omitted, the `CQLITE_OTEL_*` environment variables are consulted.
402
+ * Telemetry stays disabled unless `enabled: true` is set (here or via env)
403
+ * AND the native addon was built with the `observability` Cargo feature.
404
+ *
405
+ * Observability is initialised **once per process** on the first
406
+ * `Database.open()`, so passing different `otel` options to a later open has
407
+ * no effect.
408
+ *
409
+ * @example
410
+ * ```typescript
411
+ * const db = await Database.open('/data', {
412
+ * schema: 'schema.cql',
413
+ * otel: { enabled: true, endpoint: 'http://collector:4317', protocol: 'grpc' },
414
+ * });
415
+ * ```
416
+ */
417
+ otel?: OtelOptions;
418
+
419
+ /**
420
+ * Incoming W3C `traceparent` header to parent this handle's per-call and
421
+ * per-stream spans under a remote trace (distributed-tracing propagation).
422
+ *
423
+ * Applied as the default parent for every `execute`, `executeNative`, and
424
+ * `executeStreaming` on the returned handle. Invalid/empty values are
425
+ * ignored. Only meaningful when telemetry is enabled and the addon was built
426
+ * with the `observability` feature.
427
+ *
428
+ * @example
429
+ * ```typescript
430
+ * const db = await Database.open('/data', {
431
+ * schema: 'schema.cql',
432
+ * otel: { enabled: true },
433
+ * traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
434
+ * });
435
+ * ```
436
+ */
437
+ traceparent?: string;
438
+ }
439
+
440
+ /**
441
+ * OpenTelemetry export options for the Node.js bindings (epic #1031, issue
442
+ * #1040).
443
+ *
444
+ * Any field left unset falls back to the corresponding `CQLITE_OTEL_*`
445
+ * environment variable, then to the foundation default. Exporters are only
446
+ * installed when the effective config has `enabled: true` AND the native addon
447
+ * was built with the `observability` feature.
448
+ */
449
+ export interface OtelOptions {
450
+ /**
451
+ * Master enable switch. Unset defers to `CQLITE_OTEL_ENABLED`, then `false`.
452
+ */
453
+ enabled?: boolean;
454
+
455
+ /**
456
+ * OTLP collector endpoint: a gRPC endpoint or HTTP base URL.
457
+ * Unset defers to `CQLITE_OTEL_ENDPOINT`, then `http://localhost:4317`.
458
+ */
459
+ endpoint?: string;
460
+
461
+ /**
462
+ * Wire protocol: `"grpc"` (default) or `"http"`. Unrecognised values are
463
+ * ignored (the default/env value is kept).
464
+ */
465
+ protocol?: string;
466
+
467
+ /**
468
+ * `service.name` resource attribute.
469
+ * Unset defers to `CQLITE_OTEL_SERVICE_NAME`, then `cqlite`.
470
+ */
471
+ serviceName?: string;
472
+
473
+ /**
474
+ * `service.version` resource attribute.
475
+ * Unset defers to `CQLITE_OTEL_SERVICE_VERSION`, then the crate version.
476
+ */
477
+ serviceVersion?: string;
478
+
479
+ /**
480
+ * Trace-ID-ratio sampling probability in `[0.0, 1.0]` (clamped; non-finite
481
+ * values fall back to full sampling).
482
+ * Unset defers to `CQLITE_OTEL_SAMPLING_RATIO`, then `1.0`.
483
+ */
484
+ samplingRatio?: number;
485
+
486
+ /**
487
+ * Exporter export timeout in milliseconds.
488
+ * Unset defers to `CQLITE_OTEL_TIMEOUT_MS`, then `10000`.
489
+ */
490
+ timeoutMs?: number;
377
491
  }
378
492
 
379
493
  // ============================================================================
@@ -465,6 +579,34 @@ export interface MaintenanceReport {
465
579
  pendingCompaction: boolean;
466
580
  }
467
581
 
582
+ /**
583
+ * Report returned by `Database.refresh()`.
584
+ *
585
+ * Describes what an explicit directory refresh applied to the database's held
586
+ * SSTable reader set: newly present generations become queryable, removed
587
+ * generations stop being queried, and unchanged generations keep their warm
588
+ * parsed state (they are not re-parsed).
589
+ *
590
+ * @example
591
+ * ```typescript
592
+ * const report = await db.refresh();
593
+ * console.log(
594
+ * `scanned ${report.tablesScanned} tables, ` +
595
+ * `+${report.readersAdded}/-${report.readersRemoved} readers`
596
+ * );
597
+ * ```
598
+ */
599
+ export interface RefreshReport {
600
+ /** Number of distinct logical tables present after the refresh. */
601
+ tablesScanned: number;
602
+
603
+ /** Number of SSTable generations newly opened and made queryable. */
604
+ readersAdded: number;
605
+
606
+ /** Number of SSTable generations dropped from the reader set. */
607
+ readersRemoved: number;
608
+ }
609
+
468
610
  /**
469
611
  * Configuration for streaming query execution.
470
612
  *
@@ -649,7 +791,8 @@ export type ErrorCode =
649
791
  | 'CONSTRAINT' // Constraint violations
650
792
  | 'TRANSACTION' // Transaction errors
651
793
  | 'PLATFORM' // Platform-specific errors (WASM)
652
- | 'INTERNAL'; // Internal errors
794
+ | 'INTERNAL' // Internal errors
795
+ | 'CANCELLED'; // Cooperative scan cancellation (issue #2264 — never 'IO')
653
796
 
654
797
  /**
655
798
  * Error category names for CQLite errors.
@@ -671,7 +814,8 @@ export type ErrorCategory =
671
814
  | 'Constraint'
672
815
  | 'Transaction'
673
816
  | 'Platform'
674
- | 'Internal';
817
+ | 'Internal'
818
+ | 'Cancelled';
675
819
 
676
820
  /**
677
821
  * CQLite error interface.
@@ -831,24 +975,45 @@ export declare class Database {
831
975
  static open(dataDir: string, options?: DatabaseOptions): Promise<Database>;
832
976
 
833
977
  /**
834
- * Execute a CQL query and return results as JSON-serializable values.
978
+ * Execute a CQL query and return results as legacy JSON-serialized values.
979
+ *
980
+ * **DEPRECATED — removed in the next major. Use {@link Database.executeNative} instead.**
981
+ *
982
+ * ## ⚠️ Lossy legacy encodings and correctness hazards
983
+ *
984
+ * `execute()` routes every value through a legacy JSON encoder. Several CQL
985
+ * types come back in a lossy or bespoke encoding that no caller can round-trip
986
+ * and that differs from the native type `executeNative()` returns:
835
987
  *
836
- * Use this method when you need JSON-compatible output or don't need
837
- * native JavaScript types. For native types with full precision,
838
- * use `executeNative()` instead.
988
+ * - **`blob` base64 `string`** (not a `Buffer`).
989
+ * - **`timestamp` ISO-8601 `string`** (not a `Date`).
990
+ * - **`varint``"0x{hex}"` string.** Two's-complement big-endian hex.
991
+ * Example: 127 -> `"0x7f"`, -1 -> `"0xff"`, 256 -> `"0x0100"`.
992
+ * - **`decimal` → `"decimal:{scale}:0x{hex}"` string.** Example: 1.23
993
+ * (scale=2, unscaled=123) -> `"decimal:2:0x7b"`.
994
+ * - **`date` → `number`** (days since epoch); **`time` → `number`**
995
+ * (nanoseconds since midnight).
839
996
  *
840
- * ## Varint and Decimal Encoding
997
+ * It is also **slower** than `executeNative()`: it converts each value to JSON
998
+ * off the JS main thread, then converts that JSON to JS values on-loop — a
999
+ * double conversion `executeNative()` avoids.
841
1000
  *
842
- * This method uses hex-based encoding for arbitrary precision numbers:
843
- * - **Varint**: `"0x{hex}"` - Two's complement big-endian hex encoding
844
- * - Example: 127 -> `"0x7f"`, -1 -> `"0xff"`, 256 -> `"0x0100"`
845
- * - **Decimal**: `"decimal:{scale}:0x{hex}"` - Scale + hex-encoded unscaled value
846
- * - Example: 1.23 (scale=2, unscaled=123) -> `"decimal:2:0x7b"`
1001
+ * NOTE on precision: with this napi build (BigInt support), `bigint`/`counter`
1002
+ * are currently returned as an exact JS `BigInt`, so a value above 2^53 is
1003
+ * **not** presently rounded. Do not rely on this the encoding is legacy and
1004
+ * unsupported; `executeNative()` is the contract. (Older docs claimed a >2^53
1005
+ * rounding loss; that is not observed on the current binding — issue #1457.)
1006
+ *
1007
+ * `executeNative()` returns native types with full fidelity (`BigInt`,
1008
+ * `Buffer`, `Date`, `Set`, `Map`) and is the supported path.
847
1009
  *
848
1010
  * @param query - CQL SELECT statement to execute
849
1011
  * @returns Promise resolving to QueryResult with rows and metadata
850
1012
  * @throws {CqliteError} If the query fails
851
- * @deprecated Since 0.4.0. Use `executeNative()` for proper type fidelity.
1013
+ * @deprecated Since 0.4.0; removed in the next major. Use `executeNative()`
1014
+ * for proper type fidelity — `execute()` returns blob/decimal/varint/
1015
+ * timestamp/date/time in lossy legacy encodings and is slower. Calling it
1016
+ * emits a one-time `DeprecationWarning`.
852
1017
  *
853
1018
  * @example
854
1019
  * ```typescript
@@ -882,6 +1047,18 @@ export declare class Database {
882
1047
  * - Native Buffer handling for binary data
883
1048
  * - Native Set/Map operations
884
1049
  *
1050
+ * ## Performance: O(rows) work on the event-loop thread
1051
+ *
1052
+ * The result set is scanned off the event loop, but every row is materialized
1053
+ * into a JS object on the event-loop thread (a napi `Env` is thread-bound, so
1054
+ * this work cannot be moved off-loop). It is therefore O(rows) of synchronous
1055
+ * on-loop work: a large result set will freeze timers, HTTP handlers, and
1056
+ * other callbacks for the duration of the burst. Prefer
1057
+ * {@link Database.executeStreaming} for result sets beyond ~a few thousand
1058
+ * rows. Result sets larger than `CQLITE_NODE_MAX_NATIVE_ROWS` (default
1059
+ * 100,000) are rejected with a typed error advising `executeStreaming`
1060
+ * instead of freezing the loop (issue #1442).
1061
+ *
885
1062
  * @param query - CQL SELECT statement to execute
886
1063
  * @returns Promise resolving to NativeQueryResult with native typed rows
887
1064
  * @throws {CqliteError} If the query fails
@@ -917,6 +1094,35 @@ export declare class Database {
917
1094
  */
918
1095
  getStats(): Promise<DatabaseStats>;
919
1096
 
1097
+ /**
1098
+ * Re-discover the data directory and apply changes to the held reader set.
1099
+ *
1100
+ * A `Database` is a snapshot at `open()`: a Cassandra flush/compaction (or a
1101
+ * CQLite `--flush`) may add or remove SSTable generations under a warm handle,
1102
+ * and those changes become queryable only after an explicit `refresh()`. This
1103
+ * re-runs the same TOC/filename-based discovery `open()` used (no content
1104
+ * sniffing, no heuristics) and applies the diff:
1105
+ * - newly present generations become queryable,
1106
+ * - removed generations stop being queried,
1107
+ * - unchanged generations keep their warm parsed Index/Statistics/bloom state.
1108
+ *
1109
+ * In-flight queries are never affected: a scan already running completes
1110
+ * against the pre-refresh set; a query issued after this Promise resolves sees
1111
+ * the post-refresh set. The refresh is atomic and fail-closed — if any newly
1112
+ * discovered generation fails to open (e.g. a corrupt `Statistics.db`), the
1113
+ * Promise rejects and the previously held reader set is left unchanged.
1114
+ *
1115
+ * @returns Promise resolving to a RefreshReport with the applied counts
1116
+ * @throws {CqliteError} If the database is closed or a new generation fails to open
1117
+ *
1118
+ * @example
1119
+ * ```typescript
1120
+ * const report = await db.refresh();
1121
+ * console.log(`+${report.readersAdded}/-${report.readersRemoved} readers`);
1122
+ * ```
1123
+ */
1124
+ refresh(): Promise<RefreshReport>;
1125
+
920
1126
  /**
921
1127
  * Close the database and release resources.
922
1128
  *
@@ -952,6 +1158,17 @@ export declare class Database {
952
1158
  * - `bufferSize`: 1024 rows in flight (~1MB)
953
1159
  * - `chunkSize`: 10,000 rows per fetch chunk (~10MB)
954
1160
  *
1161
+ * Concurrency caveat: each `next()` fetches a batch of `K = bufferSize` rows
1162
+ * on a libuv threadpool thread, so N concurrent streams can each occupy a
1163
+ * libuv threadpool thread for the duration of a batch fetch. Heavy concurrent
1164
+ * `fs`/`crypto` work in the same process may therefore see added latency until
1165
+ * the follow-up (cqlite#1901) lands, which moves streaming off the libuv pool
1166
+ * onto the tokio runtime.
1167
+ *
1168
+ * Error caveat: if an error occurs mid-stream, rows already read in the
1169
+ * in-flight batch (up to `bufferSize`) are not delivered — the iterator
1170
+ * rejects with the error (errors are terminal).
1171
+ *
955
1172
  * @param query - CQL SELECT statement to execute
956
1173
  * @param config - Optional StreamingConfig for buffer/chunk sizes
957
1174
  * @returns StreamingResult async iterable (iteration triggers query execution)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cqlite/node",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Node.js bindings for CQLite - read Apache Cassandra 5.0 SSTables without cluster dependencies",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -16,7 +16,6 @@
16
16
  },
17
17
  "files": [
18
18
  "index.js",
19
- "index.d.ts",
20
19
  "lib",
21
20
  "*.node",
22
21
  "README.md",
@@ -27,13 +26,13 @@
27
26
  "access": "public"
28
27
  },
29
28
  "scripts": {
30
- "build": "napi build --platform --release --features write-support",
29
+ "build": "napi build --platform --profile release-unwind --features write-support",
31
30
  "postbuild": "node scripts/generate-loader.mjs",
32
31
  "build:debug": "napi build --platform --features write-support",
33
32
  "pretest": "node scripts/generate-loader.mjs",
34
- "test": "jest",
35
- "test:watch": "jest --watch",
36
- "test:coverage": "jest --coverage",
33
+ "test": "node --expose-gc ./node_modules/jest/bin/jest.js",
34
+ "test:watch": "node --expose-gc ./node_modules/jest/bin/jest.js --watch",
35
+ "test:coverage": "node --expose-gc ./node_modules/jest/bin/jest.js --coverage",
37
36
  "test:parity": "jest parity.test.js --testTimeout=60000",
38
37
  "typecheck": "tsc --noEmit -p examples/tsconfig.json",
39
38
  "examples": "ts-node examples/basic-query.ts",