@romedb/node 0.0.0-stage → 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/LICENSE.md +20 -0
- package/README.md +1021 -2
- package/RUST-LIBRARY-NOTICES.html +7328 -0
- package/THIRD_PARTY_CRATE_NOTICES.md +16853 -0
- package/THIRD_PARTY_NOTICES.md +1077 -0
- package/client-internal.cjs +2 -0
- package/client.cjs +483 -0
- package/client.d.ts +93 -0
- package/client.mjs +496 -0
- package/embedded-client.cjs +238 -0
- package/embedded-client.d.ts +29 -0
- package/embedded-worker.cjs +115 -0
- package/index.cjs +481 -0
- package/index.d.ts +112 -0
- package/native.cjs +20 -0
- package/package.json +68 -3
- package/request-journal.cjs +47 -0
- package/romedb.node +4 -0
- package/values.cjs +211 -0
- package/values.d.ts +15 -0
- package/values.mjs +220 -0
- package/worker.cjs +37 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { Worker } = require('node:worker_threads');
|
|
3
|
+
const { addAbortListener } = require('node:events');
|
|
4
|
+
const { randomUUID } = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs/promises');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { pathToFileURL } = require('node:url');
|
|
8
|
+
const common = require('./client.cjs');
|
|
9
|
+
const { connect, RemoteDatabase, ClientError, Float64, Record, Vector, encodeValue, decodeValue, normalizeUrl, validatePrepared, saveRequest, loadRequest } = common;
|
|
10
|
+
const { plainObject } = require('./values.cjs');
|
|
11
|
+
const { createEmbeddedClient } = require('./client-internal.cjs');
|
|
12
|
+
const native = require('./native.cjs');
|
|
13
|
+
const sessions = new Map();
|
|
14
|
+
const construction = Symbol('EmbeddedDatabase');
|
|
15
|
+
|
|
16
|
+
async function openEmbedded(options) {
|
|
17
|
+
const duration = options?.timeoutMs ?? 20000;
|
|
18
|
+
if (!Number.isInteger(duration) || duration < 1 || duration > 120000) throw new RangeError('timeoutMs must be 1..120000');
|
|
19
|
+
let abandoned = false;
|
|
20
|
+
const opening = openEmbeddedFile(options);
|
|
21
|
+
opening.then(database => { if (abandoned) void database.close().catch(() => {}); }, () => {});
|
|
22
|
+
let timer;
|
|
23
|
+
const deadline = new Promise((resolve, reject) => {
|
|
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
|
+
});
|
|
26
|
+
try { return await Promise.race([opening, deadline]); }
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error instanceof common.ClientError || error instanceof TypeError || error instanceof RangeError) throw error;
|
|
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
|
+
throw new common.ClientError(code, 'Embedded filesystem operation failed', 'not-sent');
|
|
31
|
+
} finally { clearTimeout(timer); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function openEmbeddedFile(options) {
|
|
35
|
+
if (!plainObject(options) || Object.keys(options).some(key => !['path', 'create', 'timeoutMs', 'maxOpen'].includes(key)) || typeof options.path !== 'string' || !options.path || options.path.length > 4096 || options.path.includes('\0') || (options.create !== undefined && typeof options.create !== 'boolean')) throw new TypeError('embedded options require a bounded path, optional create, timeoutMs and maxOpen');
|
|
36
|
+
if (options.timeoutMs !== undefined && (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > 120000)) throw new RangeError('timeoutMs must be 1..120000');
|
|
37
|
+
const maxOpen = options.maxOpen ?? 16;
|
|
38
|
+
if (!Number.isInteger(maxOpen) || maxOpen < 1 || maxOpen > 1024) throw new RangeError('maxOpen must be 1..1024');
|
|
39
|
+
const file = path.resolve(options.path);
|
|
40
|
+
if (options.create) {
|
|
41
|
+
if (sessions.size >= maxOpen) throw openLimit();
|
|
42
|
+
const parsed = path.parse(file);
|
|
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('RDB_CONFLICT', 'Database sidecar exists; recover it before creating a file', 'not-sent'); }
|
|
45
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
46
|
+
}
|
|
47
|
+
const handle = await fs.open(file, 'wx', 0o600);
|
|
48
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
49
|
+
const parent = await fs.open(path.dirname(file), 'r');
|
|
50
|
+
try { await parent.sync(); } finally { await parent.close(); }
|
|
51
|
+
}
|
|
52
|
+
let metadata;
|
|
53
|
+
try { metadata = await fs.lstat(file); }
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code === 'ENOENT') throw new common.ClientError('RDB_NOT_FOUND', 'Open an existing database or explicitly create one', 'not-sent');
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
if (!metadata.isFile() || metadata.nlink !== 1) throw new common.ClientError('RDB_VALIDATION', 'Use a regular database file with one link', 'not-sent');
|
|
59
|
+
const canonical = await fs.realpath(file);
|
|
60
|
+
let session = sessions.get(canonical);
|
|
61
|
+
if (session?.closing) { await session.closed; return openEmbeddedFile({ ...options, create: false }); }
|
|
62
|
+
if (!session) {
|
|
63
|
+
if (sessions.size >= maxOpen) throw openLimit();
|
|
64
|
+
session = new Session(canonical, options.create === true);
|
|
65
|
+
sessions.set(canonical, session);
|
|
66
|
+
}
|
|
67
|
+
session.references++;
|
|
68
|
+
try {
|
|
69
|
+
await session.ready;
|
|
70
|
+
const client = await createEmbeddedClient({ url: session.url, timeoutMs: options.timeoutMs }, (route, body, signal, duration) => session.request(route === '' ? 'info' : route === '/read' ? 'read' : 'write', body, signal, duration));
|
|
71
|
+
return new EmbeddedDatabase(client, session, canonical, construction);
|
|
72
|
+
} catch (error) { await releaseSession(session, canonical); throw error; }
|
|
73
|
+
}
|
|
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
|
+
|
|
106
|
+
function openLimit() {
|
|
107
|
+
return new common.ClientError('RDB_LIMIT', `Embedded database limit reached: ${sessions.size} files are open; close one or raise maxOpen`, 'not-sent');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
class EmbeddedDatabase {
|
|
111
|
+
#client; #session; #path; #closing;
|
|
112
|
+
constructor(client, session, file, key) {
|
|
113
|
+
if (key !== construction) throw new TypeError('use openEmbedded()');
|
|
114
|
+
this.#client = client; this.#session = session; this.#path = file;
|
|
115
|
+
}
|
|
116
|
+
get url() { return this.#client.url; }
|
|
117
|
+
get sequence() { return this.#client.sequence; }
|
|
118
|
+
get sequenceKnown() { return this.#client.sequenceKnown; }
|
|
119
|
+
get databaseId() { return this.#client.databaseId; }
|
|
120
|
+
get path() { return this.#path; }
|
|
121
|
+
info(options) { return this.#client.info(options); }
|
|
122
|
+
read(statements, options) { return this.#client.read(statements, options); }
|
|
123
|
+
prepareWrite(statements, options) { return this.#client.prepareWrite(statements, options); }
|
|
124
|
+
submit(request, options) { return this.#client.submit(request, options); }
|
|
125
|
+
recover(request, options) { return this.#client.recover(request, options); }
|
|
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
|
+
}
|
|
153
|
+
close() {
|
|
154
|
+
this.#closing ??= (async () => { await this.#client.close(); await releaseSession(this.#session, this.#path); })();
|
|
155
|
+
return this.#closing;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
class Session {
|
|
160
|
+
#worker; #pending = new Map(); #bytes = 0; #next = 0; #failure;
|
|
161
|
+
references = 0; closing = false; ready; closed;
|
|
162
|
+
constructor(file, create) {
|
|
163
|
+
this.url = pathToFileURL(file).href;
|
|
164
|
+
this.#worker = new Worker(require.resolve('./embedded-worker.cjs'), { workerData: { path: file, url: this.url, databaseId: randomUUID(), create } });
|
|
165
|
+
let resolveReady, rejectReady, resolveExit;
|
|
166
|
+
this.ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
|
|
167
|
+
this.closed = new Promise(resolve => { resolveExit = resolve; });
|
|
168
|
+
this.ready.catch(() => {});
|
|
169
|
+
this.#worker.on('message', message => {
|
|
170
|
+
if (message.ready) return resolveReady();
|
|
171
|
+
if (message.readyError) {
|
|
172
|
+
this.#failure = new common.ClientError(message.readyError.code, message.readyError.message, 'not-sent');
|
|
173
|
+
rejectReady(this.#failure);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const pending = this.#pending.get(message.id);
|
|
177
|
+
if (!pending) return;
|
|
178
|
+
this.#pending.delete(message.id);
|
|
179
|
+
this.#bytes -= pending.bytes;
|
|
180
|
+
pending.listener?.[Symbol.dispose]();
|
|
181
|
+
native.releaseCancellationToken(pending.token);
|
|
182
|
+
if (message.error) pending.reject(new common.ClientError(message.error.code, message.error.message, message.error.outcome));
|
|
183
|
+
else pending.resolve(message.result);
|
|
184
|
+
});
|
|
185
|
+
this.#worker.on('error', () => {
|
|
186
|
+
this.#failure ??= new common.ClientError('RDB_TRANSPORT', 'Embedded worker failed; reconcile pending writes', 'unknown');
|
|
187
|
+
rejectReady(this.#failure);
|
|
188
|
+
});
|
|
189
|
+
this.#worker.on('exit', code => {
|
|
190
|
+
this.#failure ??= new common.ClientError('RDB_CLOSED', code === 0 ? 'Embedded worker closed' : 'Embedded worker exited unexpectedly', 'unknown');
|
|
191
|
+
rejectReady(this.#failure);
|
|
192
|
+
for (const pending of this.#pending.values()) {
|
|
193
|
+
pending.listener?.[Symbol.dispose]();
|
|
194
|
+
native.releaseCancellationToken(pending.token);
|
|
195
|
+
pending.reject(this.#failure);
|
|
196
|
+
}
|
|
197
|
+
this.#pending.clear(); this.#bytes = 0;
|
|
198
|
+
resolveExit();
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
request(operation, body, signal, duration) {
|
|
202
|
+
if (this.#failure) return Promise.reject(this.#failure);
|
|
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'));
|
|
205
|
+
const bytes = body === undefined ? 0 : Buffer.byteLength(body);
|
|
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'));
|
|
207
|
+
const token = native.createCancellationToken(duration === undefined ? undefined : String(duration));
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const id = ++this.#next;
|
|
210
|
+
const listener = signal === undefined ? undefined : addAbortListener(signal, () => {
|
|
211
|
+
native.cancelOperation(token);
|
|
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'));
|
|
214
|
+
});
|
|
215
|
+
this.#pending.set(id, { resolve, reject, token, listener, bytes }); this.#bytes += bytes;
|
|
216
|
+
try { this.#worker.postMessage({ id, operation, body, cancellationKey: token }); }
|
|
217
|
+
catch {
|
|
218
|
+
this.#pending.delete(id); this.#bytes -= bytes;
|
|
219
|
+
listener?.[Symbol.dispose](); native.releaseCancellationToken(token);
|
|
220
|
+
reject(new common.ClientError('RDB_TRANSPORT', 'Embedded request could not be dispatched', 'not-sent'));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async close() {
|
|
225
|
+
this.closing = true;
|
|
226
|
+
try { if (!this.#failure) await this.request('close'); }
|
|
227
|
+
finally { await this.closed; }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function releaseSession(session, file) {
|
|
232
|
+
if (--session.references === 0) {
|
|
233
|
+
try { await session.close(); }
|
|
234
|
+
finally { if (sessions.get(file) === session) sessions.delete(file); }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { openEmbedded, EmbeddedDatabase, connect, RemoteDatabase, ClientError, Float64, Record, Vector, encodeValue, decodeValue, normalizeUrl, validatePrepared, saveRequest, loadRequest };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export * 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; }
|
|
6
|
+
export interface EmbeddedOptions { path: string; create?: boolean; timeoutMs?: number; maxOpen?: number; }
|
|
7
|
+
export class EmbeddedDatabase implements Client {
|
|
8
|
+
private constructor();
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly url: string;
|
|
11
|
+
readonly sequence: number;
|
|
12
|
+
readonly sequenceKnown: boolean;
|
|
13
|
+
readonly databaseId: string;
|
|
14
|
+
info(options?: RequestOptions): Promise<DatabaseInfo>;
|
|
15
|
+
read(statements: Statement[], options?: ReadOptions): Promise<BatchResult>;
|
|
16
|
+
prepareWrite(statements: Statement[], options?: WriteOptions): PreparedWrite;
|
|
17
|
+
submit(request: PreparedWrite, options?: RequestOptions): Promise<BatchResult>;
|
|
18
|
+
recover(request: PreparedWrite, options?: RequestOptions): Promise<BatchResult>;
|
|
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>;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export function openEmbedded(options: EmbeddedOptions): Promise<EmbeddedDatabase>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
3
|
+
const { createHash } = require('node:crypto');
|
|
4
|
+
const { NativeDatabase, cancelOperation } = require('./native.cjs');
|
|
5
|
+
const { validatePrepared } = require('./client.cjs');
|
|
6
|
+
let database;
|
|
7
|
+
let sequence = 0;
|
|
8
|
+
let fenced = false;
|
|
9
|
+
const receipts = new Map();
|
|
10
|
+
const copies = new Map();
|
|
11
|
+
try {
|
|
12
|
+
database = new NativeDatabase(workerData.path, '10000', '67108864', workerData.create ? 'create-mvcc' : 'mvcc');
|
|
13
|
+
const foreignKeys = JSON.parse(database.execute('PRAGMA foreign_keys=ON', '{}'));
|
|
14
|
+
if (foreignKeys.execution.error) throw new Error(foreignKeys.execution.error.message);
|
|
15
|
+
parentPort.postMessage({ ready: true });
|
|
16
|
+
} catch (error) {
|
|
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 } });
|
|
19
|
+
database?.close();
|
|
20
|
+
parentPort.close();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (database) parentPort.on('message', ({ id, operation, body, cancellationKey }) => {
|
|
24
|
+
try {
|
|
25
|
+
if (operation === 'close') {
|
|
26
|
+
for (const key of copies.keys()) cancelOperation(key);
|
|
27
|
+
void Promise.allSettled(copies.values()).then(() => {
|
|
28
|
+
if (!fenced) try { database.execute('PRAGMA wal_checkpoint(TRUNCATE)', '{}'); } catch {}
|
|
29
|
+
database.close();
|
|
30
|
+
parentPort.postMessage({ id });
|
|
31
|
+
parentPort.close();
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (fenced) fail('RDB_STORAGE', 'Embedded outcome is uncertain; close and reconcile the database', 'unknown');
|
|
36
|
+
if (operation === 'export') {
|
|
37
|
+
// Writes run on this thread, so none commits between the start of the copy's snapshot and reading the sequence.
|
|
38
|
+
const pending = database.startFileExport(JSON.parse(body).destination, cancellationKey);
|
|
39
|
+
const snapshot = sequence;
|
|
40
|
+
const finished = pending.finish().then(
|
|
41
|
+
bytes => parentPort.postMessage({ id, result: { sequence: snapshot, bytes: Number(bytes) } }),
|
|
42
|
+
error => {
|
|
43
|
+
const coded = /^(RDB_[A-Z0-9_]+): (.*)$/s.exec(error.message);
|
|
44
|
+
parentPort.postMessage({ id, error: { code: coded?.[1] ?? 'RDB_STORAGE', message: (coded?.[2] ?? error.message).slice(0, 2048), outcome: 'read-failed' } });
|
|
45
|
+
},
|
|
46
|
+
).finally(() => copies.delete(cancellationKey));
|
|
47
|
+
copies.set(cancellationKey, finished);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (operation === 'info') {
|
|
51
|
+
const mode = JSON.parse(database.execute('PRAGMA journal_mode', '{}'));
|
|
52
|
+
if (mode.execution.error) fail('RDB_STORAGE', 'Cannot inspect embedded journal mode', 'read-failed');
|
|
53
|
+
const engine = JSON.parse(database.execute('SELECT romedb_version()', '{}'));
|
|
54
|
+
if (engine.execution.error) fail('RDB_STORAGE', 'Cannot read the embedded engine version', 'read-failed');
|
|
55
|
+
parentPort.postMessage({ id, result: {
|
|
56
|
+
id: workerData.databaseId, sequence, adapterVersion: 1,
|
|
57
|
+
engineVersion: engine.execution.result.rows[0][0].value,
|
|
58
|
+
journalMode: mode.execution.result.rows[0][0].value,
|
|
59
|
+
capabilities: { readVersion: 2, atomicWrites: true, replayWindow: 64, exactSequenceCondition: true, sequenceScope: 'adapter', replayDurability: 'adapter-lifetime', metadata: true, checkedWork: true, foreignKeys: true },
|
|
60
|
+
limits: { maxRequestBytes: 65536, maxStatements: 32, maxRows: 1000, maxResultBytes: 262144, activeRequests: 1, queuedRequests: 32, rowsRead: 1000000, rowMutations: 100000, vmSteps: 10000000 },
|
|
61
|
+
} });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!['read', 'write'].includes(operation)) fail('RDB_UNSUPPORTED', 'Unknown embedded operation', 'not-sent');
|
|
65
|
+
const reading = operation === 'read';
|
|
66
|
+
const request = JSON.parse(body);
|
|
67
|
+
let writeBody = body;
|
|
68
|
+
if (reading) {
|
|
69
|
+
if (request.readVersion !== 2 || request.ifSequence !== undefined) fail('RDB_VALIDATION', 'Invalid fresh read envelope', 'read-failed');
|
|
70
|
+
const { readVersion, ...rest } = request;
|
|
71
|
+
writeBody = JSON.stringify(rest);
|
|
72
|
+
}
|
|
73
|
+
validatePrepared({ version: 1, url: workerData.url, databaseId: workerData.databaseId, body: writeBody });
|
|
74
|
+
const hash = createHash('sha256').update(body).digest('hex');
|
|
75
|
+
if (!reading && receipts.has(request.requestId)) {
|
|
76
|
+
const receipt = receipts.get(request.requestId);
|
|
77
|
+
if (receipt.hash !== hash) fail('RDB_REQUEST_IDENTITY', 'Request ID was reused with another body', 'unknown');
|
|
78
|
+
parentPort.postMessage({ id, result: receipt.result });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
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');
|
|
83
|
+
let result;
|
|
84
|
+
if (!reading && request.ifSequence !== undefined && request.ifSequence !== sequence) {
|
|
85
|
+
result = { error: { code: 'RDB_CONFLICT', message: 'Sequence changed; refresh before saving' } };
|
|
86
|
+
} else {
|
|
87
|
+
const report = JSON.parse(database.executeAtomicRequest(JSON.stringify({ readOnly: reading, statements: request.statements, ...(request.workLimits === undefined ? {} : { workLimits: request.workLimits }) }), cancellationKey));
|
|
88
|
+
if (report.execution.error) {
|
|
89
|
+
const error = report.execution.error;
|
|
90
|
+
if (['RDB_STORAGE', 'RDB_ROLLBACK'].includes(error.code) || report.transaction.after !== 'autocommit') {
|
|
91
|
+
fenced = true;
|
|
92
|
+
fail('RDB_STORAGE', 'Atomic request outcome is uncertain; close and reconcile the database', 'unknown');
|
|
93
|
+
}
|
|
94
|
+
if (reading) fail(error.code, error.message, 'read-failed');
|
|
95
|
+
result = { error };
|
|
96
|
+
} else result = report.execution.result;
|
|
97
|
+
}
|
|
98
|
+
if (reading) result = { ...result, readVersion: 2, requestId: request.requestId, sequence };
|
|
99
|
+
else {
|
|
100
|
+
result = { ...result, requestId: request.requestId, sequence: ++sequence };
|
|
101
|
+
receipts.set(request.requestId, { hash, result });
|
|
102
|
+
if (receipts.size > 64) receipts.delete(receipts.keys().next().value);
|
|
103
|
+
}
|
|
104
|
+
parentPort.postMessage({ id, result });
|
|
105
|
+
} catch (error) {
|
|
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') } });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function fail(code, message, outcome) {
|
|
112
|
+
const error = new Error(message);
|
|
113
|
+
Object.assign(error, { code, outcome });
|
|
114
|
+
throw error;
|
|
115
|
+
}
|