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:
commit
eb1ad6d920
21 changed files with 1506 additions and 0 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
17
.gitignore
vendored
Normal 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
53
Dockerfile
Normal 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
37
Makefile
Normal 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
107
README.md
Normal 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
93
cmd/vellum/main.go
Normal 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
45
compose.yml
Normal 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
27
entrypoint.sh
Executable 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
17
go.mod
Normal 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
50
go.sum
Normal 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
120
internal/core/config.go
Normal 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
|
||||
}
|
||||
77
internal/core/config_test.go
Normal file
77
internal/core/config_test.go
Normal 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
60
internal/core/db.go
Normal 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
34
internal/core/logging.go
Normal 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
138
internal/core/migrate.go
Normal 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()
|
||||
}
|
||||
124
internal/core/migrate_test.go
Normal file
124
internal/core/migrate_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
45
internal/httpapi/health.go
Normal file
45
internal/httpapi/health.go
Normal 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
152
internal/httpapi/server.go
Normal 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,
|
||||
})
|
||||
}
|
||||
107
internal/httpapi/server_test.go
Normal file
107
internal/httpapi/server_test.go
Normal 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
176
migrations/0001_core.sql
Normal 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
14
migrations/embed.go
Normal 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
|
||||
Loading…
Add table
Reference in a new issue