mcpField
C
convex-file-storage

convex-file-storage

github.com/waynesutton開発4052.6k 回インストール4 週間前に更新
リポジトリ

convex-file-storage について

Convex 開発者向けに、ファイルのアップロード、メタデータ保存、配信、削除の手順を説明するスキルガイドです。URL ではなくストレージ ID をデータベースに保存する方針を示します。

主な機能

  1. 01有効期限付きアップロード URL の生成とストレージ ID の保存
  2. 02Action による Blob の保存
  3. 03クエリ内でのファイル URL 解決
  4. 04HTTP Action によるファイル配信
  5. 05ファイル形式、サイズ、所有権の検証

活用シーン

  1. 01所有権を確認するファイルアップロードの実装
  2. 02クエリ結果でのファイルリンク返却
  3. 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 文字列が保存されていない

ドキュメント

コメント