From 8cef4fa55f34740b403968c6e04bb7ce28ed7ca7 Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Tue, 17 Feb 2026 12:30:20 -0600 Subject: [PATCH] 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 --- internal/db/item_files.go | 34 ++++++++------ internal/db/items.go | 60 ++++++++++++++---------- migrations/017_file_storage_metadata.sql | 7 +++ 3 files changed, 63 insertions(+), 38 deletions(-) create mode 100644 migrations/017_file_storage_metadata.sql diff --git a/internal/db/item_files.go b/internal/db/item_files.go index 736c430..6c6bcf4 100644 --- a/internal/db/item_files.go +++ b/internal/db/item_files.go @@ -8,13 +8,14 @@ import ( // ItemFile represents a file attachment on an item. type ItemFile struct { - ID string - ItemID string - Filename string - ContentType string - Size int64 - ObjectKey string - CreatedAt time.Time + 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. @@ -29,11 +30,14 @@ func NewItemFileRepository(db *DB) *ItemFileRepository { // 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) - VALUES ($1, $2, $3, $4, $5) + `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.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) @@ -44,7 +48,8 @@ func (r *ItemFileRepository) Create(ctx context.Context, f *ItemFile) error { // 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, created_at + `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, ) @@ -56,7 +61,7 @@ func (r *ItemFileRepository) ListByItem(ctx context.Context, itemID string) ([]* 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.CreatedAt); err != nil { + 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) @@ -68,10 +73,11 @@ func (r *ItemFileRepository) ListByItem(ctx context.Context, itemID string) ([]* 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, created_at + `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.CreatedAt) + ).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) } diff --git a/internal/db/items.go b/internal/db/items.go index d1b9015..f5518b7 100644 --- a/internal/db/items.go +++ b/internal/db/items.go @@ -35,11 +35,12 @@ type Revision struct { ItemID string RevisionNumber int Properties map[string]any - FileKey *string - FileVersion *string - FileChecksum *string - FileSize *int64 - ThumbnailKey *string + FileKey *string + FileVersion *string + FileChecksum *string + FileSize *int64 + FileStorageBackend string // "minio" or "filesystem" + ThumbnailKey *string CreatedAt time.Time CreatedBy *string Comment *string @@ -306,16 +307,20 @@ func (r *ItemRepository) CreateRevision(ctx context.Context, rev *Revision) erro return fmt.Errorf("marshaling properties: %w", err) } + if rev.FileStorageBackend == "" { + rev.FileStorageBackend = "minio" + } + err = r.db.pool.QueryRow(ctx, ` INSERT INTO revisions ( item_id, revision_number, properties, file_key, file_version, - file_checksum, file_size, thumbnail_key, created_by, comment + file_checksum, file_size, file_storage_backend, thumbnail_key, created_by, comment ) - SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9 + SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM items WHERE id = $1 RETURNING id, revision_number, created_at `, rev.ItemID, propsJSON, rev.FileKey, rev.FileVersion, - rev.FileChecksum, rev.FileSize, rev.ThumbnailKey, rev.CreatedBy, rev.Comment, + rev.FileChecksum, rev.FileSize, rev.FileStorageBackend, rev.ThumbnailKey, rev.CreatedBy, rev.Comment, ).Scan(&rev.ID, &rev.RevisionNumber, &rev.CreatedAt) if err != nil { return fmt.Errorf("inserting revision: %w", err) @@ -342,7 +347,8 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re if hasStatusColumn { rows, err = r.db.pool.Query(ctx, ` SELECT id, item_id, revision_number, properties, file_key, file_version, - file_checksum, file_size, thumbnail_key, created_at, created_by, comment, + file_checksum, file_size, COALESCE(file_storage_backend, 'minio'), + thumbnail_key, created_at, created_by, comment, COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels FROM revisions WHERE item_id = $1 @@ -369,7 +375,8 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re if hasStatusColumn { err = rows.Scan( &rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion, - &rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment, + &rev.FileChecksum, &rev.FileSize, &rev.FileStorageBackend, + &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment, &rev.Status, &rev.Labels, ) } else { @@ -379,6 +386,7 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re ) rev.Status = "draft" rev.Labels = []string{} + rev.FileStorageBackend = "minio" } if err != nil { return nil, fmt.Errorf("scanning revision: %w", err) @@ -412,13 +420,15 @@ func (r *ItemRepository) GetRevision(ctx context.Context, itemID string, revisio if hasStatusColumn { err = r.db.pool.QueryRow(ctx, ` SELECT id, item_id, revision_number, properties, file_key, file_version, - file_checksum, file_size, thumbnail_key, created_at, created_by, comment, + file_checksum, file_size, COALESCE(file_storage_backend, 'minio'), + thumbnail_key, created_at, created_by, comment, COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels FROM revisions WHERE item_id = $1 AND revision_number = $2 `, itemID, revisionNumber).Scan( &rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion, - &rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment, + &rev.FileChecksum, &rev.FileSize, &rev.FileStorageBackend, + &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment, &rev.Status, &rev.Labels, ) } else { @@ -433,6 +443,7 @@ func (r *ItemRepository) GetRevision(ctx context.Context, itemID string, revisio ) rev.Status = "draft" rev.Labels = []string{} + rev.FileStorageBackend = "minio" } if err == pgx.ErrNoRows { @@ -606,15 +617,16 @@ func (r *ItemRepository) CreateRevisionFromExisting(ctx context.Context, itemID // Create new revision with copied properties (and optionally file reference) newRev := &Revision{ - ItemID: itemID, - Properties: source.Properties, - FileKey: source.FileKey, - FileVersion: source.FileVersion, - FileChecksum: source.FileChecksum, - FileSize: source.FileSize, - ThumbnailKey: source.ThumbnailKey, - CreatedBy: createdBy, - Comment: &comment, + ItemID: itemID, + Properties: source.Properties, + FileKey: source.FileKey, + FileVersion: source.FileVersion, + FileChecksum: source.FileChecksum, + FileSize: source.FileSize, + FileStorageBackend: source.FileStorageBackend, + ThumbnailKey: source.ThumbnailKey, + CreatedBy: createdBy, + Comment: &comment, } // Insert the new revision @@ -626,13 +638,13 @@ func (r *ItemRepository) CreateRevisionFromExisting(ctx context.Context, itemID err = r.db.pool.QueryRow(ctx, ` INSERT INTO revisions ( item_id, revision_number, properties, file_key, file_version, - file_checksum, file_size, thumbnail_key, created_by, comment, status + file_checksum, file_size, file_storage_backend, thumbnail_key, created_by, comment, status ) - SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9, 'draft' + SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'draft' FROM items WHERE id = $1 RETURNING id, revision_number, created_at `, newRev.ItemID, propsJSON, newRev.FileKey, newRev.FileVersion, - newRev.FileChecksum, newRev.FileSize, newRev.ThumbnailKey, newRev.CreatedBy, newRev.Comment, + newRev.FileChecksum, newRev.FileSize, newRev.FileStorageBackend, newRev.ThumbnailKey, newRev.CreatedBy, newRev.Comment, ).Scan(&newRev.ID, &newRev.RevisionNumber, &newRev.CreatedAt) if err != nil { return nil, fmt.Errorf("inserting revision: %w", err) diff --git a/migrations/017_file_storage_metadata.sql b/migrations/017_file_storage_metadata.sql new file mode 100644 index 0000000..a0995bc --- /dev/null +++ b/migrations/017_file_storage_metadata.sql @@ -0,0 +1,7 @@ +-- Track which storage backend holds each attached file. +ALTER TABLE item_files + ADD COLUMN IF NOT EXISTS storage_backend TEXT NOT NULL DEFAULT 'minio'; + +-- Track which storage backend holds each revision file. +ALTER TABLE revisions + ADD COLUMN IF NOT EXISTS file_storage_backend TEXT NOT NULL DEFAULT 'minio';