mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
CI previously ran `pnpm run test || true` — test failures were silently
swallowed with no artifact, so we had no visibility into what was actually
passing across 1,296 test files.
- New `test:coverage` turbo pipeline task + root script; packages that opt
in by declaring their own `test:coverage` get picked up automatically.
- Wired up three high-value Vitest targets: apps/mana/apps/web (main
frontend, ~590 tests), shared-ui (Svelte component library), and
shared-storage (S3 client). Each emits lcov.info + coverage-summary.json
+ browsable HTML.
- apps/mana/apps/web `"test"` was running in watch mode (just `vitest`),
which hangs under turbo orchestration — changed to `vitest run` and
added `test:watch` for the interactive case.
- CI uploads coverage artifacts (14-day retention) regardless of whether
tests passed. `continue-on-error: true` replaces `|| true` so a failed
suite shows up as a warning annotation on the PR rather than being
invisible. Flip to a hard gate once main is green for a full week.
- Testing guideline documents the pattern + the template vitest config
+ the planned 80% threshold.
- ESLint flat-config `vitest.config.ts` ignore only matched at the root;
widened to `**/vitest.config.{ts,js,mjs}` so nested configs don't trip
the project-service parser.
Coverage baseline produced locally:
shared-storage: 91.37% lines (6 files, 123 tests)
shared-ui: 2.87% lines (mostly Svelte components, untested)
apps/mana/web: 9/59 test files fail — pre-existing, not regression
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|---|---|---|
| .. | ||
| src | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
@mana/shared-storage
S3-compatible object storage client for the Mana monorepo. Uses MinIO for S3-compatible storage.
Setup
Local Development
pnpm docker:up # Start MinIO + Postgres + Redis
pnpm docker:up # MinIO Console: http://localhost:9001 (minioadmin/minioadmin)
Buckets
Each app gets its own isolated bucket, created automatically by minio-init:
| Bucket | Project | Purpose |
|---|---|---|
mana-storage |
Mana | Avatars, auth assets |
picture-storage |
Picture | AI-generated images |
chat-storage |
Chat | User file uploads |
cards-storage |
Cards | Card/deck assets |
presi-storage |
Presi | Presentation slides |
calendar-storage |
Calendar | Calendar attachments |
contacts-storage |
Contacts | Contact avatars/files |
storage-storage |
Storage | Cloud drive files |
mail-storage |
Email attachments | |
inventory-storage |
Inventory | Product photos |
music-storage |
Music | Music tracks, beats, covers |
plants-storage |
Planta | Plant photos |
projectdoc-storage |
ProjectDoc | Document files |
Usage
Basic Operations
import { createPictureStorage, generateUserFileKey, getContentType } from '@mana/shared-storage';
const storage = createPictureStorage();
// Upload
const key = generateUserFileKey('user-123', 'avatar.png');
const result = await storage.upload(key, imageBuffer, {
contentType: getContentType('avatar.png'),
public: true,
maxSizeBytes: 10 * 1024 * 1024, // 10MB limit (works with buffers AND streams)
});
// Download
const buffer = await storage.download(key);
const stream = await storage.downloadStream(key); // Memory-efficient for large files
// Delete
await storage.delete(key);
await storage.deleteMany(['a.png', 'b.png', 'c.png']); // Bulk delete (auto-batches at 1000)
await storage.deleteByPrefix('users/user-123/'); // Delete all user files
// Copy & Move
const copied = await storage.copy('old/file.png', 'new/file.png');
const moved = await storage.move('src/file.png', 'dst/file.png'); // copy + delete
// Metadata (without downloading)
const meta = await storage.getMetadata(key);
// => { contentType: 'image/png', size: 4096, lastModified: Date, etag: '...', metadata: {} }
// Check existence
const exists = await storage.exists(key);
// List files (auto-paginates)
const files = await storage.list('users/user-123/');
// Presigned URLs
const uploadUrl = await storage.getUploadUrl('temp/upload.png', { expiresIn: 3600 });
const downloadUrl = await storage.getDownloadUrl(key);
// Public/CDN URLs
const publicUrl = storage.getPublicUrl(key);
const cdnUrl = storage.getCdnUrl(key); // Falls back to publicUrl if no CDN configured
Generic Factory
import { createStorage } from '@mana/shared-storage';
// Instead of app-specific factories:
const storage = createStorage('PICTURE');
const storage = createStorage('CHAT');
const storage = createStorage('MUSIC');
App-specific aliases still work: createPictureStorage(), createChatStorage(), etc.
Multipart Upload (Server-Side)
For large files uploaded through the backend:
const result = await storage.uploadMultipart('video.mp4', largeBuffer, {
contentType: 'video/mp4',
maxSizeBytes: 500 * 1024 * 1024, // 500MB limit
});
Presigned Multipart Upload (Browser Direct-Upload)
Skip the backend — browser uploads directly to S3:
// 1. Backend: initiate upload
const { uploadId, key } = await storage.createMultipartUpload(
'users/123/video.mp4',
'video/mp4'
);
// 2. Backend: generate presigned URLs for each part
const urls = await storage.getMultipartUploadUrls(key, uploadId, numberOfParts);
// => ['https://signed-url-part-1', 'https://signed-url-part-2', ...]
// 3. Browser: PUT each chunk to the corresponding URL
// (returns ETag in response headers)
// 4. Backend: complete upload with ETags from browser
const result = await storage.completeMultipartUpload(key, uploadId, [
{ partNumber: 1, etag: '"etag-from-part-1"' },
{ partNumber: 2, etag: '"etag-from-part-2"' },
]);
// If browser abandons upload:
await storage.abortMultipartUpload(key, uploadId);
Hooks (Upload Events)
Fire-and-forget event system for post-upload processing:
const storage = createPictureStorage();
// Thumbnail generation after upload
storage.hooks.on('upload', async ({ key, contentType, sizeBytes }) => {
if (contentType?.startsWith('image/')) {
await generateThumbnail(key);
}
});
// Error logging
storage.hooks.on('upload:error', ({ bucket, key, error }) => {
logger.error(`Upload failed: ${bucket}/${key}`, error);
});
// Track deletions
storage.hooks.on('delete', ({ bucket, keys }) => {
logger.info(`Deleted ${keys.length} files from ${bucket}`);
});
// Unsubscribe
const unsub = storage.hooks.on('download', handler);
unsub(); // Remove listener
// Available events: upload, upload:error, delete, delete:error, download
Metrics
In-Memory (Testing / Local Dev)
import { InMemoryMetrics, attachMetrics } from '@mana/shared-storage';
const storage = createPictureStorage();
const metrics = new InMemoryMetrics();
attachMetrics(storage.hooks, metrics);
// After some operations:
console.log(metrics.counters.uploads); // 5
console.log(metrics.counters.deletes); // 2
console.log(metrics.sizes); // [1024, 2048, ...]
Prometheus (NestJS Backends)
import { MetricsService } from '@mana/shared-nestjs-metrics';
import { createPictureStorage, createPrometheusCollector, attachMetrics } from '@mana/shared-storage';
@Injectable()
export class StorageService {
private storage = createPictureStorage();
constructor(metricsService: MetricsService) {
const collector = createPrometheusCollector(metricsService);
attachMetrics(this.storage.hooks, collector);
}
}
This creates the following Prometheus metrics:
storage_uploads_total(counter, labels: bucket, content_type)storage_upload_errors_total(counter, labels: bucket)storage_deletes_total(counter, labels: bucket)storage_downloads_total(counter, labels: bucket)storage_upload_size_bytes(histogram, labels: bucket, buckets: 1KB-100MB)
Environment Variables
# Required
S3_ENDPOINT=http://localhost:9000 # MinIO
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
# Optional
S3_PUBLIC_ENDPOINT=https://minio.mana.how # For presigned URLs (if internal != public)
PICTURE_STORAGE_PUBLIC_URL=https://... # Direct public URL per bucket
PICTURE_CDN_URL=https://cdn.example.com # CDN URL per bucket (getCdnUrl() uses this)
Testing
cd packages/shared-storage
pnpm test # Run 104 tests
pnpm test:watch # Watch mode
pnpm type-check # TypeScript check
pnpm build # Build to dist/
API Reference
StorageClient
| Method | Description |
|---|---|
upload(key, body, options?) |
Upload a file (supports maxSizeBytes for buffers and streams) |
uploadMultipart(key, body, options?) |
Multipart upload for large files (10MB parts) |
download(key) |
Download file to Buffer |
downloadStream(key) |
Download as ReadableStream (memory-efficient) |
delete(key) |
Delete a file |
deleteMany(keys) |
Bulk delete (auto-batches at 1000) |
deleteByPrefix(prefix) |
Delete all files matching prefix |
copy(src, dest) |
Copy file within bucket |
move(src, dest) |
Move file (copy + delete) |
exists(key) |
Check if file exists |
getMetadata(key) |
Get content-type, size, metadata without download |
list(prefix?, maxKeys?) |
List files (auto-paginates) |
getUploadUrl(key, options?) |
Presigned PUT URL |
getDownloadUrl(key, options?) |
Presigned GET URL |
getPublicUrl(key) |
Direct public URL |
getCdnUrl(key) |
CDN URL (falls back to public) |
createMultipartUpload(key, contentType?) |
Initiate browser direct-upload |
getMultipartUploadUrls(key, uploadId, parts) |
Presigned URLs per part |
completeMultipartUpload(key, uploadId, parts) |
Finalize multipart upload |
abortMultipartUpload(key, uploadId) |
Cancel multipart upload |
hooks |
StorageHooks instance for event listeners |
getBucketName() |
Get bucket name |
getS3Client() |
Get underlying S3Client for advanced use |