@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.
- package/README.md +849 -433
- package/THIRD_PARTY_CRATE_NOTICES.md +4 -4
- package/client.cjs +107 -21
- package/client.d.ts +15 -1
- package/client.mjs +107 -21
- package/embedded-client.cjs +71 -13
- package/embedded-client.d.ts +11 -1
- package/embedded-worker.cjs +38 -19
- package/index.cjs +18 -7
- package/index.d.ts +3 -0
- package/native.cjs +2 -2
- package/package.json +11 -11
- package/romedb.node +2 -2
- package/worker.cjs +21 -3
package/embedded-client.cjs
CHANGED
|
@@ -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('
|
|
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' ? '
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
147
|
-
if (signal?.aborted) return Promise.reject(new common.ClientError('
|
|
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('
|
|
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
|
-
|
|
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('
|
|
220
|
+
reject(new common.ClientError('RDB_TRANSPORT', 'Embedded request could not be dispatched', 'not-sent'));
|
|
163
221
|
}
|
|
164
222
|
});
|
|
165
223
|
}
|
package/embedded-client.d.ts
CHANGED
|
@@ -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>;
|
package/embedded-worker.cjs
CHANGED
|
@@ -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 = /^(
|
|
17
|
-
parentPort.postMessage({ readyError: { code: /file is locked/i.test(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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
64
|
-
if (sequence >= Number.MAX_SAFE_INTEGER - 1 && !reading) fail('
|
|
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: '
|
|
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 (['
|
|
90
|
+
if (['RDB_STORAGE', 'RDB_ROLLBACK'].includes(error.code) || report.transaction.after !== 'autocommit') {
|
|
73
91
|
fenced = true;
|
|
74
|
-
fail('
|
|
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
|
-
|
|
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' || !/^
|
|
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' && /^
|
|
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 = '
|
|
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 = '
|
|
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 = '
|
|
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 = '
|
|
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 !== '
|
|
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 = '
|
|
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 = '
|
|
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.
|
|
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:
|
|
4
|
-
sha256:
|
|
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
|
}
|