@romedb/client 0.0.0-stage → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.mjs ADDED
@@ -0,0 +1,502 @@
1
+ // Generated by build-esm.cjs from client.cjs. Edit client.cjs, then run `node build-esm.cjs`.
2
+ import * as module0 from './values.mjs';
3
+
4
+ const commonjs = { exports: {} };
5
+ (function (module, exports, require) {
6
+ 'use strict';
7
+ // Web APIs only: this file also runs in Workers and browsers through client.mjs.
8
+ const { Float64, Record, Vector, encodeValue, decodeValue, plainObject, text, checkCopyOptions } = require('./values.cjs');
9
+
10
+ const MAX_REQUEST = 65_536;
11
+ const MAX_CLOUD_REQUEST = 1_048_576;
12
+ const CLOUD_QUERY_VERSION = 3;
13
+ const MAX_RESPONSE = 524_288;
14
+ const MAX_SEQUENCE = Number.MAX_SAFE_INTEGER - 1;
15
+ const DATABASE_FILE_TYPE = 'application/vnd.romedb.database';
16
+ const construction = Symbol('RemoteDatabase');
17
+ const warmAtConnect = Symbol('warmAtConnect');
18
+ const WARM_STATES = new Set(['ready', 'starting', 'skipped']);
19
+ const encoder = new TextEncoder();
20
+
21
+ class ClientError extends Error {
22
+ constructor(code, message, outcome, details = {}) {
23
+ const { cause, ...fields } = details;
24
+ super(message, cause === undefined ? undefined : { cause });
25
+ this.name = 'ClientError';
26
+ this.code = code;
27
+ this.outcome = outcome;
28
+ Object.assign(this, fields);
29
+ }
30
+ }
31
+
32
+ async function connect(options) {
33
+ const db = new RemoteDatabase(options, construction);
34
+ db[warmAtConnect]();
35
+ if (db.deferred) return db;
36
+ try { await db.info(); return db; }
37
+ catch (error) { await db.close(); throw error; }
38
+ }
39
+
40
+ class RemoteDatabase {
41
+ #url; #token; #fetch; #timeout; #sequence = 0; #id; #capabilities; #transport;
42
+ #closed = false; #active = new Set(); #inflightBytes = 0; #sequenceKnown = false; #deferred = false; #cloud = false; #warm = false; #loopbackOrganization = false;
43
+ constructor(options, key, transport) {
44
+ if (key !== construction) throw new TypeError('use connect()');
45
+ if (!plainObject(options)) throw new TypeError('connection options require an object');
46
+ this.#url = transport ? normalizeFileUrl(options.url) : normalizeUrl(options.url);
47
+ this.#transport = transport;
48
+ if (typeof options.token !== 'string' || !/^[\x21-\x7e]{1,4096}$/.test(options.token)) throw new TypeError('token requires a bounded bearer credential');
49
+ this.#token = options.token;
50
+ const fetch = options.fetch ?? globalThis.fetch;
51
+ if (typeof fetch !== 'function') throw new TypeError('fetch must be a function');
52
+ // Workers refuse fetch called on another object, so the default keeps globalThis.
53
+ this.#fetch = options.fetch ?? fetch.bind(globalThis);
54
+ this.#timeout = timeout(options.timeoutMs ?? 20_000);
55
+ if (options.deferInfo !== undefined && typeof options.deferInfo !== 'boolean') throw new TypeError('deferInfo requires a boolean');
56
+ if (options.warm !== undefined && typeof options.warm !== 'boolean') throw new TypeError('warm requires a boolean');
57
+ const cloudId = transport ? undefined : /\/databases\/([0-9a-f-]{36})$/.exec(this.#url)?.[1];
58
+ // A loopback organization URL is a local service directory or Cloud in development;
59
+ // the details reply tells which, so these connections always read details first.
60
+ this.#loopbackOrganization = cloudId !== undefined && loopbackHost(new URL(this.#url).hostname);
61
+ this.#cloud = cloudId !== undefined && !this.#loopbackOrganization;
62
+ if (cloudId === undefined && options.deferInfo === true) throw new TypeError('deferInfo applies only to Cloud database URLs');
63
+ if (cloudId === undefined && options.warm === true) throw new TypeError('warm applies only to Cloud database URLs');
64
+ // Cloud database URLs carry the database id and have documented capabilities,
65
+ // so the details request waits until a write needs a current sequence.
66
+ if (this.#cloud && options.deferInfo !== false) {
67
+ this.#id = cloudId; this.#capabilities = capabilities(this.#url, {}, false); this.#deferred = true;
68
+ }
69
+ this.#warm = this.#cloud && options.warm !== false;
70
+ }
71
+ [warmAtConnect]() {
72
+ if (this.#warm) this.warm().catch(() => {});
73
+ }
74
+ get deferred() { return this.#deferred; }
75
+ get url() { return this.#url; }
76
+ get cloud() { return this.#cloud; }
77
+ get sequence() { return this.#sequence; }
78
+ get sequenceKnown() { return this.#sequenceKnown; }
79
+ get databaseId() { return this.#id; }
80
+ async info(options = {}) {
81
+ const info = await this.#request('', undefined, options, false);
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)) {
83
+ throw new ClientError('RDB_PROTOCOL', 'Invalid database details or changed database identity', 'read-failed');
84
+ }
85
+ if (this.#loopbackOrganization) this.#cloud = info.serviceVersion === undefined;
86
+ const supported = capabilities(this.#url, info, !this.#cloud);
87
+ this.#id = info.id;
88
+ this.#sequence = Math.max(this.#sequence, info.sequence);
89
+ this.#sequenceKnown = true;
90
+ this.#capabilities = supported;
91
+ return { ...info, capabilities: supported };
92
+ }
93
+ async warm(options = {}) {
94
+ this.#assertOpen();
95
+ if (!this.#cloud) throw new ClientError('RDB_UNSUPPORTED', 'warm applies only to Cloud database URLs', 'not-sent');
96
+ const reply = await this.#request('/warm', '{}', options, false);
97
+ if (!plainObject(reply) || !WARM_STATES.has(reply.state)) throw new ClientError('RDB_PROTOCOL', 'Invalid warm reply', 'read-failed');
98
+ return { state: reply.state };
99
+ }
100
+ async read(statements, options = {}) {
101
+ this.#assertOpen();
102
+ if (this.#capabilities.readVersion !== 2) throw new ClientError('RDB_UNSUPPORTED', 'Database does not support fresh reads', 'not-sent');
103
+ if (options.ifSequence !== undefined) throw new TypeError('fresh reads do not accept ifSequence');
104
+ const afterSequence = options.afterSequence ?? this.#sequence;
105
+ checkSequence(afterSequence);
106
+ const workLimits = encodeWorkLimits(options.workLimits);
107
+ const queryVersion = this.#queryVersion();
108
+ const body = JSON.stringify({ readVersion: 2, requestId: crypto.randomUUID(), afterSequence, statements: encodeStatements(statements, queryVersion), ...(workLimits === undefined ? {} : { workLimits }) });
109
+ checkBodySize(body, queryVersion);
110
+ const request = JSON.parse(body);
111
+ const reply = await this.#request('/read', body, options, false, undefined, queryVersion);
112
+ const result = confirmReply(reply, request, true);
113
+ this.#sequence = Math.max(this.#sequence, result.sequence);
114
+ this.#sequenceKnown = true;
115
+ return result;
116
+ }
117
+ prepareWrite(statements, options = {}) {
118
+ this.#assertOpen();
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');
121
+ const afterSequence = options.afterSequence ?? this.#sequence;
122
+ checkSequence(afterSequence);
123
+ if (options.ifSequence !== undefined) {
124
+ if (this.#capabilities.exactSequenceCondition !== true) throw new ClientError('RDB_UNSUPPORTED', 'Database does not support exact sequence conditions', 'not-sent');
125
+ checkSequence(options.ifSequence);
126
+ if (options.ifSequence < afterSequence) throw new RangeError('ifSequence must be at least afterSequence');
127
+ }
128
+ const requestId = options.requestId ?? crypto.randomUUID();
129
+ if (typeof requestId !== 'string' || !/^[A-Za-z0-9_-]{1,80}$/.test(requestId)) throw new TypeError('requestId requires 1..80 letters, digits, underscores or hyphens');
130
+ const workLimits = encodeWorkLimits(options.workLimits);
131
+ const queryVersion = this.#queryVersion();
132
+ const body = JSON.stringify({ requestId, afterSequence, ...(options.ifSequence === undefined ? {} : { ifSequence: options.ifSequence }), statements: encodeStatements(statements, queryVersion), ...(workLimits === undefined ? {} : { workLimits }) });
133
+ checkBodySize(body, queryVersion);
134
+ return Object.freeze({ version: 2, url: this.#url, databaseId: this.#id, queryVersion, body });
135
+ }
136
+ async submit(prepared, options = {}) {
137
+ this.#assertOpen();
138
+ const request = validatePrepared(prepared);
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');
141
+ try {
142
+ const reply = await this.#request('/query', prepared.body, options, true, prepared, preparedQueryVersion(prepared));
143
+ const result = confirmReply(reply, request, false, prepared);
144
+ this.#sequence = Math.max(this.#sequence, result.sequence);
145
+ this.#sequenceKnown = true;
146
+ return result;
147
+ } catch (error) {
148
+ if (error instanceof ClientError) {
149
+ if (error.outcome === 'rolled-back') this.#sequence = Math.max(this.#sequence, error.sequence);
150
+ throw error;
151
+ }
152
+ throw new ClientError('RDB_PROTOCOL', 'Write response could not be confirmed; recover the saved request', 'unknown', { request: prepared });
153
+ }
154
+ }
155
+ async recover(prepared, options = {}) {
156
+ try { return await this.submit(prepared, options); }
157
+ catch (error) {
158
+ if (error instanceof ClientError && error.outcome === 'not-sent') throw new ClientError(error.code, 'Recovery did not confirm the earlier write; keep the saved request', 'unknown', { request: prepared });
159
+ throw error;
160
+ }
161
+ }
162
+ async writeAtomic(statements, options = {}) {
163
+ if (!this.#sequenceKnown && this.#deferred && options.afterSequence === undefined) await this.info(options);
164
+ return this.submit(this.prepareWrite(statements, options), options);
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 (!plainObject(options) || Object.keys(options).some(key => !['signal', 'timeoutMs', 'emptyTables', 'newIdentity'].includes(key))) throw new TypeError('export options take signal, timeoutMs, emptyTables and newIdentity');
170
+ checkCopyOptions(options);
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' + fileQuery(options), {
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
+ }
223
+ async close() {
224
+ this.#closed = true;
225
+ for (const controller of this.#active) controller.abort();
226
+ this.#token = '';
227
+ }
228
+ #assertOpen() {
229
+ if (this.#closed) throw new ClientError('RDB_CLOSED', 'Database client is closed', 'not-sent');
230
+ }
231
+ #queryVersion() { return this.#cloud ? CLOUD_QUERY_VERSION : 2; }
232
+ async #request(path, body, options, writing, prepared, queryVersion = 2) {
233
+ this.#assertOpen();
234
+ const duration = timeout(options.timeoutMs ?? this.#timeout);
235
+ if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal');
236
+ if (options.signal?.aborted) throw new ClientError('RDB_CANCELLED', 'Request cancelled before dispatch', 'not-sent', prepared ? { request: prepared } : {});
237
+ const bodyBytes = body === undefined ? 0 : byteLength(body);
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 } : {});
239
+ const controller = new AbortController();
240
+ const abort = () => controller.abort();
241
+ options.signal?.addEventListener('abort', abort, { once: true });
242
+ const timer = setTimeout(abort, duration);
243
+ this.#active.add(controller);
244
+ this.#inflightBytes += bodyBytes;
245
+ const credential = this.#token;
246
+ const fetch = this.#fetch;
247
+ let status;
248
+ try {
249
+ if (this.#transport) return await this.#transport(path, body, controller.signal, duration);
250
+ const send = () => fetch(this.#url + path, {
251
+ method: body === undefined ? 'GET' : 'POST',
252
+ headers: { authorization: `Bearer ${credential}`, accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(this.#id === undefined ? {} : { 'x-romedb-database-id': prepared?.databaseId ?? this.#id }),
253
+ ...(queryVersion === CLOUD_QUERY_VERSION && (path === '/query' || path === '/read') ? { 'x-romedb-query-version': String(queryVersion) } : {}) },
254
+ ...(body === undefined ? {} : { body }),
255
+ // Workers have no redirect: 'error'; a redirect is refused below, never followed with the credential.
256
+ redirect: 'manual', signal: controller.signal,
257
+ });
258
+ let response = await send();
259
+ if (await queueRefused(response)) {
260
+ await pause(queueRetryDelay(response), controller.signal);
261
+ response = await send();
262
+ }
263
+ status = response.status;
264
+ if (response.type === 'opaqueredirect' || (status >= 300 && status < 400)) {
265
+ await response.body?.cancel().catch(() => {});
266
+ throw new ClientError('RDB_PROTOCOL', 'Database URL redirected; redirects are never followed with a credential', writing ? 'unknown' : 'read-failed', { status, ...(prepared ? { request: prepared } : {}) });
267
+ }
268
+ const retryAfterMs = retryAfter(response.headers.get('retry-after'));
269
+ const replyVersion = response.headers.get('x-romedb-query-version');
270
+ if ((path === '/query' || path === '/read') && replyVersion !== null && replyVersion !== '2' && replyVersion !== String(queryVersion)) {
271
+ await response.body?.cancel();
272
+ throw new ClientError('RDB_PROTOCOL', 'Unsupported database query response version', writing ? 'unknown' : 'read-failed', { status, ...(prepared ? { request: prepared } : {}) });
273
+ }
274
+ const reply = await boundedJson(response, MAX_RESPONSE);
275
+ if (plainObject(reply?.error) && typeof reply.error.message === 'string') reply.error.message = redactMessage(reply.error.message, credential);
276
+ if (!response.ok && !(writing && reply?.requestId && reply?.sequence !== undefined)) {
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);
278
+ throw new ClientError(code, safeMessage(reply, credential), writing ? 'unknown' : 'read-failed', { status, ...(retryAfterMs === undefined ? {} : { retryAfterMs }), ...(prepared ? { request: prepared } : {}) });
279
+ }
280
+ return reply;
281
+ } catch (error) {
282
+ if (error instanceof ClientError) {
283
+ const outcome = writing
284
+ ? this.#transport && error.outcome === 'not-sent' ? 'not-sent' : 'unknown'
285
+ : error.outcome === 'not-sent' ? 'not-sent' : 'read-failed';
286
+ const message = this.#transport ? error.message.slice(0, 2048) : redactMessage(error.message, credential);
287
+ throw new ClientError(error.code, message, outcome, {
288
+ ...(error.status === undefined ? {} : { status: error.status }),
289
+ ...(error.retryAfterMs === undefined ? {} : { retryAfterMs: error.retryAfterMs }),
290
+ ...(prepared ? { request: prepared } : {}),
291
+ });
292
+ }
293
+ const code = controller.signal.aborted ? 'RDB_CANCELLED' : status === undefined ? 'RDB_TRANSPORT' : 'RDB_PROTOCOL';
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 });
295
+ } finally {
296
+ clearTimeout(timer);
297
+ options.signal?.removeEventListener('abort', abort);
298
+ this.#active.delete(controller);
299
+ this.#inflightBytes -= bodyBytes;
300
+ }
301
+ }
302
+ }
303
+
304
+ require('./client-internal.cjs').createEmbeddedClient = async (options, transport) => {
305
+ const database = new RemoteDatabase({ ...options, token: 'embedded' }, construction, transport);
306
+ try { await database.info(); return database; }
307
+ catch (error) { await database.close(); throw error; }
308
+ };
309
+
310
+ function normalizeFileUrl(input) {
311
+ if (typeof input !== 'string' || input.length > 4096) throw new TypeError('file URL requires a bounded local path');
312
+ const url = new URL(input);
313
+ if (url.protocol !== 'file:' || url.hostname || url.search || url.hash || url.pathname === '/' || /%2f|%5c|%00/i.test(url.pathname)) throw new TypeError('use a local file URL without a query or fragment');
314
+ return url.href;
315
+ }
316
+
317
+ function normalizeUrl(input) {
318
+ if (typeof input !== 'string' || input.length > 4096) throw new TypeError('url requires a bounded database URL');
319
+ // romedb:// is an alias for https://; the same checks apply after the rewrite.
320
+ const url = new URL(input.startsWith('romedb://') ? 'https://' + input.slice('romedb://'.length) : input);
321
+ const loopback = loopbackHost(url.hostname);
322
+ if (url.username || url.password || url.search || url.hash || (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))) throw new TypeError('use HTTPS or loopback HTTP without URL credentials, query or fragment');
323
+ let path = url.pathname.replace(/\/$/, '').replace(/\/(?:query|read)$/, '');
324
+ if (loopback && !path) path = '/v1/database';
325
+ const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
326
+ if (!(loopback && path === '/v1/database') && !new RegExp(`^/v1/organizations/${uuid}/databases/${uuid}$`).test(path)) throw new TypeError('use a local service URL or stable organization-scoped Cloud database URL');
327
+ return url.origin + path;
328
+ }
329
+
330
+ function encodeStatements(statements, queryVersion = 2) {
331
+ if (!Array.isArray(statements) || statements.length < 1 || statements.length > 32) throw new RangeError('request requires 1..32 statements');
332
+ return statements.map(statement => {
333
+ if (!plainObject(statement) || Object.keys(statement).some(key => !['sql', 'params'].includes(key)) || typeof statement.sql !== 'string' || !statement.sql.trim()) throw new TypeError('statement requires SQL and optional params');
334
+ if (statement.params === undefined) return { sql: text(statement.sql) };
335
+ if (!plainObject(statement.params)) throw new TypeError('params require an object');
336
+ return { sql: text(statement.sql), params: Object.fromEntries(Object.entries(statement.params).map(([key, value]) => [text(key), encodeValue(value, 0, queryVersion === CLOUD_QUERY_VERSION)])) };
337
+ });
338
+ }
339
+
340
+ function validatePrepared(prepared) {
341
+ const fields = prepared?.version === 1 ? 'body,databaseId,url,version' : 'body,databaseId,queryVersion,url,version';
342
+ if (!plainObject(prepared) || Object.keys(prepared).sort().join(',') !== fields || ![1, 2].includes(prepared.version) ||
343
+ (prepared.version === 2 && ![2, CLOUD_QUERY_VERSION].includes(prepared.queryVersion)) || typeof prepared.databaseId !== 'string' || !prepared.databaseId ||
344
+ (prepared.url?.startsWith('file:') ? normalizeFileUrl(prepared.url) : normalizeUrl(prepared.url)) !== prepared.url || typeof prepared.body !== 'string') throw new TypeError('invalid saved request');
345
+ checkBodySize(prepared.body, preparedQueryVersion(prepared));
346
+ const body = JSON.parse(prepared.body);
347
+ if (!plainObject(body) || Object.keys(body).some(key => !['requestId', 'afterSequence', 'ifSequence', 'statements', 'workLimits'].includes(key)) || typeof body.requestId !== 'string' || !/^[A-Za-z0-9_-]{1,80}$/.test(body.requestId)) throw new TypeError('invalid saved request identity');
348
+ encodeWorkLimits(body.workLimits);
349
+ checkSequence(body.afterSequence);
350
+ if (body.ifSequence !== undefined) {
351
+ checkSequence(body.ifSequence);
352
+ if (body.ifSequence < body.afterSequence) throw new RangeError('invalid saved sequence condition');
353
+ }
354
+ if (!Array.isArray(body.statements) || body.statements.length < 1 || body.statements.length > 32) throw new TypeError('invalid saved statements');
355
+ for (const statement of body.statements) {
356
+ if (!plainObject(statement) || Object.keys(statement).some(key => !['sql', 'params'].includes(key)) || typeof statement.sql !== 'string' || !statement.sql.trim() || (statement.params !== undefined && !plainObject(statement.params))) throw new TypeError('invalid saved statement');
357
+ for (const value of Object.values(statement.params ?? {})) decodeValue(value);
358
+ }
359
+ return body;
360
+ }
361
+
362
+ function confirmReply(reply, request, reading, prepared) {
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 } : {});
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();
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 });
366
+ if (!Array.isArray(reply.results) || reply.results.length !== request.statements.length || reply.error !== undefined || byteLength(JSON.stringify(reply.results)) > 262_144) throw invalid();
367
+ try {
368
+ const results = reply.results.map(result => {
369
+ if (!plainObject(result) || !Array.isArray(result.columns) || result.columns.some(name => typeof name !== 'string') || !Array.isArray(result.rows) || result.rows.length > 1000 || typeof result.affected !== 'string' || !/^(?:0|[1-9][0-9]{0,19})$/.test(result.affected) || BigInt(result.affected) > (1n << 64n) - 1n) throw invalid();
370
+ return { columns: result.columns, rows: result.rows.map(row => {
371
+ if (!Array.isArray(row) || row.length !== result.columns.length) throw invalid();
372
+ return row.map(value => decodeValue(value));
373
+ }), affected: BigInt(result.affected) };
374
+ });
375
+ return { requestId: reply.requestId, sequence: reply.sequence, results, ...(reading ? { readVersion: 2 } : {}), ...(reply.executionWork === undefined ? {} : { executionWork: reply.executionWork }) };
376
+ } catch { throw invalid(); }
377
+ }
378
+
379
+ async function boundedJson(response, limit) {
380
+ if (!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '')) throw new TypeError('response requires JSON');
381
+ if (!response.body) throw new TypeError('response body is missing');
382
+ const reader = response.body.getReader();
383
+ const chunks = [];
384
+ let length = 0;
385
+ try {
386
+ for (;;) {
387
+ const { done, value } = await reader.read();
388
+ if (done) break;
389
+ length += value.byteLength;
390
+ if (length > limit) throw new RangeError('response exceeds client byte limit');
391
+ chunks.push(value);
392
+ }
393
+ const bytes = new Uint8Array(length);
394
+ let offset = 0;
395
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
396
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
397
+ } finally { await reader.cancel().catch(() => {}); }
398
+ }
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
+
428
+ function loopbackHost(hostname) { return ['127.0.0.1', 'localhost', '[::1]'].includes(hostname); }
429
+
430
+ function capabilities(url, info, local) {
431
+ if (local) {
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');
433
+ return { ...info.capabilities, provenance: 'negotiated' };
434
+ }
435
+ return { provenance: 'documented', readVersion: 2, atomicWrites: true, replayWindow: 64, exactSequenceCondition: true, negotiatedLimits: false };
436
+ }
437
+
438
+ function sequence(value, reply = false) { return Number.isSafeInteger(value) && value >= 0 && value <= (reply ? MAX_SEQUENCE + 1 : MAX_SEQUENCE); }
439
+ function encodeWorkLimits(value) {
440
+ if (value === undefined) return undefined;
441
+ const ceilings = { rowsRead: 1_000_000, rowMutations: 100_000, vmSteps: 10_000_000 };
442
+ if (!plainObject(value)) throw new TypeError('workLimits require an object');
443
+ for (const [name, limit] of Object.entries(value)) {
444
+ if (!Object.hasOwn(ceilings, name) || !Number.isSafeInteger(limit) || limit < 0 || limit > ceilings[name]) throw new RangeError('workLimits require nonnegative integers within the service ceilings');
445
+ }
446
+ return Object.fromEntries(Object.keys(ceilings).filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
447
+ }
448
+ function checkSequence(value) { if (!sequence(value)) throw new RangeError('sequence requires a nonnegative safe integer below Number.MAX_SAFE_INTEGER'); }
449
+ function timeout(value) { if (!Number.isInteger(value) || value < 1 || value > 120_000) throw new RangeError('timeoutMs must be 1..120000'); return value; }
450
+ function byteLength(text) { return encoder.encode(text).byteLength; }
451
+ function checkBodySize(body, queryVersion = 2) {
452
+ if (byteLength(body) > (queryVersion === CLOUD_QUERY_VERSION ? MAX_CLOUD_REQUEST : MAX_REQUEST))
453
+ throw new RangeError(queryVersion === CLOUD_QUERY_VERSION ? 'request exceeds 1 MiB' : 'request exceeds 64 KiB');
454
+ }
455
+ function preparedQueryVersion(prepared) { return prepared.version === 1 ? 2 : prepared.queryVersion; }
456
+ // Cloud refuses a queued request with 503 query_queue_* before dispatching it,
457
+ // so nothing ran and one retry is safe for reads and writes alike.
458
+ async function queueRefused(response) {
459
+ if (response.status !== 503) return false;
460
+ try { return /^query_queue_[a-z_]{1,40}$/.test((await response.clone().json())?.code ?? ''); } catch { return false; }
461
+ }
462
+ function queueRetryDelay(response) {
463
+ const header = response.headers.get('retry-after');
464
+ return header === null ? 0 : Math.min(retryAfter(header) ?? 1000, 2000);
465
+ }
466
+ // Retry-After holds delay seconds or an HTTP date.
467
+ function retryAfter(value) {
468
+ if (value === null) return undefined;
469
+ if (/^\s*\d{1,9}(?:\.\d+)?\s*$/.test(value)) return Math.round(Number(value) * 1000);
470
+ const date = /GMT\s*$/.test(value) ? Date.parse(value) : NaN;
471
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
472
+ }
473
+ function pause(ms, signal) {
474
+ return new Promise((resolve, reject) => {
475
+ if (signal.aborted) { reject(signal.reason); return; }
476
+ const timer = setTimeout(() => { signal.removeEventListener('abort', stop); resolve(); }, ms);
477
+ const stop = () => { clearTimeout(timer); reject(signal.reason); };
478
+ signal.addEventListener('abort', stop, { once: true });
479
+ });
480
+ }
481
+ function fileQuery({ emptyTables, newIdentity }) {
482
+ const parts = [];
483
+ if (emptyTables?.length) parts.push(`empty=${emptyTables.map(encodeURIComponent).join(',')}`);
484
+ if (newIdentity) parts.push('identity=new');
485
+ return parts.length ? `?${parts.join('&')}` : '';
486
+ }
487
+ function httpCode(status) { return status === 401 ? 'RDB_AUTH' : status === 403 ? 'RDB_FORBIDDEN' : status === 409 ? 'RDB_CONFLICT' : status === 429 || status === 413 ? 'RDB_LIMIT' : 'RDB_REMOTE'; }
488
+ function redactMessage(message, token) { return (token ? message.replaceAll(token, '[redacted]') : message).slice(0, 2048); }
489
+ 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); }
490
+
491
+ async function saveRequest(path, request) { return require('./request-journal.cjs').saveRequest(path, request, validatePrepared); }
492
+ async function loadRequest(path) { return require('./request-journal.cjs').loadRequest(path, validatePrepared); }
493
+
494
+ module.exports = { connect, RemoteDatabase, ClientError, Float64, Record, Vector, encodeValue, decodeValue, normalizeUrl, validatePrepared, saveRequest, loadRequest };
495
+ })(commonjs, commonjs.exports, specifier => {
496
+ if (specifier === './values.cjs') return module0;
497
+ if (specifier === './client-internal.cjs') return {};
498
+ if (specifier === './request-journal.cjs') throw new TypeError("saveRequest and loadRequest need Node.js file access; store the PreparedWrite yourself");
499
+ throw new TypeError(`Cannot load ${specifier}`);
500
+ });
501
+
502
+ export const { connect, RemoteDatabase, ClientError, Float64, Record, Vector, encodeValue, decodeValue, normalizeUrl, validatePrepared, saveRequest, loadRequest } = commonjs.exports;
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@romedb/client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.14",
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
+ "main": "client.cjs",
6
+ "types": "client.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./client.d.ts",
10
+ "node": "./client.cjs",
11
+ "default": "./client.mjs"
12
+ },
13
+ "./values": {
14
+ "types": "./values.d.ts",
15
+ "node": "./values.cjs",
16
+ "default": "./values.mjs"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "client.cjs",
22
+ "client.mjs",
23
+ "client.d.ts",
24
+ "client-internal.cjs",
25
+ "values.cjs",
26
+ "values.mjs",
27
+ "values.d.ts",
28
+ "request-journal.cjs",
29
+ "LICENSE.md",
30
+ "README.md"
31
+ ],
32
+ "engines": {
33
+ "node": ">=22"
34
+ },
35
+ "license": "MIT",
36
+ "keywords": [
37
+ "romedb",
38
+ "romeql",
39
+ "database",
40
+ "client",
41
+ "cloudflare-workers"
42
+ ],
43
+ "homepage": "https://github.com/romedb/romedb/tree/main/romedb/bindings/client#readme",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/romedb/romedb.git",
47
+ "directory": "romedb/bindings/client"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/romedb/romedb/issues"
51
+ }
6
52
  }
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+ const fs = require('node:fs/promises');
3
+ const { constants } = require('node:fs');
4
+ const path = require('node:path');
5
+ const { Buffer } = require('node:buffer');
6
+
7
+ const MAX_JOURNAL = 2_359_296;
8
+
9
+ async function saveRequest(file, request, validate) {
10
+ validate(request);
11
+ const text = JSON.stringify(request);
12
+ if (Buffer.byteLength(text) > MAX_JOURNAL) throw new RangeError('request journal exceeds byte limit');
13
+ const handle = await fs.open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
14
+ try {
15
+ await handle.writeFile(text, 'utf8');
16
+ await handle.sync();
17
+ } catch (error) {
18
+ await handle.close();
19
+ await fs.unlink(file);
20
+ throw error;
21
+ }
22
+ await handle.close();
23
+ const directory = await fs.open(path.dirname(path.resolve(file)), constants.O_RDONLY | (constants.O_DIRECTORY ?? 0));
24
+ try { await directory.sync(); }
25
+ finally { await directory.close(); }
26
+ }
27
+
28
+ async function loadRequest(file, validate) {
29
+ const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
30
+ try {
31
+ const stat = await handle.stat();
32
+ if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || (typeof process.getuid === 'function' && stat.uid !== process.getuid()) || stat.size > MAX_JOURNAL) throw new Error('request journal requires a private bounded regular file');
33
+ const bytes = Buffer.alloc(MAX_JOURNAL + 1);
34
+ let length = 0;
35
+ while (length < bytes.length) {
36
+ const result = await handle.read(bytes, length, bytes.length - length, null);
37
+ if (!result.bytesRead) break;
38
+ length += result.bytesRead;
39
+ }
40
+ if (length > MAX_JOURNAL) throw new RangeError('request journal exceeds byte limit');
41
+ const request = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length)));
42
+ validate(request);
43
+ return Object.freeze(request);
44
+ } finally { await handle.close(); }
45
+ }
46
+
47
+ module.exports = { saveRequest, loadRequest };