diff options
Diffstat (limited to 'packages/db/src/runtime/db-client.ts')
-rw-r--r-- | packages/db/src/runtime/db-client.ts | 254 |
1 files changed, 254 insertions, 0 deletions
diff --git a/packages/db/src/runtime/db-client.ts b/packages/db/src/runtime/db-client.ts new file mode 100644 index 000000000..21f45aa45 --- /dev/null +++ b/packages/db/src/runtime/db-client.ts @@ -0,0 +1,254 @@ +import type { InStatement } from '@libsql/client'; +import { type Config as LibSQLConfig, createClient } from '@libsql/client'; +import type { LibSQLDatabase } from 'drizzle-orm/libsql'; +import { drizzle as drizzleLibsql } from 'drizzle-orm/libsql'; +import { type SqliteRemoteDatabase, drizzle as drizzleProxy } from 'drizzle-orm/sqlite-proxy'; +import { z } from 'zod'; +import { DetailedLibsqlError, safeFetch } from './utils.js'; + +const isWebContainer = !!process.versions?.webcontainer; + +function applyTransactionNotSupported(db: SqliteRemoteDatabase) { + Object.assign(db, { + transaction() { + throw new Error( + '`db.transaction()` is not currently supported. We recommend `db.batch()` for automatic error rollbacks across multiple queries.', + ); + }, + }); +} + +type LocalDbClientOptions = { + dbUrl: string; + enableTransactions: boolean; +}; + +export function createLocalDatabaseClient(options: LocalDbClientOptions): LibSQLDatabase { + const url = isWebContainer ? 'file:content.db' : options.dbUrl; + const client = createClient({ url }); + const db = drizzleLibsql(client); + + if (!options.enableTransactions) { + applyTransactionNotSupported(db); + } + return db; +} + +const remoteResultSchema = z.object({ + columns: z.array(z.string()), + columnTypes: z.array(z.string()), + rows: z.array(z.array(z.unknown())), + rowsAffected: z.number(), + lastInsertRowid: z.unknown().optional(), +}); + +type RemoteDbClientOptions = { + dbType: 'studio' | 'libsql'; + appToken: string; + remoteUrl: string | URL; +}; + +export function createRemoteDatabaseClient(options: RemoteDbClientOptions) { + const remoteUrl = new URL(options.remoteUrl); + + return options.dbType === 'studio' + ? createStudioDatabaseClient(options.appToken, remoteUrl) + : createRemoteLibSQLClient(options.appToken, remoteUrl, options.remoteUrl.toString()); +} + +function createRemoteLibSQLClient(appToken: string, remoteDbURL: URL, rawUrl: string) { + const options: Partial<LibSQLConfig> = Object.fromEntries(remoteDbURL.searchParams.entries()); + remoteDbURL.search = ''; + + let url = remoteDbURL.toString(); + if (remoteDbURL.protocol === 'memory:') { + // libSQL expects a special string in place of a URL + // for in-memory DBs. + url = ':memory:'; + } else if ( + remoteDbURL.protocol === 'file:' && + remoteDbURL.pathname.startsWith('/') && + !rawUrl.startsWith('file:/') + ) { + // libSQL accepts relative and absolute file URLs + // for local DBs. This doesn't match the URL specification. + // Parsing `file:some.db` and `file:/some.db` should yield + // the same result, but libSQL interprets the former as + // a relative path, and the latter as an absolute path. + // This detects when such a conversion happened during parsing + // and undoes it so that the URL given to libSQL is the + // same as given by the user. + url = 'file:' + remoteDbURL.pathname.substring(1); + } + + const client = createClient({ + ...options, + authToken: appToken, + url, + }); + return drizzleLibsql(client); +} + +function createStudioDatabaseClient(appToken: string, remoteDbURL: URL) { + if (appToken == null) { + throw new Error(`Cannot create a remote client: missing app token.`); + } + + const url = new URL('/db/query', remoteDbURL); + + const db = drizzleProxy( + async (sql, parameters, method) => { + const requestBody: InStatement = { sql, args: parameters }; + const res = await safeFetch( + url, + { + method: 'POST', + headers: { + Authorization: `Bearer ${appToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + async (response) => { + throw await parseRemoteError(response); + }, + ); + + let remoteResult: z.infer<typeof remoteResultSchema>; + try { + const json = await res.json(); + remoteResult = remoteResultSchema.parse(json); + } catch { + throw new DetailedLibsqlError({ + message: await getUnexpectedResponseMessage(res), + code: KNOWN_ERROR_CODES.SQL_QUERY_FAILED, + }); + } + + if (method === 'run') { + const rawRows = Array.from(remoteResult.rows); + // Implement basic `toJSON()` for Drizzle to serialize properly + (remoteResult as any).rows.toJSON = () => rawRows; + // Using `db.run()` drizzle massages the rows into an object. + // So in order to make dev/prod consistent, we need to do the same here. + // This creates an object and loops over each row replacing it with the object. + for (let i = 0; i < remoteResult.rows.length; i++) { + let row = remoteResult.rows[i]; + let item: Record<string, any> = {}; + remoteResult.columns.forEach((col, index) => { + item[col] = row[index]; + }); + (remoteResult as any).rows[i] = item; + } + return remoteResult; + } + + // Drizzle expects each row as an array of its values + const rowValues: unknown[][] = []; + + for (const row of remoteResult.rows) { + if (row != null && typeof row === 'object') { + rowValues.push(Object.values(row)); + } + } + + if (method === 'get') { + return { rows: rowValues[0] }; + } + + return { rows: rowValues }; + }, + async (queries) => { + const stmts: InStatement[] = queries.map(({ sql, params }) => ({ sql, args: params })); + const res = await safeFetch( + url, + { + method: 'POST', + headers: { + Authorization: `Bearer ${appToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(stmts), + }, + async (response) => { + throw await parseRemoteError(response); + }, + ); + + let remoteResults: z.infer<typeof remoteResultSchema>[]; + try { + const json = await res.json(); + remoteResults = z.array(remoteResultSchema).parse(json); + } catch { + throw new DetailedLibsqlError({ + message: await getUnexpectedResponseMessage(res), + code: KNOWN_ERROR_CODES.SQL_QUERY_FAILED, + }); + } + let results: any[] = []; + for (const [idx, rawResult] of remoteResults.entries()) { + if (queries[idx]?.method === 'run') { + results.push(rawResult); + continue; + } + + // Drizzle expects each row as an array of its values + const rowValues: unknown[][] = []; + + for (const row of rawResult.rows) { + if (row != null && typeof row === 'object') { + rowValues.push(Object.values(row)); + } + } + + if (queries[idx]?.method === 'get') { + results.push({ rows: rowValues[0] }); + } + + results.push({ rows: rowValues }); + } + return results; + }, + ); + applyTransactionNotSupported(db); + return db; +} + +const errorSchema = z.object({ + success: z.boolean(), + error: z.object({ + code: z.string(), + details: z.string().optional(), + }), +}); + +const KNOWN_ERROR_CODES = { + SQL_QUERY_FAILED: 'SQL_QUERY_FAILED', +}; + +const getUnexpectedResponseMessage = async (response: Response) => + `Unexpected response from remote database:\n(Status ${response.status}) ${await response + .clone() + .text()}`; + +async function parseRemoteError(response: Response): Promise<DetailedLibsqlError> { + let error; + try { + error = errorSchema.parse(await response.clone().json()).error; + } catch { + return new DetailedLibsqlError({ + message: await getUnexpectedResponseMessage(response), + code: KNOWN_ERROR_CODES.SQL_QUERY_FAILED, + }); + } + // Strip LibSQL error prefixes + let baseDetails = + error.details?.replace(/.*SQLite error: /, '') ?? 'Error querying remote database.'; + // Remove duplicated "code" in details + const details = baseDetails.slice(baseDetails.indexOf(':') + 1).trim(); + let hint = `See the Astro DB guide for query and push instructions: https://docs.astro.build/en/guides/astro-db/#query-your-database`; + if (error.code === KNOWN_ERROR_CODES.SQL_QUERY_FAILED && details.includes('no such table')) { + hint = `Did you run \`astro db push\` to push your latest table schemas?`; + } + return new DetailedLibsqlError({ message: details, code: error.code, hint }); +} |