@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.
- package/README.md +871 -433
- package/THIRD_PARTY_CRATE_NOTICES.md +4 -4
- package/client.cjs +114 -22
- package/client.d.ts +27 -1
- package/client.mjs +114 -22
- package/embedded-client.cjs +73 -14
- package/embedded-client.d.ts +11 -1
- package/embedded-worker.cjs +39 -19
- package/index.cjs +23 -7
- package/index.d.ts +9 -0
- package/native.cjs +2 -2
- package/package.json +11 -11
- package/romedb.node +2 -2
- package/values.cjs +11 -1
- package/values.d.ts +1 -1
- package/values.mjs +12 -2
- package/worker.cjs +21 -3
package/embedded-client.cjs
CHANGED
|
@@ -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('
|
|
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,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('
|
|
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('
|
|
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('
|
|
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('
|
|
147
|
-
if (signal?.aborted) return Promise.reject(new common.ClientError('
|
|
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('
|
|
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
|
-
|
|
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('
|
|
221
|
+
reject(new common.ClientError('RDB_TRANSPORT', 'Embedded request could not be dispatched', 'not-sent'));
|
|
163
222
|
}
|
|
164
223
|
});
|
|
165
224
|
}
|
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, 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>;
|
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,36 @@ 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 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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
64
|
-
if (sequence >= Number.MAX_SAFE_INTEGER - 1 && !reading) fail('
|
|
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: '
|
|
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 (['
|
|
91
|
+
if (['RDB_STORAGE', 'RDB_ROLLBACK'].includes(error.code) || report.transaction.after !== 'autocommit') {
|
|
73
92
|
fenced = true;
|
|
74
|
-
fail('
|
|
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
|
-
|
|
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' || !/^
|
|
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' && /^
|
|
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 = '
|
|
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 = '
|
|
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 = '
|
|
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 = '
|
|
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 !== '
|
|
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 = '
|
|
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.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:
|
|
4
|
-
sha256:
|
|
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
|
}
|