feat: replace pb-init-v3.sh with PocketBase Go migrations
Adds a custom PocketBase binary (cmd/pocketbase) that embeds PocketBase as a Go framework with version-controlled migrations, replacing the fragile 419-line shell script. Migrations apply automatically on every `serve` startup. Migration 1 (20260414000001): full baseline schema — all 21 collections, both indexes on chapters_idx, and initial superuser creation from env vars. Migration 2 (20260414000002): adds three fields found in code but missing from the old script — books.rating, app_users.notify_new_chapters_push, book_comments.chapter. docker-compose: pocketbase service now uses the custom kalekber/libnovel-pocketbase image; pb-init one-shot container removed (migrations replace it entirely). Existing installs: run `migrate history-sync` once to mark migration 1 as done, then restart — migration 2 will apply the three previously missing fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
418
backend/migrations/20260414000001_initial_schema.go
Normal file
418
backend/migrations/20260414000001_initial_schema.go
Normal file
@@ -0,0 +1,418 @@
|
||||
// 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).
|
||||
//
|
||||
// Existing installs: run `migrate history-sync` once after deploying the custom
|
||||
// PocketBase binary to mark this migration as already applied, then let
|
||||
// migration 2 add the three fields that were missing from the old script.
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// ── 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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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 app.Save(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)
|
||||
}
|
||||
71
backend/migrations/20260414000002_missing_fields.go
Normal file
71
backend/migrations/20260414000002_missing_fields.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Migration 2 — add fields present in code but absent from pb-init-v3.sh.
|
||||
//
|
||||
// Discovered by auditing every PocketBase field access in the Go backend
|
||||
// and SvelteKit UI against the collection definitions in pb-init-v3.sh:
|
||||
//
|
||||
// books.rating (number) — written by WriteMetadata but never defined.
|
||||
// app_users.notify_new_chapters_push (bool) — used in UI push-notification opt-in.
|
||||
// book_comments.chapter (number) — used to scope comments to a chapter (0 = book-level).
|
||||
//
|
||||
// The check for field existence makes this migration safe to re-apply on
|
||||
// a fresh install where migration 1 already created the collections without
|
||||
// these fields.
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
type addition struct {
|
||||
collection string
|
||||
field core.Field
|
||||
}
|
||||
additions := []addition{
|
||||
{"books", &core.NumberField{Name: "rating"}},
|
||||
{"app_users", &core.BoolField{Name: "notify_new_chapters_push"}},
|
||||
{"book_comments", &core.NumberField{Name: "chapter"}},
|
||||
}
|
||||
for _, a := range additions {
|
||||
coll, err := app.FindCollectionByNameOrId(a.collection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if coll.Fields.GetByName(a.field.GetName()) != nil {
|
||||
continue // already present — idempotent
|
||||
}
|
||||
coll.Fields.Add(a.field)
|
||||
if err := app.Save(coll); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, func(app core.App) error {
|
||||
type removal struct {
|
||||
collection string
|
||||
field string
|
||||
}
|
||||
removals := []removal{
|
||||
{"books", "rating"},
|
||||
{"app_users", "notify_new_chapters_push"},
|
||||
{"book_comments", "chapter"},
|
||||
}
|
||||
for _, r := range removals {
|
||||
coll, err := app.FindCollectionByNameOrId(r.collection)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
f := coll.Fields.GetByName(r.field)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
coll.Fields.RemoveById(f.GetId())
|
||||
if err := app.Save(coll); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user