@romedb/node 0.2.12 → 0.2.13

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.
@@ -21,12 +21,12 @@ async function openEmbedded(options) {
21
21
  opening.then(database => { if (abandoned) void database.close().catch(() => {}); }, () => {});
22
22
  let timer;
23
23
  const deadline = new Promise((resolve, reject) => {
24
- timer = setTimeout(() => { abandoned = true; reject(new common.ClientError('FDB_CANCELLED', 'Embedded connection deadline expired; startup cleanup will finish when native work returns', 'not-sent')); }, duration);
24
+ timer = setTimeout(() => { abandoned = true; reject(new common.ClientError('RDB_CANCELLED', 'Embedded connection deadline expired; startup cleanup will finish when native work returns', 'not-sent')); }, duration);
25
25
  });
26
26
  try { return await Promise.race([opening, deadline]); }
27
27
  catch (error) {
28
28
  if (error instanceof common.ClientError || error instanceof TypeError || error instanceof RangeError) throw error;
29
- const code = error.code === 'EEXIST' ? 'FDB_ALREADY_EXISTS' : ['EACCES', 'EPERM'].includes(error.code) ? 'FDB_FORBIDDEN' : error.code === 'ENOENT' ? 'FDB_NOT_FOUND' : 'FDB_STORAGE';
29
+ const code = error.code === 'EEXIST' ? 'RDB_ALREADY_EXISTS' : ['EACCES', 'EPERM'].includes(error.code) ? 'RDB_FORBIDDEN' : error.code === 'ENOENT' ? 'RDB_NOT_FOUND' : 'RDB_STORAGE';
30
30
  throw new common.ClientError(code, 'Embedded filesystem operation failed', 'not-sent');
31
31
  } finally { clearTimeout(timer); }
32
32
  }
@@ -41,7 +41,7 @@ async function openEmbeddedFile(options) {
41
41
  if (sessions.size >= maxOpen) throw openLimit();
42
42
  const parsed = path.parse(file);
43
43
  for (const sidecar of [file + '-wal', file + '-shm', file + '-log', file + '-journal', path.join(parsed.dir, parsed.name + '.db-log')]) {
44
- try { await fs.lstat(sidecar); throw new common.ClientError('FDB_CONFLICT', 'Database sidecar exists; recover it before creating a file', 'not-sent'); }
44
+ try { await fs.lstat(sidecar); throw new common.ClientError('RDB_CONFLICT', 'Database sidecar exists; recover it before creating a file', 'not-sent'); }
45
45
  catch (error) { if (error.code !== 'ENOENT') throw error; }
46
46
  }
47
47
  const handle = await fs.open(file, 'wx', 0o600);
@@ -52,10 +52,10 @@ async function openEmbeddedFile(options) {
52
52
  let metadata;
53
53
  try { metadata = await fs.lstat(file); }
54
54
  catch (error) {
55
- if (error.code === 'ENOENT') throw new common.ClientError('FDB_NOT_FOUND', 'Open an existing database or explicitly create one', 'not-sent');
55
+ if (error.code === 'ENOENT') throw new common.ClientError('RDB_NOT_FOUND', 'Open an existing database or explicitly create one', 'not-sent');
56
56
  throw error;
57
57
  }
58
- if (!metadata.isFile() || metadata.nlink !== 1) throw new common.ClientError('FDB_VALIDATION', 'Use a regular database file with one link', 'not-sent');
58
+ if (!metadata.isFile() || metadata.nlink !== 1) throw new common.ClientError('RDB_VALIDATION', 'Use a regular database file with one link', 'not-sent');
59
59
  const canonical = await fs.realpath(file);
60
60
  let session = sessions.get(canonical);
61
61
  if (session?.closing) { await session.closed; return openEmbeddedFile({ ...options, create: false }); }
@@ -72,8 +72,39 @@ async function openEmbeddedFile(options) {
72
72
  } catch (error) { await releaseSession(session, canonical); throw error; }
73
73
  }
74
74
 
75
+ function checkExportOptions(options) {
76
+ if (!plainObject(options) || Object.keys(options).some(key => !['signal', 'timeoutMs'].includes(key))) throw new TypeError('export options take signal and timeoutMs');
77
+ if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal');
78
+ if (options.timeoutMs !== undefined && (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 4294967295)) throw new RangeError('timeoutMs must be 1..4294967295');
79
+ }
80
+
81
+ // The file has no name any more; the handle keeps it until the stream ends or is cancelled.
82
+ function fileStream(handle, size, signal) {
83
+ let offset = 0;
84
+ const fail = async (controller, error) => {
85
+ await handle.close().catch(() => {});
86
+ controller.error(error);
87
+ };
88
+ return new ReadableStream({
89
+ async pull(controller) {
90
+ if (signal?.aborted) return fail(controller, new common.ClientError('RDB_CANCELLED', 'Embedded export download cancelled', 'read-failed'));
91
+ try {
92
+ if (offset === size) { await handle.close(); controller.close(); return; }
93
+ const chunk = new Uint8Array(Math.min(262144, size - offset));
94
+ const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, offset);
95
+ if (bytesRead === 0) throw new Error('exported file ended early');
96
+ offset += bytesRead;
97
+ controller.enqueue(bytesRead === chunk.byteLength ? chunk : chunk.slice(0, bytesRead));
98
+ } catch (error) {
99
+ await fail(controller, new common.ClientError('RDB_STORAGE', 'Exported database file could not be read', 'read-failed', { cause: error }));
100
+ }
101
+ },
102
+ cancel() { return handle.close().catch(() => {}); },
103
+ }, { highWaterMark: 0 });
104
+ }
105
+
75
106
  function openLimit() {
76
- return new common.ClientError('FDB_LIMIT', `Embedded database limit reached: ${sessions.size} files are open; close one or raise maxOpen`, 'not-sent');
107
+ return new common.ClientError('RDB_LIMIT', `Embedded database limit reached: ${sessions.size} files are open; close one or raise maxOpen`, 'not-sent');
77
108
  }
