Files
Aether/backend/internal/files/store.go
T
marcandClaude Opus 5 6fc157bc07 feat(files): Zeichnen und Mindmaps mit Excalidraw
Eine Zeichnung ist eine Datei, kein neues Ding – das ist die ganze Entscheidung
dahinter. .excalidraw ist JSON; damit erbt sie Ordner, Gruppenordner,
Papierkorb, Versionen, Freigabelinks, interne Freigaben und die Suche. Keine
neue Tabelle, kein zweites Rechtemodell. Gespeichert wird über denselben Upload
wie alles andere, wie beim Text-Editor in der Vorschau.

Drei Sorgen, alle nachgemessen statt gehofft:

Das Hauptbündel wächst von 484,11 auf 485,22 kB – das Zeichenbrett ist ein
eigenes Stück (584 kB + 142 kB CSS) und wird nachgeladen wie der PDF-Betrachter.
Wer nie zeichnet, holt es nie.

Die Schriften: Die Richtlinie ist default-src 'self' ohne font-src, Excalidraw
hätte seine handgezeichneten Schriften von aussen geladen und still eine
Ersatzschrift gezeigt. Sie werden jetzt beim Bauen kopiert und über
window.EXCALIDRAW_ASSET_PATH gefunden – gesetzt im Modul, nicht per
Inline-Skript, das wäre von derselben Richtlinie blockiert. Xiaolai bleibt
draussen: 13 der 14 MB für die chinesische Schrift.

Die Reihenfolge der Dateiarten: .excalidraw wird vor code und text geprüft,
sonst öffnete eine Zeichnung im Texteditor als Zahlenkolonne (json steht in
codeExt).

Kein gleichzeitiges Arbeiten. Die Komponente könnte es, aber den Transport
bringt sie nicht mit – das wäre ein zweiter Dienst neben Aether. Solange es ihn
nicht gibt, überschreibt der Zweite den Ersten. Zwei Dinge fangen das ab: Vor
dem Speichern wird geprüft, ob sich die Fassung geändert hat, und dann gefragt
statt überschrieben. Und Zeichnungen behalten ihre Fassungen von der ersten
Minute an.

Der zweite Punkt ist erst durch den Selbsttest aufgefallen: Versionierung ist
sonst bewusst aus (Migration 000006), damit der Speicher nicht vollläuft. Für
Zeichnungen stimmt die Rechnung nicht – sie werden in sich immer wieder
bearbeitet und sind ein paar Kilobyte gross. Ohne sie wäre die Warnung im
Editor ein Versprechen ohne Deckung gewesen.

Neuer Selbsttest mit 12 Prüfungen: Er prüft die These nach, auf der alles
beruht – wenn eine Zeichnung wirklich eine Datei ist, muss sie können, was
Dateien können.

Nebenbei: db-tests.sh liess je Lauf ein anonymes Postgres-Volume liegen (rund
49 MB). `docker rm -v` nimmt es jetzt mit; zehn verwaiste entfernt.

Geprüft: go build/vet sauber, alle 15 Go-Pakete grün, 255 Prüfungen der acht
Selbsttest-Suiten grün, 48 Vitest-Tests, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:17:05 +02:00

