check / check (push) Successful in 12m47s
Linting and testing become the lint and test phases of the Dockerfile, and the build stage depends on both. Dockerfile.lint, CHECK_EPOCH and the tests that checked them are removed. Every docker build in script/ passes --no-cache, and script/cibuild runs script/bootstrap first. A host without Go gets the go.mod version from script/install-go in .tool/go, which bootstrap, the Makefile, fmt, fmt-check, precommit and release add to PATH; fmt-check skips .tool. The image takes its version from the VERSION build arg or git describe, dev without .git. This repo's own entries follow the canonical content in .gitignore and .editorconfig. The golangci-lint v2.14.0 findings are fixed. The rules in CLAUDE.md move into AGENTS.md. IsDevVersion counts "unknown". Model: opus-5-5
492 lines
11 KiB
Go
492 lines
11 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
"sneak.berlin/go/vaultik/internal/types"
|
|
)
|
|
|
|
// FileRepository provides access to the files table, which stores file
|
|
// metadata (path, times, permissions, ownership, symlink targets).
|
|
type FileRepository struct {
|
|
db *DB
|
|
}
|
|
|
|
// NewFileRepository creates a FileRepository backed by db.
|
|
func NewFileRepository(db *DB) *FileRepository {
|
|
return &FileRepository{db: db}
|
|
}
|
|
|
|
// Create inserts or updates a file row (upsert on path), using tx when
|
|
// non-nil. The file's ID is generated when zero and updated from the
|
|
// database's RETURNING clause.
|
|
func (r *FileRepository) Create(ctx context.Context, tx *sql.Tx, file *File) error {
|
|
// Generate UUID if not provided
|
|
if file.ID.IsZero() {
|
|
file.ID = types.NewFileID()
|
|
}
|
|
|
|
query := `
|
|
INSERT INTO files (id, path, source_path, mtime, size, mode, uid, gid, link_target)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(path) DO UPDATE SET
|
|
source_path = excluded.source_path,
|
|
mtime = excluded.mtime,
|
|
size = excluded.size,
|
|
mode = excluded.mode,
|
|
uid = excluded.uid,
|
|
gid = excluded.gid,
|
|
link_target = excluded.link_target
|
|
RETURNING id
|
|
`
|
|
|
|
var (
|
|
idStr string
|
|
err error
|
|
)
|
|
|
|
if tx != nil {
|
|
LogSQL("Execute", query,
|
|
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
|
file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID,
|
|
file.LinkTarget.String())
|
|
err = tx.QueryRowContext(ctx, query,
|
|
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
|
file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID,
|
|
file.LinkTarget.String()).Scan(&idStr)
|
|
} else {
|
|
err = r.db.QueryRowWithLog(ctx, query,
|
|
file.ID.String(), file.Path.String(), file.SourcePath.String(),
|
|
file.MTime.Unix(), file.Size, file.Mode, file.UID, file.GID,
|
|
file.LinkTarget.String()).Scan(&idStr)
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("inserting file: %w", err)
|
|
}
|
|
|
|
// Parse the returned ID
|
|
file.ID, err = types.ParseFileID(idStr)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing file ID: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetByPath returns the file at the given path, or nil if the path is not
|
|
// in the index.
|
|
func (r *FileRepository) GetByPath(ctx context.Context, path string) (*File, error) {
|
|
query := `
|
|
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
|
FROM files
|
|
WHERE path = ?
|
|
`
|
|
|
|
file, err := r.scanFile(r.db.conn.QueryRowContext(ctx, query, path))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying file: %w", err)
|
|
}
|
|
|
|
return file, nil
|
|
}
|
|
|
|
// GetByID retrieves a file by its UUID
|
|
func (r *FileRepository) GetByID(ctx context.Context, id types.FileID) (*File, error) {
|
|
query := `
|
|
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
|
FROM files
|
|
WHERE id = ?
|
|
`
|
|
|
|
file, err := r.scanFile(r.db.conn.QueryRowContext(ctx, query, id.String()))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying file: %w", err)
|
|
}
|
|
|
|
return file, nil
|
|
}
|
|
|
|
// GetByPathTx returns the file at the given path within a transaction, or
|
|
// nil if the path is not in the index.
|
|
func (r *FileRepository) GetByPathTx(
|
|
ctx context.Context, tx *sql.Tx, path string,
|
|
) (*File, error) {
|
|
query := `
|
|
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
|
FROM files
|
|
WHERE path = ?
|
|
`
|
|
|
|
LogSQL("GetByPathTx QueryRowContext", query, path)
|
|
file, err := r.scanFile(tx.QueryRowContext(ctx, query, path))
|
|
LogSQL("GetByPathTx Scan complete", query, path)
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil //nolint:nilnil // nil,nil signals not-found; callers check nil
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying file: %w", err)
|
|
}
|
|
|
|
return file, nil
|
|
}
|
|
|
|
// fileRowScanner abstracts *sql.Row and *sql.Rows for scanning a file row.
|
|
type fileRowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
// ListModifiedSince returns all files whose recorded mtime is at or after
|
|
// since, ordered by path.
|
|
func (r *FileRepository) ListModifiedSince(
|
|
ctx context.Context, since time.Time,
|
|
) ([]*File, error) {
|
|
query := `
|
|
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
|
FROM files
|
|
WHERE mtime >= ?
|
|
ORDER BY path
|
|
`
|
|
|
|
rows, err := r.db.conn.QueryContext(ctx, query, since.Unix())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying files: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
err := rows.Close()
|
|
if err != nil {
|
|
Fatalf("failed to close rows: %v", err)
|
|
}
|
|
}()
|
|
|
|
var files []*File
|
|
|
|
for rows.Next() {
|
|
file, err := r.scanFileRows(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning file: %w", err)
|
|
}
|
|
|
|
files = append(files, file)
|
|
}
|
|
|
|
return files, rows.Err()
|
|
}
|
|
|
|
// Delete removes the file row at the given path, using tx when non-nil.
|
|
func (r *FileRepository) Delete(ctx context.Context, tx *sql.Tx, path string) error {
|
|
query := `DELETE FROM files WHERE path = ?`
|
|
|
|
var err error
|
|
if tx != nil {
|
|
_, err = tx.ExecContext(ctx, query, path)
|
|
} else {
|
|
_, err = r.db.ExecWithLog(ctx, query, path)
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("deleting file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteByID deletes a file by its UUID
|
|
func (r *FileRepository) DeleteByID(
|
|
ctx context.Context, tx *sql.Tx, id types.FileID,
|
|
) error {
|
|
query := `DELETE FROM files WHERE id = ?`
|
|
|
|
var err error
|
|
if tx != nil {
|
|
_, err = tx.ExecContext(ctx, query, id.String())
|
|
} else {
|
|
_, err = r.db.ExecWithLog(ctx, query, id.String())
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("deleting file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ListByPrefix returns all files whose path starts with prefix, ordered by
|
|
// path.
|
|
func (r *FileRepository) ListByPrefix(
|
|
ctx context.Context, prefix string,
|
|
) ([]*File, error) {
|
|
query := `
|
|
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
|
FROM files
|
|
WHERE path LIKE ? || '%'
|
|
ORDER BY path
|
|
`
|
|
|
|
rows, err := r.db.conn.QueryContext(ctx, query, prefix)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying files: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
err := rows.Close()
|
|
if err != nil {
|
|
Fatalf("failed to close rows: %v", err)
|
|
}
|
|
}()
|
|
|
|
var files []*File
|
|
|
|
for rows.Next() {
|
|
file, err := r.scanFileRows(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning file: %w", err)
|
|
}
|
|
|
|
files = append(files, file)
|
|
}
|
|
|
|
return files, rows.Err()
|
|
}
|
|
|
|
// ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose
|
|
// path starts with prefix and that list at least one chunk held by no
|
|
// blob whose upload has completed (uploaded_ts set). A new snapshot
|
|
// cannot reference such a chunk, so a backup must not treat the file as
|
|
// unchanged even when its metadata matches the file on disk.
|
|
func (r *FileRepository) ListIDsWithChunksNotInUploadedBlobs(
|
|
ctx context.Context, prefix string,
|
|
) ([]types.FileID, error) {
|
|
query := `
|
|
SELECT DISTINCT f.id
|
|
FROM files f
|
|
JOIN file_chunks fc ON fc.file_id = f.id
|
|
WHERE f.path LIKE ? || '%'
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM blob_chunks bc
|
|
JOIN blobs b ON bc.blob_id = b.id
|
|
WHERE bc.chunk_hash = fc.chunk_hash
|
|
AND b.uploaded_ts IS NOT NULL
|
|
)
|
|
`
|
|
|
|
rows, err := r.db.conn.QueryContext(ctx, query, prefix)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying files: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
err := rows.Close()
|
|
if err != nil {
|
|
Fatalf("failed to close rows: %v", err)
|
|
}
|
|
}()
|
|
|
|
var ids []types.FileID
|
|
|
|
for rows.Next() {
|
|
var id types.FileID
|
|
|
|
err := rows.Scan(&id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning file ID: %w", err)
|
|
}
|
|
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
return ids, rows.Err()
|
|
}
|
|
|
|
// ListAll returns all files in the database
|
|
func (r *FileRepository) ListAll(ctx context.Context) ([]*File, error) {
|
|
query := `
|
|
SELECT id, path, source_path, mtime, size, mode, uid, gid, link_target
|
|
FROM files
|
|
ORDER BY path
|
|
`
|
|
|
|
rows, err := r.db.conn.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying files: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
err := rows.Close()
|
|
if err != nil {
|
|
Fatalf("failed to close rows: %v", err)
|
|
}
|
|
}()
|
|
|
|
var files []*File
|
|
|
|
for rows.Next() {
|
|
file, err := r.scanFileRows(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning file: %w", err)
|
|
}
|
|
|
|
files = append(files, file)
|
|
}
|
|
|
|
return files, rows.Err()
|
|
}
|
|
|
|
// CreateBatch inserts or updates multiple files in a single statement for efficiency.
|
|
// File IDs must be pre-generated before calling this method.
|
|
func (r *FileRepository) CreateBatch(
|
|
ctx context.Context, tx *sql.Tx, files []*File,
|
|
) error {
|
|
if len(files) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Each files row binds this many SQL variables.
|
|
const fileCols = 9
|
|
|
|
// Batch at 100 rows to be safe with SQLite's variable limit.
|
|
const batchSize = 100
|
|
|
|
for i := 0; i < len(files); i += batchSize {
|
|
end := min(i+batchSize, len(files))
|
|
|
|
batch := files[i:end]
|
|
|
|
query := `INSERT INTO files
|
|
(id, path, source_path, mtime, size, mode, uid, gid, link_target)
|
|
VALUES `
|
|
|
|
args := make([]any, 0, len(batch)*fileCols)
|
|
|
|
var querySb325 strings.Builder
|
|
|
|
for j, f := range batch {
|
|
if j > 0 {
|
|
querySb325.WriteString(", ")
|
|
}
|
|
|
|
querySb325.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
|
|
args = append(args,
|
|
f.ID.String(), f.Path.String(), f.SourcePath.String(),
|
|
f.MTime.Unix(), f.Size, f.Mode, f.UID, f.GID,
|
|
f.LinkTarget.String())
|
|
}
|
|
|
|
query += querySb325.String()
|
|
|
|
query += ` ON CONFLICT(path) DO UPDATE SET
|
|
source_path = excluded.source_path,
|
|
mtime = excluded.mtime,
|
|
size = excluded.size,
|
|
mode = excluded.mode,
|
|
uid = excluded.uid,
|
|
gid = excluded.gid,
|
|
link_target = excluded.link_target`
|
|
|
|
var err error
|
|
if tx != nil {
|
|
_, err = tx.ExecContext(ctx, query, args...)
|
|
} else {
|
|
_, err = r.db.ExecWithLog(ctx, query, args...)
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("batch inserting files: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteOrphaned deletes files that are not referenced by any snapshot
|
|
func (r *FileRepository) DeleteOrphaned(ctx context.Context) error {
|
|
query := `
|
|
DELETE FROM files
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM snapshot_files
|
|
WHERE snapshot_files.file_id = files.id
|
|
)
|
|
`
|
|
|
|
result, err := r.db.ExecWithLog(ctx, query)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting orphaned files: %w", err)
|
|
}
|
|
|
|
rowsAffected, _ := result.RowsAffected()
|
|
if rowsAffected > 0 {
|
|
log.Debug("Deleted orphaned files", "count", rowsAffected)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// scanFile is a helper that scans a single file row
|
|
func (r *FileRepository) scanFile(row *sql.Row) (*File, error) {
|
|
return r.scanFileFrom(row)
|
|
}
|
|
|
|
// scanFileRows is a helper that scans a file row from rows iterator
|
|
func (r *FileRepository) scanFileRows(rows *sql.Rows) (*File, error) {
|
|
return r.scanFileFrom(rows)
|
|
}
|
|
|
|
// scanFileFrom scans one file row from any row scanner.
|
|
func (r *FileRepository) scanFileFrom(row fileRowScanner) (*File, error) {
|
|
var (
|
|
file File
|
|
idStr, pathStr, sourcePathStr string
|
|
mtimeUnix int64
|
|
linkTarget sql.NullString
|
|
)
|
|
|
|
err := row.Scan(
|
|
&idStr,
|
|
&pathStr,
|
|
&sourcePathStr,
|
|
&mtimeUnix,
|
|
&file.Size,
|
|
&file.Mode,
|
|
&file.UID,
|
|
&file.GID,
|
|
&linkTarget,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
file.ID, err = types.ParseFileID(idStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing file ID: %w", err)
|
|
}
|
|
|
|
file.Path = types.FilePath(pathStr)
|
|
file.SourcePath = types.SourcePath(sourcePathStr)
|
|
|
|
file.MTime = time.Unix(mtimeUnix, 0).UTC()
|
|
if linkTarget.Valid {
|
|
file.LinkTarget = types.FilePath(linkTarget.String)
|
|
}
|
|
|
|
return &file, nil
|
|
}
|