Files
silo/internal/db/item_files.go
forbes-0023 8cef4fa55f feat(db): add storage backend metadata columns
Add storage_backend columns to track which backend (minio or filesystem)
holds each file, enabling dual-running during migration.

Migration 017_file_storage_metadata.sql:
- item_files.storage_backend TEXT NOT NULL DEFAULT 'minio'
- revisions.file_storage_backend TEXT NOT NULL DEFAULT 'minio'

DB repository changes:
- Revision struct: add FileStorageBackend field
- ItemFile struct: add StorageBackend field
- All INSERT queries include the new columns
- All SELECT queries read them (COALESCE for pre-migration compat)
- CreateRevisionFromExisting copies the backend from source revision
- Default to 'minio' when field is empty (backward compat)

Existing rows default to 'minio'. New uploads will write 'filesystem'
when the filesystem backend is active.

Closes #128
2026-02-17 12:30:20 -06:00

98 lines
2.8 KiB
Go

package db
import (
"context"
"fmt"
"time"
)
// ItemFile represents a file attachment on an item.
type ItemFile struct {
ID string
ItemID string
Filename string
ContentType string
Size int64
ObjectKey string
StorageBackend string // "minio" or "filesystem"
CreatedAt time.Time
}
// ItemFileRepository provides item_files database operations.
type ItemFileRepository struct {
db *DB
}
// NewItemFileRepository creates a new item file repository.
func NewItemFileRepository(db *DB) *ItemFileRepository {
return &ItemFileRepository{db: db}
}
// Create inserts a new item file record.
func (r *ItemFileRepository) Create(ctx context.Context, f *ItemFile) error {
if f.StorageBackend == "" {
f.StorageBackend = "minio"
}
err := r.db.pool.QueryRow(ctx,
`INSERT INTO item_files (item_id, filename, content_type, size, object_key, storage_backend)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at`,
f.ItemID, f.Filename, f.ContentType, f.Size, f.ObjectKey, f.StorageBackend,
).Scan(&f.ID, &f.CreatedAt)
if err != nil {
return fmt.Errorf("creating item file: %w", err)
}
return nil
}
// ListByItem returns all file attachments for an item.
func (r *ItemFileRepository) ListByItem(ctx context.Context, itemID string) ([]*ItemFile, error) {
rows, err := r.db.pool.Query(ctx,
`SELECT id, item_id, filename, content_type, size, object_key,
COALESCE(storage_backend, 'minio'), created_at
FROM item_files WHERE item_id = $1 ORDER BY created_at`,
itemID,
)
if err != nil {
return nil, fmt.Errorf("listing item files: %w", err)
}
defer rows.Close()
var files []*ItemFile
for rows.Next() {
f := &ItemFile{}
if err := rows.Scan(&f.ID, &f.ItemID, &f.Filename, &f.ContentType, &f.Size, &f.ObjectKey, &f.StorageBackend, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("scanning item file: %w", err)
}
files = append(files, f)
}
return files, nil
}
// Get returns a single item file by ID.
func (r *ItemFileRepository) Get(ctx context.Context, id string) (*ItemFile, error) {
f := &ItemFile{}
err := r.db.pool.QueryRow(ctx,
`SELECT id, item_id, filename, content_type, size, object_key,
COALESCE(storage_backend, 'minio'), created_at
FROM item_files WHERE id = $1`,
id,
).Scan(&f.ID, &f.ItemID, &f.Filename, &f.ContentType, &f.Size, &f.ObjectKey, &f.StorageBackend, &f.CreatedAt)
if err != nil {
return nil, fmt.Errorf("getting item file: %w", err)
}
return f, nil
}
// Delete removes an item file record.
func (r *ItemFileRepository) Delete(ctx context.Context, id string) error {
tag, err := r.db.pool.Exec(ctx, `DELETE FROM item_files WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("deleting item file: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("item file not found")
}
return nil
}