📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
# SQLite CRUD Patterns
|
||||
|
||||
Complete examples for database operations with bun:sqlite.
|
||||
|
||||
## Basic Repository
|
||||
|
||||
```typescript
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
class UserRepository {
|
||||
private stmt: {
|
||||
findById: ReturnType<Database['prepare']>;
|
||||
findByEmail: ReturnType<Database['prepare']>;
|
||||
findAll: ReturnType<Database['prepare']>;
|
||||
create: ReturnType<Database['prepare']>;
|
||||
update: ReturnType<Database['prepare']>;
|
||||
delete: ReturnType<Database['prepare']>;
|
||||
};
|
||||
|
||||
constructor(private db: Database) {
|
||||
// Initialize schema
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Prepare statements once
|
||||
this.stmt = {
|
||||
findById: this.db.prepare('SELECT * FROM users WHERE id = ?'),
|
||||
findByEmail: this.db.prepare('SELECT * FROM users WHERE email = ?'),
|
||||
findAll: this.db.prepare('SELECT * FROM users ORDER BY created_at DESC LIMIT ?'),
|
||||
create: this.db.prepare('INSERT INTO users (id, email, name) VALUES (?, ?, ?) RETURNING *'),
|
||||
update: this.db.prepare('UPDATE users SET email = ?, name = ? WHERE id = ? RETURNING *'),
|
||||
delete: this.db.prepare('DELETE FROM users WHERE id = ? RETURNING *')
|
||||
};
|
||||
}
|
||||
|
||||
findById(id: string): User | null {
|
||||
const row = this.stmt.findById.get(id) as UserRow | null;
|
||||
return row ? this.mapRow(row) : null;
|
||||
}
|
||||
|
||||
findByEmail(email: string): User | null {
|
||||
const row = this.stmt.findByEmail.get(email) as UserRow | null;
|
||||
return row ? this.mapRow(row) : null;
|
||||
}
|
||||
|
||||
findAll(limit = 100): User[] {
|
||||
const rows = this.stmt.findAll.all(limit) as UserRow[];
|
||||
return rows.map(row => this.mapRow(row));
|
||||
}
|
||||
|
||||
create(data: { email: string; name: string }): User {
|
||||
const id = crypto.randomUUID();
|
||||
const row = this.stmt.create.get(id, data.email, data.name) as UserRow;
|
||||
return this.mapRow(row);
|
||||
}
|
||||
|
||||
update(id: string, data: { email?: string; name?: string }): User | null {
|
||||
const existing = this.findById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const row = this.stmt.update.get(
|
||||
data.email ?? existing.email,
|
||||
data.name ?? existing.name,
|
||||
id
|
||||
) as UserRow;
|
||||
return this.mapRow(row);
|
||||
}
|
||||
|
||||
delete(id: string): boolean {
|
||||
const row = this.stmt.delete.get(id);
|
||||
return row !== null;
|
||||
}
|
||||
|
||||
private mapRow(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
createdAt: new Date(row.created_at)
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
const db = new Database('app.db');
|
||||
const users = new UserRepository(db);
|
||||
|
||||
// Create
|
||||
const user = users.create({
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice'
|
||||
});
|
||||
console.log('Created:', user.id);
|
||||
|
||||
// Read
|
||||
const found = users.findById(user.id);
|
||||
console.log('Found:', found?.email);
|
||||
|
||||
// Update
|
||||
const updated = users.update(user.id, { name: 'Alice Smith' });
|
||||
console.log('Updated:', updated?.name);
|
||||
|
||||
// Delete
|
||||
const deleted = users.delete(user.id);
|
||||
console.log('Deleted:', deleted);
|
||||
|
||||
// List
|
||||
const allUsers = users.findAll(10);
|
||||
console.log('All users:', allUsers.length);
|
||||
|
||||
db.close();
|
||||
```
|
||||
|
||||
## With Transactions
|
||||
|
||||
```typescript
|
||||
class AccountRepository {
|
||||
constructor(private db: Database) {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
balance INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
transfer = this.db.transaction((fromId: string, toId: string, amount: number) => {
|
||||
// Check balance
|
||||
const from = this.db.prepare('SELECT balance FROM accounts WHERE id = ?').get(fromId) as { balance: number } | null;
|
||||
|
||||
if (!from) {
|
||||
throw new Error('Source account not found');
|
||||
}
|
||||
|
||||
if (from.balance < amount) {
|
||||
throw new Error('Insufficient funds');
|
||||
}
|
||||
|
||||
// Debit
|
||||
this.db.prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?').run(amount, fromId);
|
||||
|
||||
// Credit
|
||||
this.db.prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?').run(amount, toId);
|
||||
|
||||
return { fromId, toId, amount };
|
||||
});
|
||||
|
||||
bulkCreate = this.db.transaction((accounts: Array<{ userId: string; balance: number }>) => {
|
||||
const stmt = this.db.prepare('INSERT INTO accounts (id, user_id, balance) VALUES (?, ?, ?)');
|
||||
|
||||
const created = [];
|
||||
for (const account of accounts) {
|
||||
const id = crypto.randomUUID();
|
||||
stmt.run(id, account.userId, account.balance);
|
||||
created.push(id);
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
```typescript
|
||||
type PaginatedResult<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
class UserRepository {
|
||||
// ... other methods
|
||||
|
||||
findPaginated(page: number, pageSize: number): PaginatedResult<User> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const countResult = this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
|
||||
const total = countResult.count;
|
||||
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(pageSize, offset) as UserRow[];
|
||||
|
||||
return {
|
||||
items: rows.map(row => this.mapRow(row)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const result = users.findPaginated(1, 20);
|
||||
console.log(`Page ${result.page} of ${result.totalPages}`);
|
||||
console.log(`Showing ${result.items.length} of ${result.total} users`);
|
||||
```
|
||||
|
||||
## Search with Full-Text
|
||||
|
||||
```typescript
|
||||
class PostRepository {
|
||||
constructor(private db: Database) {
|
||||
// Create main table
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Create FTS index
|
||||
this.db.run(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
title,
|
||||
content,
|
||||
content='posts',
|
||||
content_rowid='rowid'
|
||||
)
|
||||
`);
|
||||
|
||||
// Triggers to keep FTS in sync
|
||||
this.db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS posts_ai AFTER INSERT ON posts BEGIN
|
||||
INSERT INTO posts_fts(rowid, title, content)
|
||||
VALUES (new.rowid, new.title, new.content);
|
||||
END
|
||||
`);
|
||||
|
||||
this.db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS posts_ad AFTER DELETE ON posts BEGIN
|
||||
INSERT INTO posts_fts(posts_fts, rowid, title, content)
|
||||
VALUES ('delete', old.rowid, old.title, old.content);
|
||||
END
|
||||
`);
|
||||
|
||||
this.db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS posts_au AFTER UPDATE ON posts BEGIN
|
||||
INSERT INTO posts_fts(posts_fts, rowid, title, content)
|
||||
VALUES ('delete', old.rowid, old.title, old.content);
|
||||
INSERT INTO posts_fts(rowid, title, content)
|
||||
VALUES (new.rowid, new.title, new.content);
|
||||
END
|
||||
`);
|
||||
}
|
||||
|
||||
search(query: string, limit = 20) {
|
||||
return this.db.prepare(`
|
||||
SELECT posts.*, bm25(posts_fts) as rank
|
||||
FROM posts
|
||||
JOIN posts_fts ON posts.rowid = posts_fts.rowid
|
||||
WHERE posts_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`).all(query, limit);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const posts = new PostRepository(db);
|
||||
const results = posts.search('typescript tutorial');
|
||||
```
|
||||
|
||||
## JSON Storage
|
||||
|
||||
```typescript
|
||||
type Settings = {
|
||||
theme: 'light' | 'dark';
|
||||
notifications: boolean;
|
||||
language: string;
|
||||
};
|
||||
|
||||
class SettingsRepository {
|
||||
constructor(private db: Database) {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
get(userId: string): Settings | null {
|
||||
const row = this.db.prepare('SELECT data FROM settings WHERE user_id = ?').get(userId) as { data: string } | null;
|
||||
|
||||
if (!row) return null;
|
||||
return JSON.parse(row.data);
|
||||
}
|
||||
|
||||
set(userId: string, settings: Settings): void {
|
||||
this.db.prepare(`
|
||||
INSERT INTO settings (user_id, data) VALUES (?, ?)
|
||||
ON CONFLICT (user_id) DO UPDATE SET data = excluded.data
|
||||
`).run(userId, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
update(userId: string, partial: Partial<Settings>): Settings | null {
|
||||
const existing = this.get(userId);
|
||||
if (!existing) return null;
|
||||
|
||||
const updated = { ...existing, ...partial };
|
||||
this.set(userId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Query JSON fields directly
|
||||
findByTheme(theme: 'light' | 'dark') {
|
||||
return this.db.prepare(`
|
||||
SELECT user_id FROM settings
|
||||
WHERE json_extract(data, '$.theme') = ?
|
||||
`).all(theme);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## With Hono API
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { createFactory } from 'hono/factory';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
type Env = {
|
||||
Variables: {
|
||||
db: Database;
|
||||
users: UserRepository;
|
||||
};
|
||||
};
|
||||
|
||||
const factory = createFactory<Env>();
|
||||
|
||||
const dbMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const db = new Database('app.db');
|
||||
const users = new UserRepository(db);
|
||||
|
||||
c.set('db', db);
|
||||
c.set('users', users);
|
||||
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1).max(100)
|
||||
});
|
||||
|
||||
const UpdateUserSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
name: z.string().min(1).max(100).optional()
|
||||
});
|
||||
|
||||
const app = factory.createApp()
|
||||
.use('*', dbMiddleware)
|
||||
.get('/users', (c) => {
|
||||
const users = c.get('users');
|
||||
const limit = Number(c.req.query('limit')) || 20;
|
||||
return c.json({ users: users.findAll(limit) });
|
||||
})
|
||||
.get('/users/:id', (c) => {
|
||||
const users = c.get('users');
|
||||
const user = users.findById(c.req.param('id'));
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
return c.json({ user });
|
||||
})
|
||||
.post('/users', zValidator('json', CreateUserSchema), (c) => {
|
||||
const users = c.get('users');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const existing = users.findByEmail(data.email);
|
||||
if (existing) {
|
||||
throw new HTTPException(409, { message: 'Email already registered' });
|
||||
}
|
||||
|
||||
const user = users.create(data);
|
||||
return c.json({ user }, 201);
|
||||
})
|
||||
.patch('/users/:id', zValidator('json', UpdateUserSchema), (c) => {
|
||||
const users = c.get('users');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const user = users.update(c.req.param('id'), data);
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
return c.json({ user });
|
||||
})
|
||||
.delete('/users/:id', (c) => {
|
||||
const users = c.get('users');
|
||||
const deleted = users.delete(c.req.param('id'));
|
||||
|
||||
if (!deleted) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
export default app;
|
||||
```
|
||||
@@ -0,0 +1,427 @@
|
||||
# File Upload Patterns
|
||||
|
||||
Streaming file handling with Bun.file and Bun.write.
|
||||
|
||||
## Basic Upload
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.post('/upload', async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.file as File;
|
||||
|
||||
if (!file) {
|
||||
throw new HTTPException(400, { message: 'File is required' });
|
||||
}
|
||||
|
||||
const filename = `${crypto.randomUUID()}-${file.name}`;
|
||||
const filepath = `./uploads/${filename}`;
|
||||
|
||||
await Bun.write(filepath, file);
|
||||
|
||||
return c.json({
|
||||
filename,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
}, 201);
|
||||
});
|
||||
```
|
||||
|
||||
## With Validation
|
||||
|
||||
```typescript
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
app.post('/upload/image', async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.file as File;
|
||||
|
||||
if (!file) {
|
||||
throw new HTTPException(400, { message: 'File is required' });
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
throw new HTTPException(400, {
|
||||
message: `Invalid file type. Allowed: ${ALLOWED_TYPES.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (file.size > MAX_SIZE) {
|
||||
throw new HTTPException(400, {
|
||||
message: `File too large. Max size: ${MAX_SIZE / 1024 / 1024}MB`
|
||||
});
|
||||
}
|
||||
|
||||
// Generate safe filename
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || 'bin';
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
const filepath = `./uploads/${filename}`;
|
||||
|
||||
await Bun.write(filepath, file);
|
||||
|
||||
return c.json({ filename, size: file.size, type: file.type }, 201);
|
||||
});
|
||||
```
|
||||
|
||||
## Multiple Files
|
||||
|
||||
```typescript
|
||||
app.post('/upload/multiple', async (c) => {
|
||||
const body = await c.req.parseBody({ all: true });
|
||||
const files = body.files as File[];
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
throw new HTTPException(400, { message: 'At least one file is required' });
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_SIZE) {
|
||||
throw new HTTPException(400, {
|
||||
message: `File ${file.name} exceeds max size`
|
||||
});
|
||||
}
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || 'bin';
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
const filepath = `./uploads/${filename}`;
|
||||
|
||||
await Bun.write(filepath, file);
|
||||
|
||||
results.push({
|
||||
original: file.name,
|
||||
filename,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ files: results }, 201);
|
||||
});
|
||||
```
|
||||
|
||||
## Streaming Large Files
|
||||
|
||||
```typescript
|
||||
app.post('/upload/large', async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.file as File;
|
||||
|
||||
if (!file) {
|
||||
throw new HTTPException(400, { message: 'File is required' });
|
||||
}
|
||||
|
||||
const filename = `${crypto.randomUUID()}.bin`;
|
||||
const filepath = `./uploads/${filename}`;
|
||||
|
||||
// Stream directly to disk — efficient for large files
|
||||
await Bun.write(filepath, file.stream());
|
||||
|
||||
return c.json({ filename, size: file.size }, 201);
|
||||
});
|
||||
```
|
||||
|
||||
## Download Files
|
||||
|
||||
```typescript
|
||||
app.get('/files/:filename', async (c) => {
|
||||
const filename = c.req.param('filename');
|
||||
|
||||
// Prevent directory traversal
|
||||
if (filename.includes('..') || filename.includes('/')) {
|
||||
throw new HTTPException(400, { message: 'Invalid filename' });
|
||||
}
|
||||
|
||||
const filepath = `./uploads/${filename}`;
|
||||
const file = Bun.file(filepath);
|
||||
|
||||
if (!(await file.exists())) {
|
||||
throw new HTTPException(404, { message: 'File not found' });
|
||||
}
|
||||
|
||||
return c.body(file.stream(), {
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
'Content-Length': file.size.toString(),
|
||||
'Content-Disposition': `attachment; filename="${filename}"`
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Inline Display (Images)
|
||||
|
||||
```typescript
|
||||
app.get('/images/:filename', async (c) => {
|
||||
const filename = c.req.param('filename');
|
||||
|
||||
if (filename.includes('..') || filename.includes('/')) {
|
||||
throw new HTTPException(400, { message: 'Invalid filename' });
|
||||
}
|
||||
|
||||
const filepath = `./uploads/${filename}`;
|
||||
const file = Bun.file(filepath);
|
||||
|
||||
if (!(await file.exists())) {
|
||||
throw new HTTPException(404, { message: 'File not found' });
|
||||
}
|
||||
|
||||
// Verify it's an image
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new HTTPException(400, { message: 'Not an image' });
|
||||
}
|
||||
|
||||
return c.body(file.stream(), {
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
'Content-Length': file.size.toString(),
|
||||
'Cache-Control': 'public, max-age=31536000' // 1 year cache
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## With Database Metadata
|
||||
|
||||
```typescript
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
type FileRecord = {
|
||||
id: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
class FileRepository {
|
||||
constructor(private db: Database) {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT UNIQUE NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
uploaded_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
create(data: Omit<FileRecord, 'id' | 'uploadedAt'>): FileRecord {
|
||||
const id = crypto.randomUUID();
|
||||
return this.db.prepare(`
|
||||
INSERT INTO files (id, filename, original_name, mime_type, size, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
RETURNING *
|
||||
`).get(id, data.filename, data.originalName, data.mimeType, data.size, data.userId) as FileRecord;
|
||||
}
|
||||
|
||||
findById(id: string): FileRecord | null {
|
||||
return this.db.prepare('SELECT * FROM files WHERE id = ?').get(id) as FileRecord | null;
|
||||
}
|
||||
|
||||
findByUser(userId: string): FileRecord[] {
|
||||
return this.db.prepare('SELECT * FROM files WHERE user_id = ? ORDER BY uploaded_at DESC').all(userId) as FileRecord[];
|
||||
}
|
||||
|
||||
delete(id: string): boolean {
|
||||
const result = this.db.prepare('DELETE FROM files WHERE id = ? RETURNING filename').get(id) as { filename: string } | null;
|
||||
return result !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// API with metadata
|
||||
app.post('/files', async (c) => {
|
||||
const userId = c.get('userId'); // From auth middleware
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.file as File;
|
||||
|
||||
if (!file) {
|
||||
throw new HTTPException(400, { message: 'File is required' });
|
||||
}
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || 'bin';
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
const filepath = `./uploads/${filename}`;
|
||||
|
||||
await Bun.write(filepath, file);
|
||||
|
||||
const files = c.get('files') as FileRepository;
|
||||
const record = files.create({
|
||||
filename,
|
||||
originalName: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
userId
|
||||
});
|
||||
|
||||
return c.json({ file: record }, 201);
|
||||
});
|
||||
|
||||
app.delete('/files/:id', async (c) => {
|
||||
const files = c.get('files') as FileRepository;
|
||||
const record = files.findById(c.req.param('id'));
|
||||
|
||||
if (!record) {
|
||||
throw new HTTPException(404, { message: 'File not found' });
|
||||
}
|
||||
|
||||
// Delete from disk
|
||||
const filepath = `./uploads/${record.filename}`;
|
||||
const file = Bun.file(filepath);
|
||||
if (await file.exists()) {
|
||||
await Bun.write(filepath, ''); // Clear file
|
||||
// Or use node:fs for actual deletion
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
files.delete(record.id);
|
||||
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
```
|
||||
|
||||
## Image Processing
|
||||
|
||||
```typescript
|
||||
import sharp from 'sharp'; // npm install sharp
|
||||
|
||||
const THUMBNAIL_SIZE = 200;
|
||||
|
||||
app.post('/upload/image-with-thumbnail', async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.file as File;
|
||||
|
||||
if (!file) {
|
||||
throw new HTTPException(400, { message: 'File is required' });
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new HTTPException(400, { message: 'Must be an image' });
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg';
|
||||
|
||||
// Save original
|
||||
const originalPath = `./uploads/${id}.${ext}`;
|
||||
const buffer = await file.arrayBuffer();
|
||||
await Bun.write(originalPath, buffer);
|
||||
|
||||
// Create thumbnail
|
||||
const thumbnailPath = `./uploads/${id}-thumb.${ext}`;
|
||||
await sharp(Buffer.from(buffer))
|
||||
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, { fit: 'cover' })
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
return c.json({
|
||||
id,
|
||||
original: `${id}.${ext}`,
|
||||
thumbnail: `${id}-thumb.${ext}`,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
}, 201);
|
||||
});
|
||||
```
|
||||
|
||||
## Presigned URLs (S3-style)
|
||||
|
||||
```typescript
|
||||
import { sign, verify } from 'hono/jwt';
|
||||
|
||||
const SECRET = Bun.env.JWT_SECRET!;
|
||||
const EXPIRY = 3600; // 1 hour
|
||||
|
||||
// Generate presigned URL
|
||||
app.post('/files/:id/presign', async (c) => {
|
||||
const fileId = c.req.param('id');
|
||||
const files = c.get('files') as FileRepository;
|
||||
|
||||
const record = files.findById(fileId);
|
||||
if (!record) {
|
||||
throw new HTTPException(404, { message: 'File not found' });
|
||||
}
|
||||
|
||||
const token = await sign({
|
||||
fileId,
|
||||
exp: Math.floor(Date.now() / 1000) + EXPIRY
|
||||
}, SECRET);
|
||||
|
||||
const url = `${c.req.url.split('/files')[0]}/download?token=${token}`;
|
||||
|
||||
return c.json({ url, expiresIn: EXPIRY });
|
||||
});
|
||||
|
||||
// Download with presigned URL
|
||||
app.get('/download', async (c) => {
|
||||
const token = c.req.query('token');
|
||||
|
||||
if (!token) {
|
||||
throw new HTTPException(401, { message: 'Token required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await verify(token, SECRET);
|
||||
const fileId = payload.fileId as string;
|
||||
|
||||
const files = c.get('files') as FileRepository;
|
||||
const record = files.findById(fileId);
|
||||
|
||||
if (!record) {
|
||||
throw new HTTPException(404, { message: 'File not found' });
|
||||
}
|
||||
|
||||
const filepath = `./uploads/${record.filename}`;
|
||||
const file = Bun.file(filepath);
|
||||
|
||||
return c.body(file.stream(), {
|
||||
headers: {
|
||||
'Content-Type': record.mimeType,
|
||||
'Content-Length': record.size.toString(),
|
||||
'Content-Disposition': `attachment; filename="${record.originalName}"`
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
throw new HTTPException(401, { message: 'Invalid or expired token' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Cleanup Old Files
|
||||
|
||||
```typescript
|
||||
import { readdir, unlink, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
async function cleanupOldFiles(directory: string, maxAgeDays: number) {
|
||||
const files = await readdir(directory);
|
||||
const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const filename of files) {
|
||||
const filepath = join(directory, filename);
|
||||
const stats = await stat(filepath);
|
||||
|
||||
if (stats.mtimeMs < cutoff) {
|
||||
await unlink(filepath);
|
||||
console.log(`Deleted old file: ${filename}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run cleanup every hour
|
||||
setInterval(() => {
|
||||
cleanupOldFiles('./uploads', 30); // Delete files older than 30 days
|
||||
}, 60 * 60 * 1000);
|
||||
```
|
||||
Reference in New Issue
Block a user