- 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>
432 lines
14 KiB
Go
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)
|
|
}
|