n naroドキュメント

ストレージ

ユーザーがアップロードするファイル(アバター、添付、ドキュメント)をプロジェクトの URL の裏にある Cloudflare R2 に置き、誰が読み書きできるかを Supabase と同じ行レベルセキュリティのポリシーで決めます。naro-js からは supabase-js と同じ書き方で呼び出せます。

アーリーアクセスです。Storage はプロジェクトのバックエンドの上で動くので、先に naro backend enable でバックエンドをオンにしてください。コントロールプレーンで Storage が有効になるまで、naro storage enable は STORAGE_DISABLED を返します。

オンにする

naro storage enable はプロジェクトの R2 バケットを作り、そのバケットとともにバックエンドをアップロードし直します。何度実行しても安全で、チームのプランが変わった直後に実行すると、次の確認を待たずに新しい上限がすぐに適用されます。Storage がオフのときは、naro storage bucket create が先にオンにします。

naro backend enable
naro storage enable

バケット

ファイルは自分で作ったバケットに入ります。プロジェクトあたり 100 個まで、名前は 1〜63 文字の英小文字・数字・-・_ で、先頭と末尾は英小文字か数字です。authenticated・copy・info・list・move・public・render・sign・upload は Storage のルートが使う語なので名前にできません。公開バケットは URL を知っている誰にでもファイルを返しますが、一覧・アップロードなどほかの呼び出しは引き続きポリシーを通ります。バケットごとにファイルサイズの上限と受け付ける形式を決められます。

naro storage bucket create avatars --public --file-size-limit 5MiB --allowed-mime-types image/png,image/jpeg,image/webp
naro storage bucket create docs

誰が読み書きできるか

Storage のポリシーは Supabase と同じく storage.objects にかける Postgres の行レベルセキュリティで、書き方も同じです。マイグレーションに書いて naro db push で適用します。ポリシーがなければ service キーだけが通ります。storage.foldername(name)、storage.filename(name)、storage.extension(name) は Supabase と同じように動き、storage.objects のポリシーはサブクエリで自分のテーブルを読めます。

-- migrations/0005_avatars.sql
create policy "avatars are readable" on storage.objects
  for select to public using (bucket_id = 'avatars');
