Phase 0: skeleton, config, migrations, health check, container

Bootstraps Vellum as a single Go binary with one SQLite database.

- core: env-driven config, slog logging, SQLite (WAL) with pooled pragmas,
  embedded migration runner keyed on PRAGMA user_version
- migrations/0001_core.sql: identity, media, user state, infra. Media items
  are global; all per-user state (progress, favorites, grants) is a join.
  One polymorphic progress table covers every media kind.
- httpapi: request-ID, structured request logging, panic recovery, JSON
  endpoints for /healthz and /api/v1/version
- multi-arch Dockerfile (pure-Go SQLite, CGO_ENABLED=0) with an entrypoint
  that honours PUID/PGID like the rest of the self-hosted stack
- tests for migration idempotency, FK enforcement, WAL, config, middleware

Media does not exist yet. Nothing is exposed beyond health and version.
This commit is contained in:
mithral 2026-10-09 16:26:08 -04:00
commit eb1ad6d920
21 changed files with 1506 additions and 0 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
.git
.gitignore
data/
config/
dist/
*.db
*.db-wal
*.db-shm
/vellum
Dockerfile
compose.yml
README.md
*.md

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
# Build artifacts
/vellum
/dist/
*.exe
# Runtime state — never commit a database
/data/
/config/
*.db
*.db-wal
*.db-shm
# Editor / OS
.DS_Store
*.swp
.idea/
.vscode/

53
Dockerfile Normal file
View file

@ -0,0 +1,53 @@
# syntax=docker/dockerfile:1
# ── Build ──────────────────────────────────────────────────────────────
# Runs on the builder's native platform; cross-compiles to the target arch.
# CGO_ENABLED=0 works because the SQLite driver (modernc.org/sqlite) is pure
# Go, so there is no cgo and no libc to match against.
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
ARG TARGETOS=linux
ARG TARGETARCH=amd64
ARG VERSION=dev
WORKDIR /src
# Cache the module layer separately from the source.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \
-o /out/vellum ./cmd/vellum
# ── Runtime ────────────────────────────────────────────────────────────
FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata su-exec
COPY --from=build /out/vellum /usr/local/bin/vellum
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# /data persistent state (SQLite database) — back this up
# /config regenerable cache (thumbnails, extracted pages)
# /media the library, mounted read-only; Vellum never writes here
VOLUME ["/data", "/config"]
ENV VELLUM_DATA_DIR=/data \
VELLUM_CONFIG_DIR=/config \
VELLUM_LISTEN=:7788
# PUID/PGID are consumed by entrypoint.sh, which drops root privileges after
# taking ownership of the data and cache directories.
ENV PUID=1001 \
PGID=1001
EXPOSE 7788
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:7788/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

37
Makefile Normal file
View file

@ -0,0 +1,37 @@
# Vellum — developer commands.
#
# Requires Go >= 1.26. On Fedora: sudo dnf install golang
BINARY := vellum
VERSION ?= 0.1.0-dev
LDFLAGS := -s -w -X main.version=$(VERSION)
.PHONY: build run test fmt vet tidy check docker clean
build:
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/vellum
run:
go run ./cmd/vellum
test:
go test ./...
fmt:
gofmt -w .
vet:
go vet ./...
tidy:
go mod tidy
# What CI should run.
check: fmt vet test
docker:
podman build --build-arg VERSION=$(VERSION) -t localhost/vellum:$(VERSION) .
clean:
rm -f $(BINARY)
rm -rf data config

107
README.md Normal file
View file

