@romedb/node 0.2.12 → 0.2.14

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.
@@ -7,7 +7,7 @@ const path = require('node:path');
7
7
  const { pathToFileURL } = require('node:url');
8
8
  const common = require('./client.cjs');
9
9
  const { connect, RemoteDatabase, ClientError, Float64, Record, Vector, encodeValue, decodeValue, normalizeUrl, validatePrepared, saveRequest, loadRequest } = common;
10
- const { plainObject } = require('./values.cjs');
10
+ const { plainObject, checkCopyOptions } = require('./values.cjs');
11
11
  const { createEmbeddedClient } = require('./client-internal.cjs');
12
12
  const native = require('./native.cjs');
13
13
  const sessions = new Map();
@@ -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,40 @@ 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', 'emptyTables', 'newIdentity'].includes(key))) throw new TypeError('export options take signal, timeoutMs, emptyTables and newIdentity');
77
+ checkCopyOptions(options);
78
+ if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal');
79
+ if (options.timeoutMs !== undefined && (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 4294967295)) throw new RangeError('timeoutMs must be 1..4294967295');
80
+ }
81
+
82
+ // The file has no name any more; the handle keeps it until the stream ends or is cancelled.
83
+ function fileStream(handle, size, signal) {
84
+ let offset = 0;
85
+ const fail = async (controller, error) => {
86
+ await handle.close().catch(() => {});
87
+ controller.error(error);
88
+ };
89
+ return new ReadableStream({
90
+ async pull(controller) {
91
+ if (signal?.aborted) return fail(controller, new common.ClientError('RDB_CANCELLED', 'Embedded export download cancelled', 'read-failed'));
92
+ try {
93
+ if (offset === size) { await handle.close(); controller.close(); return; }
94
+ const chunk = new Uint8Array(Math.min(262144, size - offset));
95
+ const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, offset);
96
+ if (bytesRead === 0) throw new Error('exported file ended early');
97
+ offset += bytesRead;
98
+ controller.enqueue(bytesRead === chunk.byteLength ? chunk : chunk.slice(0, bytesRead));
99
+ } catch (error) {
100
+ await fail(controller, new common.ClientError('RDB_STORAGE', 'Exported database file could not be read', 'read-failed', { cause: error }));
101
+ }
102
+ },
103
+ cancel() { return handle.close().catch(() => {}); },
104
+ }, { highWaterMark: 0 });
105
+ }
106
+
75
107
  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');
108
+ return new common.ClientError('RDB_LIMIT', `Embedded database limit reached: ${sessions.size} files are open; close one or raise maxOpen`, 'not-sent');
77
109
  }
78
110
 
