@romedb/client 0.2.15 → 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/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 (romedb serve and serve --directory; not Cloud yet).
86
- * timeoutMs covers building the copy, until the file starts arriving; signal also stops the download.
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.15",
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",