import { checkLocalSnapshot, type SnapshotClientFactory, type SnapshotQueryCheck, type SnapshotQueryResult, } from "#scripts/database-snapshot-lib.ts"; import { parseApiError } from "#shared/fetch.ts"; import type { DatabaseCredentials } from "#shared/provider-types.ts"; import { requireSuccess } from "#shared/result.ts"; const tursoFileRequirement = ( sql: string, key: string, expected: string | number, message: string, ): SnapshotQueryCheck => ({ sql, verify: (result: SnapshotQueryResult) => { if (result.rows.length !== 1 || result.rows[0]?.[key] !== expected) { throw new Error(message); } }, }); const TURSO_FILE_REQUIREMENTS = [ tursoFileRequirement( "PRAGMA journal_mode", "journal_mode", "wal", "Database file must use WAL journal mode", ), tursoFileRequirement( "PRAGMA page_size", "page_size", 4096, "Database file must use 4096-byte pages", ), tursoFileRequirement( "PRAGMA auto_vacuum", "auto_vacuum", 0, "Database file must have auto-vacuum disabled", ), tursoFileRequirement( "PRAGMA encoding", "encoding", "UTF-8", "Database file must use UTF-8 encoding", ), ]; /** Check every SQLite setting required by Turso's binary upload endpoint. */ export const verifyTursoUploadFile = ( path: string, factory: SnapshotClientFactory, signal?: AbortSignal, ): Promise => checkLocalSnapshot(path, factory, TURSO_FILE_REQUIREMENTS, signal); const databaseUploadUrl = (dbUrl: string): URL => { const source = new URL(dbUrl); if (source.protocol !== "libsql:" && source.protocol !== "https:") { throw new Error("Turso database URL must use TLS"); } return new URL(`https://${source.host}/v1/upload`); }; const UPLOAD_DEADLINE_MS = 10 * 60_000; /** The error an interrupted or overdue upload rejects with. */ const abortReason = (signal: AbortSignal): Error => { const reason = signal.reason; return new Error( `Upload aborted: ${reason instanceof Error ? reason.message : String(reason)}`, ); }; /** Fetch derives Content-Length from the body, not the supplied header. */ class UploadFileBody extends Blob { constructor( private readonly file: Deno.FsFile, private readonly byteLength: number, ) { super(); } override get size(): number { return this.byteLength; } override stream() { // A fresh stream lets fetch attach the Blob size to its upload resource. return ReadableStream.from(this.file.readable); } } /** Stream a SQLite file into a Turso database without holding it in memory. */ export const uploadTursoDatabaseFile = async ( path: string, credentials: DatabaseCredentials, signal?: AbortSignal, fetcher: typeof fetch = fetch, ): Promise => { const info = await Deno.stat(path); if (!info.isFile) throw new Error(`Database snapshot is not a file: ${path}`); const controller = new AbortController(); const uploadSignal = signal === undefined ? controller.signal : AbortSignal.any([signal, controller.signal]); const timer = setTimeout( () => controller.abort(new Error("Upload exceeded the 10-minute deadline")), UPLOAD_DEADLINE_MS, ); try { uploadSignal.throwIfAborted(); using file = await Deno.open(path, { read: true }); const response = await fetcher(databaseUploadUrl(credentials.dbUrl), { body: new UploadFileBody(file, info.size), headers: { Authorization: `Bearer ${credentials.dbToken}`, Connection: "close", }, method: "POST", redirect: "error", signal: uploadSignal, }); const text = await response.text(); if (!response.ok) { requireSuccess( parseApiError({ status: response.status, text }, "Upload database"), ); } } catch (error) { if (uploadSignal.aborted) throw abortReason(uploadSignal); throw error; } finally { clearTimeout(timer); } };