Repository navigation
Expand file tree
/
Copy pathhrana-transport.ts
More file actions
600 lines (549 loc) · 27 KB
/
Copy pathhrana-transport.ts
File metadata and controls
600 lines (549 loc) · 27 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
/**
* libSQL Hrana HTTP transport (issue #424 Phase 5)
*
* The only implementation of the `LibSQLTransport` seam, and the only file in the
* provider allowed to know how Hrana encodes a request or a result: its
* `/v2/pipeline` endpoint, its request/response envelope, its `baton` and its
* `{ type, value }` value encoding. `seam-guard.test.ts` fails the build the
* moment any of that vocabulary appears elsewhere in the directory, which is what
* keeps "a WebSocket or embedded implementation is one new file" true rather than
* aspirational.
*
* Zero runtime dependency: the statement is JSON in the body of a POST and the
* answer comes back through the runtime's own `fetch`. `@libsql/client` would add
* a dependency to speak a protocol that is three JSON shapes wide.
*
* Four measured shapes drive nearly every decision below (2026-08-27, against
* `ghcr.io/tursodatabase/libsql-server` running sqld 0.24.33 and against a Turso
* Cloud database, both SQLite 3.47.0), and each is the opposite of what a JSON
* API teaches:
*
* - A failed statement answers HTTP **200**. `response.ok` says the pipeline was
* accepted, never that the statement ran, so the failure is read out of
* `results[]` (the same trap Trino's provider documents).
* - An AUTH failure uses a different envelope entirely - `{"error": "<string>"}`
* under 401 with no token and under **400** with a malformed one - so the error
* path must not assume the `{ message, code }` object shape.
* - Every integer arrives as a decimal STRING, including `last_insert_rowid`.
* That is the protocol protecting 64-bit values from a double, and reading them
* with `Number()` throws that protection away.
* - `GET /version` is a sqld route that Turso Cloud does not have. A deployment
* that publishes no version is not a broken one.
*/
import { endpointUrl, type HttpOrigin, httpOrigin, rejectRedirect } from "@/lib/db/http/endpoint";
import { DatabaseConfigError } from "@/lib/db/errors";
import { httpTransportFetch } from "@/lib/db/http/egress-policy";
import { describeFetchFailure } from "@/lib/db/http/fetch-failure";
import type { DatabaseConnection } from "@/lib/db/types";
import { keyRowsByPosition } from "@/lib/db/utils/positional-rows";
import { uniqueFieldNames } from "@/lib/db/utils/result-fields";
import { isSQLiteInt64Digits } from "../sqlite-int64";
import {
type LibSQLBatchOutcome,
type LibSQLExecuteOptions,
type LibSQLRow,
type LibSQLStatement,
type LibSQLStatementResult,
type LibSQLTransport,
LibSQLBodyLimitError,
LibSQLDeadlineError,
LibSQLTransportError,
} from "./transport";
// ============================================================================
// Constants
// ============================================================================
const DEFAULT_HOST = "localhost";
/** sqld's own default HTTP port. */
const DEFAULT_PORT = 8080;
/**
* 443, and not a libSQL-specific number: the TLS deployment this reaches is Turso
* Cloud, which serves every database on the ordinary HTTPS port of a hostname
* that identifies the database.
*/
const DEFAULT_TLS_PORT = 443;
const PIPELINE_PATH = "/v2/pipeline";
const VERSION_PATH = "/version";
const NOT_AN_ENVELOPE = "The server answered with a body that is not a libSQL answer";
const NO_RESULT = "The server accepted the pipeline and returned no result for the statement";
// ============================================================================
// Wire shapes
// ----------------------------------------------------------------------------
// Documented in full because this file is the record of what the wire looks like.
// Members are `unknown` where the code guards them: the shape is what the server
// promised, not what a proxy in front of it is guaranteed to deliver.
// ============================================================================
/** A value as Hrana spells it, in either direction. */
interface HranaValue {
type?: unknown;
value?: unknown;
base64?: unknown;
}
interface HranaColumn {
name?: unknown;
decltype?: unknown;
}
interface HranaStatementResult {
cols?: unknown;
rows?: unknown;
affected_row_count?: unknown;
last_insert_rowid?: unknown;
query_duration_ms?: unknown;
/**
* Declared and deliberately NOT surfaced, so this file stays the record of what
* the wire carries (the seam guard checks that every one of these is named here
* and nowhere else):
*
* - `replication_index` is the primary's frame number, and it is one of the two
* places the deployments differ - "1" on a self-hosted sqld, null on Turso
* Cloud. Nothing in the product consumes it, and widening the neutral result
* for it would force an embedded implementation to fabricate a value.
* - `rows_read` and `rows_written` are Turso's BILLING counters for the
* statement. They are honest numbers about work done, but not about the result:
* `SELECT COUNT(*)` reads 2000 rows and returns one, so surfacing either as a
* row count would report the wrong number in the one place a user checks.
*/
replication_index?: unknown;
rows_read?: unknown;
rows_written?: unknown;
}
/**
* The pipeline envelope itself, declared for the same reason.
*
* `baton` is the handle for a server-side stream and `base_url` is where a
* continuation must be sent. Both are answered on every call and both are ignored:
* this transport closes its stream in the same request, so there is never a
* continuation to route. An interactive transaction is exactly the feature that
* would consume them, and `supportsTransactions: false` in the provider is the
* other half of that decision.
*/
interface HranaPipelineEnvelope {
baton?: unknown;
base_url?: unknown;
results?: unknown;
}
// ============================================================================
// Pure helpers
// ============================================================================
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function parseJson(text: string): unknown {
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
/**
* An integer the protocol sent as a decimal string, as a number where a double
* holds it exactly and as that same string where it does not.
*
* SQLite integers and rowids are 64-bit, and Hrana quotes them for exactly this
* reason. `Number("9007199254740993")` answers 9007199254740992 - a corruption
* nothing downstream can detect, which is why Trino's provider quotes wide
* integers before parsing (#460). Here the wire has already done that work, so
* the only mistake available is undoing it.
*/
function decodeInteger(raw: unknown): number | string | null {
if (typeof raw === "number") return Number.isSafeInteger(raw) ? raw : String(raw);
if (typeof raw !== "string" || raw.trim() === "") return null;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return null;
return Number.isSafeInteger(parsed) ? parsed : raw;
}
/**
* Base64 as bytes. Blobs are the one SQLite type JSON cannot carry directly.
*
* A `Buffer`, not a plain `Uint8Array`, because the rows leave through
* `JSON.stringify` on their way to the browser: a plain `Uint8Array` is written as an
* object keyed by index (`{"0":222,"1":173,...}`), which the grid showed as JSON and
* the SQL export wrote back as quoted text. A `Buffer` IS a `Uint8Array`, and it
* serializes to `{"type":"Buffer","data":[...]}`, the form `asBytes` in
* `src/lib/export/binary.ts` reads. The SQLite driver seam makes the same choice.
*
* A small decoded value is a slice of Node's shared Buffer pool, so its `.buffer` is
* that pool: a consumer that reaches past the view must honour `byteOffset` and
* `byteLength`, or copy the bytes out first.
*/
function decodeBlob(raw: unknown): Uint8Array | null {
if (typeof raw !== "string") return null;
return Buffer.from(raw, "base64");
}
/**
* One wire value as a JavaScript one.
*
* An unrecognised `type` decodes to null rather than to its raw envelope: a
* future value type rendered as `{"type":"vector","value":…}` in a results grid
* would look like data the engine returned, and null at least reads as "nothing
* this build understands". Hrana has added types before (`blob` and `float` were
* not in the first version), so this branch is reachable by design.
*/
function decodeValue(raw: unknown): unknown {
const value = asRecord(raw);
if (!value) return null;
switch (value.type) {
case "null":
return null;
case "integer":
return decodeInteger(value.value);
case "float":
return typeof value.value === "number" ? value.value : Number(value.value);
case "text":
return typeof value.value === "string" ? value.value : String(value.value ?? "");
case "blob":
return decodeBlob(value.base64);
default:
return null;
}
}
/**
* Sending one back
* ----------------
* `decodeInteger` above is lossy in ONE direction that matters: `9007199254740993`
* the integer and `'9007199254740993'` the text both leave this transport as the
* same JavaScript string, so a value arriving in a bind carries no clue which it
* was. SQLite settles it by the COLUMN's affinity, and only for a column that HAS
* one: measured 2026-09-18 against sqld 0.24.33
* (`ghcr.io/tursodatabase/libsql-server:v0.24.33`, SQLite 3.45.1), on a row whose
* key is 9007199254740993 -
*
* column declared | bound as text | bound as an integer
* INTEGER / NUMERIC | matches | matches
* TEXT | matches | matches
* BLOB / undeclared | NO MATCH | matches
*
* INTEGER and NUMERIC affinity convert the text to a number before comparing and
* TEXT affinity converts the integer to text, so those answer the same either way.
* A column declared BLOB or declared NOTHING has NO affinity: SQLite compares the
* operands as they stand, a text is never equal to an integer, and the row the grid
* just read cannot be found again - `UPDATE ... WHERE id = ?` reports 0 rows changed
* and the editor tells the user nothing happened. Measured here before the fix:
* INTEGER 1 row, TEXT 1 row, BLOB 0 rows, undeclared 0 rows.
*
* Note what this is NOT: unlike bun:sqlite, the libSQL read side never rounds - the
* neighbouring row is never edited on either side of this change. Hrana quotes its
* integers, so the damage here is a silent no-op, not a wrong write.
*
* The affinity is not knowable here - a bind is a value, with no column attached,
* and the protocol never names the column an operand belongs to - so the seam
* answers the question it CAN answer exactly: it accepts back precisely what it
* handed out. `decodeInteger` emits these digits for one input only, a 64-bit
* integer outside the safe range, so reading them back as that integer is its exact
* inverse and every other string is left alone.
*
* WHICH strings those are is not restated here: `../sqlite-int64.ts` states it once,
* with the full list of shapes that stay text, and the SQLite driver asks the same
* function. Restating it in each provider is how the rule drifts - the two copies this
* replaced already disagreed in prose about exponent form.
*
* What that costs, measured and accepted: in a column with NO affinity that
* genuinely stores this shape as TEXT, the bind now misses where it used to match.
* That is the same ambiguity read from the other end, it cannot be resolved without
* the affinity, and the integer reading is the one these digits exist for. A
* TEXT-declared column is NOT affected - TEXT affinity converts the bind back to
* text - so an ordinary textual key still matches as text.
*
* This is the same rule `toSQLiteBindValue` applies in `sqlite-driver.ts`, by
* design: the two providers hand out the same shape, so they must accept the same
* shape back. It is one rule in one file now rather than a rule and its copy -
* `tests/unit/db/sqlite-int64.test.ts` fails the build if a provider grows its own.
*/
/**
* One JavaScript parameter as a wire value.
*
* `bigint` is encoded from its own decimal form rather than through `Number`, for
* the reason `decodeInteger` states in the other direction. A boolean becomes 1
* or 0 because that is what SQLite stores - it has no boolean type - and a `Date`
* becomes an ISO 8601 string because that is the only form SQLite's own date
* functions read. A STRING carrying the digits of a past-2^53 integer goes back as
* the integer it was read as, for the reason above; every other string is text.
*/
function encodeValue(param: unknown): HranaValue {
if (param === null || param === undefined) return { type: "null" };
if (typeof param === "bigint") return { type: "integer", value: param.toString() };
if (typeof param === "boolean") return { type: "integer", value: param ? "1" : "0" };
if (typeof param === "number") {
return Number.isInteger(param) ? { type: "integer", value: param.toString() } : { type: "float", value: param };
}
if (param instanceof Uint8Array) return { type: "blob", base64: Buffer.from(param).toString("base64") };
if (param instanceof Date) return { type: "text", value: param.toISOString() };
// Only a real string, never `String(param)` of some other object: the read side
// hands out strings and nothing else, so nothing else can be a value it emitted.
if (typeof param === "string" && isSQLiteInt64Digits(param)) return { type: "integer", value: param };
return { type: "text", value: String(param) };
}
/**
* The column names and the types the engine declared for them.
*
* The names are the declared ones through `uniqueFieldNames`: a column the engine did not
* name is `(No column name)` and a repeated name is numbered `id (2)`, so every column keeps a
* key of its own. A positional `column_N` used to stand in for a missing name, and it could
* collide with a column the statement itself named `column_1`.
*/
function readColumns(cols: unknown): { fieldNames: string[]; columnTypes: Record<string, string> } {
if (!Array.isArray(cols)) return { fieldNames: [], columnTypes: {} };
const declared = cols.map((raw) => (asRecord(raw) ?? {}) as HranaColumn);
const fieldNames = uniqueFieldNames(declared.map((col) => (typeof col.name === "string" ? col.name : "")));
// `Object.fromEntries` defines each type as an own property, so a column named `__proto__` keeps its type.
const columnTypes: Record<string, string> = Object.fromEntries(
declared.flatMap((col, index) =>
typeof col.decltype === "string" && col.decltype !== "" ? [[fieldNames[index], col.decltype] as const] : [],
),
);
return { fieldNames, columnTypes };
}
/**
* The rows, keyed by position under `fieldNames`. A row that is not a list, or carries a value
* count other than the column count, is refused by `keyRowsByPosition` rather than padded.
*/
function readRows(rows: unknown, fieldNames: string[], sql: string): LibSQLRow[] {
if (!Array.isArray(rows)) return [];
const values = rows.map((raw) => (Array.isArray(raw) ? raw.map(decodeValue) : []));
return keyRowsByPosition(fieldNames, values, "libsql", sql);
}
function toNumber(raw: unknown, fallback: number): number {
const value = Number(raw);
return Number.isFinite(value) ? value : fallback;
}
/**
* One step of an answered pipeline as an outcome.
*
* A step the server did not send becomes that statement's OWN failure rather
* than shifting every later result onto the wrong statement - the shape a short
* answer would otherwise silently produce.
*/
function readOutcome(raw: unknown, sql: string): LibSQLBatchOutcome {
const step = asRecord(raw);
if (!step) return { ok: false, error: new LibSQLTransportError(NO_RESULT, 200) };
if (step.type === "error") {
const error = asRecord(step.error);
const message = typeof error?.message === "string" ? error.message : "the statement failed";
const code = typeof error?.code === "string" ? error.code : null;
// Status 200 is the truth here and is carried deliberately: the provider maps
// this to a QueryError so the user reads SQLite's own wording, and a reader of
// the raw error should see that the transport itself succeeded.
return { ok: false, error: new LibSQLTransportError(message, 200, code) };
}
return { ok: true, result: toStatementResult(asRecord(step.response)?.result, sql) };
}
function toStatementResult(raw: unknown, sql: string): LibSQLStatementResult {
const result = (asRecord(raw) ?? {}) as HranaStatementResult;
const { fieldNames, columnTypes } = readColumns(result.cols);
return {
rows: readRows(result.rows, fieldNames, sql),
fieldNames,
columnTypes,
affectedRowCount: toNumber(result.affected_row_count, 0),
lastInsertRowId: result.last_insert_rowid === null ? null : decodeInteger(result.last_insert_rowid),
executionTimeMs: toNumber(result.query_duration_ms, 0),
};
}
/**
* The error a non-2xx response carried.
*
* Two envelopes, both measured: the auth failures answer `{"error": "<string>"}`
* while other refusals may answer the `{ message, code }` object the statement
* path uses. Anything else falls back to the raw body, trimmed, because a proxy's
* HTML page is more use to a reader than "request failed".
*/
function httpError(status: number, text: string): LibSQLTransportError {
const body = asRecord(parseJson(text));
const bare = typeof body?.error === "string" ? body.error : null;
const nested = asRecord(body?.error);
const message =
bare ??
(typeof nested?.message === "string" ? nested.message : null) ??
(text.trim() === "" ? `HTTP ${status}` : text.trim());
const code = typeof nested?.code === "string" ? nested.code : null;
return new LibSQLTransportError(`libSQL request failed: ${message}`, status, code);
}
/**
* The `Authorization` header a connection's credential makes, or undefined when it has none.
*
* The scheme follows the connection, never the deployment:
*
* - A non-empty `user` sends `Basic base64(user:password)`, with an empty password when the connection has
* none. That is the pair sqld checks when it is started with `SQLD_HTTP_AUTH="basic:<base64(user:password)>"`,
* which sqld calls legacy HTTP basic authentication and chooses ahead of any JWT key it was also given.
* - Otherwise the password is a TOKEN and goes as `Bearer <password>`: Turso Cloud mints a JWT per database, and
* sqld started with a JWT key checks the same header.
* - With neither, no header at all, which is what an unauthenticated local sqld expects: an empty bearer is a
* 400 there rather than an anonymous connection.
*
* A basic-auth sqld would also accept `Bearer <base64(user:password)>`, but only through leniency: its HttpBasic
* strategy splits the header at the first space, discards the scheme and accepts any token that CONTAINS the
* configured credential. Read from the sqld source (`libsql-server/src/auth/user_auth_strategies/http_basic.rs`,
* identical at libsql-server-v0.24.32, v0.24.33 and main), not measured against a live server here. Sending the
* scheme the server is configured for keeps that leniency out of the contract.
*
* The pair is encoded as UTF-8, the charset RFC 7617 names for Basic. Base64 has no character that ends a header
* line, so a user or password holding one cannot reach `fetch` as a header it refuses.
*/
function authorizationFor(config: DatabaseConnection): string | undefined {
if (config.user) return `Basic ${Buffer.from(`${config.user}:${config.password ?? ""}`, "utf8").toString("base64")}`;
return config.password ? `Bearer ${config.password}` : undefined;
}
/**
* The body read in chunks, counted in bytes, and abandoned at the first chunk that takes it past `limit`: the reader is
* cancelled, which closes the exchange, so the rest of an oversized answer is never pulled into memory. A body read
* whole is decoded once, so a character split across two chunks still decodes as one.
*/
async function readCappedBody(response: Response, limit: number): Promise<string> {
if (response.body === null) return "";
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let bytesRead = 0;
for (;;) {
// oxlint-disable-next-line no-await-in-loop -- a stream is read one chunk at a time, each counted before the next.
const { done, value } = await reader.read();
if (done) break;
bytesRead += value.byteLength;
if (bytesRead > limit) {
// oxlint-disable-next-line no-await-in-loop -- the read stops here; the loop does not continue past the cancel.
await reader.cancel();
throw new LibSQLBodyLimitError(bytesRead, limit);
}
chunks.push(value);
}
return Buffer.concat(chunks).toString("utf8");
}
/** A request that got no answer, as the seam's error: a deadline error when its own deadline is what ended it. */
function failedRequest(
cause: unknown,
url: string,
signal: AbortSignal | undefined,
timeoutMs: number | undefined,
): LibSQLTransportError {
const message = `libSQL request failed: ${describeFetchFailure(cause, url)}`;
return signal?.aborted === true && timeoutMs !== undefined
? new LibSQLDeadlineError(message, timeoutMs)
: new LibSQLTransportError(message, 0);
}
// ============================================================================
// Transport
// ============================================================================
export class LibSQLHranaTransport implements LibSQLTransport {
public readonly kind = "hrana-http" as const;
private readonly origin: HttpOrigin;
private readonly authorization: string | undefined;
constructor(config: DatabaseConnection) {
const secure = config.ssl !== undefined && config.ssl.mode !== "disable";
const port = config.port ?? (secure ? DEFAULT_TLS_PORT : DEFAULT_PORT);
this.origin = httpOrigin(secure ? "https" : "http", config.host ?? DEFAULT_HOST, port);
this.authorization = authorizationFor(config);
}
public async execute(sql: string, options: LibSQLExecuteOptions = {}): Promise<LibSQLStatementResult> {
const [outcome] = await this.pipeline([{ sql, params: options.params }], options);
// A single statement raises its own failure rather than handing one back: the
// caller asked one question, so there is no other panel for the answer to
// cost. `executeBatch` is the shape for the other case.
if (!outcome || !outcome.ok) throw outcome?.error ?? new LibSQLTransportError(NO_RESULT, 200);
return outcome.result;
}
public async executeBatch(
statements: LibSQLStatement[],
options: LibSQLExecuteOptions = {},
): Promise<LibSQLBatchOutcome[]> {
if (statements.length === 0) return [];
return this.pipeline(statements, options);
}
/**
* One round trip carrying every statement, and one outcome per statement.
*
* `close` travels with the statements instead of following them. Hrana keeps a
* server-side stream alive between requests and hands back a `baton` to
* continue it; a provider that never continues one must close it in the same
* request, or every call leaves a stream for the server to time out.
*/
private async pipeline(statements: LibSQLStatement[], options: LibSQLExecuteOptions): Promise<LibSQLBatchOutcome[]> {
const requests: Record<string, unknown>[] = statements.map((statement) => {
const stmt: Record<string, unknown> = { sql: statement.sql };
// Omitted rather than sent empty: an `args` member on a statement with no
// placeholders is accepted, but sending one makes every log and every
// capture of a plain statement look parameterised.
if (statement.params && statement.params.length > 0) stmt.args = statement.params.map(encodeValue);
return { type: "execute", stmt };
});
requests.push({ type: "close" });
const text = await this.send(PIPELINE_PATH, JSON.stringify({ requests }), options.timeoutMs, options.maxBodyBytes);
const envelope = asRecord(parseJson(text)) as HranaPipelineEnvelope | null;
const results = envelope?.results;
// The envelope is the transport's own contract, so a body that is not one
// fails the whole call: there is no per-statement answer to hand back.
if (!Array.isArray(results)) throw new LibSQLTransportError(NOT_AN_ENVELOPE, 200);
return statements.map((statement, index) => readOutcome(results[index], statement.sql));
}
public async serverVersion(): Promise<string | null> {
try {
const response = await httpTransportFetch(endpointUrl(this.origin, VERSION_PATH), {
method: "GET",
headers: this.headers(),
// Not followed, like every other request: a 3xx is one more "no version".
redirect: "manual",
});
if (!response.ok) return null;
const text = (await response.text()).trim();
return text === "" ? null : text;
} catch (error) {
if (error instanceof DatabaseConfigError) throw error;
// A deployment without the route, and a deployment that could not be
// reached at all, are both "no version to show" for this call. The version
// panel's connection has already been established by the time it runs, so a
// failure here cannot mean the server is down.
return null;
}
}
/** Nothing to release: every pipeline closes its own stream. */
public async close(): Promise<void> {
return Promise.resolve();
}
private headers(): Record<string, string> {
const headers: Record<string, string> = { "content-type": "application/json" };
if (this.authorization) headers.authorization = this.authorization;
return headers;
}
private async send(path: string, body: string, timeoutMs?: number, maxBodyBytes?: number): Promise<string> {
const url = endpointUrl(this.origin, path);
// A statement that hangs would otherwise hang the request forever: fetch has no default timeout. The signal covers
// the connect and the read, which a timer wrapped around the promise would not.
const signal = timeoutMs === undefined ? undefined : AbortSignal.timeout(timeoutMs);
let response: Response;
try {
response = await httpTransportFetch(url, {
method: "POST",
headers: this.headers(),
body,
// A followed redirect would carry the token to wherever it points.
redirect: "manual",
signal,
});
} catch (cause) {
if (cause instanceof DatabaseConfigError) throw cause;
throw failedRequest(cause, url, signal, timeoutMs);
}
const text =
maxBodyBytes === undefined
? await response.text()
: await this.readCapped(response, maxBodyBytes, url, signal, timeoutMs);
rejectRedirect(response, url);
if (!response.ok) throw httpError(response.status, text);
return text;
}
/** The capped read, with a stream that fails part-way leaving as the seam's own error, as a failed fetch does. */
private async readCapped(
response: Response,
maxBodyBytes: number,
url: string,
signal: AbortSignal | undefined,
timeoutMs: number | undefined,
): Promise<string> {
try {
return await readCappedBody(response, maxBodyBytes);
} catch (cause) {
if (cause instanceof LibSQLBodyLimitError) throw cause;
throw failedRequest(cause, url, signal, timeoutMs);
}
}
}