Implement Phase 0+1: project setup, config, database, migrations
- Go module, Makefile, .golangci.yaml, .gitignore, example config - TOML config loading with validation - SQLite database with WAL, foreign keys, busy timeout - Schema migrations: users, webauthn_credentials, notebooks, pages, strokes, share_links with indexes and cascading deletes - 4 tests: open+migrate, idempotent, foreign keys, cascade delete Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
eng-pad-server
|
||||
/srv/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.idea/
|
||||
.vscode/
|
||||
32
.golangci.yaml
Normal file
32
.golangci.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- unused
|
||||
- errorlint
|
||||
- gosec
|
||||
- staticcheck
|
||||
- revive
|
||||
- gofmt
|
||||
- goimports
|
||||
|
||||
settings:
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
govet:
|
||||
disable:
|
||||
- shadow
|
||||
gosec:
|
||||
excludes:
|
||||
- G104
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
32
Makefile
Normal file
32
Makefile
Normal file
@@ -0,0 +1,32 @@
|
||||
.PHONY: build test vet lint proto proto-lint clean all
|
||||
|
||||
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev)"
|
||||
|
||||
eng-pad-server:
|
||||
CGO_ENABLED=0 go build $(LDFLAGS) -o eng-pad-server ./cmd/eng-pad-server
|
||||
|
||||
build:
|
||||
go build ./...
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
proto:
|
||||
protoc --go_out=. --go_opt=module=git.wntrmute.dev/kyle/eng-pad-server \
|
||||
--go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/kyle/eng-pad-server \
|
||||
proto/engpad/v1/*.proto
|
||||
|
||||
proto-lint:
|
||||
buf lint
|
||||
buf breaking --against '.git#branch=master,subdir=proto'
|
||||
|
||||
clean:
|
||||
rm -f eng-pad-server
|
||||
|
||||
all: vet lint test eng-pad-server
|
||||
26
deploy/examples/eng-pad-server.toml
Normal file
26
deploy/examples/eng-pad-server.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[server]
|
||||
listen_addr = ":8443"
|
||||
grpc_addr = ":9443"
|
||||
tls_cert = "/srv/eng-pad-server/certs/cert.pem"
|
||||
tls_key = "/srv/eng-pad-server/certs/key.pem"
|
||||
|
||||
[web]
|
||||
listen_addr = ":8080"
|
||||
base_url = "https://pad.metacircular.net"
|
||||
|
||||
[database]
|
||||
path = "/srv/eng-pad-server/eng-pad-server.db"
|
||||
|
||||
[auth]
|
||||
token_ttl = "24h"
|
||||
argon2_memory = 65536
|
||||
argon2_time = 3
|
||||
argon2_threads = 4
|
||||
|
||||
[webauthn]
|
||||
rp_display_name = "Engineering Pad"
|
||||
rp_id = "pad.metacircular.net"
|
||||
rp_origins = ["https://pad.metacircular.net"]
|
||||
|
||||
[log]
|
||||
level = "info"
|
||||
23
go.mod
Normal file
23
go.mod
Normal file
@@ -0,0 +1,23 @@
|
||||
module git.wntrmute.dev/kyle/eng-pad-server
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/pelletier/go-toml/v2 v2.3.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
modernc.org/sqlite v1.47.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
63
go.sum
Normal file
63
go.sum
Normal file
@@ -0,0 +1,63 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
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-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
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.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/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.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/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.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
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=
|
||||
80
internal/config/config.go
Normal file
80
internal/config/config.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
Web WebConfig `toml:"web"`
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
Auth AuthConfig `toml:"auth"`
|
||||
WebAuthn WebAuthnConfig `toml:"webauthn"`
|
||||
Log LogConfig `toml:"log"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
ListenAddr string `toml:"listen_addr"`
|
||||
GRPCAddr string `toml:"grpc_addr"`
|
||||
TLSCert string `toml:"tls_cert"`
|
||||
TLSKey string `toml:"tls_key"`
|
||||
}
|
||||
|
||||
type WebConfig struct {
|
||||
ListenAddr string `toml:"listen_addr"`
|
||||
BaseURL string `toml:"base_url"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Path string `toml:"path"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
TokenTTL string `toml:"token_ttl"`
|
||||
Argon2Memory uint32 `toml:"argon2_memory"`
|
||||
Argon2Time uint32 `toml:"argon2_time"`
|
||||
Argon2Threads uint8 `toml:"argon2_threads"`
|
||||
}
|
||||
|
||||
func (a AuthConfig) TokenDuration() (time.Duration, error) {
|
||||
return time.ParseDuration(a.TokenTTL)
|
||||
}
|
||||
|
||||
type WebAuthnConfig struct {
|
||||
RPDisplayName string `toml:"rp_display_name"`
|
||||
RPID string `toml:"rp_id"`
|
||||
RPOrigins []string `toml:"rp_origins"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `toml:"level"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, fmt.Errorf("config validation: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if c.Database.Path == "" {
|
||||
return fmt.Errorf("database.path is required")
|
||||
}
|
||||
if c.Server.TLSCert == "" || c.Server.TLSKey == "" {
|
||||
return fmt.Errorf("server.tls_cert and server.tls_key are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
29
internal/db/db.go
Normal file
29
internal/db/db.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA foreign_keys = ON",
|
||||
"PRAGMA busy_timeout = 5000",
|
||||
}
|
||||
for _, p := range pragmas {
|
||||
if _, err := db.Exec(p); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("exec %q: %w", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
120
internal/db/db_test.go
Normal file
120
internal/db/db_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAndMigrate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
database, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer func() { _ = database.Close() }()
|
||||
|
||||
if err := Migrate(database); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
// Verify tables exist
|
||||
tables := []string{"users", "notebooks", "pages", "strokes", "share_links", "webauthn_credentials", "schema_migrations"}
|
||||
for _, table := range tables {
|
||||
var name string
|
||||
err := database.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name)
|
||||
if err != nil {
|
||||
t.Errorf("table %s not found: %v", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
database, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer func() { _ = database.Close() }()
|
||||
|
||||
if err := Migrate(database); err != nil {
|
||||
t.Fatalf("first migrate: %v", err)
|
||||
}
|
||||
if err := Migrate(database); err != nil {
|
||||
t.Fatalf("second migrate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignKeys(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
database, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer func() { _ = database.Close() }()
|
||||
|
||||
if err := Migrate(database); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
// Inserting a notebook with non-existent user_id should fail
|
||||
_, err = database.Exec("INSERT INTO notebooks (user_id, remote_id, title, page_size, synced_at) VALUES (999, 1, 'test', 'REGULAR', 0)")
|
||||
if err == nil {
|
||||
t.Fatal("expected foreign key error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCascadeDelete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
database, err := Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer func() { _ = database.Close() }()
|
||||
|
||||
if err := Migrate(database); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
// Create user, notebook, page, stroke
|
||||
res, err := database.Exec("INSERT INTO users (username, password_hash, created_at, updated_at) VALUES ('test', 'hash', 0, 0)")
|
||||
if err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
userID, _ := res.LastInsertId()
|
||||
|
||||
res, err = database.Exec("INSERT INTO notebooks (user_id, remote_id, title, page_size, synced_at) VALUES (?, 1, 'nb', 'REGULAR', 0)", userID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert notebook: %v", err)
|
||||
}
|
||||
nbID, _ := res.LastInsertId()
|
||||
|
||||
res, err = database.Exec("INSERT INTO pages (notebook_id, remote_id, page_number) VALUES (?, 1, 1)", nbID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert page: %v", err)
|
||||
}
|
||||
pageID, _ := res.LastInsertId()
|
||||
|
||||
_, err = database.Exec("INSERT INTO strokes (page_id, pen_size, color, point_data, stroke_order) VALUES (?, 1.0, 0, X'00', 1)", pageID)
|
||||
if err != nil {
|
||||
t.Fatalf("insert stroke: %v", err)
|
||||
}
|
||||
|
||||
// Delete the user — everything should cascade
|
||||
if _, err := database.Exec("DELETE FROM users WHERE id = ?", userID); err != nil {
|
||||
t.Fatalf("delete user: %v", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
_ = database.QueryRow("SELECT COUNT(*) FROM notebooks").Scan(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 notebooks, got %d", count)
|
||||
}
|
||||
_ = database.QueryRow("SELECT COUNT(*) FROM pages").Scan(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 pages, got %d", count)
|
||||
}
|
||||
_ = database.QueryRow("SELECT COUNT(*) FROM strokes").Scan(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 strokes, got %d", count)
|
||||
}
|
||||
}
|
||||
114
internal/db/migrations.go
Normal file
114
internal/db/migrations.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var migrations = []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{
|
||||
name: "001_initial_schema",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
credential_id BLOB NOT NULL UNIQUE,
|
||||
public_key BLOB NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notebooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
remote_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
page_size TEXT NOT NULL,
|
||||
synced_at INTEGER NOT NULL,
|
||||
UNIQUE(user_id, remote_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
notebook_id INTEGER NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
|
||||
remote_id INTEGER NOT NULL,
|
||||
page_number INTEGER NOT NULL,
|
||||
UNIQUE(notebook_id, remote_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS strokes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
pen_size REAL NOT NULL,
|
||||
color INTEGER NOT NULL,
|
||||
style TEXT NOT NULL DEFAULT 'plain',
|
||||
point_data BLOB NOT NULL,
|
||||
stroke_order INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
notebook_id INTEGER NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notebooks_user ON notebooks(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_notebook ON pages(notebook_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_strokes_page ON strokes(page_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_token ON share_links(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id);
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
func Migrate(database *sql.DB) error {
|
||||
// Ensure schema_migrations table exists
|
||||
_, err := database.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
var count int
|
||||
err := database.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE name = ?", m.name).Scan(&count)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check migration %s: %w", m.name, err)
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := database.Exec(m.sql); err != nil {
|
||||
return fmt.Errorf("apply migration %s: %w", m.name, err)
|
||||
}
|
||||
|
||||
if _, err := database.Exec(
|
||||
"INSERT INTO schema_migrations (name, applied_at) VALUES (?, strftime('%s','now'))",
|
||||
m.name,
|
||||
); err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", m.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user