Files
pages/internal/site/deployment.go
T
2026-08-15 07:13:00 +00:00

116 lines
3.8 KiB
Go

// Package site turns an activated deployment into HTTP responses.
//
// Everything here is built around one property: a request must never observe a
// mixture of two deployments. That is achieved by making the served state an
// immutable snapshot behind an atomic pointer — a handler loads it once, at the
// top, and every subsequent decision in that request comes from the value it
// loaded. Switching versions is one pointer store, so an in-flight request keeps
// reading the deployment it started on until it finishes.
package site
import (
"path"
"time"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/store"
)
// FileEntry is what a request path resolves to: enough to serve the bytes and
// to answer a conditional request, and nothing else.
type FileEntry struct {
Digest cas.Digest
Size int64
}
// Index is the part of a snapshot that costs something to build.
//
// It is separate from Deployment so activation can pay for it *before* it
// changes anything: if the manifest cannot be read or turned into these maps,
// the failure happens while the old deployment is still the active one.
type Index struct {
files map[string]FileEntry
dirs map[string]struct{}
totalBytes int64
}
// NewIndex builds the lookup structures for one manifest.
//
// Dirs holds every directory prefix, which is what lets Resolve tell "no such
// path" apart from "that is a directory, redirect to it with a trailing slash"
// without scanning the file map for prefixes on every miss.
func NewIndex(files []store.FileRow) *Index {
idx := &Index{
files: make(map[string]FileEntry, len(files)),
dirs: make(map[string]struct{}),
}
for _, f := range files {
idx.files[f.Path] = FileEntry{Digest: f.Digest, Size: f.Size}
idx.totalBytes += f.Size
for dir := path.Dir(f.Path); dir != "." && dir != "/"; dir = path.Dir(dir) {
if _, ok := idx.dirs[dir]; ok {
// Every shorter prefix was added with this one, so there is
// nothing left to walk.
break
}
idx.dirs[dir] = struct{}{}
}
}
return idx
}
// Deployment is an immutable snapshot of what a project serves.
//
// Nothing mutates one after NewDeployment returns. That is the whole reason a
// version switch can be a bare pointer store: readers need no synchronisation
// beyond the atomic load that handed them the snapshot.
type Deployment struct {
ID string
ProjectID int64
// Dir is the assembled tree, kept for the webroot symlink and for
// operators. It is empty under assemble_mode=none and is never used to
// serve a request — content comes from the CAS by digest.
Dir string
// CreatedAt is the Last-Modified of every file in the deployment. The CAS
// file's own mtime would be wrong: blobs are shared across projects and
// versions, so their mtime means nothing here and would leak when some
// other project first uploaded the same bytes.
CreatedAt time.Time
ActivatedAt time.Time
FileCount int
TotalBytes int64
idx *Index
}
// NewDeployment pairs a row with an already-built index. It allocates one
// struct and copies no maps, so activation can call it after the database
// commit without any risk of failing there.
func NewDeployment(dep *store.Deployment, idx *Index, dir string) *Deployment {
d := &Deployment{
ID: dep.PublicID,
ProjectID: dep.ProjectID,
Dir: dir,
CreatedAt: dep.CreatedAt,
FileCount: len(idx.files),
TotalBytes: idx.totalBytes,
idx: idx,
}
if dep.ActivatedAt != nil {
d.ActivatedAt = *dep.ActivatedAt
}
return d
}
// Lookup finds one file by its slash-separated path within the deployment.
func (d *Deployment) Lookup(rel string) (FileEntry, bool) {
e, ok := d.idx.files[rel]
return e, ok
}
// IsDir reports whether rel is a directory prefix of some file.
func (d *Deployment) IsDir(rel string) bool {
_, ok := d.idx.dirs[rel]
return ok
}