Files
libnovel/backend/migrations/20260414000001_initial_schema.go
Admin 3f436877ee
Some checks failed
Release / Test backend (push) Successful in 1m6s
Release / Check ui (push) Successful in 1m56s
Release / Docker (push) Successful in 8m19s
Release / Deploy to prod (push) Failing after 36s
Release / Gitea Release (push) Successful in 22s
fix: pocketbase healthcheck (add wget) + saveIfAbsent infinite recursion
- Add wget to pocketbase Alpine image so the docker-compose healthcheck
  (wget http://localhost:8090/api/health) can actually run
- Fix saveIfAbsent calling itself instead of app.Save(c) — was an
  infinite recursion that would stack-overflow on a fresh install

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:05:02 +05:00

432 lines
14 KiB
Go

// Migration 1 — full schema baseline.
//
// Creates all 21 collections that were previously bootstrapped by
// scripts/pb-init-v3.sh. Also creates the initial superuser from the
// POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD env vars (first run only).
//
// This migration is intentionally idempotent: each collection is skipped if it
// already exists. This makes it safe to apply on an existing install without
// running `migrate history-sync` first — existing collections are left untouched
// and migration 2 still runs to add the three fields that were missing.
package migrations
import (
"os"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
steps := []func(core.App) error{
createBooks,
createChaptersIdx,
createRanking,
createProgress,
createScrapingTasks,
createAudioJobs,
createAppUsers,
createUserSessions,
createUserLibrary,
createUserSettings,
createUserSubscriptions,
createBookComments,
createCommentVotes,
createTranslationJobs,
createImportTasks,
createNotifications,
createPushSubscriptions,
createAIJobs,
createDiscoveryVotes,
createBookRatings,
createSiteConfig,
createInitialSuperuser,
}
for _, step := range steps {
if err := step(app); err != nil {
return err
}
}
return nil
}, func(app core.App) error {
// Down: drop all collections in safe reverse order.
names := []string{
"site_config", "book_ratings", "discovery_votes", "ai_jobs",
"push_subscriptions", "notifications", "import_tasks",
"translation_jobs", "comment_votes", "book_comments",
"user_subscriptions", "user_settings", "user_library",
"user_sessions", "app_users", "audio_jobs", "scraping_tasks",
"progress", "ranking", "chapters_idx", "books",
}
for _, name := range names {
coll, err := app.FindCollectionByNameOrId(name)
if err != nil {
continue // already absent — safe to skip
}
if err := app.Delete(coll); err != nil {
return err
}
}
return nil
})
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// saveIfAbsent saves the collection only when no collection with that name
// exists yet. This makes the migration safe to run on an existing install
// without history-sync — already-created collections are simply skipped.
func saveIfAbsent(app core.App, c *core.Collection) error {
if _, err := app.FindCollectionByNameOrId(c.Name); err == nil {
return nil // already exists — skip
}
return app.Save(c)
}
// ── Collection creators ───────────────────────────────────────────────────────
func createBooks(app core.App) error {
c := core.NewBaseCollection("books")
c.Fields.Add(
&core.TextField{Name: "slug", Required: true},
&core.TextField{Name: "title", Required: true},
&core.TextField{Name: "author"},
&core.TextField{Name: "cover"},
&core.TextField{Name: "status"},
&core.JSONField{Name: "genres"},
&core.TextField{Name: "summary"},
&core.NumberField{Name: "total_chapters"},
&core.TextField{Name: "source_url"},
&core.NumberField{Name: "ranking"},
&core.TextField{Name: "meta_updated"},
&core.BoolField{Name: "archived"},
)
return saveIfAbsent(app, c)
}
func createChaptersIdx(app core.App) error {
c := core.NewBaseCollection("chapters_idx")
c.Fields.Add(
&core.TextField{Name: "slug", Required: true},
&core.NumberField{Name: "number", Required: true},
&core.TextField{Name: "title"},
)
// Enforce uniqueness on (slug, number) — prevents duplicate chapter entries.
c.AddIndex("idx_chapters_idx_slug_number", true, "slug, number", "")
// Allow fast "recently updated books" queries.
c.AddIndex("idx_chapters_idx_created", false, "created", "")
return saveIfAbsent(app, c)
}
func createRanking(app core.App) error {
c := core.NewBaseCollection("ranking")
c.Fields.Add(
&core.NumberField{Name: "rank", Required: true},
&core.TextField{Name: "slug", Required: true},
&core.TextField{Name: "title"},
&core.TextField{Name: "author"},
&core.TextField{Name: "cover"},
&core.TextField{Name: "status"},
&core.JSONField{Name: "genres"},
&core.TextField{Name: "source_url"},
)
return saveIfAbsent(app, c)
}
func createProgress(app core.App) error {
c := core.NewBaseCollection("progress")
c.Fields.Add(
&core.TextField{Name: "session_id", Required: true},
&core.TextField{Name: "slug", Required: true},
&core.NumberField{Name: "chapter"},
&core.TextField{Name: "user_id"},
&core.NumberField{Name: "audio_time"},
)
return saveIfAbsent(app, c)
}
func createScrapingTasks(app core.App) error {
c := core.NewBaseCollection("scraping_tasks")
c.Fields.Add(
&core.TextField{Name: "kind"},
&core.TextField{Name: "target_url"},
&core.NumberField{Name: "from_chapter"},
&core.NumberField{Name: "to_chapter"},
&core.TextField{Name: "worker_id"},
&core.TextField{Name: "status", Required: true},
&core.NumberField{Name: "books_found"},
&core.NumberField{Name: "chapters_scraped"},
&core.NumberField{Name: "chapters_skipped"},
&core.NumberField{Name: "errors"},
&core.TextField{Name: "error_message"},
&core.DateField{Name: "started"},
&core.DateField{Name: "finished"},
&core.DateField{Name: "heartbeat_at"},
)
return saveIfAbsent(app, c)
}
func createAudioJobs(app core.App) error {
c := core.NewBaseCollection("audio_jobs")
c.Fields.Add(
&core.TextField{Name: "cache_key", Required: true},
&core.TextField{Name: "slug", Required: true},
&core.NumberField{Name: "chapter", Required: true},
&core.TextField{Name: "voice"},
&core.TextField{Name: "worker_id"},
&core.TextField{Name: "status", Required: true},
&core.TextField{Name: "error_message"},
&core.DateField{Name: "started"},
&core.DateField{Name: "finished"},
&core.DateField{Name: "heartbeat_at"},
)
return saveIfAbsent(app, c)
}
func createAppUsers(app core.App) error {
c := core.NewBaseCollection("app_users")
c.Fields.Add(
&core.TextField{Name: "username", Required: true},
&core.TextField{Name: "password_hash"},
&core.TextField{Name: "role"},
&core.TextField{Name: "avatar_url"},
&core.TextField{Name: "email"},
&core.BoolField{Name: "email_verified"},
&core.TextField{Name: "verification_token"},
&core.TextField{Name: "verification_token_exp"},
&core.TextField{Name: "oauth_provider"},
&core.TextField{Name: "oauth_id"},
&core.TextField{Name: "polar_customer_id"},
&core.TextField{Name: "polar_subscription_id"},
&core.BoolField{Name: "notify_new_chapters"},
)
return saveIfAbsent(app, c)
}
func createUserSessions(app core.App) error {
c := core.NewBaseCollection("user_sessions")
c.Fields.Add(
&core.TextField{Name: "user_id", Required: true},
&core.TextField{Name: "session_id", Required: true},
&core.TextField{Name: "user_agent"},
&core.TextField{Name: "ip"},
&core.TextField{Name: "device_fingerprint"},
// created_at is a custom text field (not the system `created` date field).
&core.TextField{Name: "created_at"},
&core.TextField{Name: "last_seen"},
)
return saveIfAbsent(app, c)
}
func createUserLibrary(app core.App) error {
c := core.NewBaseCollection("user_library")
c.Fields.Add(
&core.TextField{Name: "session_id", Required: true},
&core.TextField{Name: "user_id"},
&core.TextField{Name: "slug", Required: true},
&core.TextField{Name: "saved_at"},
&core.TextField{Name: "shelf"},
)
return saveIfAbsent(app, c)
}
func createUserSettings(app core.App) error {
c := core.NewBaseCollection("user_settings")
c.Fields.Add(
&core.TextField{Name: "session_id", Required: true},
&core.TextField{Name: "user_id"},
&core.BoolField{Name: "auto_next"},
&core.TextField{Name: "voice"},
&core.NumberField{Name: "speed"},
&core.TextField{Name: "theme"},
&core.TextField{Name: "locale"},
&core.TextField{Name: "font_family"},
&core.NumberField{Name: "font_size"},
&core.BoolField{Name: "announce_chapter"},
&core.TextField{Name: "audio_mode"},
)
return saveIfAbsent(app, c)
}
func createUserSubscriptions(app core.App) error {
c := core.NewBaseCollection("user_subscriptions")
c.Fields.Add(
&core.TextField{Name: "follower_id", Required: true},
&core.TextField{Name: "followee_id", Required: true},
)
return saveIfAbsent(app, c)
}
func createBookComments(app core.App) error {
c := core.NewBaseCollection("book_comments")
c.Fields.Add(
&core.TextField{Name: "slug", Required: true},
&core.TextField{Name: "user_id"},
&core.TextField{Name: "username"},
&core.TextField{Name: "body"},
&core.NumberField{Name: "upvotes"},
&core.NumberField{Name: "downvotes"},
&core.TextField{Name: "parent_id"},
)
return saveIfAbsent(app, c)
}
func createCommentVotes(app core.App) error {
c := core.NewBaseCollection("comment_votes")
c.Fields.Add(
&core.TextField{Name: "comment_id", Required: true},
&core.TextField{Name: "user_id"},
&core.TextField{Name: "session_id"},
&core.TextField{Name: "vote"},
)
return saveIfAbsent(app, c)
}
func createTranslationJobs(app core.App) error {
c := core.NewBaseCollection("translation_jobs")
c.Fields.Add(
&core.TextField{Name: "cache_key", Required: true},
&core.TextField{Name: "slug", Required: true},
&core.NumberField{Name: "chapter", Required: true},
&core.TextField{Name: "lang", Required: true},
&core.TextField{Name: "worker_id"},
&core.TextField{Name: "status", Required: true},
&core.TextField{Name: "error_message"},
&core.DateField{Name: "started"},
&core.DateField{Name: "finished"},
&core.DateField{Name: "heartbeat_at"},
)
return saveIfAbsent(app, c)
}
func createImportTasks(app core.App) error {
c := core.NewBaseCollection("import_tasks")
c.Fields.Add(
&core.TextField{Name: "slug", Required: true},
&core.TextField{Name: "title", Required: true},
&core.TextField{Name: "file_name"},
&core.TextField{Name: "file_type"},
&core.TextField{Name: "object_key"},
&core.TextField{Name: "chapters_key"},
&core.TextField{Name: "author"},
&core.TextField{Name: "cover_url"},
&core.TextField{Name: "genres"},
&core.TextField{Name: "summary"},
&core.TextField{Name: "book_status"},
&core.TextField{Name: "worker_id"},
&core.TextField{Name: "initiator_user_id"},
&core.TextField{Name: "status", Required: true},
&core.NumberField{Name: "chapters_done"},
&core.NumberField{Name: "chapters_total"},
&core.TextField{Name: "error_message"},
&core.DateField{Name: "started"},
&core.DateField{Name: "finished"},
&core.DateField{Name: "heartbeat_at"},
)
return saveIfAbsent(app, c)
}
func createNotifications(app core.App) error {
c := core.NewBaseCollection("notifications")
c.Fields.Add(
&core.TextField{Name: "user_id", Required: true},
&core.TextField{Name: "title", Required: true},
&core.TextField{Name: "message"},
&core.TextField{Name: "link"},
&core.BoolField{Name: "read"},
)
return saveIfAbsent(app, c)
}
func createPushSubscriptions(app core.App) error {
c := core.NewBaseCollection("push_subscriptions")
c.Fields.Add(
&core.TextField{Name: "user_id", Required: true},
&core.TextField{Name: "endpoint", Required: true},
&core.TextField{Name: "p256dh", Required: true},
&core.TextField{Name: "auth", Required: true},
)
return saveIfAbsent(app, c)
}
func createAIJobs(app core.App) error {
c := core.NewBaseCollection("ai_jobs")
c.Fields.Add(
&core.TextField{Name: "kind", Required: true},
&core.TextField{Name: "slug"},
&core.TextField{Name: "status", Required: true},
&core.NumberField{Name: "from_item"},
&core.NumberField{Name: "to_item"},
&core.NumberField{Name: "items_done"},
&core.NumberField{Name: "items_total"},
&core.TextField{Name: "model"},
&core.TextField{Name: "payload"},
&core.TextField{Name: "error_message"},
&core.DateField{Name: "started"},
&core.DateField{Name: "finished"},
&core.DateField{Name: "heartbeat_at"},
)
return saveIfAbsent(app, c)
}
func createDiscoveryVotes(app core.App) error {
c := core.NewBaseCollection("discovery_votes")
c.Fields.Add(
&core.TextField{Name: "session_id", Required: true},
&core.TextField{Name: "user_id"},
&core.TextField{Name: "slug", Required: true},
&core.TextField{Name: "action", Required: true},
)
return saveIfAbsent(app, c)
}
func createBookRatings(app core.App) error {
c := core.NewBaseCollection("book_ratings")
c.Fields.Add(
&core.TextField{Name: "session_id", Required: true},
&core.TextField{Name: "user_id"},
&core.TextField{Name: "slug", Required: true},
&core.NumberField{Name: "rating", Required: true},
)
return saveIfAbsent(app, c)
}
func createSiteConfig(app core.App) error {
c := core.NewBaseCollection("site_config")
c.Fields.Add(
&core.TextField{Name: "decoration"},
&core.TextField{Name: "logoAnimation"},
&core.TextField{Name: "eventLabel"},
)
return saveIfAbsent(app, c)
}
// createInitialSuperuser creates the first PocketBase superuser from env vars.
// It is a no-op if a superuser with that email already exists, or if the env
// vars are not set. This replaces the superuser bootstrap block in
// scripts/pb-init-v3.sh.
func createInitialSuperuser(app core.App) error {
email := os.Getenv("POCKETBASE_ADMIN_EMAIL")
password := os.Getenv("POCKETBASE_ADMIN_PASSWORD")
if email == "" || password == "" {
return nil
}
existing, _ := app.FindFirstRecordByData("_superusers", "email", email)
if existing != nil {
return nil // superuser already exists
}
superusers, err := app.FindCollectionByNameOrId("_superusers")
if err != nil {
return err
}
record := core.NewRecord(superusers)
record.Set("email", email)
record.Set("password", password)
record.Set("passwordConfirm", password)
return app.Save(record)
}