78
109
 
79
110
  class EmbeddedDatabase {
@@ -93,6 +124,32 @@ class EmbeddedDatabase {
93
124
  submit(request, options) { return this.#client.submit(request, options); }
94
125
  recover(request, options) { return this.#client.recover(request, options); }
95
126
  writeAtomic(statements, options) { return this.#client.writeAtomic(statements, options); }
127
+ exportFile(destination, options) {
128
+ if (this.#closing) return Promise.reject(new common.ClientError('RDB_CLOSED', 'Database client is closed', 'not-sent'));
129
+ return typeof destination === 'string' ? this.#exportTo(destination, options ?? {}) : this.#exportStream(destination ?? {});
130
+ }
131
+ async #exportTo(destination, options) {
132
+ checkExportOptions(options);
133
+ if (!destination || destination.length > 4096 || destination.includes('\0')) throw new TypeError('exportFile requires a bounded destination path');
134
+ const { sequence, bytes } = await this.#session.request('export', JSON.stringify({ destination: path.resolve(destination) }), options.signal, options.timeoutMs);
135
+ return { sequence, bytes };
136
+ }
137
+ async #exportStream(options) {
138
+ checkExportOptions(options);
139
+ let folder;
140
+ try {
141
+ folder = await fs.mkdtemp(path.join(path.dirname(this.#path), '.romedb-download-'));
142
+ const file = path.join(folder, 'export.romedb');
143
+ const { sequence, bytes } = await this.#exportTo(file, options);
144
+ const handle = await fs.open(file, 'r');
145
+ await fs.rm(folder, { recursive: true });
146
+ return { sequence, size: bytes, body: fileStream(handle, bytes, options.signal) };
147
+ } catch (error) {
148
+ if (folder !== undefined) await fs.rm(folder, { recursive: true, force: true }).catch(() => {});
149
+ if (error instanceof common.ClientError || error instanceof TypeError || error instanceof RangeError) throw error;
150
+ throw new common.ClientError(['EACCES', 'EPERM'].includes(error.code) ? 'RDB_FORBIDDEN' : error.code === 'ENOENT' ? 'RDB_NOT_FOUND' : 'RDB_STORAGE', 'Embedded export file operation failed', 'read-failed', { cause: error });
151
+ }
152
+ }
96
153
  close() {
97
154
  this.#closing ??= (async () => { await this.#client.close(); await releaseSession(this.#session, this.#path); })();
98
155
  return this.#closing;
@@ -126,11 +183,11 @@ class Session {
126
183
  else pending.resolve(message.result);
127
184
  });
128
185
  this.#worker.on('error', () => {
129
- this.#failure ??= new common.ClientError('FDB_TRANSPORT', 'Embedded worker failed; reconcile pending writes', 'unknown');
186
+ this.#failure ??= new common.ClientError('RDB_TRANSPORT', 'Embedded worker failed; reconcile pending writes', 'unknown');
130
187
  rejectReady(this.#failure);
131
188
  });
132
189
  this.#worker.on('exit', code => {
133
- this.#failure ??= new common.ClientError('FDB_CLOSED', code === 0 ? 'Embedded worker closed' : 'Embedded worker exited unexpectedly', 'unknown');
190
+ this.#failure ??= new common.ClientError('RDB_CLOSED', code === 0 ? 'Embedded worker closed' : 'Embedded worker exited unexpectedly', 'unknown');
134
191
  rejectReady(this.#failure);
135
192
  for (const pending of this.#pending.values()) {
136
193
  pending.listener?.[Symbol.dispose]();
@@ -143,23 +200,24 @@ class Session {
143
200
  }
144
201
  request(operation, body, signal, duration) {
145
202
  if (this.#failure) return Promise.reject(this.#failure);
146
- if (this.closing && operation !== 'close') return Promise.reject(new common.ClientError('FDB_CLOSED', 'Embedded session is closing', 'not-sent'));
147
- if (signal?.aborted) return Promise.reject(new common.ClientError('FDB_CANCELLED', 'Request cancelled before dispatch', 'not-sent'));
203
+ if (this.closing && operation !== 'close') return Promise.reject(new common.ClientError('RDB_CLOSED', 'Embedded session is closing', 'not-sent'));
204
+ if (signal?.aborted) return Promise.reject(new common.ClientError('RDB_CANCELLED', 'Request cancelled before dispatch', 'not-sent'));
148
205
  const bytes = body === undefined ? 0 : Buffer.byteLength(body);
149
- if (operation !== 'close' && (this.#pending.size >= 32 || this.#bytes + bytes > 2097152)) return Promise.reject(new common.ClientError('FDB_LIMIT', 'Embedded request queue limit reached', 'not-sent'));
206
+ if (operation !== 'close' && (this.#pending.size >= 32 || this.#bytes + bytes > 2097152)) return Promise.reject(new common.ClientError('RDB_LIMIT', 'Embedded request queue limit reached', 'not-sent'));
150
207
  const token = native.createCancellationToken(duration === undefined ? undefined : String(duration));
151
208
  return new Promise((resolve, reject) => {
152
209
  const id = ++this.#next;
153
210
  const listener = signal === undefined ? undefined : addAbortListener(signal, () => {
154
211
  native.cancelOperation(token);
155
- reject(new common.ClientError('FDB_CANCELLED', operation === 'write' ? 'Write was not confirmed; recover the saved request' : 'Embedded request cancelled', operation === 'write' ? 'unknown' : 'read-failed'));
212
+ // An export settles when the worker reports it, so a late cancellation cannot hide a finished file.
213
+ if (operation !== 'export') reject(new common.ClientError('RDB_CANCELLED', operation === 'write' ? 'Write was not confirmed; recover the saved request' : 'Embedded request cancelled', operation === 'write' ? 'unknown' : 'read-failed'));
156
214
  });
157
215
  this.#pending.set(id, { resolve, reject, token, listener, bytes }); this.#bytes += bytes;
158
216
  try { this.#worker.postMessage({ id, operation, body, cancellationKey: token }); }
159
217
  catch {
160
218
  this.#pending.delete(id); this.#bytes -= bytes;
161
219
  listener?.[Symbol.dispose](); native.releaseCancellationToken(token);
162
- reject(new common.ClientError('FDB_TRANSPORT', 'Embedded request could not be dispatched', 'not-sent'));
220
+ reject(new common.ClientError('RDB_TRANSPORT', 'Embedded request could not be dispatched', 'not-sent'));
163
221
  }
164
222
  });
165
223
  }
@@ -1,5 +1,8 @@
1
1
  export * from './client';
2
- import type { Client, DatabaseInfo, Statement, RequestOptions, ReadOptions, WriteOptions, BatchResult, PreparedWrite } from './client';
2
+ import type { Client, DatabaseInfo, Statement, RequestOptions, ReadOptions, WriteOptions, BatchResult, PreparedWrite, DatabaseFile } from './client';
3
+ /** timeoutMs has no default for exports: without it, an export runs until it finishes or signal aborts it. */
4
+ export interface ExportOptions { signal?: AbortSignal; timeoutMs?: number; }
5
+ export interface ExportedFile { sequence: number; bytes: number; }
3
6
  export interface EmbeddedOptions { path: string; create?: boolean; timeoutMs?: number; maxOpen?: number; }
4
7
  export class EmbeddedDatabase implements Client {
5
8
  private constructor();
@@ -14,6 +17,13 @@ export class EmbeddedDatabase implements Client {
14
17
  submit(request: PreparedWrite, options?: RequestOptions): Promise<BatchResult>;
15
18
  recover(request: PreparedWrite, options?: RequestOptions): Promise<BatchResult>;
16
19
  writeAtomic(statements: Statement[], options?: WriteOptions): Promise<BatchResult>;
20
+ /**
21
+ * Copy the database into a new private file while reads and writes continue. The destination
22
+ * and its log files must not exist. Writes up to and including `sequence` are in the file.
23
+ */
24
+ exportFile(destination: string, options?: ExportOptions): Promise<ExportedFile>;
25
+ /** Copy the database and stream it; nothing is left on disk once the body ends or is cancelled. */
26
+ exportFile(options?: ExportOptions): Promise<DatabaseFile>;
17
27
  close(): Promise<void>;
18
28
  }
19
29
  export function openEmbedded(options: EmbeddedOptions): Promise<EmbeddedDatabase>;
@@ -1,20 +1,21 @@
1
1
  'use strict';
2
2
  const { parentPort, workerData } = require('node:worker_threads');
3
3
  const { createHash } = require('node:crypto');
4
- const { NativeDatabase } = require('./native.cjs');
4
+ const { NativeDatabase, cancelOperation } = require('./native.cjs');
5
5
  const { validatePrepared } = require('./client.cjs');
6
6
  let database;
7
7
  let sequence = 0;
8
8
  let fenced = false;
9
9
  const receipts = new Map();
10
+ const copies = new Map();
10
11
  try {
11
12
  database = new NativeDatabase(workerData.path, '10000', '67108864', workerData.create ? 'create-mvcc' : 'mvcc');
12
13
  const foreignKeys = JSON.parse(database.execute('PRAGMA foreign_keys=ON', '{}'));
13
14
  if (foreignKeys.execution.error) throw new Error(foreignKeys.execution.error.message);
14
15
  parentPort.postMessage({ ready: true });
15
16
  } catch (error) {
16
- const coded = /^(FDB_[A-Z0-9_]+): (.*)$/s.exec(error.message);
17
- parentPort.postMessage({ readyError: { code: /file is locked/i.test(error.message) ? 'FDB_BUSY' : coded?.[1] ?? (error.code?.startsWith('FDB_') ? error.code : 'FDB_STORAGE'), message: coded?.[2] ?? error.message } });
17
+ const coded = /^(RDB_[A-Z0-9_]+): (.*)$/s.exec(error.message);
18
+ parentPort.postMessage({ readyError: { code: /file is locked/i.test(error.message) ? 'RDB_BUSY' : coded?.[1] ?? (error.code?.startsWith('RDB_') ? error.code : 'RDB_STORAGE'), message: coded?.[2] ?? error.message } });
18
19
  database?.close();
19
20
  parentPort.close();
20
21
  }
@@ -22,18 +23,35 @@ try {
22
23
  if (database) parentPort.on('message', ({ id, operation, body, cancellationKey }) => {
23
24
  try {
24
25
  if (operation === 'close') {
25
- if (!fenced) try { database.execute('PRAGMA wal_checkpoint(TRUNCATE)', '{}'); } catch {}
26
- database.close();
27
- parentPort.postMessage({ id });
28
- parentPort.close();
26
+ for (const key of copies.keys()) cancelOperation(key);
27
+ void Promise.allSettled(copies.values()).then(() => {
28
+ if (!fenced) try { database.execute('PRAGMA wal_checkpoint(TRUNCATE)', '{}'); } catch {}
29
+ database.close();
30
+ parentPort.postMessage({ id });
31
+ parentPort.close();
32
+ });
33
+ return;
34
+ }
35
+ if (fenced) fail('RDB_STORAGE', 'Embedded outcome is uncertain; close and reconcile the database', 'unknown');
36
+ if (operation === 'export') {
37
+ // Writes run on this thread, so none commits between the start of the copy's snapshot and reading the sequence.
38
+ const pending = database.startFileExport(JSON.parse(body).destination, cancellationKey);
39
+ const snapshot = sequence;
40
+ const finished = pending.finish().then(
41
+ bytes => parentPort.postMessage({ id, result: { sequence: snapshot, bytes: Number(bytes) } }),
42
+ error => {
43
+ const coded = /^(RDB_[A-Z0-9_]+): (.*)$/s.exec(error.message);
44
+ parentPort.postMessage({ id, error: { code: coded?.[1] ?? 'RDB_STORAGE', message: (coded?.[2] ?? error.message).slice(0, 2048), outcome: 'read-failed' } });
45
+ },
46
+ ).finally(() => copies.delete(cancellationKey));
47
+ copies.set(cancellationKey, finished);
29
48
  return;
30
49
  }
31
- if (fenced) fail('FDB_STORAGE', 'Embedded outcome is uncertain; close and reconcile the database', 'unknown');
32
50
  if (operation === 'info') {
33
51
  const mode = JSON.parse(database.execute('PRAGMA journal_mode', '{}'));
34
- if (mode.execution.error) fail('FDB_STORAGE', 'Cannot inspect embedded journal mode', 'read-failed');
52
+ if (mode.execution.error) fail('RDB_STORAGE', 'Cannot inspect embedded journal mode', 'read-failed');
35
53
  const engine = JSON.parse(database.execute('SELECT romedb_version()', '{}'));
36
- if (engine.execution.error) fail('FDB_STORAGE', 'Cannot read the embedded engine version', 'read-failed');
54
+ if (engine.execution.error) fail('RDB_STORAGE', 'Cannot read the embedded engine version', 'read-failed');
37
55
  parentPort.postMessage({ id, result: {
38
56
  id: workerData.databaseId, sequence, adapterVersion: 1,
39
57
  engineVersion: engine.execution.result.rows[0][0].value,
@@ -43,12 +61,12 @@ if (database) parentPort.on('message', ({ id, operation, body, cancellationKey }
43
61
  } });
44
62
  return;
45
63
  }
46
- if (!['read', 'write'].includes(operation)) fail('FDB_UNSUPPORTED', 'Unknown embedded operation', 'not-sent');
64
+ if (!['read', 'write'].includes(operation)) fail('RDB_UNSUPPORTED', 'Unknown embedded operation', 'not-sent');
47
65
  const reading = operation === 'read';
48
66
  const request = JSON.parse(body);
49
67
  let writeBody = body;
50
68
  if (reading) {
51
- if (request.readVersion !== 2 || request.ifSequence !== undefined) fail('FDB_VALIDATION', 'Invalid fresh read envelope', 'read-failed');
69
+ if (request.readVersion !== 2 || request.ifSequence !== undefined) fail('RDB_VALIDATION', 'Invalid fresh read envelope', 'read-failed');
52
70
  const { readVersion, ...rest } = request;
53
71
  writeBody = JSON.stringify(rest);
54
72
  }
@@ -56,22 +74,22 @@ if (database) parentPort.on('message', ({ id, operation, body, cancellationKey }
56
74
  const hash = createHash('sha256').update(body).digest('hex');
57
75
  if (!reading && receipts.has(request.requestId)) {
58
76
  const receipt = receipts.get(request.requestId);
59
- if (receipt.hash !== hash) fail('FDB_REQUEST_IDENTITY', 'Request ID was reused with another body', 'unknown');
77
+ if (receipt.hash !== hash) fail('RDB_REQUEST_IDENTITY', 'Request ID was reused with another body', 'unknown');
60
78
  parentPort.postMessage({ id, result: receipt.result });
61
79
  return;
62
80
  }
63
- if (request.afterSequence > sequence || (!reading && sequence - request.afterSequence >= 64)) fail('FDB_CONFLICT', 'Request position is ahead or its replay window expired; reconcile the request', reading ? 'read-failed' : 'unknown');
64
- if (sequence >= Number.MAX_SAFE_INTEGER - 1 && !reading) fail('FDB_LIMIT', 'Adapter sequence is exhausted', 'not-sent');
81
+ if (request.afterSequence > sequence || (!reading && sequence - request.afterSequence >= 64)) fail('RDB_CONFLICT', 'Request position is ahead or its replay window expired; reconcile the request', reading ? 'read-failed' : 'unknown');
82
+ if (sequence >= Number.MAX_SAFE_INTEGER - 1 && !reading) fail('RDB_LIMIT', 'Adapter sequence is exhausted', 'not-sent');
65
83
  let result;
66
84
  if (!reading && request.ifSequence !== undefined && request.ifSequence !== sequence) {
67
- result = { error: { code: 'FDB_CONFLICT', message: 'Sequence changed; refresh before saving' } };
85
+ result = { error: { code: 'RDB_CONFLICT', message: 'Sequence changed; refresh before saving' } };
68
86
  } else {
69
87
  const report = JSON.parse(database.executeAtomicRequest(JSON.stringify({ readOnly: reading, statements: request.statements, ...(request.workLimits === undefined ? {} : { workLimits: request.workLimits }) }), cancellationKey));
70
88
  if (report.execution.error) {
71
89
  const error = report.execution.error;
72
- if (['FDB_STORAGE', 'FDB_ROLLBACK'].includes(error.code) || report.transaction.after !== 'autocommit') {
90
+ if (['RDB_STORAGE', 'RDB_ROLLBACK'].includes(error.code) || report.transaction.after !== 'autocommit') {
73
91
  fenced = true;
74
- fail('FDB_STORAGE', 'Atomic request outcome is uncertain; close and reconcile the database', 'unknown');
92
+ fail('RDB_STORAGE', 'Atomic request outcome is uncertain; close and reconcile the database', 'unknown');
75
93
  }
76
94
  if (reading) fail(error.code, error.message, 'read-failed');
77
95
  result = { error };
@@ -85,7 +103,8 @@ if (database) parentPort.on('message', ({ id, operation, body, cancellationKey }
85
103
  }
86
104
  parentPort.postMessage({ id, result });
87
105
  } catch (error) {
88
- parentPort.postMessage({ id, error: { code: error.code ?? 'FDB_VALIDATION', message: String(error.message).slice(0, 2048), outcome: error.outcome ?? (operation === 'read' ? 'read-failed' : 'unknown') } });
106
+ const coded = operation === 'export' ? /^(RDB_[A-Z0-9_]+): (.*)$/s.exec(error.message) : null;
107
+ parentPort.postMessage({ id, error: { code: coded?.[1] ?? error.code ?? 'RDB_VALIDATION', message: String(coded?.[2] ?? error.message).slice(0, 2048), outcome: error.outcome ?? (operation === 'read' || operation === 'export' ? 'read-failed' : 'unknown') } });
89
108
  }
90
109
  });
91
110
 
package/index.cjs CHANGED
@@ -186,7 +186,7 @@ function decodeProfile(raw) {
186
186
  metrics: Object.fromEntries(Object.entries(metrics).map(([key, value]) => [key, BigInt(value)])) };
187
187
  }
188
188
  function isRomeDBError(value) {
189
- if (!(value instanceof Error) || typeof value.code !== 'string' || !/^FDB_[A-Z][A-Z0-9_]*$/.test(value.code)) return false;
189
+ if (!(value instanceof Error) || typeof value.code !== 'string' || !/^RDB_[A-Z][A-Z0-9_]*$/.test(value.code)) return false;
190
190
  const transaction = value.transaction;
191
191
  if (!(transaction === undefined || (transaction !== null && typeof transaction === 'object' &&
192
192
  (transaction.before === 'autocommit' || transaction.before === 'active') &&
@@ -196,19 +196,19 @@ function isRomeDBError(value) {
196
196
  typeof migration.version === 'bigint' && migration.version > 0n &&
197
197
  typeof migration.offset === 'bigint' && migration.offset >= 0n &&
198
198
  migration.cause !== null && typeof migration.cause === 'object' &&
199
- typeof migration.cause.code === 'string' && /^FDB_[A-Z][A-Z0-9_]*$/.test(migration.cause.code) &&
199
+ typeof migration.cause.code === 'string' && /^RDB_[A-Z][A-Z0-9_]*$/.test(migration.cause.code) &&
200
200
  typeof migration.cause.message === 'string');
201
201
  }
202
202
  exports.isRomeDBError = isRomeDBError;
203
203
  function cardinalityError(count, transaction) {
204
204
  const error = new RangeError(`expected exactly one row, got ${count}`);
205
- error.code = 'FDB_CARDINALITY';
205
+ error.code = 'RDB_CARDINALITY';
206
206
  error.transaction = transaction;
207
207
  return error;
208
208
  }
209
209
  function closedError(message = 'database is closed') {
210
210
  const error = new Error(message);
211
- error.code = 'FDB_CLOSED';
211
+ error.code = 'RDB_CLOSED';
212
212
  return error;
213
213
  }
214
214
  class Database {
@@ -313,7 +313,7 @@ class AsyncDatabase {
313
313
  #fail(error, shutdown = false) {
314
314
  if (!this.#failure) {
315
315
  this.#failure = new Error(`database worker failure: ${error.message}`, { cause: error });
316
- this.#failure.code = 'FDB_WORKER';
316
+ this.#failure.code = 'RDB_WORKER';
317
317
  }
318
318
  this.#readyReject(this.#failure);
319
319
  for (const pending of this.#pending.values()) { pending.cleanup(); pending.reject(this.#failure); }
@@ -331,7 +331,7 @@ class AsyncDatabase {
331
331
  if (this.#closing && !closing) return Promise.reject(closedError('database is closing or closed'));
332
332
  const bytes = args.reduce((size, arg) => size + Buffer.byteLength(arg), 0);
333
333
  if (!closing && (this.#pending.size >= 256 || this.#bytes + bytes > 128 * 1024 * 1024)) {
334
- const error = new RangeError('database worker queue limit exceeded'); error.code = 'FDB_LIMIT';
334
+ const error = new RangeError('database worker queue limit exceeded'); error.code = 'RDB_LIMIT';
335
335
  return Promise.reject(error);
336
336
  }
337
337
  const id = ++this.#next;
@@ -412,6 +412,17 @@ class AsyncDatabase {
412
412
  return { alreadyApplied: report.execution.result.alreadyApplied,
413
413
  applied: report.execution.result.applied.map(BigInt), transaction: report.transaction };
414
414
  }
415
+ async exportFile(destination, options = {}) {
416
+ if (typeof destination !== 'string' || !destination || destination.length > 4096 || destination.includes('\0')) throw new TypeError('exportFile requires a bounded destination path');
417
+ try { return await this.#request('exportFile', [require('node:path').resolve(destination)], false, options.signal, options.timeoutMs); }
418
+ catch (error) {
419
+ const coded = /^(RDB_[A-Z0-9_]+): (.*)$/s.exec(error.message);
420
+ if (!coded) throw error;
421
+ const failure = new Error(coded[2]);
422
+ failure.code = coded[1];
423
+ throw failure;
424
+ }
425
+ }
415
426
  async all(sql, parameters, options) { return (await this.execute(sql, parameters, options)).rows; }
416
427
  async first(sql, parameters, options) { return (await this.all(sql, parameters, options))[0]; }
417
428
  async exactlyOne(sql, parameters, options) {
@@ -442,7 +453,7 @@ function collectionClient(db, name, asynchronous) {
442
453
  const check = info => {
443
454
  if (info.rows[0]?.[0]?.model !== 'document') throw new TypeError('collection methods require a document collection');
444
455
  };
445
- const missing = error => { if (error.code !== 'FDB_NOT_FOUND') throw error; };
456
+ const missing = error => { if (error.code !== 'RDB_NOT_FOUND') throw error; };
446
457
  if (asynchronous) return (async () => {
447
458
  try { check(await db.execute(`INFO FOR TABLE ${table}`, {}, options)); } catch (error) { missing(error); }
448
459
  return project(await db.execute(sql, params, options));
package/index.d.ts CHANGED
@@ -66,6 +66,7 @@ export class Database {
66
66
  }
67
67
 
68
68
  export interface ExecuteOptions { signal?: AbortSignal; timeoutMs?: number; }
69
+ export interface FileExport { bytes: number; }
69
70
  export class AsyncDatabase {
70
71
  collection<T extends object = Document>(name: string): AsyncCollection<T>;
71
72
  private constructor();
@@ -75,6 +76,8 @@ export class AsyncDatabase {
75
76
  migrate(migrations: Migration[], options?: ExecuteOptions): Promise<MigrationReport>;
76
77
  exportDocuments(table: string, format?: TransferFormat, options?: ExecuteOptions): Promise<string>;
77
78
  importDocuments(table: string, input: string, format?: TransferFormat, options?: ExecuteOptions): Promise<ImportReport>;
79
+ /** Copy an MVCC database into a new private file while queries continue; the destination and its log files must not exist. */
80
+ exportFile(destination: string, options?: ExecuteOptions): Promise<FileExport>;
78
81
  execute(sql: string, parameters?: Parameters, options?: ExecuteOptions): Promise<QueryResult>;
79
82
  profileSelect(sql: string, parameters?: Parameters, options?: ExecuteOptions): Promise<ProfiledQuery>;
80
83
  /** Checks write results atomically; does not bound engine or logical materialization memory. */
package/native.cjs CHANGED
@@ -3,7 +3,7 @@ const minimumNodeMajor = 22;
3
3
  const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10);
4
4
  if (nodeMajor < minimumNodeMajor) {
5
5
  const error = new Error(`RomeDB requires Node.js ${minimumNodeMajor} or newer; this process is running Node ${process.versions.node}.`);
6
- error.code = 'FDB_RUNTIME_VERSION';
6
+ error.code = 'RDB_RUNTIME_VERSION';
7
7
  throw error;
8
8
  }
9
9
  try {
@@ -15,6 +15,6 @@ try {
15
15
  'See the package README for supported build and installation instructions.',
16
16
  { cause },
17
17
  );
18
- error.code = 'FDB_NATIVE_LOAD';
18
+ error.code = 'RDB_NATIVE_LOAD';
19
19
  throw error;
20
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@romedb/node",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "description": "RomeDB for Node.js: the embedded database (Linux x64 native addon) and a Web-API client for romedb serve and RomeDB Cloud",
5
5
  "main": "index.cjs",
6
6
  "types": "index.d.ts",
@@ -32,6 +32,15 @@
32
32
  "typescript": "5.8.3"
33
33
  },
34
34
  "license": "MIT",
35
+ "homepage": "https://github.com/romedb/romedb/tree/main/romedb/bindings/node#readme",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/romedb/romedb.git",
39
+ "directory": "romedb/bindings/node"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/romedb/romedb/issues"
43
+ },
35
44
  "files": [
36
45
  "index.cjs",
37
46
  "client.cjs",
@@ -58,14 +67,5 @@
58
67
  "scripts": {
59
68
  "test": "node build-esm.cjs --check && node --test --test-concurrency=2 test.cjs client.test.cjs client-web.test.mjs client-native.test.cjs embedded-client.test.cjs",
60
69
  "typecheck": "tsc --noEmit --strict --target ES2022 --module Node16 types.test.ts client.types.test.ts embedded-client.types.test.ts values.types.test.ts"
61
- },
62
- "homepage": "https://github.com/romedb/romedb/tree/main/romedb/bindings/node#readme",
63
- "repository": {
64
- "type": "git",
65
- "url": "git+https://github.com/romedb/romedb.git",
66
- "directory": "romedb/bindings/node"
67
- },
68
- "bugs": {
69
- "url": "https://github.com/romedb/romedb/issues"
70
70
  }
71
- }
71
+ }
package/romedb.node CHANGED
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/romedb.node
3
- size: 41228032 bytes
4
- sha256: d77c5d1e9f732191c5670a75fa184e6b7fccd37fdcca863ddc76a5efcbd5ed2d
3
+ size: 41428704 bytes
4
+ sha256: 3baeea0d26a9eb94d1cad57563330062c82226e3ecd7de6ce1394f96c6f893a8
package/worker.cjs CHANGED
@@ -1,17 +1,35 @@
1
1
  'use strict';
2
2
  const { parentPort, workerData } = require('node:worker_threads');
3
- const { NativeDatabase } = require('./native.cjs');
3
+ const { NativeDatabase, createCancellationToken, cancelOperation, releaseCancellationToken } = require('./native.cjs');
4
4
  const db = new NativeDatabase(workerData.path, ...(workerData.limits ?? []));
5
- const methods = new Set(['execute', 'executeAtomicRequest', 'writeWithResultLimits', 'profileSelect', 'profileSelectWithLimits', 'checkCollectionIntegrity', 'executeBatch', 'migrate', 'exportDocuments', 'importDocuments', 'close']);
5
+ const methods = new Set(['execute', 'executeAtomicRequest', 'writeWithResultLimits', 'profileSelect', 'profileSelectWithLimits', 'checkCollectionIntegrity', 'executeBatch', 'migrate', 'exportDocuments', 'importDocuments', 'exportFile', 'close']);
6
+ const copies = new Map();
6
7
  parentPort.on('message', ({ id, method, args, cancellationKey }) => {
7
8
  try {
8
9
  if (!methods.has(method)) throw new Error('unknown database worker operation');
10
+ if (method === 'exportFile') {
11
+ const key = cancellationKey ?? createCancellationToken();
12
+ const finished = Promise.resolve()
13
+ .then(() => db.startFileExport(args[0], key).finish())
14
+ .then(bytes => parentPort.postMessage({ id, result: { bytes: Number(bytes) } }), error => parentPort.postMessage({ id, error: { message: error.message } }))
15
+ .finally(() => { copies.delete(key); if (cancellationKey === undefined) releaseCancellationToken(key); });
16
+ copies.set(key, finished);
17
+ return;
18
+ }
19
+ if (method === 'close') {
20
+ for (const key of copies.keys()) cancelOperation(key);
21
+ void Promise.allSettled(copies.values()).then(() => {
22
+ db.close();
23
+ parentPort.postMessage({ id, result: undefined });
24
+ parentPort.close();
25
+ });
26
+ return;
27
+ }
9
28
  const result = method === 'execute' && cancellationKey !== undefined
10
29
  ? db.executeCancellable(...args, cancellationKey)
11
30
  : (method === 'executeAtomicRequest' || method === 'writeWithResultLimits' || method === 'profileSelect' || method === 'profileSelectWithLimits' || method === 'checkCollectionIntegrity' || method === 'executeBatch' || method === 'importDocuments' || method === 'exportDocuments' || method === 'migrate') && cancellationKey !== undefined
12
31
  ? db[method](...args, cancellationKey) : db[method](...args);
13
32
  parentPort.postMessage({ id, result });
14
- if (method === 'close') parentPort.close();
15
33
  } catch (error) {
16
34
  parentPort.postMessage({ id, error: { message: error.message } });
17
35
  }