@ -0,0 +1,107 @@
# Vellum
One application, one process, one database for every media type — ebooks,
comics, audiobooks, music, photos, games, and video — behind a single account
system with real per-user progress and per-library permissions.
A modular monolith. One Go binary, one SQLite file, one web frontend.
The full plan lives in `~/.opencode/plan/vellum.md`.
## Status
| Phase | Scope | State |
| --- | --- | --- |
| **0** | Repo, config, migrations, health, Docker | **done** |
| 1 | Users, sessions, library grants | not started |
| 2 | Scanner, items, assets, FTS5 search | not started |
| 3 | EPUB reader + progress | not started |
| 4 | Comic (CBZ/PDF) viewers | not started |
| 5 | Metadata providers | not started |
| 6 | Audiobooks, music, photos | not started |
| 7 | Games | not started |
| — | Video (stays on Jellyfin for now) | deferred |
Phase 0 delivers a binary that boots, migrates its schema, serves a health
check, and shuts down cleanly. No media features yet.
## Quick start
```bash
sudo dnf install golang # once; Go is not in the base image here
make build
make run
curl localhost:7788/healthz # {"database":"ok","status":"ok",...}
```
Run with media roots configured:
```bash
VELLUM_MEDIA_DIRS=/mnt/storage/media/ebooks:/mnt/storage/media/comics make run
```
## Configuration
All configuration is environment variables. Every one has a default, so the
binary runs with no configuration at all.
| Variable | Default | Meaning |
| --- | --- | --- |
| `VELLUM_LISTEN` | `:7788` | Bind address |
| `VELLUM_DATA_DIR` | `./data` | SQLite database lives here |
| `VELLUM_CONFIG_DIR` | `./config` | Regenerable cache (thumbnails, pages) |
| `VELLUM_MEDIA_DIRS` | *(none)* | Read-only library roots, colon-separated |
| `VELLUM_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
| `VELLUM_LOG_FORMAT` | `text` | `text` or `json` |
| `VELLUM_SESSION_TTL` | `720h` | Login session lifetime |
| `VELLUM_SHUTDOWN_GRACE` | `15s` | Graceful shutdown timeout |
## Layout
```
cmd/vellum/ main — wiring and lifecycle only
internal/
core/ config, logging, database, migration runner
httpapi/ router, middleware, handlers
migrations/ embedded SQL, applied in order at boot
```
**Module rule:** the core never imports a feature module; modules import the
core and mount their own routes, tables, and scanner rules. Adding a media type
is a new package plus a migration, never a core edit.
**Schema rule:** media items are global; everything user-specific is a join.
Nothing may hold per-user state on a media row.
## Database
SQLite in WAL mode, opened through `modernc.org/sqlite` — a pure-Go driver, so
the binary builds with `CGO_ENABLED=0` and cross-compiles without a toolchain
per target.
Migrations are embedded and applied in filename order; applied files are
recorded only by number in `PRAGMA user_version`, so **never edit an applied
migration** — add a new one.
Back up by copying the database (or its directory); `VACUUM INTO 'backup.db'`
takes a consistent snapshot while running.
## Deploy
```bash
make docker # podman build
podman compose up -d # see compose.yml
```
The container mounts `/data` (database), `/config` (cache), and `/media`
(read-only). On SELinux hosts every bind mount needs `:z` — `compose.yml`
already has it.
The application never writes to `/media`.
## Development
```bash
make check # gofmt + vet + test
make test
```

93
cmd/vellum/main.go Normal file
View file

@ -0,0 +1,93 @@
// Command vellum is the unified media server: one process, one database,
// every media type.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/httpapi"
"github.com/mithral/vellum/migrations"
)
// version is overridden at build time with -ldflags "-X main.version=...".
var version = "dev"
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "vellum: fatal:", err)
os.Exit(1)
}
}
func run() error {
cfg, err := core.LoadConfig()
if err != nil {
return err
}
log := core.NewLogger(cfg.LogLevel, cfg.LogFormat)
log.Info("starting vellum", "version", version, "listen", cfg.Listen)
if err := cfg.Validate(); err != nil {
return err
}
db, err := core.OpenDB(cfg.DBPath)
if err != nil {
return err
}
defer db.Close()
migCtx, cancelMig := context.WithTimeout(context.Background(), 60*time.Second)
err = core.Migrate(migCtx, db, migrations.FS, log)
cancelMig()
if err != nil {
return err
}
api := httpapi.New(db, log, version)
srv := &http.Server{
Addr: cfg.Listen,
Handler: api.Handler(),
// ReadHeaderTimeout protects against slowloris. WriteTimeout is
// deliberately zero: streaming large media files must not be cut off.
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
ErrorLog: nil,
}
errCh := make(chan error, 1)
go func() {
log.Info("listening", "addr", cfg.Listen, "data_dir", cfg.DataDir)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
select {
case err := <-errCh:
return fmt.Errorf("http server: %w", err)
case sig := <-stop:
log.Info("shutting down", "signal", sig.String())
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownGrace)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
log.Info("stopped")
return nil
}

45
compose.yml Normal file
View file

@ -0,0 +1,45 @@
# Vellum — one container, alongside the existing stack.
#
# Mounts match the conventions used elsewhere in this stack:
# :z SELinux relabel (mandatory on this host, getenforce = Enforcing)
# :ro the media library is read-only; Vellum never writes to it
#
# Paths here are the TARGET HOST's (/opt/mediaserver, /mnt/storage). They do
# not exist on the dev workstation — build locally with the Dockerfile instead.
services:
vellum:
build:
context: .
args:
VERSION: 0.1.0-dev
image: localhost/vellum:0.1.0-dev
container_name: vellum
restart: unless-stopped
security_opt:
- no-new-privileges:true
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
environment:
- PUID=1001
- PGID=1001
- TZ=America/New_York
- VELLUM_LISTEN=:7788
- VELLUM_LOG_LEVEL=info
# Read-only library roots, colon-separated. Add comics/music/etc. as
# those modules land.
- VELLUM_MEDIA_DIRS=/media/ebooks:/media/comics
volumes:
- /opt/mediaserver/vellum/data:/data:z
- /opt/mediaserver/vellum/config:/config:z
- /mnt/storage/media:/media:ro,z
ports:
- 127.0.0.1:7788:7788
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

27
entrypoint.sh Executable file
View file

@ -0,0 +1,27 @@
#!/bin/sh
# Vellum container entrypoint.
#
# Honours PUID/PGID the same way the rest of this stack does: when started as
# root, take ownership of the data and cache directories and drop privileges to
# the requested user. This is what lets a bind-mounted host directory owned by
# an arbitrary UID work without the operator matching IDs by hand.
#
# If already running as non-root (rootless podman with --userns=keep-id), skip
# all of it and just exec the binary.
set -eu
PUID="${PUID:-1001}"
PGID="${PGID:-1001}"
if [ "$(id -u)" != "0" ]; then
exec /usr/local/bin/vellum "$@"
fi
# Non-recursive: the application creates its own files, and recursively
# chowning a large thumbnail/page cache on every boot would be needlessly slow.
for dir in /data /config; do
mkdir -p "$dir"
chown "$PUID:$PGID" "$dir" 2>/dev/null || true
done
exec su-exec "$PUID:$PGID" /usr/local/bin/vellum "$@"

17
go.mod Normal file
View file

@ -0,0 +1,17 @@
module github.com/mithral/vellum
go 1.26.8
require modernc.org/sqlite v1.60.1
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.48.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)

50
go.sum Normal file
View file

@ -0,0 +1,50 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

120
internal/core/config.go Normal file
View file

@ -0,0 +1,120 @@
package core
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// Config is the complete runtime configuration, loaded from the environment.
// Every field has a working default so the binary runs with no configuration
// at all — important for "podman run" first-run experience.
type Config struct {
// Listen is the address the HTTP server binds, e.g. ":7788".
Listen string
// DataDir holds the SQLite database and persistent state.
DataDir string
// ConfigDir holds derived, regenerable artifacts (thumbnails, page cache).
ConfigDir string
// MediaDirs are read-only library roots. Vellum never writes here.
MediaDirs []string
DBPath string
LogLevel string
LogFormat string
SessionTTL time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
ShutdownGrace time.Duration
}
// LoadConfig reads configuration from the environment and fills in defaults.
// It does not create directories; call Validate for that.
func LoadConfig() (Config, error) {
c := Config{
Listen: env("VELLUM_LISTEN", ":7788"),
DataDir: env("VELLUM_DATA_DIR", "./data"),
ConfigDir: env("VELLUM_CONFIG_DIR", "./config"),
MediaDirs: envList("VELLUM_MEDIA_DIRS", nil),
LogLevel: env("VELLUM_LOG_LEVEL", "info"),
LogFormat: env("VELLUM_LOG_FORMAT", "text"), // text|json
SessionTTL: envDuration("VELLUM_SESSION_TTL", 30*24*time.Hour),
ReadTimeout: envDuration("VELLUM_READ_TIMEOUT", 15*time.Second),
WriteTimeout: envDuration("VELLUM_WRITE_TIMEOUT", 0), // 0 = no limit; streaming needs this
IdleTimeout: envDuration("VELLUM_IDLE_TIMEOUT", 120*time.Second),
ShutdownGrace: envDuration("VELLUM_SHUTDOWN_GRACE", 15*time.Second),
}
abs, err := filepath.Abs(c.DataDir)
if err != nil {
return Config{}, fmt.Errorf("resolve data dir: %w", err)
}
c.DataDir = abs
c.DBPath = filepath.Join(abs, "vellum.db")
if abs, err := filepath.Abs(c.ConfigDir); err == nil {
c.ConfigDir = abs
}
return c, nil
}
// Validate creates the data and config directories if missing and checks that
// the configured media roots exist. A missing media root is an error, not a
// warning: silently scanning nothing is worse than failing loudly at boot.
func (c Config) Validate() error {
for _, dir := range []string{c.DataDir, c.ConfigDir} {
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create %s: %w", dir, err)
}
}
for _, dir := range c.MediaDirs {
info, err := os.Stat(dir)
if err != nil {
return fmt.Errorf("media dir %s: %w", dir, err)
}
if !info.IsDir() {
return fmt.Errorf("media dir %s: not a directory", dir)
}
}
return nil
}
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return def
}
// envList parses a colon-separated list, the same separator as $PATH.
func envList(key string, def []string) []string {
v, ok := os.LookupEnv(key)
if !ok || strings.TrimSpace(v) == "" {
return def
}
var out []string
for _, part := range strings.Split(v, ":") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
func envDuration(key string, def time.Duration) time.Duration {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def
}
d, err := time.ParseDuration(v)
if err != nil {
return def
}
return d
}

View file

@ -0,0 +1,77 @@
package core
import (
"path/filepath"
"strings"
"testing"
)
func TestLoadConfigDefaults(t *testing.T) {
// No environment set: every field should have a usable default.
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Listen != ":7788" {
t.Errorf("Listen = %q, want :7788", cfg.Listen)
}
if !filepath.IsAbs(cfg.DataDir) {
t.Errorf("DataDir = %q, want absolute", cfg.DataDir)
}
if cfg.DBPath != filepath.Join(cfg.DataDir, "vellum.db") {
t.Errorf("DBPath = %q, want <DataDir>/vellum.db", cfg.DBPath)
}
if cfg.LogLevel != "info" {
t.Errorf("LogLevel = %q, want info", cfg.LogLevel)
}
}
func TestLoadConfigFromEnv(t *testing.T) {
t.Setenv("VELLUM_LISTEN", "127.0.0.1:9999")
t.Setenv("VELLUM_DATA_DIR", "/tmp/vellum-data")
t.Setenv("VELLUM_MEDIA_DIRS", "/media/a:/media/b")
t.Setenv("VELLUM_SESSION_TTL", "48h")
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Listen != "127.0.0.1:9999" {
t.Errorf("Listen = %q", cfg.Listen)
}
if cfg.DataDir != "/tmp/vellum-data" {
t.Errorf("DataDir = %q", cfg.DataDir)
}
if len(cfg.MediaDirs) != 2 || cfg.MediaDirs[0] != "/media/a" {
t.Errorf("MediaDirs = %v", cfg.MediaDirs)
}
if cfg.SessionTTL.Hours() != 48 {
t.Errorf("SessionTTL = %v, want 48h", cfg.SessionTTL)
}
}
func TestValidateRejectsMissingMediaDir(t *testing.T) {
cfg := Config{
DataDir: t.TempDir(),
ConfigDir: t.TempDir(),
MediaDirs: []string{"/definitely/not/a/real/path"},
}
err := cfg.Validate()
if err == nil {
t.Fatal("expected error for missing media dir")
}
if !strings.Contains(err.Error(), "/definitely/not/a/real/path") {
t.Errorf("error %q should name the offending path", err)
}
}
func TestValidateCreatesDirs(t *testing.T) {
base := t.TempDir()
cfg := Config{
DataDir: filepath.Join(base, "data"),
ConfigDir: filepath.Join(base, "config"),
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
}

60
internal/core/db.go Normal file
View file

@ -0,0 +1,60 @@
package core
import (
"database/sql"
"fmt"
"net/url"
"strings"
"time"
// Pure-Go SQLite driver. No cgo means CGO_ENABLED=0 static binaries and
// trivial cross-compilation for arm64.
_ "modernc.org/sqlite"
)
// pragmas applied to every pooled connection, via the DSN.
//
// These are passed in the DSN rather than run as statements after connecting
// because database/sql pools connections: a one-off PRAGMA would configure
// only whichever single connection happened to serve it.
var sqlitePragmas = []string{
"journal_mode(WAL)", // concurrent readers during a scan
"busy_timeout(5000)", // wait for the writer instead of failing
"foreign_keys(1)", // enforce the schema's referential integrity
"synchronous(NORMAL)", // safe under WAL, far faster than FULL
"cache_size(-32000)", // ~32 MB page cache
}
// OpenDB opens the SQLite database with the pragmas Vellum depends on.
func OpenDB(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", buildDSN(path, sqlitePragmas))
if err != nil {
return nil, fmt.Errorf("open sqlite %s: %w", path, err)
}
// SQLite is a single-writer database. A large pool just produces
// SQLITE_BUSY contention; a small pool with a busy_timeout is faster.
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(8)
db.SetConnMaxLifetime(0)
db.SetConnMaxIdleTime(5 * time.Minute)
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping sqlite %s: %w", path, err)
}
return db, nil
}
// buildDSN assembles a modernc.org/sqlite DSN with repeated _pragma params.
func buildDSN(path string, pragmas []string) string {
var b strings.Builder
b.WriteString("file:")
b.WriteString(path)
b.WriteString("?_time_format=sqlite")
for _, p := range pragmas {
b.WriteString("&_pragma=")
b.WriteString(url.QueryEscape(p))
}
return b.String()
}

34
internal/core/logging.go Normal file
View file

@ -0,0 +1,34 @@
package core
import (
"log/slog"
"os"
"strings"
)
// NewLogger builds a structured logger from the configured level and format.
// Unknown values fall back rather than failing: a bad log level must never
// stop the server from booting.
func NewLogger(level, format string) *slog.Logger {
var lvl slog.Level
switch strings.ToLower(level) {
case "debug":
lvl = slog.LevelDebug
case "warn", "warning":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: lvl}
var h slog.Handler
if strings.EqualFold(format, "json") {
h = slog.NewJSONHandler(os.Stdout, opts)
} else {
h = slog.NewTextHandler(os.Stdout, opts)
}
return slog.New(h)
}

138
internal/core/migrate.go Normal file
View file

@ -0,0 +1,138 @@
package core
import (
"context"
"database/sql"
"fmt"
"io/fs"
"log/slog"
"sort"
"strconv"
"strings"
)
// Migrate applies every embedded migration whose number is greater than the
// database's current PRAGMA user_version, in ascending order, each inside its
// own transaction.
//
// A migration is recorded by its numeric prefix. This is why applied files are
// never edited: the runner has no checksum, only a version number, so changing
// an applied file would silently diverge a running database from the schema.
func Migrate(ctx context.Context, db *sql.DB, fsys fs.FS, log *slog.Logger) error {
names, err := fs.Glob(fsys, "*.sql")
if err != nil {
return fmt.Errorf("list migrations: %w", err)
}
sort.Strings(names)
current, err := userVersion(ctx, db)
if err != nil {
return err
}
log.Debug("schema version", "current", current, "available", len(names))
for _, name := range names {
n, err := migrationNumber(name)
if err != nil {
return err
}
if n <= current {
continue
}
if err := applyMigration(ctx, db, fsys, name, n); err != nil {
return fmt.Errorf("migration %s: %w", name, err)
}
log.Info("applied migration", "file", name, "version", n)
}
return nil
}
func applyMigration(ctx context.Context, db *sql.DB, fsys fs.FS, name string, n int) error {
body, err := fs.ReadFile(fsys, name)
if err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // no-op once Commit succeeds
for _, stmt := range splitStatements(string(body)) {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("exec: %w\nstatement:\n%s", err, stmt)
}
}
// PRAGMA does not accept a bound parameter. n is parsed from the filename
// as an integer above, so it is not attacker-controlled.
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", n)); err != nil {
return fmt.Errorf("set user_version: %w", err)
}
return tx.Commit()
}
func userVersion(ctx context.Context, db *sql.DB) (int, error) {
var v int
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&v); err != nil {
return 0, fmt.Errorf("read user_version: %w", err)
}
return v, nil
}
// migrationNumber parses the numeric prefix of "0001_core.sql" -> 1.
func migrationNumber(name string) (int, error) {
base := name
if i := strings.IndexByte(base, '_'); i >= 0 {
base = base[:i]
} else if i := strings.IndexByte(base, '.'); i >= 0 {
base = base[:i]
}
n, err := strconv.Atoi(base)
if err != nil {
return 0, fmt.Errorf("migration %q: filename must start with a number, e.g. 0001_core.sql", name)
}
return n, nil
}
// splitStatements splits a SQL file on semicolons that terminate a statement.
//
// This is not a general SQL parser. It is correct for the schema files this
// project writes: no stored procedures, no BEGIN...END blocks, no semicolons
// inside string literals. Comments are stripped first so that a ';' inside a
// comment cannot split a statement.
func splitStatements(sqlText string) []string {
var out []string
for _, stmt := range strings.Split(stripSQLComments(sqlText), ";") {
if s := strings.TrimSpace(stmt); s != "" {
out = append(out, s)
}
}
return out
}
// stripSQLComments removes -- line comments and /* */ block comments.
func stripSQLComments(s string) string {
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); {
switch {
case strings.HasPrefix(s[i:], "--"):
for i < len(s) && s[i] != '\n' {
i++
}
case strings.HasPrefix(s[i:], "/*"):
i += 2
for i < len(s) && !strings.HasPrefix(s[i:], "*/") {
i++
}
i += 2
default:
b.WriteByte(s[i])
i++
}
}
return b.String()
}

View file

@ -0,0 +1,124 @@
package core
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"strings"
"testing"
"github.com/mithral/vellum/migrations"
)
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func openTempDB(t *testing.T) *sql.DB {
t.Helper()
db, err := OpenDB(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func TestMigrateAppliesAndIsIdempotent(t *testing.T) {
ctx := context.Background()
db := openTempDB(t)
if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil {
t.Fatalf("first Migrate: %v", err)
}
// Running again must be a no-op, not an error.
if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil {
t.Fatalf("second Migrate (should be no-op): %v", err)
}
var version int
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
t.Fatalf("read user_version: %v", err)
}
if version != 1 {
t.Errorf("user_version = %d, want 1", version)
}
var tables int
if err := db.QueryRowContext(ctx,
"SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&tables); err != nil {
t.Fatalf("count tables: %v", err)
}
if tables < 16 {
t.Errorf("table count = %d, want >= 16", tables)
}
// WAL must be active: progress writes during a scan depend on it.
var mode string
if err := db.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&mode); err != nil {
t.Fatalf("read journal_mode: %v", err)
}
if !strings.EqualFold(mode, "wal") {
t.Errorf("journal_mode = %q, want wal", mode)
}
}
func TestForeignKeyEnforcementIsOn(t *testing.T) {
ctx := context.Background()
db := openTempDB(t)
if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil {
t.Fatalf("Migrate: %v", err)
}
// library_grants references users and libraries; neither 999 exists.
_, err := db.ExecContext(ctx,
"INSERT INTO library_grants(user_id, library_id) VALUES (999, 999)")
if err == nil {
t.Fatal("expected foreign key violation, got nil")
}
}
func TestMigrationNumber(t *testing.T) {
cases := map[string]int{
"0001_core.sql": 1,
"0042_audiobooks.sql": 42,
"7_shorthand.sql": 7,
"0010_no_ext": 10,
}
for name, want := range cases {
got, err := migrationNumber(name)
if err != nil {
t.Errorf("migrationNumber(%q): %v", name, err)
continue
}
if got != want {
t.Errorf("migrationNumber(%q) = %d, want %d", name, got, want)
}
}
if _, err := migrationNumber("core.sql"); err == nil {
t.Error("expected error for filename without a numeric prefix")
}
}
func TestSplitStatementsIgnoresComments(t *testing.T) {
in := `
-- a comment with a semicolon; which must not split
CREATE TABLE a (id INTEGER); -- trailing comment;
/* block; comment; here */
CREATE TABLE b (id INTEGER);
`
got := splitStatements(in)
if len(got) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(got), got)
}
if !strings.Contains(got[0], "CREATE TABLE a") {
t.Errorf("statement 0 = %q", got[0])
}
if !strings.Contains(got[1], "CREATE TABLE b") {
t.Errorf("statement 1 = %q", got[1])
}
}

View file

@ -0,0 +1,45 @@
package httpapi
import (
"context"
"net/http"
"time"
)
// handleHealth reports process liveness and database reachability.
//
// Returns 503 when the database cannot be pinged, so a container orchestrator
// restarts a wedged process instead of routing traffic to it.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dbStatus := "ok"
code := http.StatusOK
if err := s.db.PingContext(ctx); err != nil {
dbStatus = "unreachable"
code = http.StatusServiceUnavailable
s.log.Error("health check: database unreachable", "err", err)
}
status := "ok"
if code != http.StatusOK {
status = "degraded"
}
writeJSON(w, code, map[string]any{
"status": status,
"version": s.version,
"uptime_seconds": int(time.Since(s.started).Seconds()),
"database": dbStatus,
})
}
// handleVersion returns build information.
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"name": "vellum",
"version": s.version,
"api": "v1",
})
}

152
internal/httpapi/server.go Normal file
View file

@ -0,0 +1,152 @@
// Package httpapi is the HTTP layer: routing, middleware, and handlers.
//
// In Phase 0 it serves only health and version. Feature modules (auth, library,
// ebooks, comics, ...) mount their own routes onto the same mux via
// Server.Handle, so this package never imports a feature module.
package httpapi
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"time"
)
// Server holds the dependencies every handler needs.
type Server struct {
db *sql.DB
log *slog.Logger
version string
started time.Time
mux *http.ServeMux
}
// New builds a Server and registers the core routes.
func New(db *sql.DB, log *slog.Logger, version string) *Server {
s := &Server{
db: db,
log: log,
version: version,
started: time.Now(),
mux: http.NewServeMux(),
}
s.routes()
return s
}
// Handle lets a module mount routes on the shared mux.
func (s *Server) Handle(pattern string, h http.Handler) {
s.mux.Handle(pattern, h)
}
func (s *Server) routes() {
s.mux.HandleFunc("GET /healthz", s.handleHealth)
s.mux.HandleFunc("GET /api/v1/version", s.handleVersion)
s.mux.HandleFunc("/", s.handleNotFound)
}
// Handler returns the fully wrapped root handler.
//
// Order matters: the request ID must be attached before the logging
// middleware runs, so the log line can include it.
func (s *Server) Handler() http.Handler {
return s.withRecovery(s.withRequestID(s.withRequestLogging(s.mux)))
}
// ── Middleware ─────────────────────────────────────────────────────────
type ctxKey string
const ctxRequestID ctxKey = "request_id"
func withValue(ctx context.Context, k ctxKey, v string) context.Context {
return context.WithValue(ctx, k, v)
}
func valueString(ctx context.Context, k ctxKey) string {
if v, ok := ctx.Value(k).(string); ok {
return v
}
return ""
}
func (s *Server) withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
var b [8]byte
_, _ = rand.Read(b[:])
id = hex.EncodeToString(b[:])
}
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(withValue(r.Context(), ctxRequestID, id)))
})
}
func (s *Server) withRequestLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
s.log.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", valueString(r.Context(), ctxRequestID),
)
})
}
func (s *Server) withRecovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic",
"path", r.URL.Path,
"recover", rec,
"request_id", valueString(r.Context(), ctxRequestID),
)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
}()
next.ServeHTTP(w, r)
})
}
// ── Helpers ────────────────────────────────────────────────────────────
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "not found",
"path": r.URL.Path,
})
}

View file

@ -0,0 +1,107 @@
package httpapi
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return New(db, log, "test")
}
func TestHealth(t *testing.T) {
srv := newTestServer(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["status"] != "ok" {
t.Errorf("status = %v, want ok", body["status"])
}
if body["version"] != "test" {
t.Errorf("version = %v, want test", body["version"])
}
}
func TestNotFoundIsJSON(t *testing.T) {
srv := newTestServer(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/nope", nil)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct == "" {
t.Error("missing Content-Type on 404")
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("404 body is not JSON: %v", err)
}
}
func TestRequestIDHeaderAndLog(t *testing.T) {
srv := newTestServer(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
srv.Handler().ServeHTTP(rec, req)
if rec.Header().Get("X-Request-ID") == "" {
t.Error("X-Request-ID header not set")
}
// A caller-supplied ID must be preserved.
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/healthz", nil)
req2.Header.Set("X-Request-ID", "abc123")
srv.Handler().ServeHTTP(rec2, req2)
if got := rec2.Header().Get("X-Request-ID"); got != "abc123" {
t.Errorf("X-Request-ID = %q, want abc123", got)
}
}
func TestRecoveryMiddleware(t *testing.T) {
srv := newTestServer(t)
srv.Handle("GET /panic", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
panic("boom")
}))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/panic", nil)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
}

176
migrations/0001_core.sql Normal file
View file

@ -0,0 +1,176 @@
-- 0001_core.sql — Vellum core schema.
--
-- Structural rule: media items are global; everything user-specific is a join.
-- Nothing in this file may hold per-user state on a media row.
--
-- Timestamps are ISO-8601 UTC TEXT. Booleans are INTEGER 0/1.
-- `kind` columns are intentionally unrestricted TEXT: media modules declare
-- their own kinds and must not require a core migration to do so.
-- ── Identity ───────────────────────────────────────────────────────────
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
device_label TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT NOT NULL
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
-- A library is a root path plus a media kind. Global.
CREATE TABLE libraries (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
root_path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- Per-user access to a library. The ONLY place read permission is expressed.
CREATE TABLE library_grants (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
can_read INTEGER NOT NULL DEFAULT 1,
can_download INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, library_id)
);
-- ── Media (global) ─────────────────────────────────────────────────────
CREATE TABLE media_items (
id INTEGER PRIMARY KEY,
library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
path TEXT NOT NULL,
parent_id INTEGER REFERENCES media_items(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
sort_title TEXT NOT NULL DEFAULT '',
year INTEGER,
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE (library_id, path)
);
CREATE INDEX idx_items_parent ON media_items(parent_id);
CREATE INDEX idx_items_kind ON media_items(kind);
CREATE INDEX idx_items_library ON media_items(library_id);
CREATE TABLE media_files (
id INTEGER PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
path TEXT NOT NULL UNIQUE,
size INTEGER NOT NULL DEFAULT 0,
mtime TEXT NOT NULL DEFAULT '',
inode INTEGER NOT NULL DEFAULT 0,
container TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_files_item ON media_files(item_id);
CREATE TABLE people (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE COLLATE NOCASE
);
CREATE TABLE item_people (
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
role TEXT NOT NULL,
ord INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (item_id, person_id, role)
);
-- External metadata identifiers. UNIQUE so a provider record maps to one item.
CREATE TABLE provider_ids (
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
external_id TEXT NOT NULL,
PRIMARY KEY (item_id, provider),
UNIQUE (provider, external_id)
);
-- Covers, thumbnails, extracted comic pages. Global, shared by all users.
CREATE TABLE assets (
id INTEGER PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
role TEXT NOT NULL,
path TEXT NOT NULL,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
bytes INTEGER NOT NULL DEFAULT 0,
UNIQUE (item_id, role, path)
);
-- ── User state (all joins) ─────────────────────────────────────────────
-- One polymorphic progress table for every media kind.
-- audio/video/music -> position and duration in milliseconds
-- ebook -> position is a spine index or CFI, duration 0
-- comic/pdf -> position is the page number, duration 0
-- PK includes file_id so multi-file items (audiobook tracks) resume per track.
CREATE TABLE progress (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
file_id INTEGER NOT NULL REFERENCES media_files(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
duration INTEGER NOT NULL DEFAULT 0,
percent REAL NOT NULL DEFAULT 0,
finished INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (user_id, item_id, file_id)
);
CREATE INDEX idx_progress_user_updated ON progress(user_id, updated_at DESC);
CREATE TABLE collections (
id INTEGER PRIMARY KEY,
owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'private'
);
CREATE TABLE collection_items (
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (collection_id, item_id)
);
CREATE TABLE favorites (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (user_id, item_id)
);
-- ── Infrastructure ─────────────────────────────────────────────────────
CREATE TABLE jobs (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}',
state TEXT NOT NULL DEFAULT 'queued', -- queued|running|done|failed
attempts INTEGER NOT NULL DEFAULT 0,
run_after TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX idx_jobs_claim ON jobs(state, run_after);
CREATE TABLE settings (
k TEXT PRIMARY KEY,
v TEXT NOT NULL DEFAULT ''
);

14
migrations/embed.go Normal file
View file

@ -0,0 +1,14 @@
// Package migrations embeds the numbered SQL schema files.
//
// Files are applied in filename order by internal/core.Migrate. A new schema
// change is always a NEW file (NNNN_description.sql); existing files are never
// edited, because applied files are recorded only by their number in
// PRAGMA user_version and rewriting history silently corrupts existing DBs.
package migrations
import "embed"
// FS holds every .sql migration, embedded into the binary.
//
//go:embed *.sql
var FS embed.FS