@romedb/node 0.2.12 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +849 -433
- package/THIRD_PARTY_CRATE_NOTICES.md +4 -4
- package/client.cjs +107 -21
- package/client.d.ts +15 -1
- package/client.mjs +107 -21
- package/embedded-client.cjs +71 -13
- package/embedded-client.d.ts +11 -1
- package/embedded-worker.cjs +38 -19
- package/index.cjs +18 -7
- package/index.d.ts +3 -0
- package/native.cjs +2 -2
- package/package.json +11 -11
- package/romedb.node +2 -2
- package/worker.cjs +21 -3
|
@@ -4,7 +4,7 @@ Generated from the pinned Cargo dependency inventory and checksummed source arch
|
|
|
4
4
|
|
|
5
5
|
This is a partial notice collection. It supplements LICENSE.md and THIRD_PARTY_NOTICES.md. Inline attributions, unconventional filenames and other bundled components require further review before a complete distribution-notice claim.
|
|
6
6
|
|
|
7
|
-
Target: `x86_64-unknown-linux-gnu`. Cargo.lock SHA-256: `
|
|
7
|
+
Target: `x86_64-unknown-linux-gnu`. Cargo.lock SHA-256: `092e973bbc52cf5defe48c0c1c1cf5bc8755b603f5a6ef47927c31bfa5276aa3`.
|
|
8
8
|
|
|
9
9
|
## Packages without collected archive or supplemental texts
|
|
10
10
|
|
|
@@ -703,13 +703,13 @@ Target: `x86_64-unknown-linux-gnu`. Cargo.lock SHA-256: `f065eb377ff1eed208add1e
|
|
|
703
703
|
- `LICENSE-APACHE`: [source text a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2](#text-a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2)
|
|
704
704
|
- `LICENSE-MIT`: [source text 42d3bf7e7d4d49d72c0555d14ed99c3ee7ce9ce3cbffbc38bbafe8c103f50969](#text-42d3bf7e7d4d49d72c0555d14ed99c3ee7ce9ce3cbffbc38bbafe8c103f50969)
|
|
705
705
|
|
|
706
|
-
### romedb-node 0.2.
|
|
706
|
+
### romedb-node 0.2.13
|
|
707
707
|
- Workspace `LICENSE.md`: [source text b646f9ee8bcaf87e8de75153b9df7a2861c7ac445c87e741768b3c2bccf47bc5](#text-b646f9ee8bcaf87e8de75153b9df7a2861c7ac445c87e741768b3c2bccf47bc5)
|
|
708
708
|
|
|
709
|
-
### romedb 0.2.
|
|
709
|
+
### romedb 0.2.13
|
|
710
710
|
- Workspace `LICENSE.md`: [source text b646f9ee8bcaf87e8de75153b9df7a2861c7ac445c87e741768b3c2bccf47bc5](#text-b646f9ee8bcaf87e8de75153b9df7a2861c7ac445c87e741768b3c2bccf47bc5)
|
|
711
711
|
|
|
712
|
-
### romeql-parser 0.2.
|
|
712
|
+
### romeql-parser 0.2.13
|
|
713
713
|
- Workspace `LICENSE.md`: [source text b646f9ee8bcaf87e8de75153b9df7a2861c7ac445c87e741768b3c2bccf47bc5](#text-b646f9ee8bcaf87e8de75153b9df7a2861c7ac445c87e741768b3c2bccf47bc5)
|
|
714
714
|
|
|
715
715
|
### rquickjs-core 0.13.0
|
package/client.cjs
CHANGED
|
@@ -7,6 +7,7 @@ const MAX_CLOUD_REQUEST = 1_048_576;
|
|
|
7
7
|
const CLOUD_QUERY_VERSION = 3;
|
|
8
8
|
const MAX_RESPONSE = 524_288;
|
|
9
9
|
const MAX_SEQUENCE = Number.MAX_SAFE_INTEGER - 1;
|
|
10
|
+
const DATABASE_FILE_TYPE = 'application/vnd.romedb.database';
|
|
10
11
|
const construction = Symbol('RemoteDatabase');
|
|
11
12
|
const warmAtConnect = Symbol('warmAtConnect');
|
|
12
13
|
const WARM_STATES = new Set(['ready', 'starting', 'skipped']);
|
|
@@ -74,7 +75,7 @@ class RemoteDatabase {
|
|
|
74
75
|
async info(options = {}) {
|
|
75
76
|
const info = await this.#request('', undefined, options, false);
|
|
76
77
|
if (!plainObject(info) || typeof info.id !== 'string' || !/^[A-Za-z0-9_-]{1,80}$/.test(info.id) || !sequence(info.sequence, true) || (this.#id !== undefined && this.#id !== info.id)) {
|
|
77
|
-
throw new ClientError('
|
|
78
|
+
throw new ClientError('RDB_PROTOCOL', 'Invalid database details or changed database identity', 'read-failed');
|
|
78
79
|
}
|
|
79
80
|
if (this.#loopbackOrganization) this.#cloud = info.serviceVersion === undefined;
|
|
80
81
|
const supported = capabilities(this.#url, info, !this.#cloud);
|
|
@@ -86,14 +87,14 @@ class RemoteDatabase {
|
|
|
86
87
|
}
|
|
87
88
|
async warm(options = {}) {
|
|
88
89
|
this.#assertOpen();
|
|
89
|
-
if (!this.#cloud) throw new ClientError('
|
|
90
|
+
if (!this.#cloud) throw new ClientError('RDB_UNSUPPORTED', 'warm applies only to Cloud database URLs', 'not-sent');
|
|
90
91
|
const reply = await this.#request('/warm', '{}', options, false);
|
|
91
|
-
if (!plainObject(reply) || !WARM_STATES.has(reply.state)) throw new ClientError('
|
|
92
|
+
if (!plainObject(reply) || !WARM_STATES.has(reply.state)) throw new ClientError('RDB_PROTOCOL', 'Invalid warm reply', 'read-failed');
|
|
92
93
|
return { state: reply.state };
|
|
93
94
|
}
|
|
94
95
|
async read(statements, options = {}) {
|
|
95
96
|
this.#assertOpen();
|
|
96
|
-
if (this.#capabilities.readVersion !== 2) throw new ClientError('
|
|
97
|
+
if (this.#capabilities.readVersion !== 2) throw new ClientError('RDB_UNSUPPORTED', 'Database does not support fresh reads', 'not-sent');
|
|
97
98
|
if (options.ifSequence !== undefined) throw new TypeError('fresh reads do not accept ifSequence');
|
|
98
99
|
const afterSequence = options.afterSequence ?? this.#sequence;
|
|
99
100
|
checkSequence(afterSequence);
|
|
@@ -110,12 +111,12 @@ class RemoteDatabase {
|
|
|
110
111
|
}
|
|
111
112
|
prepareWrite(statements, options = {}) {
|
|
112
113
|
this.#assertOpen();
|
|
113
|
-
if (!this.#sequenceKnown && this.#deferred && options.afterSequence === undefined) throw new ClientError('
|
|
114
|
-
if (this.#capabilities.atomicWrites !== true || this.#capabilities.replayWindow !== 64) throw new ClientError('
|
|
114
|
+
if (!this.#sequenceKnown && this.#deferred && options.afterSequence === undefined) throw new ClientError('RDB_SEQUENCE_UNKNOWN', 'Read, call info() or pass afterSequence before preparing a write on a deferred connection', 'not-sent');
|
|
115
|
+
if (this.#capabilities.atomicWrites !== true || this.#capabilities.replayWindow !== 64) throw new ClientError('RDB_UNSUPPORTED', 'Database does not support atomic writes with recovery', 'not-sent');
|
|
115
116
|
const afterSequence = options.afterSequence ?? this.#sequence;
|
|
116
117
|
checkSequence(afterSequence);
|
|
117
118
|
if (options.ifSequence !== undefined) {
|
|
118
|
-
if (this.#capabilities.exactSequenceCondition !== true) throw new ClientError('
|
|
119
|
+
if (this.#capabilities.exactSequenceCondition !== true) throw new ClientError('RDB_UNSUPPORTED', 'Database does not support exact sequence conditions', 'not-sent');
|
|
119
120
|
checkSequence(options.ifSequence);
|
|
120
121
|
if (options.ifSequence < afterSequence) throw new RangeError('ifSequence must be at least afterSequence');
|
|
121
122
|
}
|
|
@@ -130,8 +131,8 @@ class RemoteDatabase {
|
|
|
130
131
|
async submit(prepared, options = {}) {
|
|
131
132
|
this.#assertOpen();
|
|
132
133
|
const request = validatePrepared(prepared);
|
|
133
|
-
if (this.#capabilities.atomicWrites !== true || this.#capabilities.replayWindow !== 64 || (request.ifSequence !== undefined && this.#capabilities.exactSequenceCondition !== true)) throw new ClientError('
|
|
134
|
-
if (prepared.url !== this.#url || prepared.databaseId !== this.#id) throw new ClientError('
|
|
134
|
+
if (this.#capabilities.atomicWrites !== true || this.#capabilities.replayWindow !== 64 || (request.ifSequence !== undefined && this.#capabilities.exactSequenceCondition !== true)) throw new ClientError('RDB_UNSUPPORTED', 'Database does not support this write contract', 'not-sent', { request: prepared });
|
|
135
|
+
if (prepared.url !== this.#url || prepared.databaseId !== this.#id) throw new ClientError('RDB_REQUEST_IDENTITY', 'Saved request belongs to another database', 'not-sent');
|
|
135
136
|
try {
|
|
136
137
|
const reply = await this.#request('/query', prepared.body, options, true, prepared, preparedQueryVersion(prepared));
|
|
137
138
|
const result = confirmReply(reply, request, false, prepared);
|
|
@@ -143,7 +144,7 @@ class RemoteDatabase {
|
|
|
143
144
|
if (error.outcome === 'rolled-back') this.#sequence = Math.max(this.#sequence, error.sequence);
|
|
144
145
|
throw error;
|
|
145
146
|
}
|
|
146
|
-
throw new ClientError('
|
|
147
|
+
throw new ClientError('RDB_PROTOCOL', 'Write response could not be confirmed; recover the saved request', 'unknown', { request: prepared });
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
150
|
async recover(prepared, options = {}) {
|
|
@@ -157,22 +158,79 @@ class RemoteDatabase {
|
|
|
157
158
|
if (!this.#sequenceKnown && this.#deferred && options.afterSequence === undefined) await this.info(options);
|
|
158
159
|
return this.submit(this.prepareWrite(statements, options), options);
|
|
159
160
|
}
|
|
161
|
+
async exportFile(options = {}) {
|
|
162
|
+
this.#assertOpen();
|
|
163
|
+
if (this.#transport) throw new ClientError('RDB_UNSUPPORTED', 'Export embedded databases through EmbeddedDatabase.exportFile', 'not-sent');
|
|
164
|
+
if (this.#cloud) throw new ClientError('RDB_UNSUPPORTED', 'Cloud databases do not export database files yet', 'not-sent');
|
|
165
|
+
if (!plainObject(options) || Object.keys(options).some(key => !['signal', 'timeoutMs'].includes(key))) throw new TypeError('export options take signal and timeoutMs');
|
|
166
|
+
const duration = timeout(options.timeoutMs ?? this.#timeout);
|
|
167
|
+
if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal');
|
|
168
|
+
if (options.signal?.aborted) throw new ClientError('RDB_CANCELLED', 'Request cancelled before dispatch', 'not-sent');
|
|
169
|
+
if (this.#active.size >= 32) throw new ClientError('RDB_LIMIT', 'Client request limit reached', 'not-sent');
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
const abort = () => controller.abort();
|
|
172
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
173
|
+
const timer = setTimeout(abort, duration);
|
|
174
|
+
this.#active.add(controller);
|
|
175
|
+
let open = true;
|
|
176
|
+
const finish = () => {
|
|
177
|
+
if (!open) return;
|
|
178
|
+
open = false;
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
options.signal?.removeEventListener('abort', abort);
|
|
181
|
+
this.#active.delete(controller);
|
|
182
|
+
};
|
|
183
|
+
const credential = this.#token;
|
|
184
|
+
let status;
|
|
185
|
+
try {
|
|
186
|
+
const response = await this.#fetch(this.#url + '/file', {
|
|
187
|
+
method: 'GET',
|
|
188
|
+
headers: { authorization: `Bearer ${credential}`, accept: `${DATABASE_FILE_TYPE}, application/json`, ...(this.#id === undefined ? {} : { 'x-romedb-database-id': this.#id }) },
|
|
189
|
+
redirect: 'manual', signal: controller.signal,
|
|
190
|
+
});
|
|
191
|
+
// The deadline covers building the copy; the caller's signal still stops the download.
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
status = response.status;
|
|
194
|
+
if (response.type === 'opaqueredirect' || (status >= 300 && status < 400)) throw new ClientError('RDB_PROTOCOL', 'Database URL redirected; redirects are never followed with a credential', 'read-failed', { status });
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
const retryAfterMs = retryAfter(response.headers.get('retry-after'));
|
|
197
|
+
const reply = await boundedJson(response, MAX_RESPONSE).catch(() => undefined);
|
|
198
|
+
const code = plainObject(reply?.error) && typeof reply.error.code === 'string' && /^RDB_[A-Z0-9_]{1,80}$/.test(reply.error.code) ? reply.error.code : httpCode(status);
|
|
199
|
+
throw new ClientError(code, safeMessage(reply, credential), 'read-failed', { status, ...(retryAfterMs === undefined ? {} : { retryAfterMs }) });
|
|
200
|
+
}
|
|
201
|
+
const size = Number(/^\d{1,16}$/.exec(response.headers.get('content-length') ?? '')?.[0] ?? NaN);
|
|
202
|
+
const exported = Number(/^\d{1,16}$/.exec(response.headers.get('x-romedb-sequence') ?? '')?.[0] ?? NaN);
|
|
203
|
+
const identity = response.headers.get('x-romedb-database-id');
|
|
204
|
+
if ((response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase() !== DATABASE_FILE_TYPE || !Number.isSafeInteger(size) || !sequence(exported, true) || !response.body) {
|
|
205
|
+
throw new ClientError('RDB_PROTOCOL', 'Invalid database file response', 'read-failed', { status });
|
|
206
|
+
}
|
|
207
|
+
if (identity === null || (this.#id !== undefined && identity !== this.#id)) throw new ClientError('RDB_REQUEST_IDENTITY', 'Database file belongs to another database', 'read-failed', { status });
|
|
208
|
+
this.#id ??= identity;
|
|
209
|
+
this.#sequence = Math.max(this.#sequence, exported);
|
|
210
|
+
this.#sequenceKnown = true;
|
|
211
|
+
return { sequence: exported, size, body: countedBody(response.body, size, controller, finish) };
|
|
212
|
+
} catch (error) {
|
|
213
|
+
finish();
|
|
214
|
+
if (error instanceof ClientError) throw new ClientError(error.code, redactMessage(error.message, credential), error.outcome, { ...(error.status === undefined ? {} : { status: error.status }), ...(error.retryAfterMs === undefined ? {} : { retryAfterMs: error.retryAfterMs }) });
|
|
215
|
+
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
|
+
}
|
|
217
|
+
}
|
|
160
218
|
async close() {
|
|
161
219
|
this.#closed = true;
|
|
162
220
|
for (const controller of this.#active) controller.abort();
|
|
163
221
|
this.#token = '';
|
|
164
222
|
}
|
|
165
223
|
#assertOpen() {
|
|
166
|
-
if (this.#closed) throw new ClientError('
|
|
224
|
+
if (this.#closed) throw new ClientError('RDB_CLOSED', 'Database client is closed', 'not-sent');
|
|
167
225
|
}
|
|
168
226
|
#queryVersion() { return this.#cloud ? CLOUD_QUERY_VERSION : 2; }
|
|
169
227
|
async #request(path, body, options, writing, prepared, queryVersion = 2) {
|
|
170
228
|
this.#assertOpen();
|
|
171
229
|
const duration = timeout(options.timeoutMs ?? this.#timeout);
|
|
172
230
|
if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal');
|
|
173
|
-
if (options.signal?.aborted) throw new ClientError('
|
|
231
|
+
if (options.signal?.aborted) throw new ClientError('RDB_CANCELLED', 'Request cancelled before dispatch', 'not-sent', prepared ? { request: prepared } : {});
|
|
174
232
|
const bodyBytes = body === undefined ? 0 : byteLength(body);
|
|
175
|
-
if (this.#active.size >= 32 || this.#inflightBytes + bodyBytes > 2_097_152) throw new ClientError('
|
|
233
|
+
if (this.#active.size >= 32 || this.#inflightBytes + bodyBytes > 2_097_152) throw new ClientError('RDB_LIMIT', 'Client request limit reached', 'not-sent', prepared ? { request: prepared } : {});
|
|
176
234
|
const controller = new AbortController();
|
|
177
235
|
const abort = () => controller.abort();
|
|
178
236
|
options.signal?.addEventListener('abort', abort, { once: true });
|
|
@@ -200,18 +258,18 @@ class RemoteDatabase {
|
|
|
200
258
|
status = response.status;
|
|
201
259
|
if (response.type === 'opaqueredirect' || (status >= 300 && status < 400)) {
|
|
202
260
|
await response.body?.cancel().catch(() => {});
|
|
203
|
-
throw new ClientError('
|
|
261
|
+
throw new ClientError('RDB_PROTOCOL', 'Database URL redirected; redirects are never followed with a credential', writing ? 'unknown' : 'read-failed', { status, ...(prepared ? { request: prepared } : {}) });
|
|
204
262
|
}
|
|
205
263
|
const retryAfterMs = retryAfter(response.headers.get('retry-after'));
|
|
206
264
|
const replyVersion = response.headers.get('x-romedb-query-version');
|
|
207
265
|
if ((path === '/query' || path === '/read') && replyVersion !== null && replyVersion !== '2' && replyVersion !== String(queryVersion)) {
|
|
208
266
|
await response.body?.cancel();
|
|
209
|
-
throw new ClientError('
|
|
267
|
+
throw new ClientError('RDB_PROTOCOL', 'Unsupported database query response version', writing ? 'unknown' : 'read-failed', { status, ...(prepared ? { request: prepared } : {}) });
|
|
210
268
|
}
|
|
211
269
|
const reply = await boundedJson(response, MAX_RESPONSE);
|
|
212
270
|
if (plainObject(reply?.error) && typeof reply.error.message === 'string') reply.error.message = redactMessage(reply.error.message, credential);
|
|
213
271
|
if (!response.ok && !(writing && reply?.requestId && reply?.sequence !== undefined)) {
|
|
214
|
-
const code = plainObject(reply?.error) && typeof reply.error.code === 'string' && /^
|
|
272
|
+
const code = plainObject(reply?.error) && typeof reply.error.code === 'string' && /^RDB_[A-Z0-9_]{1,80}$/.test(reply.error.code) ? reply.error.code : httpCode(status);
|
|
215
273
|
throw new ClientError(code, safeMessage(reply, credential), writing ? 'unknown' : 'read-failed', { status, ...(retryAfterMs === undefined ? {} : { retryAfterMs }), ...(prepared ? { request: prepared } : {}) });
|
|
216
274
|
}
|
|
217
275
|
return reply;
|
|
@@ -227,7 +285,7 @@ class RemoteDatabase {
|
|
|
227
285
|
...(prepared ? { request: prepared } : {}),
|
|
228
286
|
});
|
|
229
287
|
}
|
|
230
|
-
const code = controller.signal.aborted ? '
|
|
288
|
+
const code = controller.signal.aborted ? 'RDB_CANCELLED' : status === undefined ? 'RDB_TRANSPORT' : 'RDB_PROTOCOL';
|
|
231
289
|
throw new ClientError(code, writing ? 'Write was not confirmed; recover the saved request' : 'Database request failed', writing ? 'unknown' : 'read-failed', { ...(status === undefined ? {} : { status }), ...(prepared ? { request: prepared } : {}), cause: error });
|
|
232
290
|
} finally {
|
|
233
291
|
clearTimeout(timer);
|
|
@@ -297,9 +355,9 @@ function validatePrepared(prepared) {
|
|
|
297
355
|
}
|
|
298
356
|
|
|
299
357
|
function confirmReply(reply, request, reading, prepared) {
|
|
300
|
-
const invalid = () => new ClientError('
|
|
358
|
+
const invalid = () => new ClientError('RDB_PROTOCOL', reading ? 'Read response identity or results differ' : 'Write response could not be confirmed; recover the saved request', reading ? 'read-failed' : 'unknown', prepared ? { request: prepared } : {});
|
|
301
359
|
if (!plainObject(reply) || reply.requestId !== request.requestId || !sequence(reply.sequence, true) || (reading ? reply.readVersion !== 2 || reply.sequence < request.afterSequence : reply.readVersion !== undefined || reply.sequence <= request.afterSequence || reply.sequence - request.afterSequence > 64)) throw invalid();
|
|
302
|
-
if (plainObject(reply.error) && typeof reply.error.code === 'string' && /^
|
|
360
|
+
if (plainObject(reply.error) && typeof reply.error.code === 'string' && /^RDB_[A-Z0-9_]+$/.test(reply.error.code) && typeof reply.error.message === 'string' && reply.results === undefined) throw new ClientError(reply.error.code, reply.error.message.slice(0, 2048), reading ? 'read-failed' : 'rolled-back', { sequence: reply.sequence, request: prepared });
|
|
303
361
|
if (!Array.isArray(reply.results) || reply.results.length !== request.statements.length || reply.error !== undefined || byteLength(JSON.stringify(reply.results)) > 262_144) throw invalid();
|
|
304
362
|
try {
|
|
305
363
|
const results = reply.results.map(result => {
|
|
@@ -334,11 +392,39 @@ async function boundedJson(response, limit) {
|
|
|
334
392
|
} finally { await reader.cancel().catch(() => {}); }
|
|
335
393
|
}
|
|
336
394
|
|
|
395
|
+
// Passes the download through and fails it unless exactly `size` bytes arrive.
|
|
396
|
+
function countedBody(body, size, controller, finish) {
|
|
397
|
+
const reader = body.getReader();
|
|
398
|
+
let received = 0;
|
|
399
|
+
const failure = (message, cause) => new ClientError(controller.signal.aborted ? 'RDB_CANCELLED' : 'RDB_TRANSPORT', message, 'read-failed', cause === undefined ? {} : { cause });
|
|
400
|
+
return new ReadableStream({
|
|
401
|
+
async pull(stream) {
|
|
402
|
+
let chunk;
|
|
403
|
+
try { chunk = await reader.read(); }
|
|
404
|
+
catch (error) { finish(); stream.error(failure('Database file download failed', error)); return; }
|
|
405
|
+
if (chunk.done) {
|
|
406
|
+
finish();
|
|
407
|
+
if (received === size) stream.close();
|
|
408
|
+
else stream.error(failure('Database file ended before its declared size'));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
received += chunk.value.byteLength;
|
|
412
|
+
if (received > size) {
|
|
413
|
+
finish(); controller.abort();
|
|
414
|
+
stream.error(new ClientError('RDB_PROTOCOL', 'Database file is larger than its declared size', 'read-failed'));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
stream.enqueue(chunk.value);
|
|
418
|
+
},
|
|
419
|
+
cancel(reason) { finish(); controller.abort(); return reader.cancel(reason).catch(() => {}); },
|
|
420
|
+
}, { highWaterMark: 0 });
|
|
421
|
+
}
|
|
422
|
+
|
|
337
423
|
function loopbackHost(hostname) { return ['127.0.0.1', 'localhost', '[::1]'].includes(hostname); }
|
|
338
424
|
|
|
339
425
|
function capabilities(url, info, local) {
|
|
340
426
|
if (local) {
|
|
341
|
-
if ((url.startsWith('file:') ? info.adapterVersion : info.serviceVersion) !== 1 || !plainObject(info.capabilities) || !plainObject(info.limits) || info.capabilities.readVersion !== 2 || typeof info.capabilities.atomicWrites !== 'boolean' || typeof info.capabilities.exactSequenceCondition !== 'boolean' || (info.capabilities.atomicWrites && info.capabilities.replayWindow !== 64)) throw new ClientError('
|
|
427
|
+
if ((url.startsWith('file:') ? info.adapterVersion : info.serviceVersion) !== 1 || !plainObject(info.capabilities) || !plainObject(info.limits) || info.capabilities.readVersion !== 2 || typeof info.capabilities.atomicWrites !== 'boolean' || typeof info.capabilities.exactSequenceCondition !== 'boolean' || (info.capabilities.atomicWrites && info.capabilities.replayWindow !== 64)) throw new ClientError('RDB_PROTOCOL', 'Unsupported database client contract', 'read-failed');
|
|
342
428
|
return { ...info.capabilities, provenance: 'negotiated' };
|
|
343
429
|
}
|
|
344
430
|
return { provenance: 'documented', readVersion: 2, atomicWrites: true, replayWindow: 64, exactSequenceCondition: true, negotiatedLimits: false };
|
|
@@ -387,7 +473,7 @@ function pause(ms, signal) {
|
|
|
387
473
|
signal.addEventListener('abort', stop, { once: true });
|
|
388
474
|
});
|
|
389
475
|
}
|
|
390
|
-
function httpCode(status) { return status === 401 ? '
|
|
476
|
+
function httpCode(status) { return status === 401 ? 'RDB_AUTH' : status === 403 ? 'RDB_FORBIDDEN' : status === 409 ? 'RDB_CONFLICT' : status === 429 || status === 413 ? 'RDB_LIMIT' : 'RDB_REMOTE'; }
|
|
391
477
|
function redactMessage(message, token) { return (token ? message.replaceAll(token, '[redacted]') : message).slice(0, 2048); }
|
|
392
478
|
function safeMessage(reply, token) { return redactMessage(typeof reply?.error === 'string' ? reply.error : typeof reply?.error?.message === 'string' ? reply.error.message : 'Database request was rejected', token); }
|
|
393
479
|
|
package/client.d.ts
CHANGED
|
@@ -25,6 +25,15 @@ export interface ConnectOptions {
|
|
|
25
25
|
warm?: boolean;
|
|
26
26
|
}
|
|
27
27
|
export interface WarmResult { state: 'ready' | 'starting' | 'skipped'; }
|
|
28
|
+
/** A copy of the database at one sequence: a complete .romedb file that opens without a log beside it. */
|
|
29
|
+
export interface DatabaseFile {
|
|
30
|
+
/** Every write up to and including this sequence is in the file, and none after it. */
|
|
31
|
+
sequence: number;
|
|
32
|
+
/** File size in bytes; the body fails unless exactly this many arrive. */
|
|
33
|
+
size: number;
|
|
34
|
+
/** Read it to the end or cancel it, so the copy's storage is freed. */
|
|
35
|
+
body: ReadableStream<Uint8Array>;
|
|
36
|
+
}
|
|
28
37
|
export type Outcome = 'not-sent' | 'read-failed' | 'unknown' | 'rolled-back';
|
|
29
38
|
export class ClientError extends Error {
|
|
30
39
|
readonly code: string;
|
|
@@ -34,7 +43,7 @@ export class ClientError extends Error {
|
|
|
34
43
|
readonly sequence?: number;
|
|
35
44
|
/** The server's Retry-After delay in milliseconds, on HTTP error replies that send one. */
|
|
36
45
|
readonly retryAfterMs?: number;
|
|
37
|
-
/** For
|
|
46
|
+
/** For RDB_TRANSPORT, RDB_PROTOCOL and RDB_CANCELLED without a reply: the fetch or parse error underneath. */
|
|
38
47
|
readonly cause?: unknown;
|
|
39
48
|
constructor(code: string, message: string, outcome: Outcome, details?: { request?: PreparedWrite; status?: number; sequence?: number; retryAfterMs?: number; cause?: unknown });
|
|
40
49
|
}
|
|
@@ -60,6 +69,11 @@ export class RemoteDatabase implements Client {
|
|
|
60
69
|
readonly deferred: boolean;
|
|
61
70
|
/** Ask Cloud to start the database's container and open the database without running a query. */
|
|
62
71
|
warm(options?: RequestOptions): Promise<WarmResult>;
|
|
72
|
+
/**
|
|
73
|
+
* Download the database as a .romedb file (romedb serve and serve --directory; not Cloud yet).
|
|
74
|
+
* timeoutMs covers building the copy, until the file starts arriving; signal also stops the download.
|
|
75
|
+
*/
|
|
76
|
+
exportFile(options?: RequestOptions): Promise<DatabaseFile>;
|
|
63
77
|
readonly sequence: number;
|
|
64
78
|
readonly databaseId: string;
|
|
65
79
|
info(options?: RequestOptions): Promise<DatabaseInfo>;
|
package/client.mjs
CHANGED
|
@@ -12,6 +12,7 @@ const MAX_CLOUD_REQUEST = 1_048_576;
|
|
|
12
12
|
const CLOUD_QUERY_VERSION = 3;
|
|
13
13
|
const MAX_RESPONSE = 524_288;
|
|
14
14
|
const MAX_SEQUENCE = Number.MAX_SAFE_INTEGER - 1;
|
|
15
|
+
const DATABASE_FILE_TYPE = 'application/vnd.romedb.database';
|
|
15
16
|
const construction = Symbol('RemoteDatabase');
|
|
16
17
|
const warmAtConnect = Symbol('warmAtConnect');
|
|
17
18
|
const WARM_STATES = new Set(['ready', 'starting', 'skipped']);
|
|
@@ -79,7 +80,7 @@ class RemoteDatabase {
|
|
|
79
80
|
async info(options = {}) {
|
|
80
81
|
const info = await this.#request('', undefined, options, false);
|
|
81
82
|
if (!plainObject(info) || typeof info.id !== 'string' || !/^[A-Za-z0-9_-]{1,80}$/.test(info.id) || !sequence(info.sequence, true) || (this.#id !== undefined && this.#id !== info.id)) {
|
|
82
|
-
throw new ClientError('
|
|
83
|
+
throw new ClientError('RDB_PROTOCOL', 'Invalid database details or changed database identity', 'read-failed');
|
|
83
84
|
}
|
|
84
85
|
if (this.#loopbackOrganization) this.#cloud = info.serviceVersion === undefined;
|
|
85
86
|
const supported = capabilities(this.#url, info, !this.#cloud);
|
|
@@ -91,14 +92,14 @@ class RemoteDatabase {
|
|
|
91
92
|
}
|
|
92
93
|
async warm(options = {}) {
|
|
93
94
|
this.#assertOpen();
|
|
94
|
-
if (!this.#cloud) throw new ClientError('
|
|
95
|
+
if (!this.#cloud) throw new ClientError('RDB_UNSUPPORTED', 'warm applies only to Cloud database URLs', 'not-sent');
|
|
95
96
|
const reply = await this.#request('/warm', '{}', options, false);
|
|
96
|
-
if (!plainObject(reply) || !WARM_STATES.has(reply.state)) throw new ClientError('
|
|
97
|
+
if (!plainObject(reply) || !WARM_STATES.has(reply.state)) throw new ClientError('RDB_PROTOCOL', 'Invalid warm reply', 'read-failed');
|
|
97
98
|
return { state: reply.state };
|
|
98
99
|
}
|
|
99
100
|
async read(statements, options = {}) {
|
|
100
101
|
this.#assertOpen();
|
|
101
|
-
if (this.#capabilities.readVersion !== 2) throw new ClientError('
|
|
102
|
+
if (this.#capabilities.readVersion !== 2) throw new ClientError('RDB_UNSUPPORTED', 'Database does not support fresh reads', 'not-sent');
|
|
102
103
|
if (options.ifSequence !== undefined) throw new TypeError('fresh reads do not accept ifSequence');
|
|
103
104
|
const afterSequence = options.afterSequence ?? this.#sequence;
|
|
104
105
|
checkSequence(afterSequence);
|
|
@@ -115,12 +116,12 @@ class RemoteDatabase {
|
|
|
115
116
|
}
|
|
116
117
|
prepareWrite(statements, options = {}) {
|
|
117
118
|
this.#assertOpen();
|
|
118
|
-
if (!this.#sequenceKnown && this.#deferred && options.afterSequence === undefined) throw new ClientError('
|
|
119
|
-
if (this.#capabilities.atomicWrites !== true || this.#capabilities.replayWindow !== 64) throw new ClientError('
|
|
119
|
+
if (!this.#sequenceKnown && this.#deferred && options.afterSequence === undefined) throw new ClientError('RDB_SEQUENCE_UNKNOWN', 'Read, call info() or pass afterSequence before preparing a write on a deferred connection', 'not-sent');
|
|
120
|
+
if (this.#capabilities.atomicWrites !== true || this.#capabilities.replayWindow !== 64) throw new ClientError('RDB_UNSUPPORTED', 'Database does not support atomic writes with recovery', 'not-sent');
|
|
120
121
|
const afterSequence = options.afterSequence ?? this.#sequence;
|
|
121
122
|
checkSequence(afterSequence);
|
|
122
123
|
if (options.ifSequence !== undefined) {
|
|
123
|
-
if (this.#capabilities.exactSequenceCondition !== true) throw new ClientError('
|
|
124
|
+
if (this.#capabilities.exactSequenceCondition !== true) throw new ClientError('RDB_UNSUPPORTED', 'Database does not support exact sequence conditions', 'not-sent');
|
|
124
125
|
checkSequence(options.ifSequence);
|
|
125
126
|
if (options.ifSequence < afterSequence) throw new RangeError('ifSequence must be at least afterSequence');
|
|
126
127
|
}
|
|
@@ -135,8 +136,8 @@ class RemoteDatabase {
|
|
|
135
136
|
async submit(prepared, options = {}) {
|
|
136
137
|
this.#assertOpen();
|
|
137
138
|
const request = validatePrepared(prepared);
|
|
138
|
-
if (this.#capabilities.atomicWrites !== true || this.#capabilities.replayWindow !== 64 || (request.ifSequence !== undefined && this.#capabilities.exactSequenceCondition !== true)) throw new ClientError('
|
|
139
|
-
if (prepared.url !== this.#url || prepared.databaseId !== this.#id) throw new ClientError('
|
|
139
|
+
if (this.#capabilities.atomicWrites !== true || this.#capabilities.replayWindow !== 64 || (request.ifSequence !== undefined && this.#capabilities.exactSequenceCondition !== true)) throw new ClientError('RDB_UNSUPPORTED', 'Database does not support this write contract', 'not-sent', { request: prepared });
|
|
140
|
+
if (prepared.url !== this.#url || prepared.databaseId !== this.#id) throw new ClientError('RDB_REQUEST_IDENTITY', 'Saved request belongs to another database', 'not-sent');
|
|
140
141
|
try {
|
|
141
142
|
const reply = await this.#request('/query', prepared.body, options, true, prepared, preparedQueryVersion(prepared));
|
|
142
143
|
const result = confirmReply(reply, request, false, prepared);
|
|
@@ -148,7 +149,7 @@ class RemoteDatabase {
|
|
|
148
149
|
if (error.outcome === 'rolled-back') this.#sequence = Math.max(this.#sequence, error.sequence);
|
|
149
150
|
throw error;
|
|
150
151
|
}
|
|
151
|
-
throw new ClientError('
|
|
152
|
+
throw new ClientError('RDB_PROTOCOL', 'Write response could not be confirmed; recover the saved request', 'unknown', { request: prepared });
|
|
152
153
|
}
|
|
153
154
|
}
|
|
154
155
|
async recover(prepared, options = {}) {
|
|
@@ -162,22 +163,79 @@ class RemoteDatabase {
|
|
|
162
163
|
if (!this.#sequenceKnown && this.#deferred && options.afterSequence === undefined) await this.info(options);
|
|
163
164
|
return this.submit(this.prepareWrite(statements, options), options);
|
|
164
165
|
}
|
|
166
|
+
async exportFile(options = {}) {
|
|
167
|
+
this.#assertOpen();
|
|
168
|
+
if (this.#transport) throw new ClientError('RDB_UNSUPPORTED', 'Export embedded databases through EmbeddedDatabase.exportFile', 'not-sent');
|
|
169
|
+
if (this.#cloud) throw new ClientError('RDB_UNSUPPORTED', 'Cloud databases do not export database files yet', 'not-sent');
|
|
170
|
+
if (!plainObject(options) || Object.keys(options).some(key => !['signal', 'timeoutMs'].includes(key))) throw new TypeError('export options take signal and timeoutMs');
|
|
171
|
+
const duration = timeout(options.timeoutMs ?? this.#timeout);
|
|
172
|
+
if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal');
|
|
173
|
+
if (options.signal?.aborted) throw new ClientError('RDB_CANCELLED', 'Request cancelled before dispatch', 'not-sent');
|
|
174
|
+
if (this.#active.size >= 32) throw new ClientError('RDB_LIMIT', 'Client request limit reached', 'not-sent');
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
const abort = () => controller.abort();
|
|
177
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
178
|
+
const timer = setTimeout(abort, duration);
|
|
179
|
+
this.#active.add(controller);
|
|
180
|
+
let open = true;
|
|
181
|
+
const finish = () => {
|
|
182
|
+
if (!open) return;
|
|
183
|
+
open = false;
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
options.signal?.removeEventListener('abort', abort);
|
|
186
|
+
this.#active.delete(controller);
|
|
187
|
+
};
|
|
188
|
+
const credential = this.#token;
|
|
189
|
+
let status;
|
|
190
|
+
try {
|
|
191
|
+
const response = await this.#fetch(this.#url + '/file', {
|
|
192
|
+
method: 'GET',
|
|
193
|
+
headers: { authorization: `Bearer ${credential}`, accept: `${DATABASE_FILE_TYPE}, application/json`, ...(this.#id === undefined ? {} : { 'x-romedb-database-id': this.#id }) },
|
|
194
|
+
redirect: 'manual', signal: controller.signal,
|
|
195
|
+
});
|
|
196
|
+
// The deadline covers building the copy; the caller's signal still stops the download.
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
status = response.status;
|
|
199
|
+
if (response.type === 'opaqueredirect' || (status >= 300 && status < 400)) throw new ClientError('RDB_PROTOCOL', 'Database URL redirected; redirects are never followed with a credential', 'read-failed', { status });
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
const retryAfterMs = retryAfter(response.headers.get('retry-after'));
|
|
202
|
+
const reply = await boundedJson(response, MAX_RESPONSE).catch(() => undefined);
|
|
203
|
+
const code = plainObject(reply?.error) && typeof reply.error.code === 'string' && /^RDB_[A-Z0-9_]{1,80}$/.test(reply.error.code) ? reply.error.code : httpCode(status);
|
|
204
|
+
throw new ClientError(code, safeMessage(reply, credential), 'read-failed', { status, ...(retryAfterMs === undefined ? {} : { retryAfterMs }) });
|
|
205
|
+
}
|
|
206
|
+
const size = Number(/^\d{1,16}$/.exec(response.headers.get('content-length') ?? '')?.[0] ?? NaN);
|
|
207
|
+
const exported = Number(/^\d{1,16}$/.exec(response.headers.get('x-romedb-sequence') ?? '')?.[0] ?? NaN);
|
|
208
|
+
const identity = response.headers.get('x-romedb-database-id');
|
|
209
|
+
if ((response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase() !== DATABASE_FILE_TYPE || !Number.isSafeInteger(size) || !sequence(exported, true) || !response.body) {
|
|
210
|
+
throw new ClientError('RDB_PROTOCOL', 'Invalid database file response', 'read-failed', { status });
|
|
211
|
+
}
|
|
212
|
+
if (identity === null || (this.#id !== undefined && identity !== this.#id)) throw new ClientError('RDB_REQUEST_IDENTITY', 'Database file belongs to another database', 'read-failed', { status });
|
|
213
|
+
this.#id ??= identity;
|
|
214
|
+
this.#sequence = Math.max(this.#sequence, exported);
|
|
215
|
+
this.#sequenceKnown = true;
|
|
216
|
+
return { sequence: exported, size, body: countedBody(response.body, size, controller, finish) };
|
|
217
|
+
} catch (error) {
|
|
218
|
+
finish();
|
|
219
|
+
if (error instanceof ClientError) throw new ClientError(error.code, redactMessage(error.message, credential), error.outcome, { ...(error.status === undefined ? {} : { status: error.status }), ...(error.retryAfterMs === undefined ? {} : { retryAfterMs: error.retryAfterMs }) });
|
|
220
|
+
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
|
+
}
|
|
222
|
+
}
|
|
165
223
|
async close() {
|
|
166
224
|
this.#closed = true;
|
|
167
225
|
for (const controller of this.#active) controller.abort();
|
|
168
226
|
this.#token = '';
|
|
169
227
|
}
|
|
170
228
|
#assertOpen() {
|
|
171
|
-
if (this.#closed) throw new ClientError('
|
|
229
|
+
if (this.#closed) throw new ClientError('RDB_CLOSED', 'Database client is closed', 'not-sent');
|
|
172
230
|
}
|
|
173
231
|
#queryVersion() { return this.#cloud ? CLOUD_QUERY_VERSION : 2; }
|
|
174
232
|
async #request(path, body, options, writing, prepared, queryVersion = 2) {
|
|
175
233
|
this.#assertOpen();
|
|
176
234
|
const duration = timeout(options.timeoutMs ?? this.#timeout);
|
|
177
235
|
if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal');
|
|
178
|
-
if (options.signal?.aborted) throw new ClientError('
|
|
236
|
+
if (options.signal?.aborted) throw new ClientError('RDB_CANCELLED', 'Request cancelled before dispatch', 'not-sent', prepared ? { request: prepared } : {});
|
|
179
237
|
const bodyBytes = body === undefined ? 0 : byteLength(body);
|
|
180
|
-
if (this.#active.size >= 32 || this.#inflightBytes + bodyBytes > 2_097_152) throw new ClientError('
|
|
238
|
+
if (this.#active.size >= 32 || this.#inflightBytes + bodyBytes > 2_097_152) throw new ClientError('RDB_LIMIT', 'Client request limit reached', 'not-sent', prepared ? { request: prepared } : {});
|
|
181
239
|
const controller = new AbortController();
|
|
182
240
|
const abort = () => controller.abort();
|
|
183
241
|
options.signal?.addEventListener('abort', abort, { once: true });
|
|
@@ -205,18 +263,18 @@ class RemoteDatabase {
|
|
|
205
263
|
status = response.status;
|
|
206
264
|
if (response.type === 'opaqueredirect' || (status >= 300 && status < 400)) {
|
|
207
265
|
await response.body?.cancel().catch(() => {});
|
|
208
|
-
throw new ClientError('
|
|
266
|
+
throw new ClientError('RDB_PROTOCOL', 'Database URL redirected; redirects are never followed with a credential', writing ? 'unknown' : 'read-failed', { status, ...(prepared ? { request: prepared } : {}) });
|
|
209
267
|
}
|
|
210
268
|
const retryAfterMs = retryAfter(response.headers.get('retry-after'));
|
|
211
269
|
const replyVersion = response.headers.get('x-romedb-query-version');
|
|
212
270
|
if ((path === '/query' || path === '/read') && replyVersion !== null && replyVersion !== '2' && replyVersion !== String(queryVersion)) {
|
|
213
271
|
await response.body?.cancel();
|
|
214
|
-
throw new ClientError('
|
|
272
|
+
throw new ClientError('RDB_PROTOCOL', 'Unsupported database query response version', writing ? 'unknown' : 'read-failed', { status, ...(prepared ? { request: prepared } : {}) });
|
|
215
273
|
}
|
|
216
274
|
const reply = await boundedJson(response, MAX_RESPONSE);
|
|
217
275
|
if (plainObject(reply?.error) && typeof reply.error.message === 'string') reply.error.message = redactMessage(reply.error.message, credential);
|
|
218
276
|
if (!response.ok && !(writing && reply?.requestId && reply?.sequence !== undefined)) {
|
|
219
|
-
const code = plainObject(reply?.error) && typeof reply.error.code === 'string' && /^
|
|
277
|
+
const code = plainObject(reply?.error) && typeof reply.error.code === 'string' && /^RDB_[A-Z0-9_]{1,80}$/.test(reply.error.code) ? reply.error.code : httpCode(status);
|
|
220
278
|
throw new ClientError(code, safeMessage(reply, credential), writing ? 'unknown' : 'read-failed', { status, ...(retryAfterMs === undefined ? {} : { retryAfterMs }), ...(prepared ? { request: prepared } : {}) });
|
|
221
279
|
}
|
|
222
280
|
return reply;
|
|
@@ -232,7 +290,7 @@ class RemoteDatabase {
|
|
|
232
290
|
...(prepared ? { request: prepared } : {}),
|
|
233
291
|
});
|
|
234
292
|
}
|
|
235
|
-
const code = controller.signal.aborted ? '
|
|
293
|
+
const code = controller.signal.aborted ? 'RDB_CANCELLED' : status === undefined ? 'RDB_TRANSPORT' : 'RDB_PROTOCOL';
|
|
236
294
|
throw new ClientError(code, writing ? 'Write was not confirmed; recover the saved request' : 'Database request failed', writing ? 'unknown' : 'read-failed', { ...(status === undefined ? {} : { status }), ...(prepared ? { request: prepared } : {}), cause: error });
|
|
237
295
|
} finally {
|
|
238
296
|
clearTimeout(timer);
|
|
@@ -302,9 +360,9 @@ function validatePrepared(prepared) {
|
|
|
302
360
|
}
|
|
303
361
|
|
|
304
362
|
function confirmReply(reply, request, reading, prepared) {
|
|
305
|
-
const invalid = () => new ClientError('
|
|
363
|
+
const invalid = () => new ClientError('RDB_PROTOCOL', reading ? 'Read response identity or results differ' : 'Write response could not be confirmed; recover the saved request', reading ? 'read-failed' : 'unknown', prepared ? { request: prepared } : {});
|
|
306
364
|
if (!plainObject(reply) || reply.requestId !== request.requestId || !sequence(reply.sequence, true) || (reading ? reply.readVersion !== 2 || reply.sequence < request.afterSequence : reply.readVersion !== undefined || reply.sequence <= request.afterSequence || reply.sequence - request.afterSequence > 64)) throw invalid();
|
|
307
|
-
if (plainObject(reply.error) && typeof reply.error.code === 'string' && /^
|
|
365
|
+
if (plainObject(reply.error) && typeof reply.error.code === 'string' && /^RDB_[A-Z0-9_]+$/.test(reply.error.code) && typeof reply.error.message === 'string' && reply.results === undefined) throw new ClientError(reply.error.code, reply.error.message.slice(0, 2048), reading ? 'read-failed' : 'rolled-back', { sequence: reply.sequence, request: prepared });
|
|
308
366
|
if (!Array.isArray(reply.results) || reply.results.length !== request.statements.length || reply.error !== undefined || byteLength(JSON.stringify(reply.results)) > 262_144) throw invalid();
|
|
309
367
|
try {
|
|
310
368
|
const results = reply.results.map(result => {
|
|
@@ -339,11 +397,39 @@ async function boundedJson(response, limit) {
|
|
|
339
397
|
} finally { await reader.cancel().catch(() => {}); }
|
|
340
398
|
}
|
|
341
399
|
|
|
400
|
+
// Passes the download through and fails it unless exactly `size` bytes arrive.
|
|
401
|
+
function countedBody(body, size, controller, finish) {
|
|
402
|
+
const reader = body.getReader();
|
|
403
|
+
let received = 0;
|
|
404
|
+
const failure = (message, cause) => new ClientError(controller.signal.aborted ? 'RDB_CANCELLED' : 'RDB_TRANSPORT', message, 'read-failed', cause === undefined ? {} : { cause });
|
|
405
|
+
return new ReadableStream({
|
|
406
|
+
async pull(stream) {
|
|
407
|
+
let chunk;
|
|
408
|
+
try { chunk = await reader.read(); }
|
|
409
|
+
catch (error) { finish(); stream.error(failure('Database file download failed', error)); return; }
|
|
410
|
+
if (chunk.done) {
|
|
411
|
+
finish();
|
|
412
|
+
if (received === size) stream.close();
|
|
413
|
+
else stream.error(failure('Database file ended before its declared size'));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
received += chunk.value.byteLength;
|
|
417
|
+
if (received > size) {
|
|
418
|
+
finish(); controller.abort();
|
|
419
|
+
stream.error(new ClientError('RDB_PROTOCOL', 'Database file is larger than its declared size', 'read-failed'));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
stream.enqueue(chunk.value);
|
|
423
|
+
},
|
|
424
|
+
cancel(reason) { finish(); controller.abort(); return reader.cancel(reason).catch(() => {}); },
|
|
425
|
+
}, { highWaterMark: 0 });
|
|
426
|
+
}
|
|
427
|
+
|
|
342
428
|
function loopbackHost(hostname) { return ['127.0.0.1', 'localhost', '[::1]'].includes(hostname); }
|
|
343
429
|
|
|
344
430
|
function capabilities(url, info, local) {
|
|
345
431
|
if (local) {
|
|
346
|
-
if ((url.startsWith('file:') ? info.adapterVersion : info.serviceVersion) !== 1 || !plainObject(info.capabilities) || !plainObject(info.limits) || info.capabilities.readVersion !== 2 || typeof info.capabilities.atomicWrites !== 'boolean' || typeof info.capabilities.exactSequenceCondition !== 'boolean' || (info.capabilities.atomicWrites && info.capabilities.replayWindow !== 64)) throw new ClientError('
|
|
432
|
+
if ((url.startsWith('file:') ? info.adapterVersion : info.serviceVersion) !== 1 || !plainObject(info.capabilities) || !plainObject(info.limits) || info.capabilities.readVersion !== 2 || typeof info.capabilities.atomicWrites !== 'boolean' || typeof info.capabilities.exactSequenceCondition !== 'boolean' || (info.capabilities.atomicWrites && info.capabilities.replayWindow !== 64)) throw new ClientError('RDB_PROTOCOL', 'Unsupported database client contract', 'read-failed');
|
|
347
433
|
return { ...info.capabilities, provenance: 'negotiated' };
|
|
348
434
|
}
|
|
349
435
|
return { provenance: 'documented', readVersion: 2, atomicWrites: true, replayWindow: 64, exactSequenceCondition: true, negotiatedLimits: false };
|
|
@@ -392,7 +478,7 @@ function pause(ms, signal) {
|
|
|
392
478
|
signal.addEventListener('abort', stop, { once: true });
|
|
393
479
|
});
|
|
394
480
|
}
|
|
395
|
-
function httpCode(status) { return status === 401 ? '
|
|
481
|
+
function httpCode(status) { return status === 401 ? 'RDB_AUTH' : status === 403 ? 'RDB_FORBIDDEN' : status === 409 ? 'RDB_CONFLICT' : status === 429 || status === 413 ? 'RDB_LIMIT' : 'RDB_REMOTE'; }
|
|
396
482
|
function redactMessage(message, token) { return (token ? message.replaceAll(token, '[redacted]') : message).slice(0, 2048); }
|
|
397
483
|
function safeMessage(reply, token) { return redactMessage(typeof reply?.error === 'string' ? reply.error : typeof reply?.error?.message === 'string' ? reply.error.message : 'Database request was rejected', token); }
|
|
398
484
|
|