convex-file-storage について
Convex 開発者向けに、ファイルのアップロード、メタデータ保存、配信、削除の手順を説明するスキルガイドです。URL ではなくストレージ ID をデータベースに保存する方針を示します。
主な機能
- 01有効期限付きアップロード URL の生成とストレージ ID の保存
- 02Action による Blob の保存
- 03クエリ内でのファイル URL 解決
- 04HTTP Action によるファイル配信
- 05ファイル形式、サイズ、所有権の検証
活用シーン
- 01所有権を確認するファイルアップロードの実装
- 02クエリ結果でのファイルリンク返却
- 03安定した HTTP パスやカスタム応答でのファイル配信
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ waynesutton/ builder-skills --skill convex-file-storageインストール後、Claude Code で/convex-file-storageを入力して読み込まれたか確認します。
同梱ファイルagents/openai.yamlassets/small-logo.svg
詳細
Convex のファイルストレージ
Convex でファイルをアップロード、保存、配信、削除する関数を提供します。ルールは 1 つです。URL 文字列ではなく、Id<"_storage"> を永続化してください。URL は読み取り時にクエリ内で ctx.storage.getUrl を使って解決します。
スキーマ
アプリのメタデータ(名前、所有者、用途)を保存する独自のテーブルを用意し、ID で _storage を参照します。Convex はサイズ、コンテンツタイプ、ハッシュを _storage システムテーブルに保持します。
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
files: defineTable({
storageId: v.id("_storage"),
name: v.string(),
contentType: v.string(),
size: v.number(),
ownerId: v.id("users"),
}).index("by_owner", ["ownerId"]),
});
アップロードの流れ
手順は 3 つです。ミューテーションで有効期間の短いアップロード URL を発行し、クライアントがその URL にファイルを POST し、続いて別のミューテーションで返された storageId を保存します。アップロード URL は 1 時間で期限切れになるため、アップロードごとに新しく生成し、決して保存しないでください。
// convex/files.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getCurrentUser } from "./lib/auth";
const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "application/pdf"];
export const generateUploadUrl = mutation({
args: {},
returns: v.string(),
handler: async (ctx) => {
await getCurrentUser(ctx); // only signed in users get an upload slot
return await ctx.storage.generateUploadUrl();
},
});
export const saveFile = mutation({
args: { storageId: v.id("_storage"), name: v.string() },
returns: v.id("files"),
handler: async (ctx, args) => {
const user = await getCurrentUser(ctx);
// Server side validation reads the real size and type from _storage.
// Client checks are for UX only; anyone can POST to the upload URL.
const meta = await ctx.db.system.get(args.storageId);
if (!meta) throw new Error("Upload not found");
if (meta.size > MAX_BYTES || !ALLOWED_TYPES.includes(meta.contentType ?? "")) {
await ctx.storage.delete(args.storageId);
throw new Error("File type or size not allowed");
}
return await ctx.db.insert("files", {
storageId: args.storageId,
name: args.name,
contentType: meta.contentType ?? "application/octet-stream",
size: meta.size,
ownerId: user._id,
});
},
});
// src/FileUploader.tsx
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
import { useState } from "react";
const MAX_BYTES = 10 * 1024 * 1024;
export function FileUploader() {
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const saveFile = useMutation(api.files.saveFile);
const [status, setStatus] = useState<"idle" | "uploading" | "error">("idle");
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > MAX_BYTES) return setStatus("error");
setStatus("uploading");
try {
const uploadUrl = await generateUploadUrl();
const res = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type }, // recorded as the file's contentType
body: file,
});
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
const { storageId } = (await res.json()) as { storageId: Id<"_storage"> };
await saveFile({ storageId, name: file.name });
setStatus("idle");
} catch {
setStatus("error");
}
}
return <input type="file" accept="image/*,.pdf" onChange={handleChange} disabled={status === "uploading"} />;
}
アクションから Blob を保存する
ctx.storage.store はアクションと HTTP アクションで使用できますが、ミューテーションでは使用できません。デフォルトのランタイムでは通常の fetch が使えるため、Node ライブラリでバイト列を生成する場合にのみ "use node" が必要です。バイト列を自分で作成する場合は、まず次のようにラップしてください。new Blob([bytes], { type: "application/pdf" })。type がない場合、ファイルは application/octet-stream として配信されます。
// convex/importFile.ts
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
export const importFromUrl = action({
args: { url: v.string(), name: v.string(), ownerId: v.id("users") },
returns: v.id("files"),
handler: async (ctx, args) => {
const response = await fetch(args.url);
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
// response.blob() carries the Content-Type header into blob.type
const blob = await response.blob();
const storageId = await ctx.storage.store(blob);
return await ctx.runMutation(internal.files.saveImported, {
storageId,
name: args.name,
ownerId: args.ownerId,
});
},
});
getUrl を使った配信
ファイルを返すクエリ内で URL を解決します。クライアントは url を img、iframe、またはダウンロードリンクで直接レンダリングします。
// convex/files.ts
export const listMine = query({
args: {},
returns: v.array(
v.object({
_id: v.id("files"),
name: v.string(),
contentType: v.string(),
size: v.number(),
url: v.union(v.string(), v.null()),
}),
),
handler: async (ctx) => {
const user = await getCurrentUser(ctx);
const files = await ctx.db
.query("files")
.withIndex("by_owner", (q) => q.eq("ownerId", user._id))
.order("desc")
.collect();
return await Promise.all(
files.map(async (f) => ({
_id: f._id,
name: f.name,
contentType: f.contentType,
size: f.size,
url: await ctx.storage.getUrl(f.storageId),
})),
);
},
});
ファイルが削除されている場合、getUrl は null を返します。文字列であると想定せず、UI 側で処理してください。
HTTP アクション経由での配信
認証チェック、カスタムの Content-Disposition、または https://<deployment>.convex.site/files/<storageId> にある安定したパスが必要な場合に使用します。ctx.storage.get は Blob を返します。ルートを convex/http.ts のルーターに追加してください。
// convex/http.ts
import { Id } from "./_generated/dataModel";
http.route({
pathPrefix: "/files/",
method: "GET",
handler: httpAction(async (ctx, request) => {
const storageId = new URL(request.url).pathname.slice("/files/".length) as Id<"_storage">;
const identity = await ctx.auth.getUserIdentity();
if (!identity) return new Response("Unauthorized", { status: 401 });
const blob = await ctx.storage.get(storageId);
if (!blob) return new Response("Not found", { status: 404 });
return new Response(blob, {
headers: { "Content-Type": blob.type || "application/octet-stream" },
});
}),
});
_storage テーブルのメタデータ
上記の saveFile と同様に、任意のクエリまたはミューテーションで ctx.db.system.get(storageId) を使ってメタデータを読み取ります。ctx.storage.getMetadata は非推奨のため、新しいコードには使用しないでください。ドキュメントの形状は次のとおりです。
type StorageDoc = {
_id: Id<"_storage">;
_creationTime: number;
contentType?: string; // from the upload's Content-Type header or blob.type
sha256: string;
size: number; // bytes
};
対応するバリデーターは v.object({ _id: v.id("_storage"), _creationTime: v.number(), contentType: v.optional(v.string()), sha256: v.string(), size: v.number() }) です。ctx.db.system.query("_storage") は保存済みのすべてのファイルを列挙します。files の行から参照されていない孤立ファイルを見つけるのに便利です。
削除
どちらも孤立しないよう、同じミューテーション内で Blob と行を削除します。クライアントを信頼せず、行の所有権を確認してください。複数の行が同じ storageId を共有できる場合は、最後の参照がなくなったときにのみ Blob を削除します。
// convex/files.ts
export const remove = mutation({
args: { fileId: v.id("files") },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getCurrentUser(ctx);
const file = await ctx.db.get(args.fileId);
if (!file) return null; // idempotent: already gone
if (file.ownerId !== user._id) throw new Error("Not allowed");
await ctx.storage.delete(file.storageId);
await ctx.db.delete(args.fileId);
return null;
},
});
よくある間違い
| 間違い | 問題が起きる理由 | 代わりに行うこと |
|---|---|---|
getUrl が返す URL、またはアップロード URL をドキュメントに保存する | アップロード URL は 1 時間で期限切れになり、保存した URL も削除後は無効になります | Id<"_storage"> を保存し、クエリ内で getUrl を使って解決する |
ストレージ ID に v.string() を使う | 型安全性が失われ、無効な値も受け入れる | v.id("_storage") |
ミューテーション内で ctx.storage.store を呼び出す | ミューテーションでは使用できない | アクションまたは HTTP アクションを使い、その後 runMutation で保存する |
アップロード POST で Content-Type を指定しない | ファイルは application/octet-stream として配信され、画像がインライン表示されない | headers: { "Content-Type": file.type } を設定する |
| サイズとタイプの検証をブラウザー内だけで行う | 誰でもアップロード URL に直接 POST できる | 保存ミューテーション内で ctx.db.system.get を使って再確認する |
ctx.storage.getMetadata(id) | 非推奨 | ctx.db.system.get(id) |
files の行を削除して Blob を削除しない | ストレージ料金が増え続ける | 1 つのミューテーション内で両方を削除する |
| HTTP アクション経由で大きなファイルをアップロードする | リクエスト上限は 20MB | アップロード URL の流れを使う |
チェックリスト
- スキーマに
v.id("_storage")と、アプリケーションレベルの名前、種類、サイズ、所有者が保存されている -
generateUploadUrlでサインイン済みユーザーを必須とし、クライアントがContent-Type: file.typeを指定して POST する - 保存するミューテーションで
ctx.db.system.getを使ってサイズとコンテンツタイプを再度検証し、拒否したアップロードを削除する - クエリで
ctx.storage.getUrlからurlを返し、UI でnullを処理する - アクションで正しい
typeを指定してctx.storage.store(blob)を使い、続いてrunMutationで参照を保存する - HTTP 配信ルートで
ctx.storage.getの前に認証を確認する - 削除するミューテーションで Blob と行をまとめて削除し、冪等性を確保する
-
ctx.storage.getMetadataの呼び出しがなく、データベースに URL 文字列が保存されていない