79
111
  class EmbeddedDatabase {
@@ -93,6 +125,32 @@ class EmbeddedDatabase {
93
125
  submit(request, options) { return this.#client.submit(request, options); }
94
126
  recover(request, options) { return this.#client.recover(request, options); }
95
127
  writeAtomic(statements, options) { return this.#client.writeAtomic(statements, options); }
128
+ exportFile(destination, options) {
129
+ if (this.#closing) return Promise.reject(new common.ClientError('RDB_CLOSED', 'Database client is closed', 'not-sent'));
130
+ return typeof destination === 'string' ? this.#exportTo(destination, options ?? {}) : this.#exportStream(destination ?? {});
131
+ }
132
+ async #exportTo(destination, options) {
133
+ checkExportOptions(options);
134
+ if (!destination || destination.length > 4096 || destination.includes('\0')) throw new TypeError('exportFile requires a bounded destination path');
135
+ const { sequence, bytes } = await this.#session.request('export', JSON.stringify({ destination: path.resolve(destination), emptyTables: options.emptyTables, newIdentity: options.newIdentity }), options.signal, options.timeoutMs);
136
+ return { sequence, bytes };
137
+ }
138
+ async #exportStream(options) {
139
+ checkExportOptions(options);
140
+ let folder;
141
+ try {
142
+ folder = await fs.mkdtemp(path.join(path.dirname(this.#path), '.romedb-download-'));
143
+ const file = path.join(folder, 'export.romedb');
144
+ const { sequence, bytes } = await this.#exportTo(file, options);
145
+ const handle = await fs.open(file, 'r');
146
+ await fs.rm(folder, { recursive: true });
147
+ return { sequence, size: bytes, body: fileStream(handle, bytes, options.signal) };
148
+ } catch (error) {
149
+ if (folder !== undefined) await fs.rm(folder, { recursive: true, force: true }).catch(() => {});
150
+ if (error instanceof common.ClientError || error instanceof TypeError || error instanceof RangeError) throw error;
151
+ 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 });
152
+ }
153
+ }
96
154
  close() {
97
155
  this.#closing ??= (async () => { await this.#client.close(); await releaseSession(this.#session, this.#path); })();
98
156
  return this.#closing;
@@ -126,11 +184,11 @@ class Session {
126
184
  else pending.resolve(message.result);
127
185
  });
128
186
  this.#worker.on('error', () => {
129
- this.#failure ??= new common.ClientError('FDB_TRANSPORT', 'Embedded worker failed; reconcile pending writes', 'unknown');
187
+ this.#failure ??= new common.ClientError('RDB_TRANSPORT', 'Embedded worker failed; reconcile pending writes', 'unknown');
130
188
  rejectReady(this.#failure);
131
189
  });
132
190
  this.#worker.on('exit', code => {
133
- this.#failure ??= new common.ClientError('FDB_CLOSED', code === 0 ? 'Embedded worker closed' : 'Embedded worker exited unexpectedly', 'unknown');
191
+ this.#failure ??= new common.ClientError('RDB_CLOSED', code === 0 ? 'Embedded worker closed' : 'Embedded worker exited unexpectedly', 'unknown');
134
192
  rejectReady(this.#failure);
135
193
  for (const pending of this.#pending.values()) {
136
194
  pending.listener?.[Symbol.dispose]();
@@ -143,23 +201,24 @@ class Session {
143
201
  }
144
202
  request(operation, body, signal, duration) {
145
203
  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'));
204
+ if (this.closing && operation !== 'close') return Promise.reject(new common.ClientError('RDB_CLOSED', 'Embedded session is closing', 'not-sent'));
205
+ if (signal?.aborted) return Promise.reject(new common.ClientError('RDB_CANCELLED', 'Request cancelled before dispatch', 'not-sent'));
148
206
  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'));
207
+ 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
208
  const token = native.createCancellationToken(duration === undefined ? undefined : String(duration));
151
209
  return new Promise((resolve, reject) => {
152
210
  const id = ++this.#next;
153
211
  const listener = signal === undefined ? undefined : addAbortListener(signal, () => {
154
212
  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'));
213
+ // An export settles when the worker reports it, so a late cancellation cannot hide a finished file.
214
+ 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
215
  });
157
216
  this.#pending.set(id, { resolve, reject, token, listener, bytes }); this.#bytes += bytes;
158
217
  try { this.#worker.postMessage({ id, operation, body, cancellationKey: token }); }
159
218
  catch {
160
219
  this.#pending.delete(id); this.#bytes -= bytes;
161
220
  listener?.[Symbol.dispose](); native.releaseCancellationToken(token);
162
- reject(new common.ClientError('FDB_TRANSPORT', 'Embedded request could not be dispatched', 'not-sent'));
221
+ reject(new common.ClientError('RDB_TRANSPORT', 'Embedded request could not be dispatched', 'not-sent'));
163
222
  }
164
223
  });
165
224
  }
@@ -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, FileExportOptions } 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 extends FileExportOptions {}
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,36 @@ 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 copy = JSON.parse(body);
39
+ const pending = database.startFileExport(copy.destination, cancellationKey, copy.emptyTables, copy.newIdentity);
40
+ const snapshot = sequence;
41
+ const finished = pending.finish().then(
42
+ bytes => parentPort.postMessage({ id, result: { sequence: snapshot, bytes: Number(bytes) } }),
43
+ error => {
44
+ const coded = /^(RDB_[A-Z0-9_]+): (.*)$/s.exec(error.message);
45
+ parentPort.postMessage({ id, error: { code: coded?.[1] ?? 'RDB_STORAGE', message: (coded?.[2] ?? error.message).slice(0, 2048), outcome: 'read-failed' } });
46
+ },
47
+ ).finally(() => copies.delete(cancellationKey));
48
+ copies.set(cancellationKey, finished);
29
49
  return;
30
50
  }
31
- if (fenced) fail('FDB_STORAGE', 'Embedded outcome is uncertain; close and reconcile the database', 'unknown');
32
51
  if (operation === 'info') {
33
52
  const mode = JSON.parse(database.execute('PRAGMA journal_mode', '{}'));
34
- if (mode.execution.error) fail('FDB_STORAGE', 'Cannot inspect embedded journal mode', 'read-failed');
53
+ if (mode.execution.error) fail('RDB_STORAGE', 'Cannot inspect embedded journal mode', 'read-failed');
35
54
  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');
55
+ if (engine.execution.error) fail('RDB_STORAGE', 'Cannot read the embedded engine version', 'read-failed');
37
56
  parentPort.postMessage({ id, result: {
38
57
  id: workerData.databaseId, sequence, adapterVersion: 1,
39
58
  engineVersion: engine.execution.result.rows[0][0].value,
@@ -43,12 +62,12 @@ if (database) parentPort.on('message', ({ id, operation, body, cancellationKey }
43
62
  } });
44
63
  return;
45
64
  }
46
- if (!['read', 'write'].includes(operation)) fail('FDB_UNSUPPORTED', 'Unknown embedded operation', 'not-sent');
65
+ if (!['read', 'write'].includes(operation)) fail('RDB_UNSUPPORTED', 'Unknown embedded operation', 'not-sent');
47
66
  const reading = operation === 'read';
48
67
  const request = JSON.parse(body);
49
68
  let writeBody = body;
50
69
  if (reading) {
51
- if (request.readVersion !== 2 || request.ifSequence !== undefined) fail('FDB_VALIDATION', 'Invalid fresh read envelope', 'read-failed');
70
+ if (request.readVersion !== 2 || request.ifSequence !== undefined) fail('RDB_VALIDATION', 'Invalid fresh read envelope', 'read-failed');
52
71
  const { readVersion, ...rest } = request;
53
72
  writeBody = JSON.stringify(rest);
54
73
  }
@@ -56,22 +75,22 @@ if (database) parentPort.on('message', ({ id, operation, body, cancellationKey }
56
75
  const hash = createHash('sha256').update(body).digest('hex');
57
76
  if (!reading && receipts.has(request.requestId)) {
58
77
  const receipt = receipts.get(request.requestId);
59
- if (receipt.hash !== hash) fail('FDB_REQUEST_IDENTITY', 'Request ID was reused with another body', 'unknown');
78
+ if (receipt.hash !== hash) fail('RDB_REQUEST_IDENTITY', 'Request ID was reused with another body', 'unknown');
60
79
  parentPort.postMessage({ id, result: receipt.result });
61
80
  return;
62
81
  }
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');
82
+ 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');
83
+ if (sequence >= Number.MAX_SAFE_INTEGER - 1 && !reading) fail('RDB_LIMIT', 'Adapter sequence is exhausted', 'not-sent');
65
84
  let result;
66
85
  if (!reading && request.ifSequence !== undefined && request.ifSequence !== sequence) {
67
- result = { error: { code: 'FDB_CONFLICT', message: 'Sequence changed; refresh before saving' } };
86
+ result = { error: { code: 'RDB_CONFLICT', message: 'Sequence changed; refresh before saving' } };
68
87
  } else {
69
88
  const report = JSON.parse(database.executeAtomicRequest(JSON.stringify({ readOnly: reading, statements: request.statements, ...(request.workLimits === undefined ? {} : { workLimits: request.workLimits }) }), cancellationKey));
70
89
  if (report.execution.error) {
71
90
  const error = report.execution.error;
72
- if (['FDB_STORAGE', 'FDB_ROLLBACK'].includes(error.code) || report.transaction.after !== 'autocommit') {
91
+ if (['RDB_STORAGE', 'RDB_ROLLBACK'].includes(error.code) || report.transaction.after !== 'autocommit') {
73
92
  fenced = true;
74
- fail('FDB_STORAGE', 'Atomic request outcome is uncertain; close and reconcile the database', 'unknown');
93
+ fail('RDB_STORAGE', 'Atomic request outcome is uncertain; close and reconcile the database', 'unknown');
75
94
  }
76
95
  if (reading) fail(error.code, error.message, 'read-failed');
77
96
  result = { error };
@@ -85,7 +104,8 @@ if (database) parentPort.on('message', ({ id, operation, body, cancellationKey }
85
104
  }
86
105
  parentPort.postMessage({ id, result });
87
106
  } 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') } });
107
+ const coded = operation === 'export' ? /^(RDB_[A-Z0-9_]+): (.*)$/s.exec(error.message) : null;
108
+ 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
109
  }
90
110
  });
91
111
 
package/index.cjs CHANGED
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ const { checkCopyOptions } = require('./values.cjs');
2
3
  const { NativeDatabase, interruptConnection, createCancellationToken, cancelOperation, releaseCancellationToken, vectorFromComponents, vectorFromSparseEntries } = require('./native.cjs');
3
4
  class Record {
4
5
  constructor(table, key) {
@@ -6,6 +7,8 @@ class Record {
6
7
  this.table = table; this.key = key;
7
8
  Object.freeze(this);
8
9
  }
10
+ toString() { return `${this.table}:${this.key}`; }
11
+ equals(other) { return other instanceof Record && other.table === this.table && other.key === this.key; }
9
12
  }
10
13
  class Vector {
11
14
  static float32(values) { return constructVector('float32', values); }
@@ -186,7 +189,7 @@ function decodeProfile(raw) {
186
189
  metrics: Object.fromEntries(Object.entries(metrics).map(([key, value]) => [key, BigInt(value)])) };
187
190
  }
188
191
  function isRomeDBError(value) {
189
- if (!(value instanceof Error) || typeof value.code !== 'string' || !/^FDB_[A-Z][A-Z0-9_]*$/.test(value.code)) return false;
192
+ if (!(value instanceof Error) || typeof value.code !== 'string' || !/^RDB_[A-Z][A-Z0-9_]*$/.test(value.code)) return false;
190
193
  const transaction = value.transaction;
191
194
  if (!(transaction === undefined || (transaction !== null && typeof transaction === 'object' &&
192
195
  (transaction.before === 'autocommit' || transaction.before === 'active') &&
@@ -196,19 +199,19 @@ function isRomeDBError(value) {
196
199
  typeof migration.version === 'bigint' && migration.version > 0n &&
197
200
  typeof migration.offset === 'bigint' && migration.offset >= 0n &&
198
201
  migration.cause !== null && typeof migration.cause === 'object' &&
199
- typeof migration.cause.code === 'string' && /^FDB_[A-Z][A-Z0-9_]*$/.test(migration.cause.code) &&
202
+ typeof migration.cause.code === 'string' && /^RDB_[A-Z][A-Z0-9_]*$/.test(migration.cause.code) &&
200
203
  typeof migration.cause.message === 'string');
201
204
  }
202
205
  exports.isRomeDBError = isRomeDBError;
203
206
  function cardinalityError(count, transaction) {
204
207
  const error = new RangeError(`expected exactly one row, got ${count}`);
205
- error.code = 'FDB_CARDINALITY';
208
+ error.code = 'RDB_CARDINALITY';
206
209
  error.transaction = transaction;
207
210
  return error;
208
211
  }
209
212
  function closedError(message = 'database is closed') {
210
213
  const error = new Error(message);
211
- error.code = 'FDB_CLOSED';
214
+ error.code = 'RDB_CLOSED';
212
215
  return error;
213
216
  }
214
217
  class Database {
@@ -313,7 +316,7 @@ class AsyncDatabase {
313
316
  #fail(error, shutdown = false) {
314
317
  if (!this.#failure) {
315
318
  this.#failure = new Error(`database worker failure: ${error.message}`, { cause: error });
316
- this.#failure.code = 'FDB_WORKER';
319
+ this.#failure.code = 'RDB_WORKER';
317
320
  }
318
321
  this.#readyReject(this.#failure);
319
322
  for (const pending of this.#pending.values()) { pending.cleanup(); pending.reject(this.#failure); }
@@ -331,7 +334,7 @@ class AsyncDatabase {
331
334
  if (this.#closing && !closing) return Promise.reject(closedError('database is closing or closed'));
332
335
  const bytes = args.reduce((size, arg) => size + Buffer.byteLength(arg), 0);
333
336
  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';
337
+ const error = new RangeError('database worker queue limit exceeded'); error.code = 'RDB_LIMIT';
335
338
  return Promise.reject(error);
336
339
  }
337
340
  const id = ++this.#next;
@@ -412,6 +415,19 @@ class AsyncDatabase {
412
415
  return { alreadyApplied: report.execution.result.alreadyApplied,
413
416
  applied: report.execution.result.applied.map(BigInt), transaction: report.transaction };
414
417
  }
418
+ async exportFile(destination, options = {}) {
419
+ if (typeof destination !== 'string' || !destination || destination.length > 4096 || destination.includes('\0')) throw new TypeError('exportFile requires a bounded destination path');
420
+ checkCopyOptions(options);
421
+ const copy = JSON.stringify({ emptyTables: options.emptyTables, newIdentity: options.newIdentity });
422
+ try { return await this.#request('exportFile', [require('node:path').resolve(destination), copy], false, options.signal, options.timeoutMs); }
423
+ catch (error) {
424
+ const coded = /^(RDB_[A-Z0-9_]+): (.*)$/s.exec(error.message);
425
+ if (!coded) throw error;
426
+ const failure = new Error(coded[2]);
427
+ failure.code = coded[1];
428
+ throw failure;
429
+ }
430
+ }
415
431
  async all(sql, parameters, options) { return (await this.execute(sql, parameters, options)).rows; }
416
432
  async first(sql, parameters, options) { return (await this.all(sql, parameters, options))[0]; }
417
433
  async exactlyOne(sql, parameters, options) {
@@ -442,7 +458,7 @@ function collectionClient(db, name, asynchronous) {
442
458
  const check = info => {
443
459
  if (info.rows[0]?.[0]?.model !== 'document') throw new TypeError('collection methods require a document collection');
444
460
  };
445
- const missing = error => { if (error.code !== 'FDB_NOT_FOUND') throw error; };
461
+ const missing = error => { if (error.code !== 'RDB_NOT_FOUND') throw error; };
446
462
  if (asynchronous) return (async () => {
447
463
  try { check(await db.execute(`INFO FOR TABLE ${table}`, {}, options)); } catch (error) { missing(error); }
448
464
  return project(await db.execute(sql, params, options));
package/index.d.ts CHANGED
@@ -66,6 +66,13 @@ export class Database {
66
66
  }
67
67
 
68
68
  export interface ExecuteOptions { signal?: AbortSignal; timeoutMs?: number; }
69
+ export interface FileExport { bytes: number; }
70
+ export interface FileExportOptions extends ExecuteOptions {
71
+ /** These tables keep their schema and indexes but no rows, and the copy gets a new identity. */
72
+ emptyTables?: readonly string[];
73
+ /** Leave out the database ID, the sequence and the receipts: the copy is a new database. */
74
+ newIdentity?: boolean;
75
+ }
69
76
  export class AsyncDatabase {
70
77
  collection<T extends object = Document>(name: string): AsyncCollection<T>;
71
78
  private constructor();
@@ -75,6 +82,8 @@ export class AsyncDatabase {
75
82
  migrate(migrations: Migration[], options?: ExecuteOptions): Promise<MigrationReport>;
76
83
  exportDocuments(table: string, format?: TransferFormat, options?: ExecuteOptions): Promise<string>;
77
84
  importDocuments(table: string, input: string, format?: TransferFormat, options?: ExecuteOptions): Promise<ImportReport>;
85
+ /** Copy an MVCC database into a new private file while queries continue; the destination and its log files must not exist. */
86
+ exportFile(destination: string, options?: FileExportOptions): Promise<FileExport>;
78
87
  execute(sql: string, parameters?: Parameters, options?: ExecuteOptions): Promise<QueryResult>;
79
88
  profileSelect(sql: string, parameters?: Parameters, options?: ExecuteOptions): Promise<ProfiledQuery>;
80
89
  /** 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.14",
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: 41464424 bytes
4
+ sha256: d98971746cedc29b00b8333f763433c47db42f65306b933966e636e0221aac3c
package/values.cjs CHANGED
@@ -18,6 +18,8 @@ class Record {
18
18
  this.key = key;
19
19
  Object.freeze(this);
20
20
  }
21
+ toString() { return `${this.table}:${this.key}`; }
22
+ equals(other) { return other instanceof Record && other.table === this.table && other.key === this.key; }
21
23
  }
22
24
 
23
25
  class Vector {
@@ -204,8 +206,16 @@ function checkDepth(depth) {
204
206
  if (depth > 64) throw new RangeError('value nesting exceeds 64');
205
207
  }
206
208
 
209
+ function checkCopyOptions(options) {
210
+ const { emptyTables, newIdentity } = options;
211
+ if (emptyTables !== undefined && (!Array.isArray(emptyTables) || emptyTables.length > 256 || emptyTables.some(name => typeof name !== 'string' || !name || name.length > 255 || /[,\0]/.test(name)))) {
212
+ throw new TypeError('emptyTables takes up to 256 table names without commas');
213
+ }
214
+ if (newIdentity !== undefined && typeof newIdentity !== 'boolean') throw new TypeError('newIdentity must be a boolean');
215
+ }
216
+
207
217
  function plainObject(value) {
208
218
  return value !== null && typeof value === 'object' && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
209
219
  }
210
220
 
211
- module.exports = { Float64, Record, Vector, encodeValue, decodeValue, plainObject, text };
221
+ module.exports = { Float64, Record, Vector, encodeValue, decodeValue, plainObject, text, checkCopyOptions };
package/values.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export class Float64 { constructor(value: number); readonly value: number; }
2
- export class Record { constructor(table: string, key: string | bigint); readonly table: string; readonly key: string | bigint; }
2
+ export class Record { constructor(table: string, key: string | bigint); readonly table: string; readonly key: string | bigint; /** `table:key`. */ toString(): string; /** The same table and key, including the key's type. */ equals(other: unknown): boolean; }
3
3
  export class Vector {
4
4
  constructor(bytes: Uint8Array);
5
5
  /** A copy of the encoded bytes. */
package/values.mjs CHANGED
@@ -22,6 +22,8 @@ class Record {
22
22
  this.key = key;
23
23
  Object.freeze(this);
24
24
  }
25
+ toString() { return `${this.table}:${this.key}`; }
26
+ equals(other) { return other instanceof Record && other.table === this.table && other.key === this.key; }
25
27
  }
26
28
 
27
29
  class Vector {
@@ -208,13 +210,21 @@ function checkDepth(depth) {
208
210
  if (depth > 64) throw new RangeError('value nesting exceeds 64');
209
211
  }
210
212
 
213
+ function checkCopyOptions(options) {
214
+ const { emptyTables, newIdentity } = options;
215
+ if (emptyTables !== undefined && (!Array.isArray(emptyTables) || emptyTables.length > 256 || emptyTables.some(name => typeof name !== 'string' || !name || name.length > 255 || /[,\0]/.test(name)))) {
216
+ throw new TypeError('emptyTables takes up to 256 table names without commas');
217
+ }
218
+ if (newIdentity !== undefined && typeof newIdentity !== 'boolean') throw new TypeError('newIdentity must be a boolean');
219
+ }
220
+
211
221
  function plainObject(value) {
212
222
  return value !== null && typeof value === 'object' && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
213
223
  }
214
224
 
215
- module.exports = { Float64, Record, Vector, encodeValue, decodeValue, plainObject, text };
225
+ module.exports = { Float64, Record, Vector, encodeValue, decodeValue, plainObject, text, checkCopyOptions };
216
226
  })(commonjs, commonjs.exports, specifier => {
217
227
  throw new TypeError(`Cannot load ${specifier}`);
218
228
  });
219
229
 
220
- export const { Float64, Record, Vector, encodeValue, decodeValue, plainObject, text } = commonjs.exports;
230
+ export const { Float64, Record, Vector, encodeValue, decodeValue, plainObject, text, checkCopyOptions } = commonjs.exports;
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(() => { const copy = JSON.parse(args[1]); return db.startFileExport(args[0], key, copy.emptyTables, copy.newIdentity).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
  }