// 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 saveIfAbsent(app, 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) }