diff --git a/.changeset/wild-suits-remain.md b/.changeset/wild-suits-remain.md new file mode 100644 index 000000000000..219fa154fd42 --- /dev/null +++ b/.changeset/wild-suits-remain.md @@ -0,0 +1,5 @@ +--- +"@astrojs/db": patch +--- + +Fixes some situations where failing requests would not error properly diff --git a/packages/db/src/core/cli/commands/link/index.ts b/packages/db/src/core/cli/commands/link/index.ts index f92a1818ca41..905d2095efd8 100644 --- a/packages/db/src/core/cli/commands/link/index.ts +++ b/packages/db/src/core/cli/commands/link/index.ts @@ -7,7 +7,7 @@ import ora from 'ora'; import prompts from 'prompts'; import { MISSING_SESSION_ID_ERROR } from '../../../errors.js'; import { PROJECT_ID_FILE, getSessionIdFromFile } from '../../../tokens.js'; -import { getAstroStudioUrl } from '../../../utils.js'; +import { type Result, getAstroStudioUrl, safeFetch } from '../../../utils.js'; export async function cmd() { const sessionToken = await getSessionIdFromFile(); @@ -51,29 +51,31 @@ async function linkProject(id: string) { async function getWorkspaceId(): Promise { const linkUrl = new URL(getAstroStudioUrl() + '/api/cli/workspaces.list'); - const response = await fetch(linkUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${await getSessionIdFromFile()}`, - 'Content-Type': 'application/json', + const response = await safeFetch( + linkUrl, + { + method: 'POST', + headers: { + Authorization: `Bearer ${await getSessionIdFromFile()}`, + 'Content-Type': 'application/json', + }, }, - }); - if (!response.ok) { - // Unauthorized - if (response.status === 401) { - console.error( - `${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan( - 'astro db login' - )} to authenticate and then try linking again.\n\n` - ); + (res) => { + // Unauthorized + if (res.status === 401) { + console.error( + `${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan( + 'astro db login' + )} to authenticate and then try linking again.\n\n` + ); + process.exit(1); + } + console.error(`Failed to fetch user workspace: ${res.status} ${res.statusText}`); process.exit(1); } - console.error(`Failed to fetch user workspace: ${response.status} ${response.statusText}`); - process.exit(1); - } - const { data, success } = (await response.json()) as - | { success: false; data: unknown } - | { success: true; data: { id: string }[] }; + ); + + const { data, success } = (await response.json()) as Result<{ id: string }[]>; if (!success) { console.error(`Failed to fetch user's workspace.`); process.exit(1); @@ -91,30 +93,32 @@ export async function createNewProject({ region: string; }) { const linkUrl = new URL(getAstroStudioUrl() + '/api/cli/projects.create'); - const response = await fetch(linkUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${await getSessionIdFromFile()}`, - 'Content-Type': 'application/json', + const response = await safeFetch( + linkUrl, + { + method: 'POST', + headers: { + Authorization: `Bearer ${await getSessionIdFromFile()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ workspaceId, name, region }), }, - body: JSON.stringify({ workspaceId, name, region }), - }); - if (!response.ok) { - // Unauthorized - if (response.status === 401) { - console.error( - `${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan( - 'astro db login' - )} to authenticate and then try linking again.\n\n` - ); + (res) => { + // Unauthorized + if (res.status === 401) { + console.error( + `${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan( + 'astro db login' + )} to authenticate and then try linking again.\n\n` + ); + process.exit(1); + } + console.error(`Failed to create project: ${res.status} ${res.statusText}`); process.exit(1); } - console.error(`Failed to create project: ${response.status} ${response.statusText}`); - process.exit(1); - } - const { data, success } = (await response.json()) as - | { success: false; data: unknown } - | { success: true; data: { id: string; idName: string } }; + ); + + const { data, success } = (await response.json()) as Result<{ id: string; idName: string }>; if (!success) { console.error(`Failed to create project.`); process.exit(1); @@ -124,30 +128,31 @@ export async function createNewProject({ export async function promptExistingProjectName({ workspaceId }: { workspaceId: string }) { const linkUrl = new URL(getAstroStudioUrl() + '/api/cli/projects.list'); - const response = await fetch(linkUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${await getSessionIdFromFile()}`, - 'Content-Type': 'application/json', + const response = await safeFetch( + linkUrl, + { + method: 'POST', + headers: { + Authorization: `Bearer ${await getSessionIdFromFile()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ workspaceId }), }, - body: JSON.stringify({ workspaceId }), - }); - if (!response.ok) { - // Unauthorized - if (response.status === 401) { - console.error( - `${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan( - 'astro db login' - )} to authenticate and then try linking again.\n\n` - ); + (res) => { + if (res.status === 401) { + console.error( + `${bgRed('Unauthorized')}\n\n Are you logged in?\n Run ${cyan( + 'astro db login' + )} to authenticate and then try linking again.\n\n` + ); + process.exit(1); + } + console.error(`Failed to fetch projects: ${res.status} ${res.statusText}`); process.exit(1); } - console.error(`Failed to fetch projects: ${response.status} ${response.statusText}`); - process.exit(1); - } - const { data, success } = (await response.json()) as - | { success: false; data: unknown } - | { success: true; data: { id: string; idName: string }[] }; + ); + + const { data, success } = (await response.json()) as Result<{ id: string; idName: string }[]>; if (!success) { console.error(`Failed to fetch projects.`); process.exit(1); diff --git a/packages/db/src/core/cli/commands/push/index.ts b/packages/db/src/core/cli/commands/push/index.ts index d4dd9c515193..760ec7986e16 100644 --- a/packages/db/src/core/cli/commands/push/index.ts +++ b/packages/db/src/core/cli/commands/push/index.ts @@ -3,7 +3,7 @@ import type { Arguments } from 'yargs-parser'; import { MIGRATION_VERSION } from '../../../consts.js'; import { getManagedAppTokenOrExit } from '../../../tokens.js'; import { type DBConfig, type DBSnapshot } from '../../../types.js'; -import { getRemoteDatabaseUrl } from '../../../utils.js'; +import { type Result, getRemoteDatabaseUrl, safeFetch } from '../../../utils.js'; import { createCurrentSnapshot, createEmptySnapshot, @@ -82,19 +82,23 @@ async function pushSchema({ return new Response(null, { status: 200 }); } const url = new URL('/db/push', getRemoteDatabaseUrl()); - const response = await fetch(url, { - method: 'POST', - headers: new Headers({ - Authorization: `Bearer ${appToken}`, - }), - body: JSON.stringify(requestBody), - }); - if (!response.ok) { - console.error(`${url.toString()} failed: ${response.status} ${response.statusText}`); - console.error(await response.text()); - throw new Error(`/db/push fetch failed: ${response.status} ${response.statusText}`); - } - const result = (await response.json()) as { success: false } | { success: true }; + const response = await safeFetch( + url, + { + method: 'POST', + headers: new Headers({ + Authorization: `Bearer ${appToken}`, + }), + body: JSON.stringify(requestBody), + }, + async (res) => { + console.error(`${url.toString()} failed: ${res.status} ${res.statusText}`); + console.error(await res.text()); + throw new Error(`/db/push fetch failed: ${res.status} ${res.statusText}`); + } + ); + + const result = (await response.json()) as Result; if (!result.success) { console.error(`${url.toString()} unsuccessful`); console.error(await response.text()); diff --git a/packages/db/src/core/cli/migration-queries.ts b/packages/db/src/core/cli/migration-queries.ts index 0301d2e11945..d8b27db0dc29 100644 --- a/packages/db/src/core/cli/migration-queries.ts +++ b/packages/db/src/core/cli/migration-queries.ts @@ -32,7 +32,7 @@ import { type NumberColumn, type TextColumn, } from '../types.js'; -import { getRemoteDatabaseUrl } from '../utils.js'; +import { type Result, getRemoteDatabaseUrl, safeFetch } from '../utils.js'; const sqlite = new SQLiteAsyncDialect(); const genTempTableName = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10); @@ -425,20 +425,22 @@ export async function getProductionCurrentSnapshot({ }): Promise { const url = new URL('/db/schema', getRemoteDatabaseUrl()); - const response = await fetch(url, { - method: 'POST', - headers: new Headers({ - Authorization: `Bearer ${appToken}`, - }), - }); - if (!response.ok) { - console.error(`${url.toString()} failed: ${response.status} ${response.statusText}`); - console.error(await response.text()); - throw new Error(`/db/schema fetch failed: ${response.status} ${response.statusText}`); - } - const result = (await response.json()) as - | { success: false; data: undefined } - | { success: true; data: DBSnapshot }; + const response = await safeFetch( + url, + { + method: 'POST', + headers: new Headers({ + Authorization: `Bearer ${appToken}`, + }), + }, + async (res) => { + console.error(`${url.toString()} failed: ${res.status} ${res.statusText}`); + console.error(await res.text()); + throw new Error(`/db/schema fetch failed: ${res.status} ${res.statusText}`); + } + ); + + const result = (await response.json()) as Result; if (!result.success) { console.error(`${url.toString()} unsuccessful`); console.error(await response.text()); diff --git a/packages/db/src/core/tokens.ts b/packages/db/src/core/tokens.ts index d8be850a0c4f..e7f9c630ff0e 100644 --- a/packages/db/src/core/tokens.ts +++ b/packages/db/src/core/tokens.ts @@ -3,7 +3,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { MISSING_PROJECT_ID_ERROR, MISSING_SESSION_ID_ERROR } from './errors.js'; -import { getAstroStudioEnv, getAstroStudioUrl } from './utils.js'; +import { getAstroStudioEnv, getAstroStudioUrl, safeFetch } from './utils.js'; export const SESSION_LOGIN_FILE = pathToFileURL(join(homedir(), '.astro', 'session-token')); export const PROJECT_ID_FILE = pathToFileURL(join(process.cwd(), '.astro', 'link')); @@ -31,13 +31,20 @@ class ManagedRemoteAppToken implements ManagedAppToken { renewTimer: NodeJS.Timeout | undefined; static async create(sessionToken: string, projectId: string) { - const response = await fetch(new URL(`${getAstroStudioUrl()}/auth/cli/token-create`), { - method: 'POST', - headers: new Headers({ - Authorization: `Bearer ${sessionToken}`, - }), - body: JSON.stringify({ projectId }), - }); + const response = await safeFetch( + new URL(`${getAstroStudioUrl()}/auth/cli/token-create`), + { + method: 'POST', + headers: new Headers({ + Authorization: `Bearer ${sessionToken}`, + }), + body: JSON.stringify({ projectId }), + }, + (res) => { + throw new Error(`Failed to create token: ${res.status} ${res.statusText}`); + } + ); + const { token: shortLivedAppToken, ttl } = await response.json(); return new ManagedRemoteAppToken({ token: shortLivedAppToken, @@ -56,14 +63,20 @@ class ManagedRemoteAppToken implements ManagedAppToken { } private async fetch(url: string, body: unknown) { - return fetch(`${getAstroStudioUrl()}${url}`, { - method: 'POST', - headers: { - Authorization: `Bearer ${this.session}`, - 'Content-Type': 'application/json', + return safeFetch( + `${getAstroStudioUrl()}${url}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${this.session}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }); + () => { + throw new Error(`Failed to fetch ${url}.`); + } + ); } async renew() { diff --git a/packages/db/src/core/utils.ts b/packages/db/src/core/utils.ts index b2395754a59e..549a8c6540ef 100644 --- a/packages/db/src/core/utils.ts +++ b/packages/db/src/core/utils.ts @@ -26,3 +26,24 @@ export function getDbDirectoryUrl(root: URL | string) { export function defineDbIntegration(integration: AstroDbIntegration): AstroIntegration { return integration; } + +/** + * Small wrapper around fetch that throws an error if the response is not OK. Allows for custom error handling as well through the onNotOK callback. + */ +export async function safeFetch( + url: Parameters[0], + options: Parameters[1] = {}, + onNotOK: (response: Response) => void | Promise = () => { + throw new Error(`Request to ${url} returned a non-OK status code.`); + } +): Promise { + const response = await fetch(url, options); + + if (!response.ok) { + await onNotOK(response); + } + + return response; +} + +export type Result = { success: true; data: T } | { success: false; data: unknown }; diff --git a/packages/db/src/runtime/db-client.ts b/packages/db/src/runtime/db-client.ts index db8535e15ed4..6695779a17cf 100644 --- a/packages/db/src/runtime/db-client.ts +++ b/packages/db/src/runtime/db-client.ts @@ -4,6 +4,7 @@ import type { LibSQLDatabase } from 'drizzle-orm/libsql'; import { drizzle as drizzleLibsql } from 'drizzle-orm/libsql'; import { drizzle as drizzleProxy } from 'drizzle-orm/sqlite-proxy'; import { z } from 'zod'; +import { safeFetch } from '../core/utils.js'; const isWebContainer = !!process.versions?.webcontainer; @@ -29,19 +30,22 @@ export function createRemoteDatabaseClient(appToken: string, remoteDbURL: string const db = drizzleProxy( async (sql, parameters, method) => { const requestBody: InStatement = { sql, args: parameters }; - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${appToken}`, - 'Content-Type': 'application/json', + const res = await safeFetch( + url, + { + method: 'POST', + headers: { + Authorization: `Bearer ${appToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), }, - body: JSON.stringify(requestBody), - }); - if (!res.ok) { - throw new Error( - `Failed to execute query.\nQuery: ${sql}\nFull error: ${res.status} ${await res.text()}}` - ); - } + (response) => { + throw new Error( + `Failed to execute query.\nQuery: ${sql}\nFull error: ${response.status} ${response.statusText}` + ); + } + ); let remoteResult: z.infer; try { @@ -74,19 +78,22 @@ export function createRemoteDatabaseClient(appToken: string, remoteDbURL: string }, async (queries) => { const stmts: InStatement[] = queries.map(({ sql, params }) => ({ sql, args: params })); - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${appToken}`, - 'Content-Type': 'application/json', + const res = await safeFetch( + url, + { + method: 'POST', + headers: { + Authorization: `Bearer ${appToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(stmts), }, - body: JSON.stringify(stmts), - }); - if (!res.ok) { - throw new Error( - `Failed to execute batch queries.\nFull error: ${res.status} ${await res.text()}}` - ); - } + (response) => { + throw new Error( + `Failed to execute batch queries.\nFull error: ${response.status} ${response.statusText}}` + ); + } + ); let remoteResults: z.infer[]; try {