diff --git a/scraper/internal/storage/hybrid.go b/scraper/internal/storage/hybrid.go index a3ad2f2..01ac07d 100644 --- a/scraper/internal/storage/hybrid.go +++ b/scraper/internal/storage/hybrid.go @@ -39,6 +39,9 @@ func NewHybridStore(ctx context.Context, pbCfg PocketBaseConfig, minioCfg MinioC // Non-fatal: 400/422 means collections already exist. log.Warn("EnsureCollections returned an error (may be safe to ignore)", "err", err) } + if err := pb.EnsureMigrations(ctx); err != nil { + log.Warn("EnsureMigrations returned an error", "err", err) + } return &HybridStore{pb: pb, minio: mc, log: log}, nil } diff --git a/scraper/internal/storage/pocketbase.go b/scraper/internal/storage/pocketbase.go index 3445bde..c7ef6fd 100644 --- a/scraper/internal/storage/pocketbase.go +++ b/scraper/internal/storage/pocketbase.go @@ -389,6 +389,82 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { return nil } +// ─── Schema migrations ──────────────────────────────────────────────────────── + +// migration describes a single field to guarantee exists in a collection. +type migration struct { + collection string + fieldName string + fieldType string +} + +// migrations is the ordered list of schema changes applied on every startup. +var migrations = []migration{ + // user_id was added to progress after initial deploy. + {"progress", "user_id", "text"}, +} + +// EnsureMigrations idempotently adds any fields that are missing from existing +// collections. It fetches the current schema, checks for each field by name, +// and PATCHes the collection only when something is absent. +// Safe to call on every startup — no-ops when schema is already up to date. +func (s *PocketBaseStore) EnsureMigrations(ctx context.Context) error { + for _, m := range migrations { + if err := s.ensureField(ctx, m); err != nil { + return err + } + } + return nil +} + +func (s *PocketBaseStore) ensureField(ctx context.Context, m migration) error { + // Fetch current collection schema. + resp, err := s.pb.do(ctx, http.MethodGet, "/api/collections/"+m.collection, nil) + if err != nil { + return fmt.Errorf("pocketbase: ensureField %s.%s: fetch schema: %w", m.collection, m.fieldName, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("pocketbase: ensureField %s.%s: fetch schema status %d: %s", m.collection, m.fieldName, resp.StatusCode, body) + } + + var schema struct { + ID string `json:"id"` + Fields []map[string]interface{} `json:"fields"` + } + if err := json.Unmarshal(body, &schema); err != nil { + return fmt.Errorf("pocketbase: ensureField %s.%s: decode schema: %w", m.collection, m.fieldName, err) + } + + // Check if field already exists. + for _, f := range schema.Fields { + if name, _ := f["name"].(string); name == m.fieldName { + s.log.Debug("pocketbase: field already exists, skipping migration", + "collection", m.collection, "field", m.fieldName) + return nil + } + } + + // Append the new field and PATCH the collection. + newFields := append(schema.Fields, map[string]interface{}{ + "name": m.fieldName, + "type": m.fieldType, + }) + patch := map[string]interface{}{"fields": newFields} + patchResp, err := s.pb.do(ctx, http.MethodPatch, "/api/collections/"+schema.ID, patch) + if err != nil { + return fmt.Errorf("pocketbase: ensureField %s.%s: patch: %w", m.collection, m.fieldName, err) + } + defer patchResp.Body.Close() + patchBody, _ := io.ReadAll(patchResp.Body) + if patchResp.StatusCode != http.StatusOK { + return fmt.Errorf("pocketbase: ensureField %s.%s: patch status %d: %s", m.collection, m.fieldName, patchResp.StatusCode, patchBody) + } + s.log.Info("pocketbase: schema migration applied", "collection", m.collection, "field", m.fieldName, "type", m.fieldType) + return nil +} + // ─── Book metadata ──────────────────────────────────────────────────────────── func (s *PocketBaseStore) UpsertBook(ctx context.Context, slug, title, author, cover, status, summary, sourceURL string, genres []string, totalChapters, ranking int) error {