581 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package files
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("not found")
var ErrNameTaken = errors.New("name already exists in this folder")
var ErrCycle = errors.New("target is a descendant of the folder being moved")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{db: db}
}
const fileCols = `id, owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key, has_thumbnail, version, versioning_enabled, document_date, created_at, updated_at`
func scanFile(row interface{ Scan(...any) error }, f *File) error {
return row.Scan(
&f.ID, &f.OwnerID, &f.GroupID, &f.FolderID, &f.Name, &f.MimeType,
&f.Size, &f.Checksum, &f.StorageKey, &f.HasThumbnail,
&f.Version, &f.VersioningEnabled, &f.DocumentDate, &f.CreatedAt, &f.UpdatedAt,
)
}
// ── Files ────────────────────────────────────────────────────────────────────
func (s *Store) CreateFile(ctx context.Context, f *File) (*File, error) {
var out File
err := scanFile(s.db.QueryRow(ctx,
`INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key,
versioning_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING `+fileCols,
f.OwnerID, f.GroupID, f.FolderID, f.Name, f.MimeType, f.Size, f.Checksum, f.StorageKey,
versioniertVonAnfang(f.Name),
), &out)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("create file: %w", err)
}
return &out, nil
}
// versioniertVonAnfang: Welche Dateien ihre Fassungen von der ersten Minute an
// behalten.
//
// Versionierung ist sonst bewusst abgeschaltet (Migration 000006): Jede
// Ueberschreibung hiesse eine weitere Fassung, und der Speicher liefe
// unbemerkt voll.
//
// Fuer Zeichnungen stimmt diese Rechnung nicht. Sie werden **in sich** immer
// wieder bearbeitet – das ist ihr normales Leben, anders als bei einer
// hochgeladenen Rechnung – und sie sind ein paar Kilobyte gross. Vor allem
// aber: Ohne gleichzeitiges Arbeiten kann der Zweite den Ersten
// ueberschreiben, und die Versionshistorie ist dann das Einzige, was die
// Arbeit zurueckholt. Ohne sie waere die Warnung im Editor ein Versprechen
// ohne Deckung.
func versioniertVonAnfang(name string) bool {
return strings.HasSuffix(strings.ToLower(name), ".excalidraw")
}
// GetFileByID fetches a file without owner check (used by public share downloads).
func (s *Store) GetFileByID(ctx context.Context, id uuid.UUID) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`SELECT `+fileCols+` FROM files WHERE id = $1 AND is_deleted = false`, id,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get file by id: %w", err)
}
return &f, nil
}
func (s *Store) GetFile(ctx context.Context, id, ownerID uuid.UUID) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`SELECT `+fileCols+` FROM files WHERE id = $1 AND (owner_id = $2 OR group_id = $2) AND is_deleted = false`,
id, ownerID,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get file: %w", err)
}
return &f, nil
}
func (s *Store) ListFiles(ctx context.Context, ownerID uuid.UUID, folderID *uuid.UUID) ([]*File, error) {
var rows pgx.Rows
var err error
if folderID == nil {
rows, err = s.db.Query(ctx,
`SELECT `+fileCols+` FROM files
WHERE (owner_id = $1 OR group_id = $1) AND folder_id IS NULL AND is_deleted = false ORDER BY name`,
ownerID,
)
} else {
rows, err = s.db.Query(ctx,
`SELECT `+fileCols+` FROM files
WHERE (owner_id = $1 OR group_id = $1) AND folder_id = $2 AND is_deleted = false ORDER BY name`,
ownerID, folderID,
)
}
if err != nil {
return nil, fmt.Errorf("list files: %w", err)
}
defer rows.Close()
return scanFiles(rows)
}
// SoftDeleteFile marks a file as deleted and updates its storage_key to the trash path.
// The caller must move the blob to trashKey before calling this.
func (s *Store) SoftDeleteFile(ctx context.Context, id, ownerID uuid.UUID, trashKey string) error {
tag, err := s.db.Exec(ctx,
`UPDATE files SET is_deleted = true, deleted_at = NOW(), storage_key = $3
WHERE id = $1 AND (owner_id = $2 OR group_id = $2) AND is_deleted = false`,
id, ownerID, trashKey,
)
if err != nil {
return fmt.Errorf("soft delete file: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// GetTrashedFile returns a soft-deleted file by ID (needed before restore/hard-delete).
func (s *Store) GetTrashedFile(ctx context.Context, id, ownerID uuid.UUID) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`SELECT `+fileCols+` FROM files WHERE id=$1 AND (owner_id = $2 OR group_id = $2) AND is_deleted=true`,
id, ownerID,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get trashed file: %w", err)
}
return &f, nil
}
func (s *Store) RenameFile(ctx context.Context, id, ownerID uuid.UUID, name, storageKey string) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`UPDATE files SET name=$3, storage_key=$4
WHERE id=$1 AND (owner_id = $2 OR group_id = $2) AND is_deleted=false
RETURNING `+fileCols,
id, ownerID, name, storageKey,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
if isUniqueViolation(err) {
return nil, ErrNameTaken
}
return nil, fmt.Errorf("rename file: %w", err)
}
return &f, nil
}
func (s *Store) MoveFile(ctx context.Context, id, ownerID uuid.UUID, newFolderID *uuid.UUID, storageKey string) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`UPDATE files SET folder_id=$3, storage_key=$4
WHERE id=$1 AND (owner_id = $2 OR group_id = $2) AND is_deleted=false
RETURNING `+fileCols,
id, ownerID, newFolderID, storageKey,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("move file: %w", err)
}
return &f, nil
}
type TrashFile struct {
File
FolderPath *string `json:"folder_path"`
}
func (s *Store) ListTrash(ctx context.Context, userID uuid.UUID) ([]*TrashFile, error) {
// Sichtbar ist der eigene Papierkorb plus der jeder Gruppe, in der man
// Mitglied ist. Ohne den zweiten Teil verschwände eine gelöschte
// Gruppendatei spurlos: Sie gehört keinem Konto, tauchte also in keinem
// persönlichen Papierkorb auf und wäre nicht wiederherstellbar.
const principal = `(f.owner_id = $1 OR f.group_id IN (
SELECT group_id FROM group_members WHERE user_id = $1))`
rows, err := s.db.Query(ctx,
`WITH RECURSIVE folder_paths AS (
SELECT id, name::text AS full_path
FROM folders
WHERE parent_id IS NULL
AND (owner_id = $1 OR group_id IN (
SELECT group_id FROM group_members WHERE user_id = $1))
UNION ALL
SELECT fo.id, fp.full_path || ' / ' || fo.name
FROM folders fo JOIN folder_paths fp ON fo.parent_id = fp.id
)
SELECT f.id, f.owner_id, f.group_id, f.folder_id, f.name, f.mime_type, f.size, f.checksum,
f.storage_key, f.has_thumbnail, f.version, f.versioning_enabled,
f.created_at, f.updated_at,
fp.full_path AS folder_path
FROM files f
LEFT JOIN folder_paths fp ON fp.id = f.folder_id
WHERE `+principal+` AND f.is_deleted = true
ORDER BY f.deleted_at DESC`,
userID,
)
if err != nil {
return nil, fmt.Errorf("list trash: %w", err)
}
defer rows.Close()
result := make([]*TrashFile, 0)
for rows.Next() {
var item TrashFile
if err := rows.Scan(
&item.ID, &item.OwnerID, &item.GroupID, &item.FolderID, &item.Name, &item.MimeType,
&item.Size, &item.Checksum, &item.StorageKey, &item.HasThumbnail,
&item.Version, &item.VersioningEnabled, &item.CreatedAt, &item.UpdatedAt,
&item.FolderPath,
); err != nil {
return nil, err
}
result = append(result, &item)
}
return result, rows.Err()
}
// RestoreFile marks a file as active again and updates its storage_key to the restored path.
// The caller must move the blob from .trash/ back to newStorageKey before calling this.
func (s *Store) RestoreFile(ctx context.Context, id, ownerID uuid.UUID, newStorageKey string) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`UPDATE files SET is_deleted = false, deleted_at = NULL, storage_key = $3
WHERE id = $1 AND (owner_id = $2 OR group_id = $2) AND is_deleted = true
RETURNING `+fileCols,
id, ownerID, newStorageKey,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("restore file: %w", err)
}
return &f, nil
}
// HardDeleteFile permanently removes a file and returns its storage key plus all version
// storage keys so the caller can delete the blobs. file_versions rows are removed by
// the ON DELETE CASCADE on the FK.
// EmptyTrash löscht alle Papierkorb-Einträge eines Nutzers endgültig und
// liefert sämtliche Blob-Schlüssel zurück, damit der Aufrufer die Bytes
// entfernen kann.
//
// Die IDs werden mit zurückgegeben, weil zu jeder Datei noch ein
// Vorschaubild unter "thumbs/<id>.jpg" liegen kann.
func (s *Store) EmptyTrash(ctx context.Context, ownerID uuid.UUID) (fileIDs []uuid.UUID, keys []string, err error) {
// Erst die Versionsblobs einsammeln – die Kaskade räumt die Zeilen mit weg.
rows, err := s.db.Query(ctx,
`SELECT v.storage_key
FROM file_versions v
JOIN files f ON f.id = v.file_id
WHERE (f.owner_id = $1 OR f.group_id = $1) AND f.is_deleted = true`, ownerID)
if err != nil {
return nil, nil, fmt.Errorf("list trash version keys: %w", err)
}
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
rows.Close()
return nil, nil, err
}
keys = append(keys, k)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, nil, err
}
rows, err = s.db.Query(ctx,
`DELETE FROM files WHERE (owner_id = $1 OR group_id = $1) AND is_deleted = true
RETURNING id, storage_key`, ownerID)
if err != nil {
return nil, nil, fmt.Errorf("empty trash: %w", err)
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
var k string
if err := rows.Scan(&id, &k); err != nil {
return nil, nil, err
}
fileIDs = append(fileIDs, id)
keys = append(keys, k)
}
return fileIDs, keys, rows.Err()
}
func (s *Store) HardDeleteFile(ctx context.Context, id, ownerID uuid.UUID) (storageKey string, versionKeys []string, err error) {
// Collect version blobs before the cascade removes them.
rows, err := s.db.Query(ctx,
`SELECT storage_key FROM file_versions WHERE file_id = $1`, id)
if err != nil {
return "", nil, fmt.Errorf("list version keys: %w", err)
}
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
rows.Close()
return "", nil, err
}
versionKeys = append(versionKeys, k)
}
rows.Close()
if err := rows.Err(); err != nil {
return "", nil, err
}
err = s.db.QueryRow(ctx,
`DELETE FROM files WHERE id = $1 AND (owner_id = $2 OR group_id = $2) AND is_deleted = true
RETURNING storage_key`,
id, ownerID,
).Scan(&storageKey)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil, ErrNotFound
}
if err != nil {
return "", nil, fmt.Errorf("hard delete file: %w", err)
}
return storageKey, versionKeys, nil
}
func (s *Store) UsedBytes(ctx context.Context, ownerID uuid.UUID) (int64, error) {
var n int64
err := s.db.QueryRow(ctx,
`SELECT COALESCE(SUM(size), 0) FROM files WHERE (owner_id = $1 OR group_id = $1) AND is_deleted = false`,
ownerID,
).Scan(&n)
return n, err
}
// GetFolderPath resolves the full slash-separated path for a folder, e.g. "Urlaub/2024".
// Returns "" for root (nil folderID).
func (s *Store) GetFolderPath(ctx context.Context, folderID *uuid.UUID) (string, error) {
if folderID == nil {
return "", nil
}
var path string
err := s.db.QueryRow(ctx,
`WITH RECURSIVE p AS (
SELECT id, name, parent_id, name::text AS full_path
FROM folders WHERE id = $1
UNION ALL
SELECT f.id, f.name, f.parent_id, f.name || '/' || p.full_path
FROM folders f JOIN p ON f.id = p.parent_id
)
SELECT full_path FROM p WHERE parent_id IS NULL`,
*folderID,
).Scan(&path)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return path, err
}
// GetFileByName finds a non-deleted file by name in a folder (for versioning on re-upload).
func (s *Store) GetFileByName(ctx context.Context, ownerID uuid.UUID, folderID *uuid.UUID, name string) (*File, error) {
var f File
var err error
if folderID == nil {
err = scanFile(s.db.QueryRow(ctx,
`SELECT `+fileCols+` FROM files
WHERE (owner_id = $1 OR group_id = $1) AND folder_id IS NULL AND name=$2 AND is_deleted=false`,
ownerID, name,
), &f)
} else {
err = scanFile(s.db.QueryRow(ctx,
`SELECT `+fileCols+` FROM files
WHERE (owner_id = $1 OR group_id = $1) AND folder_id=$2 AND name=$3 AND is_deleted=false`,
ownerID, *folderID, name,
), &f)
}
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &f, err
}
func (s *Store) CreateVersion(ctx context.Context, fileID uuid.UUID, version int, size int64, checksum, storageKey string) error {
_, err := s.db.Exec(ctx,
`INSERT INTO file_versions (file_id, version, size, checksum, storage_key)
VALUES ($1, $2, $3, $4, $5)`,
fileID, version, size, checksum, storageKey,
)
return err
}
// UpdateFileContent replaces blob metadata and increments version. Resets has_thumbnail.
func (s *Store) UpdateFileContent(ctx context.Context, id uuid.UUID, size int64, checksum, storageKey string) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`UPDATE files SET size=$2, checksum=$3, storage_key=$4, version=version+1, has_thumbnail=false
WHERE id=$1 AND is_deleted=false RETURNING `+fileCols,
id, size, checksum, storageKey,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &f, err
}
// DeleteVersion entfernt einen Versionseintrag. Wird beim Wiederherstellen
// gebraucht: dessen Inhalt ist danach die aktive Fassung, der Eintrag also
// gegenstandslos. Der Blob wird nicht angefasst – er wandert zur Datei.
func (s *Store) DeleteVersion(ctx context.Context, versionID, fileID uuid.UUID) error {
_, err := s.db.Exec(ctx,
`DELETE FROM file_versions WHERE id=$1 AND file_id=$2`, versionID, fileID)
return err
}
// SetVersioning schaltet die Versionierung einer Datei um.
//
// Bestehende Fassungen bleiben beim Ausschalten erhalten – sie zu löschen wäre
// stiller Datenverlust. Es entstehen ab dann nur keine neuen mehr.
func (s *Store) SetVersioning(ctx context.Context, id, ownerID uuid.UUID, enabled bool) (*File, error) {
var f File
err := scanFile(s.db.QueryRow(ctx,
`UPDATE files SET versioning_enabled=$3
WHERE id=$1 AND (owner_id = $2 OR group_id = $2) AND is_deleted=false RETURNING `+fileCols,
id, ownerID, enabled,
), &f)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &f, err
}
// PruneVersions keeps the newest maxKeep versions and deletes the rest.
// Returns storage keys of deleted versions so the caller can remove the blobs.
func (s *Store) PruneVersions(ctx context.Context, fileID uuid.UUID, maxKeep int) ([]string, error) {
rows, err := s.db.Query(ctx,
`DELETE FROM file_versions
WHERE id IN (
SELECT id FROM file_versions WHERE file_id=$1
ORDER BY version DESC OFFSET $2
) RETURNING storage_key`,
fileID, maxKeep,
)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err == nil {
keys = append(keys, k)
}
}
return keys, rows.Err()
}
func (s *Store) ListVersions(ctx context.Context, fileID, ownerID uuid.UUID) ([]*FileVersion, error) {
// Verify ownership via JOIN
rows, err := s.db.Query(ctx,
`SELECT fv.id, fv.file_id, fv.version, fv.size, fv.checksum, fv.storage_key, fv.created_at
FROM file_versions fv
JOIN files f ON f.id = fv.file_id
WHERE fv.file_id=$1 AND (f.owner_id = $2 OR f.group_id = $2)
ORDER BY fv.version DESC`,
fileID, ownerID,
)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]*FileVersion, 0)
for rows.Next() {
var v FileVersion
if err := rows.Scan(&v.ID, &v.FileID, &v.Version, &v.Size, &v.Checksum, &v.StorageKey, &v.CreatedAt); err != nil {
return nil, err
}
result = append(result, &v)
}
return result, rows.Err()
}
func (s *Store) GetVersion(ctx context.Context, versionID, fileID, ownerID uuid.UUID) (*FileVersion, error) {
var v FileVersion
err := s.db.QueryRow(ctx,
`SELECT fv.id, fv.file_id, fv.version, fv.size, fv.checksum, fv.storage_key, fv.created_at
FROM file_versions fv
JOIN files f ON f.id = fv.file_id
WHERE fv.id=$1 AND fv.file_id=$2 AND (f.owner_id = $3 OR f.group_id = $3)`,
versionID, fileID, ownerID,
).Scan(&v.ID, &v.FileID, &v.Version, &v.Size, &v.Checksum, &v.StorageKey, &v.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &v, err
}
func (s *Store) UpdateThumbnail(ctx context.Context, id uuid.UUID, hasThumbnail bool) error {
_, err := s.db.Exec(ctx,
`UPDATE files SET has_thumbnail = $2 WHERE id = $1`, id, hasThumbnail)
return err
}
// ── helpers ───────────────────────────────────────────────────────────────────
func scanFiles(rows pgx.Rows) ([]*File, error) {
result := make([]*File, 0)
for rows.Next() {
var f File
if err := scanFile(rows, &f); err != nil {
return nil, err
}
result = append(result, &f)
}
return result, rows.Err()
}
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
type sqlStater interface{ SQLState() string }
var se sqlStater
if errors.As(err, &se) {
return se.SQLState() == "23505"
}
return false
}
// PreviewVersion sagt, aus welcher Fassung das zwischengespeicherte PDF
// entstanden ist – 0 heißt: es gibt keins.
//
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes
// Vorschau-PDF für herrenlos und löschte es nach einem Tag weg.
func (s *Store) FilesWithPreview(ctx context.Context) ([]string, error) {
rows, err := s.db.Query(ctx, `SELECT id::text FROM files WHERE preview_version > 0`)
if err != nil {
return nil, fmt.Errorf("files with preview: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}