Files
2026-08-15 07:13:00 +00:00

231 lines
7.1 KiB
Go

package store
import (
"context"
"database/sql"
"fmt"
"time"
)
// Project is a row of the projects table.
//
// The serving-related fields (IndexFile, NotFoundFile, SPAFallback,
// CacheControl) are copied into the in-memory site registry; the limits are
// enforced when a manifest is accepted.
type Project struct {
ID int64
Name string
DisplayName string
IndexFile string
NotFoundFile string // "" means no custom 404 document
SPAFallback bool
CacheControl string
RetentionCount int
RetentionGraceS int
MaxFiles int
MaxFileBytes int64
MaxTotalBytes int64
CreatedAt time.Time
UpdatedAt time.Time
}
// DefaultProject returns a project carrying the same defaults the schema does,
// as the starting point for a create request.
func DefaultProject(name string) *Project {
return &Project{
Name: name,
IndexFile: "index.html",
CacheControl: "public, max-age=0, must-revalidate",
RetentionCount: 10,
RetentionGraceS: 3600,
MaxFiles: 50000,
MaxFileBytes: 256 << 20,
MaxTotalBytes: 2 << 30,
}
}
const projectColumns = `id, name, display_name, index_file, not_found_file, spa_fallback,
cache_control, retention_count, retention_grace_s, max_files, max_file_bytes,
max_total_bytes, created_at, updated_at`
type rowScanner interface {
Scan(dest ...any) error
}
func scanProject(row rowScanner) (*Project, error) {
var p Project
var notFound sql.NullString
var created, updated int64
err := row.Scan(&p.ID, &p.Name, &p.DisplayName, &p.IndexFile, &notFound, &p.SPAFallback,
&p.CacheControl, &p.RetentionCount, &p.RetentionGraceS, &p.MaxFiles, &p.MaxFileBytes,
&p.MaxTotalBytes, &created, &updated)
if err != nil {
return nil, mapErr(err)
}
p.NotFoundFile = notFound.String
p.CreatedAt = time.Unix(created, 0).UTC()
p.UpdatedAt = time.Unix(updated, 0).UTC()
return &p, nil
}
// CreateProject inserts p and fills in its ID and timestamps. Name uniqueness is
// enforced by the schema, so a duplicate returns ErrExists rather than racing.
func (d *DB) CreateProject(ctx context.Context, p *Project) error {
now := unixNow()
return d.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx, `
INSERT INTO projects (name, display_name, index_file, not_found_file, spa_fallback,
cache_control, retention_count, retention_grace_s, max_files,
max_file_bytes, max_total_bytes, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.Name, p.DisplayName, p.IndexFile, nullString(p.NotFoundFile), p.SPAFallback,
p.CacheControl, p.RetentionCount, p.RetentionGraceS, p.MaxFiles,
p.MaxFileBytes, p.MaxTotalBytes, now, now)
if err != nil {
return mapErr(err)
}
id, err := res.LastInsertId()
if err != nil {
return err
}
p.ID = id
p.CreatedAt = time.Unix(now, 0).UTC()
p.UpdatedAt = p.CreatedAt
return nil
})
}
// ProjectByName looks a project up by its URL name.
func (d *DB) ProjectByName(ctx context.Context, name string) (*Project, error) {
return scanProject(d.r.QueryRowContext(ctx,
`SELECT `+projectColumns+` FROM projects WHERE name = ?`, name))
}
// ProjectByID looks a project up by its primary key.
func (d *DB) ProjectByID(ctx context.Context, id int64) (*Project, error) {
return scanProject(d.r.QueryRowContext(ctx,
`SELECT `+projectColumns+` FROM projects WHERE id = ?`, id))
}
// ListProjects returns up to limit projects ordered by name, starting after the
// cursor. The cursor is the last name returned, which is stable under
// concurrent inserts in a way that an offset is not.
func (d *DB) ListProjects(ctx context.Context, limit int, cursor string) (projects []*Project, next string, err error) {
if limit <= 0 || limit > 500 {
limit = 100
}
// One extra row tells us whether another page exists without a second query.
rows, err := d.r.QueryContext(ctx,
`SELECT `+projectColumns+` FROM projects WHERE name > ? ORDER BY name LIMIT ?`,
cursor, limit+1)
if err != nil {
return nil, "", err
}
defer rows.Close()
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, "", err
}
projects = append(projects, p)
}
if err := rows.Err(); err != nil {
return nil, "", err
}
if len(projects) > limit {
projects = projects[:limit]
next = projects[len(projects)-1].Name
}
return projects, next, nil
}
// AllProjects returns every project, for building the in-memory registry at
// startup. The registry holds them all anyway, so paging here would be theatre.
func (d *DB) AllProjects(ctx context.Context) ([]*Project, error) {
rows, err := d.r.QueryContext(ctx, `SELECT `+projectColumns+` FROM projects ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Project
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// UpdateProject writes p's mutable fields back. Name and ID are immutable: a
// rename would invalidate every deployed URL and every cached symlink, and the
// API offers delete-and-recreate instead.
func (d *DB) UpdateProject(ctx context.Context, p *Project) error {
now := unixNow()
return d.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx, `
UPDATE projects SET display_name = ?, index_file = ?, not_found_file = ?,
spa_fallback = ?, cache_control = ?, retention_count = ?,
retention_grace_s = ?, max_files = ?, max_file_bytes = ?,
max_total_bytes = ?, updated_at = ?
WHERE id = ?`,
p.DisplayName, p.IndexFile, nullString(p.NotFoundFile), p.SPAFallback,
p.CacheControl, p.RetentionCount, p.RetentionGraceS, p.MaxFiles,
p.MaxFileBytes, p.MaxTotalBytes, now, p.ID)
if err != nil {
return mapErr(err)
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
p.UpdatedAt = time.Unix(now, 0).UTC()
return nil
})
}
// DeleteProject removes a project and, by cascade, its keys, deployments and
// manifest rows. Blob refcounts fall as the manifest rows go, so the next GC
// pass reclaims the content.
//
// The caller is responsible for the parts the database does not know about: the
// registry entry, the webroot symlink and the assembled directories.
func (d *DB) DeleteProject(ctx context.Context, id int64) error {
return d.Tx(ctx, func(tx *sql.Tx) error {
// Delete the manifest rows explicitly rather than trusting the cascade to
// fire the refcount triggers (see the schema comment).
if _, err := tx.ExecContext(ctx, `
DELETE FROM deployment_files
WHERE deployment_id IN (SELECT id FROM deployments WHERE project_id = ?)`, id); err != nil {
return err
}
res, err := tx.ExecContext(ctx, `DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return mapErr(err)
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
})
}
// CountProjects is used by /api/v1/system/info.
func (d *DB) CountProjects(ctx context.Context) (int64, error) {
var n int64
if err := d.r.QueryRowContext(ctx, `SELECT count(*) FROM projects`).Scan(&n); err != nil {
return 0, fmt.Errorf("count projects: %w", err)
}
return n, nil
}