@romedb/client 0.2.14 → 0.2.16
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 +13 -7
- package/client.cjs +52 -2
- package/client.d.ts +8 -3
- package/client.mjs +52 -2
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
serve --directory` or RomeDB Cloud. It runs in Node.js, Cloudflare Workers, browsers
|
|
5
5
|
and any other runtime with `fetch`.
|
|
6
6
|
|
|
7
|
-
The package is about 30 KB to download, has no dependencies and has no native addon.
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
The package is about 30 KB to download, has no dependencies and has no native addon. To
|
|
8
|
+
open a local database file, use [`@romedb/node`](https://www.npmjs.com/package/@romedb/node),
|
|
9
|
+
which depends on this package.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
@@ -52,10 +52,16 @@ export default {
|
|
|
52
52
|
|
|
53
53
|
## Using it with `@romedb/node`
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
`@romedb/node` depends on this package at the same version. Its `client`, `values` and
|
|
56
|
+
`embedded-client` entries use the classes from here, so a `Record` or `Float64` made by
|
|
57
|
+
one package works in the other.
|
|
58
|
+
|
|
59
|
+
That needs one installed copy of `@romedb/client`, so keep both packages at the same
|
|
60
|
+
version. If the versions differ, your package manager installs a second copy, and a
|
|
61
|
+
client refuses values made by the other copy with `unsupported parameter value`.
|
|
62
|
+
|
|
63
|
+
The `Database` and `AsyncDatabase` classes of the main `@romedb/node` entry keep their
|
|
64
|
+
own `Record` and `Vector`. Use the types of that entry with them.
|
|
59
65
|
|
|
60
66
|
## Saved requests
|
|
61
67
|
|
package/client.cjs
CHANGED
|
@@ -161,12 +161,14 @@ class RemoteDatabase {
|
|
|
161
161
|
async exportFile(options = {}) {
|
|
162
162
|
this.#assertOpen();
|
|
163
163
|
if (this.#transport) throw new ClientError('RDB_UNSUPPORTED', 'Export embedded databases through EmbeddedDatabase.exportFile', 'not-sent');
|
|
164
|
-
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');
|
|
164
|
+
if (!plainObject(options) || Object.keys(options).some(key => !['signal', 'timeoutMs', 'waitMs', 'emptyTables', 'newIdentity'].includes(key))) throw new TypeError('export options take signal, timeoutMs, emptyTables and newIdentity');
|
|
165
165
|
checkCopyOptions(options);
|
|
166
166
|
const duration = timeout(options.timeoutMs ?? this.#timeout);
|
|
167
167
|
if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal');
|
|
168
168
|
if (options.signal?.aborted) throw new ClientError('RDB_CANCELLED', 'Request cancelled before dispatch', 'not-sent');
|
|
169
169
|
if (this.#active.size >= 32) throw new ClientError('RDB_LIMIT', 'Client request limit reached', 'not-sent');
|
|
170
|
+
if (options.waitMs !== undefined && (!Number.isInteger(options.waitMs) || options.waitMs < 1 || options.waitMs > MAX_EXPORT_WAIT)) throw new RangeError(`waitMs must be 1..${MAX_EXPORT_WAIT}`);
|
|
171
|
+
const finished = this.#cloud ? await this.#exportJob(options, duration) : undefined;
|
|
170
172
|
const controller = new AbortController();
|
|
171
173
|
const abort = () => controller.abort();
|
|
172
174
|
options.signal?.addEventListener('abort', abort, { once: true });
|
|
@@ -183,7 +185,7 @@ class RemoteDatabase {
|
|
|
183
185
|
const credential = this.#token;
|
|
184
186
|
let status;
|
|
185
187
|
try {
|
|
186
|
-
const response = await this.#fetch(this.#url + '/file' + fileQuery(options), {
|
|
188
|
+
const response = await this.#fetch(this.#url + (finished ?? '/file' + fileQuery(options)), {
|
|
187
189
|
method: 'GET',
|
|
188
190
|
headers: { authorization: `Bearer ${credential}`, accept: `${DATABASE_FILE_TYPE}, application/json`, ...(this.#id === undefined ? {} : { 'x-romedb-database-id': this.#id }) },
|
|
189
191
|
redirect: 'manual', signal: controller.signal,
|
|
@@ -215,6 +217,53 @@ class RemoteDatabase {
|
|
|
215
217
|
throw new ClientError(controller.signal.aborted ? 'RDB_CANCELLED' : status === undefined ? 'RDB_TRANSPORT' : 'RDB_PROTOCOL', 'Database file request failed', 'read-failed', { ...(status === undefined ? {} : { status }), cause: error });
|
|
216
218
|
}
|
|
217
219
|
}
|
|
220
|
+
// Cloud builds a file in a job that outlasts any one request, so this starts the job
|
|
221
|
+
// and waits for it. It gives the path of the finished file, or undefined from a
|
|
222
|
+
// service without jobs, whose `/file` builds the copy while the request waits.
|
|
223
|
+
async #exportJob(options, duration) {
|
|
224
|
+
const controller = new AbortController();
|
|
225
|
+
const abort = () => controller.abort();
|
|
226
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
227
|
+
this.#active.add(controller);
|
|
228
|
+
const credential = this.#token, deadline = Date.now() + (options.waitMs ?? MAX_EXPORT_WAIT);
|
|
229
|
+
const call = (method, path) => this.#fetch(this.#url + path, {
|
|
230
|
+
method, redirect: 'manual', signal: AbortSignal.any([controller.signal, AbortSignal.timeout(duration)]),
|
|
231
|
+
headers: { authorization: `Bearer ${credential}`, accept: 'application/json', ...(this.#id === undefined ? {} : { 'x-romedb-database-id': this.#id }) },
|
|
232
|
+
});
|
|
233
|
+
const refused = async response => {
|
|
234
|
+
const retryAfterMs = retryAfter(response.headers.get('retry-after'));
|
|
235
|
+
const reply = await boundedJson(response, MAX_RESPONSE).catch(() => undefined);
|
|
236
|
+
const code = plainObject(reply?.error) && typeof reply.error.code === 'string' && /^RDB_[A-Z0-9_]{1,80}$/.test(reply.error.code) ? reply.error.code : httpCode(response.status);
|
|
237
|
+
return new ClientError(code, safeMessage(reply, credential), 'read-failed', { status: response.status, ...(retryAfterMs === undefined ? {} : { retryAfterMs }) });
|
|
238
|
+
};
|
|
239
|
+
try {
|
|
240
|
+
const started = await call('POST', '/file-exports' + fileQuery(options));
|
|
241
|
+
if ([404, 405, 503].includes(started.status) && started.headers.get('retry-after') === null) { await started.body?.cancel(); return undefined; }
|
|
242
|
+
if (started.status !== 202) throw await refused(started);
|
|
243
|
+
const job = await boundedJson(started, MAX_RESPONSE);
|
|
244
|
+
if (!plainObject(job) || typeof job.id !== 'string' || !/^[0-9a-f-]{36}$/.test(job.id)) throw new ClientError('RDB_PROTOCOL', 'Invalid file export response', 'read-failed');
|
|
245
|
+
for (let failures = 0; ;) {
|
|
246
|
+
if (Date.now() >= deadline) throw new ClientError('RDB_TIMEOUT', 'The file export is still running', 'read-failed');
|
|
247
|
+
await pause(EXPORT_POLL_MS, controller.signal);
|
|
248
|
+
const reply = await call('GET', `/file-exports/${job.id}`).catch(error => {
|
|
249
|
+
if (controller.signal.aborted || ++failures > EXPORT_POLL_FAILURES) throw error;
|
|
250
|
+
});
|
|
251
|
+
if (!reply) continue;
|
|
252
|
+
if (!reply.ok) throw await refused(reply);
|
|
253
|
+
failures = 0;
|
|
254
|
+
const state = await boundedJson(reply, MAX_RESPONSE);
|
|
255
|
+
if (state?.state === 'ready') return `/file-exports/${job.id}/file`;
|
|
256
|
+
if (state?.state === 'failed') throw new ClientError('RDB_STORAGE', typeof state.error === 'string' ? state.error : 'The file export did not finish', 'read-failed');
|
|
257
|
+
if (state?.state !== 'running') throw new ClientError('RDB_PROTOCOL', 'Invalid file export state', 'read-failed');
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error instanceof ClientError) throw error;
|
|
261
|
+
throw new ClientError(controller.signal.aborted ? 'RDB_CANCELLED' : 'RDB_TRANSPORT', 'File export request failed', 'read-failed');
|
|
262
|
+
} finally {
|
|
263
|
+
options.signal?.removeEventListener('abort', abort);
|
|
264
|
+
this.#active.delete(controller);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
218
267
|
async close() {
|
|
219
268
|
this.#closed = true;
|
|
220
269
|
for (const controller of this.#active) controller.abort();
|
|
@@ -441,6 +490,7 @@ function encodeWorkLimits(value) {
|
|
|
441
490
|
return Object.fromEntries(Object.keys(ceilings).filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
|
|
442
491
|
}
|
|
443
492
|
function checkSequence(value) { if (!sequence(value)) throw new RangeError('sequence requires a nonnegative safe integer below Number.MAX_SAFE_INTEGER'); }
|
|
493
|
+
const MAX_EXPORT_WAIT = 3 * 3600_000, EXPORT_POLL_MS = 2000, EXPORT_POLL_FAILURES = 10;
|
|
444
494
|
function timeout(value) { if (!Number.isInteger(value) || value < 1 || value > 120_000) throw new RangeError('timeoutMs must be 1..120000'); return value; }
|
|
445
495
|
function byteLength(text) { return encoder.encode(text).byteLength; }
|
|
446
496
|
function checkBodySize(body, queryVersion = 2) {
|
package/client.d.ts
CHANGED
|
@@ -13,7 +13,10 @@ export interface CopyOptions {
|
|
|
13
13
|
/** Leave out the database ID, the sequence and the receipts: the copy is a new database. */
|
|
14
14
|
newIdentity?: boolean;
|
|
15
15
|
}
|
|
16
|
-
export interface FileExportOptions extends RequestOptions, CopyOptions {
|
|
16
|
+
export interface FileExportOptions extends RequestOptions, CopyOptions {
|
|
17
|
+
/** How long Cloud's job may take to build the file. The default is 3 hours, the longest it runs. */
|
|
18
|
+
waitMs?: number;
|
|
19
|
+
}
|
|
17
20
|
export interface WorkLimits { rowsRead?: number; rowMutations?: number; vmSteps?: number; }
|
|
18
21
|
export interface ReadOptions extends RequestOptions { afterSequence?: number; workLimits?: WorkLimits; }
|
|
19
22
|
export interface WriteOptions extends ReadOptions { requestId?: string; ifSequence?: number; }
|
|
@@ -82,8 +85,10 @@ export class RemoteDatabase implements Client {
|
|
|
82
85
|
/** Ask Cloud to start the database's container and open the database without running a query. */
|
|
83
86
|
warm(options?: RequestOptions): Promise<WarmResult>;
|
|
84
87
|
/**
|
|
85
|
-
* Download the database as a .romedb file
|
|
86
|
-
*
|
|
88
|
+
* Download the database as a .romedb file. timeoutMs covers each request, until the file starts
|
|
89
|
+
* arriving; signal also stops the download. Cloud builds the file in a job, so a large database
|
|
90
|
+
* can take minutes: this waits for it, up to waitMs, then downloads it. The file stays in Cloud
|
|
91
|
+
* for a day.
|
|
87
92
|
*/
|
|
88
93
|
exportFile(options?: FileExportOptions): Promise<DatabaseFile>;
|
|
89
94
|
readonly sequence: number;
|
package/client.mjs
CHANGED
|
@@ -166,12 +166,14 @@ class RemoteDatabase {
|
|
|
166
166
|
async exportFile(options = {}) {
|
|
167
167
|
this.#assertOpen();
|
|
168
168
|
if (this.#transport) throw new ClientError('RDB_UNSUPPORTED', 'Export embedded databases through EmbeddedDatabase.exportFile', 'not-sent');
|
|
169
|
-
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');
|
|
169
|
+
if (!plainObject(options) || Object.keys(options).some(key => !['signal', 'timeoutMs', 'waitMs', 'emptyTables', 'newIdentity'].includes(key))) throw new TypeError('export options take signal, timeoutMs, emptyTables and newIdentity');
|
|
170
170
|
checkCopyOptions(options);
|
|
171
171
|
const duration = timeout(options.timeoutMs ?? this.#timeout);
|
|
172
172
|
if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal');
|
|
173
173
|
if (options.signal?.aborted) throw new ClientError('RDB_CANCELLED', 'Request cancelled before dispatch', 'not-sent');
|
|
174
174
|
if (this.#active.size >= 32) throw new ClientError('RDB_LIMIT', 'Client request limit reached', 'not-sent');
|
|
175
|
+
if (options.waitMs !== undefined && (!Number.isInteger(options.waitMs) || options.waitMs < 1 || options.waitMs > MAX_EXPORT_WAIT)) throw new RangeError(`waitMs must be 1..${MAX_EXPORT_WAIT}`);
|
|
176
|
+
const finished = this.#cloud ? await this.#exportJob(options, duration) : undefined;
|
|
175
177
|
const controller = new AbortController();
|
|
176
178
|
const abort = () => controller.abort();
|
|
177
179
|
options.signal?.addEventListener('abort', abort, { once: true });
|
|
@@ -188,7 +190,7 @@ class RemoteDatabase {
|
|
|
188
190
|
const credential = this.#token;
|
|
189
191
|
let status;
|
|
190
192
|
try {
|
|
191
|
-
const response = await this.#fetch(this.#url + '/file' + fileQuery(options), {
|
|
193
|
+
const response = await this.#fetch(this.#url + (finished ?? '/file' + fileQuery(options)), {
|
|
192
194
|
method: 'GET',
|
|
193
195
|
headers: { authorization: `Bearer ${credential}`, accept: `${DATABASE_FILE_TYPE}, application/json`, ...(this.#id === undefined ? {} : { 'x-romedb-database-id': this.#id }) },
|
|
194
196
|
redirect: 'manual', signal: controller.signal,
|
|
@@ -220,6 +222,53 @@ class RemoteDatabase {
|
|
|
220
222
|
throw new ClientError(controller.signal.aborted ? 'RDB_CANCELLED' : status === undefined ? 'RDB_TRANSPORT' : 'RDB_PROTOCOL', 'Database file request failed', 'read-failed', { ...(status === undefined ? {} : { status }), cause: error });
|
|
221
223
|
}
|
|
222
224
|
}
|
|
225
|
+
// Cloud builds a file in a job that outlasts any one request, so this starts the job
|
|
226
|
+
// and waits for it. It gives the path of the finished file, or undefined from a
|
|
227
|
+
// service without jobs, whose `/file` builds the copy while the request waits.
|
|
228
|
+
async #exportJob(options, duration) {
|
|
229
|
+
const controller = new AbortController();
|
|
230
|
+
const abort = () => controller.abort();
|
|
231
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
232
|
+
this.#active.add(controller);
|
|
233
|
+
const credential = this.#token, deadline = Date.now() + (options.waitMs ?? MAX_EXPORT_WAIT);
|
|
234
|
+
const call = (method, path) => this.#fetch(this.#url + path, {
|
|
235
|
+
method, redirect: 'manual', signal: AbortSignal.any([controller.signal, AbortSignal.timeout(duration)]),
|
|
236
|
+
headers: { authorization: `Bearer ${credential}`, accept: 'application/json', ...(this.#id === undefined ? {} : { 'x-romedb-database-id': this.#id }) },
|
|
237
|
+
});
|
|
238
|
+
const refused = async response => {
|
|
239
|
+
const retryAfterMs = retryAfter(response.headers.get('retry-after'));
|
|
240
|
+
const reply = await boundedJson(response, MAX_RESPONSE).catch(() => undefined);
|
|
241
|
+
const code = plainObject(reply?.error) && typeof reply.error.code === 'string' && /^RDB_[A-Z0-9_]{1,80}$/.test(reply.error.code) ? reply.error.code : httpCode(response.status);
|
|
242
|
+
return new ClientError(code, safeMessage(reply, credential), 'read-failed', { status: response.status, ...(retryAfterMs === undefined ? {} : { retryAfterMs }) });
|
|
243
|
+
};
|
|
244
|
+
try {
|
|
245
|
+
const started = await call('POST', '/file-exports' + fileQuery(options));
|
|
246
|
+
if ([404, 405, 503].includes(started.status) && started.headers.get('retry-after') === null) { await started.body?.cancel(); return undefined; }
|
|
247
|
+
if (started.status !== 202) throw await refused(started);
|
|
248
|
+
const job = await boundedJson(started, MAX_RESPONSE);
|
|
249
|
+
if (!plainObject(job) || typeof job.id !== 'string' || !/^[0-9a-f-]{36}$/.test(job.id)) throw new ClientError('RDB_PROTOCOL', 'Invalid file export response', 'read-failed');
|
|
250
|
+
for (let failures = 0; ;) {
|
|
251
|
+
if (Date.now() >= deadline) throw new ClientError('RDB_TIMEOUT', 'The file export is still running', 'read-failed');
|
|
252
|
+
await pause(EXPORT_POLL_MS, controller.signal);
|
|
253
|
+
const reply = await call('GET', `/file-exports/${job.id}`).catch(error => {
|
|
254
|
+
if (controller.signal.aborted || ++failures > EXPORT_POLL_FAILURES) throw error;
|
|
255
|
+
});
|
|
256
|
+
if (!reply) continue;
|
|
257
|
+
if (!reply.ok) throw await refused(reply);
|
|
258
|
+
failures = 0;
|
|
259
|
+
const state = await boundedJson(reply, MAX_RESPONSE);
|
|
260
|
+
if (state?.state === 'ready') return `/file-exports/${job.id}/file`;
|
|
261
|
+
if (state?.state === 'failed') throw new ClientError('RDB_STORAGE', typeof state.error === 'string' ? state.error : 'The file export did not finish', 'read-failed');
|
|
262
|
+
if (state?.state !== 'running') throw new ClientError('RDB_PROTOCOL', 'Invalid file export state', 'read-failed');
|
|
263
|
+
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (error instanceof ClientError) throw error;
|
|
266
|
+
throw new ClientError(controller.signal.aborted ? 'RDB_CANCELLED' : 'RDB_TRANSPORT', 'File export request failed', 'read-failed');
|
|
267
|
+
} finally {
|
|
268
|
+
options.signal?.removeEventListener('abort', abort);
|
|
269
|
+
this.#active.delete(controller);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
223
272
|
async close() {
|
|
224
273
|
this.#closed = true;
|
|
225
274
|
for (const controller of this.#active) controller.abort();
|
|
@@ -446,6 +495,7 @@ function encodeWorkLimits(value) {
|
|
|
446
495
|
return Object.fromEntries(Object.keys(ceilings).filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
|
|
447
496
|
}
|
|
448
497
|
function checkSequence(value) { if (!sequence(value)) throw new RangeError('sequence requires a nonnegative safe integer below Number.MAX_SAFE_INTEGER'); }
|
|
498
|
+
const MAX_EXPORT_WAIT = 3 * 3600_000, EXPORT_POLL_MS = 2000, EXPORT_POLL_FAILURES = 10;
|
|
449
499
|
function timeout(value) { if (!Number.isInteger(value) || value < 1 || value > 120_000) throw new RangeError('timeoutMs must be 1..120000'); return value; }
|
|
450
500
|
function byteLength(text) { return encoder.encode(text).byteLength; }
|
|
451
501
|
function checkBodySize(body, queryVersion = 2) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@romedb/client",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.16",
|
|
4
4
|
"description": "RomeDB client for Node.js, Cloudflare Workers and browsers. Connects by URL to romedb serve or RomeDB Cloud, with no native addon.",
|
|
5
5
|
"main": "client.cjs",
|
|
6
6
|
"types": "client.d.ts",
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
"node": "./values.cjs",
|
|
16
16
|
"default": "./values.mjs"
|
|
17
17
|
},
|
|
18
|
+
"./internal": {
|
|
19
|
+
"node": "./client-internal.cjs"
|
|
20
|
+
},
|
|
18
21
|
"./package.json": "./package.json"
|
|
19
22
|
},
|
|
20
23
|
"files": [
|