init
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
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, ¬Found, &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
|
||||
}
|
||||
Reference in New Issue
Block a user