create policy "upload into own folder" on storage.objects
  for insert to authenticated
  with check (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "replace own files" on storage.objects
  for update to authenticated
  using (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "remove own files" on storage.objects
  for delete to authenticated
  using (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);
naro db push

アプリから

naro.storage.from(bucket) でファイルのアップロード、ダウンロード、一覧、削除ができます。どの呼び出しも { data, error } を返し、例外を投げません。アップロードはファイルのバイトをそのまま 50 MiB まで、ファイルの形式(なければ application/octet-stream)で送ります。upsert: true なら既存のファイルを置き換えます。

import { createClient } from "naro-js";

const naro = createClient({
  url: "https://my-app.naro.sh",
  anonKey: "naro_pk_…",
});
const {
  data: { user },
} = await naro.auth.getUser();

const avatars = naro.storage.from("avatars");
await avatars.upload(`${user.id}/me.png`, file, { upsert: true });
const { data: image } = avatars.getPublicUrl(`${user.id}/me.png`); // no request
await avatars.list(user.id, { limit: 20 });
await avatars.remove([`${user.id}/old.png`]);

// A private bucket: a link for ten minutes.
const { data: link } = await naro.storage
  .from("docs")
  .createSignedUrl("team-1/plan.pdf", 600);

公開 URL と署名付き URL

getPublicUrl はリクエストなしで公開バケットの URL を作ります。createSignedUrl は呼び出し元が読めるファイルへのリンクを expiresIn 秒(最大 7 日)のあいだ発行します。ファイルを置き換えたり削除したりすると、そのリンクは開けなくなります。

ターミナルから

naro storage ls・cp・rm はプロジェクトの service キーで naro://<bucket>/<path> を扱います。キーは NARO_SERVICE_KEY、なければ警告を出して取得します。バイトは naro を経由せず、プロジェクトのバックエンドへ直接送られます。cp は両方向に使え、ローカルのファイルは上書きします。すでにあるオブジェクトを置き換えるには --upsert が必要です。rm は先に確認し、--recursive でフォルダの下をすべて削除します。naro://avatars/u1/ はフォルダだけ、naro://avatars/u1 は名前が u1 のオブジェクトも削除します。

naro storage cp ./me.png naro://avatars/u1/me.png
naro storage ls naro://avatars/u1
naro storage rm naro://avatars/u1/old.png

Supabase から移行する

メソッド名・引数・結果は supabase-js と同じです(FileObject の snake_case のフィールドも含みます)。違うのは次の点です。

  • supabase-js の upload(path, file) は multipart/form-data のフォームで送ります。Naro はファイルのバイトを本文としてそのまま受け取り、フォームは 400 INVALID_REQUEST で拒否するので、クライアントを naro-js に替えるか、下のように fetch でバイトを送ってください。
  • アップロードの形式はファイルの形式(Blob.type)です。形式のないバイトは supabase-js の既定値 text/plain ではなく application/octet-stream、文字列は text/plain;charset=utf-8 です。
  • info は list と同じ snake_case の FileObject を返します(サイズと形式は metadata.size・metadata.mimetype)。supabase-js の info は camelCase のフィールド(bucketId・createdAt・size・contentType)です。
  • エラーは naro-js のほかの呼び出しと同じ NaroError { code, message, details? } です。
  • バケットはクライアントからではなく、naro storage bucket create か MCP サーバーで作ります。
  • move、copy、createSignedUrls、list の search、50 MiB を超えるファイルはまだありません。
  • パスは書いた文字のまま保存されます(日本語や絵文字も含み、正規化しません)。.・.. のセグメント、空のセグメント、先頭や末尾の /、制御文字、バックスラッシュ(\)、双方向制御文字(U+202E など)は拒否します。
  • Supabase のドキュメントにあるポリシーの書き方のうち二つは、マイグレーションを push するときに拒否されます。(select auth.uid()::text) ではなく (select auth.uid())::text と書き、storage.foldername(name) は (storage.foldername(name))[n](n は 1〜16)の形でだけ使えます。any(...) の中では使えません。storage.allow_only_operation と storage.allow_any_operation はありません。

naro-js を使わない場合は、ファイルのバイトをそのまま本文に載せます。

await fetch(
  `https://my-app.naro.sh/_naro/v1/storage/object/avatars/${user.id}/me.png`,
  {
    method: "POST",
    headers: {
      apikey: "naro_pk_…",
      authorization: `Bearer ${token}`, // a signed-in user's token
      "content-type": file.type,
      "x-upsert": "true",
    },
    body: file,
  },
);

上限

項目上限
1 回のアップロード50 MiB
プロジェクトのファイル全体Hobby 1 GiB、Pro 100 GiB、Enterprise 1 TiB
1 日(UTC)の書き込み操作Hobby 50,000、Pro 500,000、Enterprise 5,000,000
バケットプロジェクトあたり 100 個
ファイルのパスUTF-8 で 900 バイト
署名付き URL1 秒から 7 日まで

容量はチームのプランに従い、15 分ごとに確認し直します。超えると、ファイルを削除するまでアップロードは 507 STORAGE_QUOTA_EXCEEDED を返します。アップロードは 1 回ごとに書き込み操作として数えられ、1 日の上限を超えると、00:00 UTC までアップロードは 429 STORAGE_OPS_QUOTA_EXCEEDED を返します。サイズは R2 自身が測っており、報告が 30 分から 1 時間半ほど遅れます。ファイルを削除したあとは、新しいサイズが報告されて確認されるまでアップロードは再開しません。ダウンロードと削除は止まりません。50 MiB を超えるファイルは、バイトを送る前に 413 OBJECT_TOO_LARGE になります。

オフと削除

naro backend disable はバックエンドといっしょに Storage も止めます。再びオンにするまで URL は 404 を返し、ファイルは残ります。プロジェクトを削除すると、バケットとその中のすべてのファイルが削除されます。空のバケットはすぐに、そうでなければすべてのファイルが 1 日以内に期限切れになるよう設定され、そのあとでバケットが削除されます。削除したファイルは元に戻せません。