File Storage — vyuh_server_plugin_storage
StoragePlugin for files / blobs — avatars, document uploads, exported reports, generated PDFs. Distinct from DbPlugin (relational data); this is the file-storage half of the runtime, addressable as vyuh.storage.
Operations are keyed by bucket (top-level container) + path (slash-delimited object key within it). The package ships a SupabaseStoragePlugin implementation; the same plugin shape works for S3, GCS, or any other backend you wire.
Install
dependencies:
vyuh_server_plugin_storage:
hosted: https://pub.vyuh.tech
version: ^0.1.1Wiring
The Supabase implementation resolves its SupabaseClient from vyuh.di by default — the canonical pattern is to put a an app-provided plugin that registers SupabaseClient ahead of it in the plugins list.
import 'package:vyuh_server/vyuh_server.dart';
import 'package:vyuh_server_plugin_storage/vyuh_server_plugin_storage.dart';
final runtime = await VyuhServer.bootstrap(
name: 'media-api',
plugins: [
EnvPlugin(),
YourSupabaseClientPlugin(), // registers SupabaseClient in vyuh.di
SupabaseStoragePlugin(), // resolves it from vyuh.di
// ...
],
);For tests or for hosts that own the SupabaseClient lifecycle themselves, pass an explicit client:
SupabaseStoragePlugin(client: myExplicitClient);Using vyuh.storage
Upload
final stored = await vyuh.storage.upload(
bucket: 'avatars',
path: 'users/$userId.png',
bytes: pngBytes,
contentType: 'image/png',
upsert: true, // overwrite if exists; default false → 409 on collision
);Returns the canonical stored path.
Download
final bytes = await vyuh.storage.download(
bucket: 'reports',
path: 'q3/summary.pdf',
);Public URL (sync — no IO)
For objects in publicly readable buckets, getPublicUrl is pure URL construction and synchronous:
final url = vyuh.storage.getPublicUrl(
bucket: 'public-assets',
path: 'logos/brand.svg',
);Use this for assets the client should fetch directly from the CDN without going through the API.
Signed URL (async, TTL)
For private buckets, mint a short-lived signed URL:
final signed = await vyuh.storage.createSignedUrl(
bucket: 'reports',
path: 'q3/$tenantId-summary.pdf',
expiresIn: Duration(minutes: 5),
);
return Response(
statusCode: 307,
headers: Headers({'location': signed}),
);The default expiry is 15 minutes; tune to match your URL-sharing profile.
List
final objects = await vyuh.storage.list(
bucket: 'avatars',
prefix: 'users/',
limit: 100,
);
for (final obj in objects) {
obj.name; // 'users/abc123.png'
obj.size; // bytes
obj.contentType; // 'image/png'
obj.lastModified; // DateTime
obj.etag; // backend etag, opaque
}StorageObject keeps only the fields every reasonable backend can produce — your handler can serialize them directly to the client.
Move and Copy
// Move within a bucket (rename)
await vyuh.storage.move(
bucket: 'tmp',
fromPath: 'upload-$nonce.bin',
toPath: 'archive/$id.bin',
);
// Copy within a bucket
await vyuh.storage.copy(
bucket: 'reports',
fromPath: 'live.pdf',
toPath: 'history/$timestamp.pdf',
);Delete
await vyuh.storage.delete(
bucket: 'avatars',
paths: ['users/$userId.png'],
);The list accepts multiple paths for batch delete. Backends typically no-op (rather than throwing) for already-missing paths — check return values from any higher-level wrapper if you need exact accounting.
Error Handling
The framework translates the backend's error type into a single StorageException at the boundary, so handlers catch one shape regardless of which backend is wired:
try {
await vyuh.storage.download(bucket: 'reports', path: missingPath);
} on StorageException catch (e) {
// e.code (e.g. 'not_found'), e.message, e.cause
throw StructuredException(
code: 'reports.not_found',
message: 'Report $missingPath was not found',
);
}Pair with ErrorCodesDescriptor so a thrown StructuredException formats into the right HTTP status — see Error Handling.
Wiring an Upload Endpoint
A complete upload route, with size cap and content-type guard:
final mediaModule = RouteModule(
name: 'app.media',
basePath: '/media',
protectedPaths: ['/'],
setup: (scope) {
scope.post('/avatar', _uploadAvatar);
},
);
Future<Response> _uploadAvatar(Request req) async {
final actor = req.actor;
final bytes = await req.readAsBytes();
if (bytes.length > 2 * 1024 * 1024) {
throw StructuredException(
code: 'media.too_large',
message: 'Avatar must be 2 MB or smaller',
);
}
final contentType = req.headers.value('content-type') ?? 'image/png';
if (!const ['image/png', 'image/jpeg', 'image/webp'].contains(contentType)) {
throw StructuredException(
code: 'media.bad_content_type',
message: 'Avatar must be png, jpeg, or webp',
);
}
await vyuh.storage.upload(
bucket: 'avatars',
path: 'users/${actor.id}',
bytes: bytes,
contentType: contentType,
upsert: true,
);
vyuh.telemetry.counter('media.avatar.uploaded', delta: 1);
return jsonResponse(
statusCode: 201,
body: {
'success': true,
'data': {
'url': vyuh.storage.getPublicUrl(
bucket: 'avatars',
path: 'users/${actor.id}',
),
},
},
);
}Storage vs DB
The two slots are complementary and unrelated; each is independently optional.
| Slot | Verb | Plugin type | Backend examples |
|---|---|---|---|
| Relational | vyuh.db | DbPlugin | PostgresDbPlugin, future SQLite, etc. |
| File / blob | vyuh.storage | StoragePlugin | SupabaseStoragePlugin, future S3, GCS, etc. |
A typical web service uses both — relational rows referencing storage paths (e.g., a users.avatar_path TEXT column whose value is passed to vyuh.storage.download(...) or getPublicUrl(...)).
Where to Go Next
vyuh.storage— typed verb reference- Postgres — the relational sibling,
vyuh.db - Architecture — where both slots sit in the bootstrap pipeline