@aiquants/storage-api
v0.14.0
Published
Server-side TypeScript client for the multi-backend storage-api ConnectRPC service (local filesystem, Google Cloud Storage, Google Drive, in-memory) over a UNIX domain socket.
Readme
@aiquants/storage-api
Server-side TypeScript client for the multi-backend storage-api ConnectRPC service.
ローカルファイルシステム / Google Cloud Storage / Google Drive / インメモリを 単一の API で扱えます。バックエンドは URI スキームで切り替わるだけで、呼び出し側のコードは変わりません。
実処理は Go 実装のサービス (storage.v1.StorageService) が担い、本パッケージは UNIX ドメインソケット経由の ConnectRPC クライアント (事前知識による暗号化なしの HTTP/2) として振る舞います。サービスバイナリは自動で起動されるため、利用側で Go ツールチェーンやサービス管理は不要です。
Node 専用:
node:child_process/node:netを使用します。ブラウザ向けバンドルへ含めないでください。
Install
npm install @aiquants/storage-api # pnpm add / yarn add でも同じサービスバイナリはプラットフォーム別のパッケージとして配布され、npm が実行環境に合うものだけを自動選択します (esbuild や Biome と同じ方式)。本体パッケージ自体は約 100 KB で、バイナリを含みません。
| プラットフォーム | 対応 | | --- | --- | | Linux x64 / arm64 | ✅ | | macOS x64 (Intel) / arm64 (Apple Silicon) | ✅ | | Windows x64 / arm64 | ✅ |
postinstall スクリプトは不要です (実行権限は npm の bin 契約で保証されます)。ライフサイクルスクリプトを禁止している環境でもそのまま動きます。
サービスを自分でビルドし、その場所を教えてください。
git clone https://github.com/fehde-k/aiquants.git
cd aiquants/packages/go/storage-api
go build -o dist/storage-api ./cmd/storage-apiconst storage = createStorageClient({ binaryPath: "/path/to/storage-api" })
// または環境変数 STORAGE_API_BINARY で指定Quick start
import { createStorageClient, toFileUri } from "@aiquants/storage-api"
// workspaceRoot の外へのアクセスは file:// バックエンドが拒否する (パス走査対策)
const storage = createStorageClient({ workspaceRoot: process.cwd() })
const markdown = await storage.readText(toFileUri(`${process.cwd()}/README.md`))
const tree = await storage.walkDirectory(toFileUri(process.cwd()), { excludeGlobs: ["node_modules", ".git"] })サービスが未起動の場合は同梱バイナリを自動起動します。既に待ち受け中のサーバ (開発時の concurrently、本番の supervisord) があればそれを再利用するため、呼び出し側はライフサイクルを意識する必要はありません。
URI scheme
| Scheme | Backend | Example |
| --- | --- | --- |
| file (既定) | ローカルファイルシステム | file:///srv/app/docs/guide.md |
| gcs | Google Cloud Storage | gcs://bucket/path/object.md |
| gdrive | Google Drive | gdrive://id:1AbC... |
| memory | インメモリ (テスト用) | memory://docs/a.txt |
パスはパーセントエンコードしません。サーバ側はスキームを除いた残りをそのままパス / オブジェクト名として扱います。
スキームは表のとおり小文字で書きます。GCS://bucket/object のように大文字を含むスキームは、サービスがどのバックエンドへも渡さずに invalid_argument で拒否します (getScheme は大小を区別せずに分類しますが、サービスは小文字の綴りだけを受け付けます)。サービスにバックエンドが登録されていないスキーム (資格情報が無く gcs:// / gdrive:// を登録できなかったサービスなど) は unavailable です。
API
| Method | 説明 |
| --- | --- |
| readFile(uri) / readText(uri) | 生バイト列 / UTF-8 テキストとして読み込む (バッファ読み。既定 64 MiB 上限、maxReadBufferBytes オプションで変更可) |
| readStream(uri, { range }) | 内容 (または 1 つのバイト範囲) を Web ReadableStream として開く (サイズ上限なし。1 回の読み取りは最大 64 KiB。Response の body へ直結可能。下記「Range reads」) |
| writeFile(uri, content, { mimeType, keepRevision, ifVersionMatch }) | 作成または上書きし、書いたオブジェクト (StorageResource) を返す (下記「Conditional writes」) |
| updateFile(uri, content, { mimeType, keepRevision, ifVersionMatch }) | バックエンド上の同一性 (Drive のファイル ID 等) を保ったまま更新し、書いたオブジェクトを返す |
| deleteFile(uri) | 削除 |
| exists(uri) | 存在確認 |
| getResource(uri) | メタ情報 (size / mtime / mimeType 等、内容の版 version、編集の可否 canEdit) を取得 |
| listDirectory(uri) | 直下の項目を列挙 (別名の項目は targetUri に指す先を持つ。Google Drive の仮想の一覧は下記) |
| walkDirectory(uri, { excludeGlobs }) | 1 往復で 再帰的に全子孫を取得 |
| isWithin(uris, ancestorUris) | オブジェクトが境界のフォルダの内側にあるかを、読み取りと同じ解決で判定する (Google Drive のみ。下記「Placement」) |
| listAncestors(uri, boundaryUri) | 境界とオブジェクトの間のコンテナを、listDirectory が与える行の URI で名乗る (Google Drive・GCS・ローカル。下記「Ancestors」) |
| generateSignedUrl(uri, { expiresInSeconds }) | 1 つのオブジェクトを期限まで読める URL ({ url, expiresAt }) を発行 (gcs:// だけ。下記「Signed URLs」) |
| uploadResumable(uri, source, options) | 分割・レジューム可能なアップロード (下記参照) |
| initiateUpload / uploadChunk / uploadStatus / completeUpload / abortUpload | レジュームアップロードの低レベル操作 |
| listRevisions(uri) | バージョン履歴を古い順に列挙 (現行世代の判定は isHead で行う) |
| readRevision(uri, revisionId) | 過去世代の内容を読み込む |
| retainRevision(uri, revisionId, keep) | リビジョンを自動削除から保護する / 解除する (Drive の過去世代は解除不可) |
| close() | 自動起動したサービスを停止する (既存サーバへの接続時は何もしない)。主にテストの後片付け用 |
walkDirectory の excludeGlobs に一致したディレクトリは、プロバイダが枝刈りに対応していればサブツリーごと走査を打ち切ります (node_modules のような巨大ディレクトリの走査回避)。パターンはサーバ側でそのまま適用され、否定パターン (!) は評価されません。
注意: gitignore の完全な意味論 (否定による再包含) を実装する場合、否定パターンを含むパターン集合を
excludeGlobsへ渡さないでください。サーバが先に枝刈りしてしまうと、後から復活させるべき項目が結果に現れません。否定を含むときはexcludeGlobsを空にして全件取得し、判定を呼び出し側の gitignore 実装へ一本化します。
listDirectory / walkDirectory の項目の targetUri は、別名 (Google Drive のショートカット) が指すオブジェクトの URI (gdrive://id:<指す先の ID>) です。それ以外の項目と、指す先が削除されたショートカットでは "" です。ショートカット自身の uri と name はそのまま残ります。
Google Drive の listDirectory は、フォルダのほかに 2 つの仮想の一覧を受け付けます。
| URI | 行 |
| --- | --- |
| gdrive://shared (gdrive://drives も同じ) | 呼び出し元がメンバーである共有ドライブ (gdrive://id:<driveId> のフォルダ) |
| gdrive://shared-with-me (完全一致) | 他の人が呼び出し元へ共有した項目 (マイドライブでも共有ドライブでもない「共有アイテム」)。呼び出し元が所有するファイルは、他の人の共有フォルダの中にあっても含みません。メンバーでない共有ドライブから直接共有された項目は含みます。見える ancestor (親を辿って届くフォルダ) に別の共有項目を持つ項目は外すため、同じ項目がツリーの 2 か所に現れません。行の形はフォルダの一覧と同じです |
gdrive://shared-with-me はオブジェクトではありません。読み取り (readFile・getResource など) は not_found で失敗し、isWithin / listAncestors はオブジェクトとしては not_found と位置付けます (マイドライブに shared-with-me という名前の項目があっても、この URI では届きません)。重複を外すための親の遡りは isWithin と同じメタデータ (資格情報ごとに 30 秒のメモ、1 回 1,000 件の予算) を使い、まだメモに無い見える ancestor 1 件につき要求 1 件です。予算を超えると一覧は resource_exhausted です。
buildStorageTree(walkResult) でフラットな結果を入れ子ツリーへ組み立てられます。ディレクトリエントリを返さないバックエンド (フラットなオブジェクトストア) では中間ディレクトリを子の URI から補完します。
Range reads
readStream(uri, { range: { offset, length } }) は 1 つのバイト範囲だけを読みます。サービスもバックエンドからその範囲だけを読む (ローカルはシーク、GCS は範囲付きの読み取り、Drive は Range ヘッダー) ため、大きな動画の後半へ移るときに先頭から読み直しません。HTTP の Range 要求 (206 Partial Content) に答えるルートの土台です。
const resource = await storage.getResource(uri)
// bytes=1000-1999 に答える
const body = await storage.readStream(uri, { range: { offset: 1000, length: 1000 } })
return new Response(body, {
status: 206,
headers: { "Content-Range": `bytes 1000-1999/${resource.size}`, "Content-Length": "1000", "Accept-Ranges": "bytes" },
})| 値 | 意味 |
| --- | --- |
| { offset: 0, length: 0 } (または range の省略) | オブジェクト全体 |
| length: 0 | offset から末尾まで |
| offset + length が末尾を越える | 末尾までの少ないバイトが届く |
offset と length は 0 以上の安全な整数でなければならず、NaN・負の値・小数・無限大・2 ** 53 以上・数値以外・null は、サービスへ接続する前に invalid_argument で拒否します (丸めも既定値への置き換えもしません)。サービスからの失敗は最初のチャンクより前に StorageError として throw されます。
| code | 状況 |
| --- | --- |
| out_of_range | 全体以外の範囲で、末尾以降から始まるもの (空のファイルへの範囲を含む)。HTTP なら 416 を返す場面 |
| failed_precondition | 読み取り時に変換して配るため、範囲が指す保存済みのバイト列が無いオブジェクト (Google ネイティブの Drive ファイル、Content-Encoding: gzip で保存した GCS オブジェクトの途中から始まる範囲)。全体なら読める |
| invalid_argument | 範囲の値が不正 |
バージョン履歴
listRevisions / readRevision / retainRevision は版管理を持つバックエンドでのみ利用できます。対応していないバックエンド (file://) では unimplemented で失敗するので、isStorageError(error, "unimplemented") で機能の有無として分岐できます。
| バックエンド | 一覧 / 読み出し | 保護 (retainRevision) |
| --- | --- | --- |
| gdrive:// | 対応 | 対応 (過去世代の解除だけ不可) |
| gcs:// | 対応 (世代 = generation) | 非対応 (unimplemented) |
| memory:// | 対応 | 対応 |
| file:// | 非対応 (unimplemented) | — |
memory:// は既定では登録されていません。実サービス越しに版管理を検証したい場合のみ createStorageClient({ enableMemoryProvider: true }) で有効化してください (プロセス内の無制限マップなので本番では有効化しないこと)。
const revisions = await client.listRevisions("gdrive://reports/daily.json")
// 古い順。現行世代は isHead で判定する (通常は末尾。GCS の全世代削除済み
// オブジェクト等では現行世代が 1 件も返らないことがある)
const previous = revisions.at(-2)
if (previous) {
const bytes = await client.readRevision("gdrive://reports/daily.json", previous.id)
}Google Drive の保護 (
keepForever) は、外せる場合と外せない場合があります。 2026-07-29 に実機で切り分けた結果は次のとおりです。| 対象 |
retainRevision(uri, id, false)| | --- | --- | | 未保護のリビジョン | 成功 (何も起きない) | | 保護済みの現行世代 (isHead) | 成功 (解除できる) | | 保護済みの過去世代 |failed_preconditionで失敗 |過去世代の保護を外す唯一の手段は、そのリビジョンを完全に削除することです (内容ごと失われるため「解除」とは呼べません)。UI に解除操作を置く場合は、
isHeadがfalseかつkeepForeverがtrueの項目では無効化してください。Drive は保護していないバイナリリビジョンを概ね 30 日で自動削除します。監査や巻き戻しのために履歴を残したい場合は、書き込み時に
writeFile(uri, content, { keepRevision: true })を指定してください。なお Google は「保護されていないバイナリリビジョンはダウンロードできない」とも文書化していますが、上記の実機検証では未保護の過去世代を問題なく読み出せました。条件付きの制限か記述が古いかは不明なので、どちらにも依存しない実装にしてください (読めない場合に備えて
failed_preconditionを扱いつつ、読めることを前提にもしない)。Drive は編集頻度の高いファイルについて古いリビジョンを応答から省くことがあります。返った一覧を完全な履歴として扱わず、「N 世代目」のような通し番号や隣接世代どうしの差分を前提にしないでください。
Placement (isWithin)
isWithin(uris, ancestorUris, options?) は、各オブジェクトを境界のコンテナ (ancestor) のどれが含むかを答えます。Drive の ID はファイルの置き場所を何も語らないため、URI の文字列からは包含を判定できず、呼び出し元自身の資格情報 (accessToken) を持つサービスが親を辿って判定します。表示する文書を特定のフォルダや共有ドライブの中に閉じ込めるときの土台です。
判定は常に呼び出し元自身の資格情報で行い、サービスアカウントでは行いません (サービスアカウントの広い視野で判定すると、呼び出し元に見えない文書まで内側に数えてしまうため)。accessToken は必須で、authMode は省略するか "oauth2" にします。どちらでも要求は oauth2 を名指します。accessToken が無い・空白だけ、または authMode が "oauth2" 以外なら、サービスへ接続する前に invalid_argument です (サービスも、使えるトークンを持たない要求とサービスアカウントで動く要求を invalid_argument で拒否します)。
const { placements, ancestorStates } = await storage.isWithin(["gdrive://id:<root>/guide/intro.md", "gdrive://id:<file>"], ["gdrive://id:<root>"], { accessToken })
if (ancestorStates[0] !== "usable") {
// 境界そのものが見えない・ファイルを指している: この境界の中には何も無い
}
const first = placements[0]
if (first.state === "within") {
// 問い合わせた URI ではなく resolvedUri を読む: 判定したオブジェクトそのものに届く
const body = await storage.readText(first.resolvedUri, { accessToken })
}オブジェクト: 読み取りと同じ解決 (名前・ID 起点の相対パス・キャッシュと 404 での修復を含む) で特定し、内容は読みません。Drive のショートカットは指す先で位置付けます (境界の中にある外向きのショートカットは外、外にある内向きのショートカットは内)。
ancestor:
gdrive://id:<id>だけです (<id>は英字・数字・-・_)。フォルダ・共有ドライブ (driveIdだけで判定)・マイドライブ (gdrive://id:root)・フォルダへのショートカット (1 回だけ辿る) を指せます。名前のパス・gdrive://id:<base>/<rel>・gdrive://shared・gdrive://drives・gdrive://shared-with-meは呼び出し全体がinvalid_argumentです (一覧は何も閉じ込めないため。listAncestorsはgdrive://sharedとgdrive://shared-with-meを境界として受け付けます)。答え:
placementsはurisと同じ順に 1 つずつ、ancestorStatesはancestorUrisと同じ順に 1 つずつです。|
state| 意味 | | --- | --- | |within| 使える ancestor の少なくとも 1 つの内側。ancestorIndexes(昇順・重複無し・空にならない) とresolvedUri(位置付けたオブジェクト。ショートカットなら指す先) を持つ | |outside| 呼び出し元から見えて、どの使える ancestor の内側でもない | |not_found| 存在しない・見えない・使えないショートカット (指す先が無い、ショートカットを指す)・ドライブのルートより上を指す相対パス (gdrive://id:<base>/../../x.mdなど) | |trashed| ゴミ箱の中 (自身、またはショートカットの指す先) ||
ancestorStates| 意味 | | --- | --- | |usable| 位置付けられるコンテナ (フォルダ・共有ドライブ・マイドライブ) | |unavailable| 存在しない・見えない・ゴミ箱の中・使えないショートカット。何も含まない | |not_a_container| ファイル、またはファイルへのショートカット。何も含まない |使えない ancestor は呼び出しを失敗させず、他の ancestor はそのまま位置付けます。オブジェクト 1 つの事情 (上の
not_found/trashed) も、そのオブジェクトの答えになるだけで呼び出しを失敗させません。呼び出し全体が失敗するのは、資格情報の拒否・割り当ての超過・サービスの障害・取り消し・期限だけです。上限:
urisは 1〜MAX_PLACEMENT_URIS(100)、ancestorUrisは 1〜MAX_PLACEMENT_ANCESTORS(32) で、個数・配列でない値・文字列でない要素 (穴のある配列の穴を含む) はサービスへ接続する前にinvalid_argumentです。2 つの一覧は呼び出した時点で複製するため、呼び出しの後に配列を書き換えて使い回しても、問い合わせと答えの照合は呼び出した時点の内容で行われます。各 URI は空白だけでなく 16384 バイト以下で、全体が 1 つのスキームでなければなりません (サービスがinvalid_argument)。1 回の呼び出しが使える Drive のメタデータ要求は 1,000 件で、超えるとresource_exhaustedです (要求を分ければ答えられます)。鮮度: オブジェクト自身は毎回 Drive から取得するため、移動は直ちに反映されます。境界と辿った親フォルダは資格情報ごとに 30 秒メモするため、フォルダの移動は最長 30 秒遅れて反映されます。
対応バックエンド: Google Drive だけです。
file://・gcs://・memory://はunimplementedで失敗します (isStorageError(error, "unimplemented")で分岐できます)。答えの検証: 数のずれ・未指定の状態・範囲外や使えない ancestor への添字・
withinに無い (または他の状態にある)resolvedUriを含む答えは使わず、unknownで失敗します。
Ancestors (listAncestors)
listAncestors(uri, boundaryUri, options?) は、境界 (ツリーの根) とオブジェクトの間にあるコンテナを、境界の直下の子からオブジェクトの直接の親へ向かう順に答えます。どのコンテナの URI も、その親を listDirectory したときの行の uri そのものなので、境界を根とするツリーをこの順に展開すればオブジェクトの行へ届きます。Drive の ID (gdrive://id:<fileId>) は自分を置くフォルダを何も語らないため、URI の文字列からはこの連なりを作れません。
const ancestry = await storage.listAncestors("gdrive://id:<file>", "gdrive://id:<root>", { accessToken })
if (ancestry.state === "within") {
for (const folderUri of ancestry.containerUris) {
// 境界の行から順に、この URI の行を開く
}
// 問い合わせた URI ではなく resolvedUri の行を選ぶ (ショートカットなら指す先)
}オブジェクト: 読み取りと同じ解決で特定し、内容は読みません。別名 (Drive のショートカット・シンボリックリンク) は指す先で位置付けます。
バックエンドごとの規則:
| バックエンド | 境界 | 包含と連なり | コンテナの URI | | --- | --- | --- | --- | | Google Drive |
isWithinと同じ形 (gdrive://id:<id>のフォルダ・共有ドライブ・gdrive://id:root・フォルダへのショートカット)、または仮想の一覧gdrive://shared・gdrive://shared-with-me(完全一致。常にusable。下記) | 親を辿った最短の経路 (同じ長さなら先に並ぶ親)。メタデータ要求は 1 回 1,000 件まで |gdrive://id:<folderId>| | GCS |gcs://<bucket>かgcs://<bucket>/<prefix>(仮想のフォルダで境界は確かめないため、存在しないバケットの中でも常にusable) | 同じバケットで名前が接頭辞で始まれば内側。連なりは名前の/ごとの接頭辞 |gcs://<bucket>/<prefix>/| | ローカル | ディレクトリ (listDirectoryが受け付ける任意の形) | リンクをすべて解決したパスで判定し、書かれたままの境界の下で名乗る |file://<絶対パス>|Google Drive の仮想の一覧を境界にする: 連なりは一覧の行から始まるため、一覧を根とするツリーをそのまま展開できます。
| 境界 |
withinの連なり |outside| | --- | --- | --- | |gdrive://shared| 共有ドライブの行gdrive://id:<driveId>から、オブジェクトの直接の親まで (ドライブ自身は空) | どの共有ドライブにも無い・一覧に出ないドライブの中 (メンバーでない人へ直接共有された項目)・呼び出し元から見えないフォルダの下 (failed_preconditionではありません) | |gdrive://shared-with-me| オブジェクト自身かその上にある最も上の共有項目 (一覧の行) から、オブジェクトの直接の親まで (オブジェクト自身が行なら空)。他の人の共有フォルダの中にある呼び出し元自身のファイルは、そのフォルダを通ります | 自身にも上にも共有項目が無い (マイドライブだけの項目など) |資格情報: Google Drive と GCS は呼び出し元自身の資格情報でだけ答えるので、
accessTokenを渡します (要求はoauth2を名指します)。渡さなければサービスがinvalid_argumentで断ります。ローカルのファイルシステムはどの呼び出し元にも同じに見えるため要りません。authModeは省略するか"oauth2"にします。それ以外はサービスアカウントで動き得るため、サービスへ接続する前にinvalid_argumentです。答え:
|
state| 意味 | | --- | --- | |within| 境界の内側。containerUris(最大MAX_ANCESTOR_CHAIN_LENGTH= 100 個。境界の直下のオブジェクトと境界自身では空) とresolvedUri(位置付けたオブジェクトの行の URI) を持ち、boundaryStateは常にusable| |outside| 呼び出し元から見えて、境界の内側にない (boundaryStateがusableでなければ常にこれ) | |not_found| 存在しない・見えない・使えないショートカット | |trashed| ゴミ箱の中 (自身、またはショートカットの指す先) | |through_shortcut| 実体は境界の外にあるが、境界の内側の別名 (Drive のショートカット・シンボリックリンク) を通して届いた。連なりは名乗らない |boundaryStateはisWithinのancestorStatesと同じ値です。ただし GCS は境界を確かめないため、存在しないバケットや呼び出し元が読めないバケットの中の境界もusableと読めます。バケットを確かめると要求が 1 回増え、呼び出し元にバケットの有無を教えてしまうためで、そうしたバケットのオブジェクトは自身の検索でどのみちnot_foundになります (ほかのバケットのオブジェクトはoutside)。一部だけの連なりは返しません: 100 個を超える連なり・Drive の入れ子の上限で止まった遡り・Drive のメタデータ要求の 1,000 件超は
resource_exhausted、境界に指した共有ドライブ (gdrive://id:<driveId>) の中にあるのに上のフォルダが呼び出し元から見えないときはfailed_preconditionです。連なりを名乗らないバックエンド (memory) はunimplemented、契約を破る答え (状態と食い違う連なり、別のスキームや繰り返しのコンテナ) はunknownです。引数:
uriとboundaryUriは空白以外の文字を含む同じスキームの文字列で、そうでなければサービスへ接続する前にinvalid_argumentです。16384 バイトを超える URI はサービスがinvalid_argumentで拒否します。
Signed URLs (generateSignedUrl)
generateSignedUrl(uri, { expiresInSeconds }) は、1 つのオブジェクトを、ほかの資格情報なしに expiresAt まで GET で読める URL を { url, expiresAt } で返します。expiresInSeconds は必須で、1 から MAX_SIGNED_URL_LIFETIME_SECONDS (604800。7 日で、V4 の署名が許す最長) までの整数です。省いた値・範囲の外・小数・NaN は呼び出しの前に invalid_argument で拒否します。expiresAt は URL 自身が名乗る期限です。
import { getDefaultStorageClient, isStorageError } from "@aiquants/storage-api"
const storage = getDefaultStorageClient()
try {
const { url, expiresAt } = await storage.generateSignedUrl("gcs://bucket/videos/a.mp4", { expiresInSeconds: 300 })
// url は expiresAt まで、持つ者すべてにオブジェクトを読ませる
} catch (error) {
if (!isStorageError(error, "unimplemented")) {
throw error
}
// このスキームは署名付き URL を発行しない。サービス越しに配る
}| 結果 | いつ |
| --- | --- |
| { url, expiresAt } | gcs://: サービスの Cloud Storage の資格情報で署名した V4 の URL |
| unimplemented | file://・gdrive://・memory://: 署名付き URL を発行しないスキーム |
| failed_precondition | gcs:// で、サービスの資格情報が署名できない (秘密鍵も署名の主体も無い、または IAM の signBlob が断った) |
| invalid_argument | 有効期間が 1 から MAX_SIGNED_URL_LIFETIME_SECONDS までの整数でない |
失敗はほかの URL を伴いません (プロキシの URL に落ちない)。代わりに何を配るかは、呼び出し側がコードだけで決めます。URL を持つ者は誰でも期限まで署名したアカウントの権限でオブジェクトを読めるため、URL を渡す前に呼び出し側で許可を判定します。
Resumable uploads
大きなファイルを分割して送り、切断・プロセス再起動から再開できます。バックエンドのネイティブなレジューム機能の有無に関わらず、file:// / gcs:// / gdrive:// / memory:// すべてで同じ意味論が得られます。
import { createStorageClient, sourceFromBytes, toFileUri } from "@aiquants/storage-api"
const storage = createStorageClient()
// 途中で切れても、オフセットのずれも、内側で吸収される
const resource = await storage.uploadResumable(toFileUri("/srv/app/big.bin"), sourceFromBytes(bytes), {
chunkSize: 8 * 1024 * 1024,
checksum: { algorithm: "sha256", value: digestHex },
onProgress: ({ received, totalSize }) => console.log(`${received}/${totalSize}`),
})uploadResumable のオプション:
| Option | 既定値 | 説明 |
| --- | --- | --- |
| chunkSize | DEFAULT_CHUNK_SIZE (8 MiB) | 1 チャンクのバイト数。サーバの -max-message-bytes (既定 64 MiB) 未満にすること |
| maxRetries | DEFAULT_MAX_RETRIES (5) | 一時障害時のチャンクあたり再試行回数 (オフセット巻き戻しと予算を共有) |
| retryDelayMs | 指数バックオフ | 再試行 n 回目 (0 始まり) の待機時間を返す関数 |
| uploadId | — | 既存セッションの再開 (下記「プロセスを跨いだ再開」) |
| onProgress | — | チャンク受理ごとのコールバック |
| mimeType / keepRevision | — | 確定オブジェクトへ適用する書き込みメタデータ (Write metadata 参照) |
| checksum | — | sha256 / crc32c。完了時に検証し、失敗時は宛先へ一切書かない |
| ttlSeconds | サーバ既定 | セッション寿命の上書き |
| timeoutMs | クライアントの defaultTimeoutMs | アップロード全体ではなく、発行する各 RPC (チャンク・状態照会・確定・破棄) の期限。大きなファイルの確定が既定の期限より長くかかる場合に延ばす (0 で期限なし。上限は MAX_TIMEOUT_MS)。時間切れ (deadline_exceeded) は一時障害として再試行する |
新しいセッションは供給源の大きさ (source.size) を申告します。サービスは正の大きさを申告しないセッションを開かないため、空の供給源はサービスの拒否 (invalid_argument、reason は size_required) がそのまま StorageError として throw されます (再開するセッションが無いため ResumableUploadFailedError ではありません)。空のオブジェクトは writeFile で書いてください。
ResumableSource は「サイズ」と「任意オフセットからの読み出し」だけを要求します。ストリームではなくこの形なのは、レジュームには巻き戻しが必須で、一度きりのストリームでは原理的に再開できないためです。メモリに載せたくない場合は read をファイル読み出しで実装してください。
const fileSource: ResumableSource = {
size: stat.size,
read: async (offset, length) => {
const buffer = Buffer.alloc(length)
await handle.read(buffer, 0, length, offset)
return buffer
},
}プロセスを跨いだ再開
uploadId を永続化しておけば、別プロセスからでも続きを送れます (セッションは同一 storage-api インスタンスの spool に束縛されるため、同じ uploadSpoolDir を共有するサーバに接続すること。再開時に checksum / mimeType / keepRevision / ttlSeconds を指定し直すことはできず、指定するとエラーになります。再開するセッションが申告した大きさと供給源の大きさが違えば、送信の前に止めます)。
低レベルの initiateUpload(uri, { totalSize }) は正の totalSize (バイト) を必ず取ります。サービスはそれを超えるチャンクを size_exceeded で断り、ちょうどその大きさで完了します。
const session = await storage.initiateUpload(uri, { totalSize })
persist(session.uploadId)
// ...プロセスが落ちる...
await storage.uploadResumable(uri, source, { uploadId: loadPersisted() })契約
| 状況 | 挙動 |
| --- | --- |
| offset == received | 追記する |
| offset < received (再送) | 重複区間が保存済みバイト列と一致すれば冪等に成功、食い違えば chunk_conflict |
| offset > received (ギャップ) | offset_mismatch を返し、期待オフセットを添える |
| uploadChunk が成功して返った | そのバイト列は fsync 済みで、プロセスのクラッシュを跨いで生存する |
| completeUpload | サイズとチェックサムを宛先へ書く前に検証する。失敗時はセッションを残すため安全に再試行できる |
| abortUpload | 冪等 (未知の ID でも成功) |
再送が冪等なので、ACK を取りこぼした際の再送は安全です。uploadResumable は offset_mismatch を受けたら状態照会の往復を挟まずに expectedOffset へ即座に追従します。
失敗の分類は StorageError.resumable から機械可読に取れます (resumableFailureOf(error) は低レベル操作が投げる StorageError に対して使います。uploadResumable が投げる ResumableUploadFailedError では resumableFailureOf(error.cause) のように cause へ適用してください)。
import { resumableFailureOf } from "@aiquants/storage-api"
const failure = resumableFailureOf(error)
if (failure?.reason === "offset_mismatch") {
resumeFrom(failure.expectedOffset)
}reason は not_found / expired / offset_mismatch / chunk_conflict / checksum_mismatch / size_exceeded / incomplete / unsupported_checksum / size_required (正の総サイズを申告しないセッション、invalid_argument) / too_many_sessions (サービスが許す数のセッションが既に開いている、resource_exhausted。どれかの完了・破棄・期限切れで空く) のいずれかです。
Write metadata
writeFile / updateFile / uploadResumable はオブジェクトへ付けるメタデータを受け取ります。保持できないバックエンドでは無視され、エラーにはなりません。
| オプション | 効果 | 対応バックエンド |
| --- | --- | --- |
| mimeType | 保存オブジェクトのコンテンツタイプ | memory / gcs / gdrive (file は無視) |
| keepRevision | この書き込みが作るリビジョンを恒久保持 | gdrive のみ |
await storage.writeFile(uri, bytes, { mimeType: "application/pdf", keepRevision: true })Google Drive は Files.Update のたびにリビジョンを作りますが、バイナリファイルのリビジョンは既定で自動削除の対象です (keepForever=false)。監査や巻き戻しのために履歴を残したい場合は keepRevision: true を指定してください。1 ファイルあたりの恒久保持数には Drive 側の上限があります。
Conditional writes
getResource と書き込みが返す StorageResource は、内容の版 version (不透明な文字列で、等しいかだけを比べる) と、呼び出し元が内容を変えられるか canEdit を持ちます。一覧の項目 (StorageItem) は持ちません。
| バックエンド | version | canEdit |
| --- | --- | --- |
| gcs | 世代 | null |
| gdrive | 先頭のリビジョンの ID (Google ネイティブのファイルは null) | capabilities.canEdit |
| file | 更新時刻 (ns) と大きさ | null |
| memory | 先頭のリビジョン (r000001 …) | null |
ifVersionMatch を与えた書き込みは、オブジェクトの今の version がその値のときだけ書きます。読んだ時の版を名指せば、その後の他人の変更を上書きしません。
const credential = { accessToken, authMode: "oauth2" } as const
const resource = await storage.getResource(uri, credential)
if (resource.version === null) {
// 版を持たないオブジェクト (Google ネイティブのファイル) は条件つきで書けない
throw new Error(`${uri} keeps no version`)
}
const text = await storage.readText(uri, credential)
try {
const written = await storage.updateFile(uri, edit(text), { ...credential, ifVersionMatch: resource.version })
// written.version が次の書き込みの名指す版
} catch (error) {
if (isStorageError(error, "conflict")) {
// 誰かが先に書いた: 読み直して統合する
}
}- 別の版は
conflict、オブジェクトが無い (作成になる書き込みを含む) はnot_found、空文字は呼び出しの前にinvalid_argumentです。 - 書き込みが返すオブジェクトは同じ呼び出しで得たもので、後から読み直した値ではありません (読み直しは間に入った他人の書き込みの版を返し得ます)。
- GCS は世代の前提条件で不可分に照合します。Google Drive には条件つきの更新が無いため、更新の直前に先頭のリビジョンを読みます (往復 1 回分の窓があるため、すべての版を残すなら
keepRevision: trueを併せて指定します)。ローカルはサービスのプロセスの内で照合し、外のプログラムによる書き込みとは競い得ます。
Errors
個別操作は失敗時に StorageError を送出します。code は Node の errno でも Connect の内部コードでもない安定した列挙値です。code はサービスが返した Connect のコードで決まり、メッセージの文言では変わりません (メッセージは呼び出し側の URI を引用し得るため、"Forbidden Archive" という名前の境界を拒否した invalid_argument は invalid_argument のままです)。コード自体が何も分類しない Unknown / Internal のときだけ、バックエンドのエラーの文言 (Error 404・not found・Error 403・forbidden・unauthorized など) から not_found / permission_denied を補います。例外は高レベル駆動の uploadResumable で、再開に必要な文脈 (uploadId / received) を運ぶ ResumableUploadFailedError を送出します (原因の StorageError は cause に入ります)。
| code | 意味 |
| --- | --- |
| not_found | リソースが存在しない |
| is_a_directory | ファイル指定箇所にディレクトリが渡された |
| permission_denied | プロバイダ境界 (ワークスペースルート外) や OS による拒否 |
| invalid_argument | リクエスト自体が不正 (range・timeoutMs・defaultTimeoutMs・streamStallTimeoutMs の値の検証を含む。期限は MAX_TIMEOUT_MS (2147483647) を超えると拒否する。isWithin の個数・穴のある配列・URI の大きさ・スキームの混在・受け付けない ancestor の形・accessToken の欠落・oauth2 以外の authMode。大文字を含むスキーム (GCS:// など)。正の総サイズを申告しないレジュームのセッション (size_required)) |
| out_of_range | オフセットが有効範囲外 (レジュームアップロード、末尾以降から始まる readStream の範囲) |
| conflict | 現在の状態と競合 (条件つきの書き込みの版の不一致、チャンク矛盾)。読み直してからやり直す |
| failed_precondition | 前提状態を満たしていない (期限切れ・未完了、範囲で読めないオブジェクトへの range、境界に指した共有ドライブ (gdrive://id:<driveId>) の中にあり上のフォルダが呼び出し元から見えないオブジェクトへの listAncestors、サービスアカウントの Google Drive の権限が読み取りだけのサービスへのサービスアカウントとしての Drive への書き込み (authMode: "service_account"、またはトークンの無い呼び出し)) |
| data_loss | データ破損を検出 (チェックサム不一致) |
| unavailable | ストレージサービスへ到達できない、呼び出しの載っていた接続が PING に答えず捨てられた (次の呼び出しは新しい接続を開く)、またはサービスが URI のスキームのバックエンドを登録していない (起動時に資格情報からクライアントを作れなかったなど。サービスは理由を記録し、/healthz に登録済みのスキームを並べる) |
| deadline_exceeded | 時間切れ。単発の呼び出しの期限 (defaultTimeoutMs / timeoutMs) の経過、または消費側が streamStallTimeoutMs の間読まなかった readStream の取り消し。到達不能 (unavailable) とは区別する |
| unimplemented | バックエンドが当該操作を未実装 (版管理を持たないバックエンドへのバージョン履歴、Google Drive 以外への isWithin、memory への listAncestors) |
| resource_exhausted | readFile / readText のバッファ読み上限 (既定 64 MiB、maxReadBufferBytes) の超過 (readStream を使えば上限なしで読める)、または接続ごとのストリームの上限 (サービスの -max-streams-per-connection) の超過。後者は待たされずに直ちに返り、メッセージが上限と開いている数を名指しする (ストリームが終われば再試行できる)。isWithin が 1 回に使える Drive のメタデータ要求 (1,000 件) の超過。listAncestors の連なりの上限 (MAX_ANCESTOR_CHAIN_LENGTH) の超過、Drive の入れ子の上限での停止、Drive のメタデータ要求の 1,000 件超も含む。gdrive://shared-with-me の listDirectory が重複を外すのに 1,000 件を超える要求を要するときも同じ。レジュームのセッションの上限 (too_many_sessions) |
| canceled | 呼び出し側による中断 (AbortSignal / ブラウザ切断)。障害ではない |
| unknown | それ以外 (契約を破る isWithin / listAncestors の答えを含む) |
import { isStorageError } from "@aiquants/storage-api"
try {
await storage.readText(uri)
} catch (error) {
if (isStorageError(error, "not_found")) {
// ...
}
}Configuration
| Option | 環境変数 | 既定値 |
| --- | --- | --- |
| socketPath | STORAGE_SOCKET_PATH | (必須。どちらも無いか空なら createStorageClient が invalid_argument で断る。パスは推し量らない。サービスも同じ変数を読む) |
| workspaceRoot | STORAGE_WORKSPACE_ROOT | (未指定: サーバの実行時カレントディレクトリ) |
| uploadSpoolDir | STORAGE_UPLOAD_SPOOL_DIR / -upload-spool-dir | <tmp>/storage-api-uploads |
| binaryPath | STORAGE_API_BINARY | プラットフォームパッケージのバイナリを自動解決 |
| autoStart | — | true |
| startupTimeoutMs | — | 10000 |
| defaultTimeoutMs | — | DEFAULT_TIMEOUT_MS (120000)。readStream 以外のすべての呼び出しの期限 (下記「Deadlines and abandoned streams」)。1 から MAX_TIMEOUT_MS (2147483647) までの整数 |
| streamStallTimeoutMs | — | DEFAULT_STREAM_STALL_TIMEOUT_MS (60000)。readStream の消費側が、渡された断片 (最大 64 KiB) の次を求めないままでいてよい時間。1 から MAX_TIMEOUT_MS (2147483647) までの整数 |
| enableMemoryProvider | — | false (テスト専用) |
| — | STORAGE_SOCKET_MODE | 0600 (サービスのソケットの権限。0600 か 0660 だけ。ソケットの上では何も認証しないため、ほかの利用者へは開かない) |
| — | STORAGE_DRIVE_SERVICE_ACCOUNT_ACCESS | read-only (サービスアカウントの Google Drive の権限。read-write でだけサービスアカウントとして書ける。呼び出し元自身のトークンの呼び出しには効かない) |
spool は 1 インスタンスが排他的に確保します (
flock)。同一ホストで storage-api を複数立てる場合 (テスト等) は、インスタンスごとにuploadSpoolDirを分けてください。既定の共有パスのままだと、2 つ目以降はレジュームアップロードのみ無効化されて起動します (read/write/list は影響を受けません)。
binaryPath/STORAGE_API_BINARYは自前ビルドの差し込み口です。指定しない場合は実行環境に対応するプラットフォームパッケージから解決されます。アセット URL の接頭辞 2 種とソケットの権限・サービスアカウントの Google Drive の権限は環境変数のみで指定します (自動起動時は親プロセスの環境がサーバへ引き継がれます)。受け付けない値ではサービスが起動しません。ライブラリに消費アプリのルートを焼き込まないための中立的なカスタマイズポイントで、既定値は従来挙動と同一です。
enableMemoryProviderは自動起動するサーバへ-enable-memory-providerを渡し、memory://を登録します。 プロセス内の無制限マップなので、本番で有効化するとメモリ枯渇の的になります。版管理を持つバックエンドをネットワーク無しで検証するためだけのオプションです。既に起動済みのサーバへ接続する場合は起動引数のため効果がありません。
workspaceRoot は 自動起動するサーバ に渡す -workspace-root です。既に起動済みのサーバへ接続する場合、そのサーバのルートが優先されます。
Deadlines and abandoned streams
期限の無い呼び出しは、答えないサービスや詰まった接続の上で永久に待ちます。読まれずに捨てられた readStream の本文は、取り消されない限りサービスのファイル・goroutine・バッファと、このクライアントの全呼び出しが共有する HTTP/2 セッションのストリームの枠を握り続け、積み重なると同じクライアントの次の呼び出し (小さな readFile を含む) が答えなくなります。クライアントとサービスの両方がこれを有限の時間で回収します。
| 対象 | 期限 | 超えたとき |
| --- | --- | --- |
| readStream 以外のすべての呼び出し (readFile / readText を含む) | defaultTimeoutMs (既定 120 秒)。呼び出しごとの timeoutMs が置き換え、0 はその呼び出しに期限を設けない | deadline_exceeded。サービスもその処理をやめる |
| readStream | 合計の時間の期限は無い (呼び出しごとの timeoutMs を与えた場合だけ合計の期限になる)。代わりに、消費側が streamStallTimeoutMs (既定 60 秒) の間何も読まなければ取り消す | ストリームが deadline_exceeded でエラーになり、RPC を取り消してサービス側の資源を解放する (クライアントが受信済みの応答も読み捨て、接続を塞がない)。console.warn に 1 行記録する (読まれずに捨てられた本文を見つける手掛かり) |
readStream の期限が数えるのは消費側が読まない時間だけです。readStream は受け取ったメッセージを最大 64 KiB の断片に切り分けて 1 回の読み取りごとに 1 つ渡し (先読みの待ち行列は持たない)、時計はストリームを開いた時と断片を渡した時に張り、消費側が次を求めた時に外します。サービスを待つ時間も転送の合計の時間も数えないため、期限の間に 64 KiB 以上を読み続ける消費側 (既定で毎秒約 1 KiB) は、大きなファイルでも遅い回線へ書き出していても切られません。サービスも 64 KiB 以下のメッセージで送るため、同じ速さで読み続ける消費側はサービス側の期限にも切られません。ただし、ブラウザが一時停止した動画の本文のように長く読まれない本文と、一度も読まれずに捨てられた本文 (HEAD への応答など) は取り消されます (範囲の読み取りに答えるルートなら、ブラウザは続きを Range 要求で取り直します)。期限は呼び出し側の signal とは独立に働き、どちらが先でも同じ取り消しの経路 (RPC の中断) を通ります。
捨てられた本文は、もう 1 つの経路でも接続を止めます。取り消したストリームへその後も届く DATA フレームを、Node の HTTP/2 の実装 (nghttp2) は異常の兆し (glitch) として接続ごとに数え、1000 回 (毎秒 33 回ずつ回復) を超えるとアプリへ知らせずにセッションを自ら打ち切ります。Node はそのセッションの接続を読むのもやめるため、その上の呼び出しは答えなくなり、同じクライアントの以後の呼び出しも同じセッションへ載ります。クライアントは 2 つの方法でこれに備えます。
- 圧縮を求めない: メッセージを圧縮すると、縮む内容 (ゼロ埋めやテキスト) の 64 KiB が 100 バイト余りのフレーム 1 つになり、取り消した 1 本のストリームへ数百のフレームが届き得ます。クライアントはサービスに圧縮を求めず、サービスも圧縮しません。取り消したストリームの窓に残り得るのは 16 KiB 前後のフレームが数個 (64 KiB 分) だけです。
- 答えない接続を捨てる: それでも、読み始めた本文を捨てる読み手が短い間に数百続けば (1 本ごとに数フレームが届く) 打ち切りは起こり得ます。クライアントは接続が 5 秒間生きている兆し (応答・応答のバイト・PING の答え) を見せなければ、呼び出しが開いている間と、静かだった接続で呼び出しを始める前に PING で確かめ、5 秒答えない接続を捨てます。その上の呼び出しは
unavailableで終わり、次の呼び出しは新しい接続を開くため、打ち切られたセッションが止めるのは長くとも 10 秒ほどです。
サービス側にも同じ考え方の上限があり (サービスの README の「Service Configuration & Limits」)、起動フラグか環境変数で変えられます。
| 環境変数 (サービス) | 既定値 | 意味 |
| --- | --- | --- |
| STORAGE_STREAM_STALL_TIMEOUT | 60s | クライアントが読まない (または要求本文を送らない) まま進まないストリームをリセットする時間。サービスは 64 KiB 以下のメッセージで送るため、期限の間に 64 KiB 以上を読み続ける消費側は切られない。streamStallTimeoutMs はこれより長くしないこと (長いとサービスが先にストリームをリセットし、消費側にはストリームのエラーとして見える) |
| STORAGE_MAX_STREAMS_PER_CONNECTION | 100 | 1 つの接続 (= 1 つのクライアント) が同時に開ける readStream / readFile / readText の数。超えた呼び出しは待たされずに resource_exhausted で失敗し、他の呼び出しは答え続ける |
| STORAGE_HTTP2_MAX_CONCURRENT_STREAMS | 250 | HTTP/2 の同時ストリーム数の上限 (1 から 2147483647。上の値はこれ未満) |
| STORAGE_IDLE_TIMEOUT | 30m | ストリームの無い接続を閉じるまでの時間 |
| STORAGE_READ_HEADER_TIMEOUT | 10s | 新しい接続の HTTP/2 の接続の前置きと、HTTP/1.1 の要求ヘッダーを読む時間 (前置きの途中で止まった接続は長くともこの 2 倍で閉じる) |
Codegen
プロトコル定義は Go パッケージ側が単一の真実の源です。.proto を変更したら以下で TypeScript スタブを再生成してください。
pnpm --filter @aiquants/storage-api run generate生成後に Biome で整形するところまでが generate に含まれます (protoc プラグインの出力はリポジトリの整形規約と異なるため)。generate は buf を pnpm dlx で取得します。プラグイン (protoc-gen-es / protoc-gen-connect-es) は devDependencies の版を使うため、ネットワークの無い環境では手元の buf を PATH 上の node_modules/.bin と組み合わせて buf generate ../go/storage-api/proto --template buf.gen.yaml を実行し、続けて biome check --write src/gen で同じ結果になります。
License
MIT
