Files
gh-whamp-whamp-claude-tools…/skills/pocketbase/references/go/go_overview.md
2025-11-30 09:06:02 +08:00

18 KiB
Raw Blame History

Go Overview - PocketBase

Overview

PocketBase can be extended using Go, allowing you to:

  • Add custom API endpoints
  • Implement event hooks
  • Create custom database migrations
  • Build scheduled jobs
  • Add custom middleware
  • Integrate external services
  • Extend authentication

Project Structure

myapp/
├── go.mod
├── pocketbase.go
├── migrations/
│   └── 1703123456_initial.go
├── hooks/
│   └── hooks.go
└── main.go

Creating a Go Extension

1. Initialize Go Module

go mod init myapp

2. Install PocketBase SDK

go get github.com/pocketbase/pocketbase@latest

3. Basic PocketBase App

Create main.go:

package main

import (
    "log"
    "net/http"

    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/core"
)

func main() {
    app := pocketbase.New()

    // Add custom API endpoint
    app.OnServe().BindFunc(func(se *core.ServeEvent) error {
        // Custom routes
        se.Router.GET("/api/hello", func(e *core.RequestEvent) error {
            return e.JSON(200, map[string]string{
                "message": "Hello from Go!",
            })
        })

        return se.Next()
    })

    // Start the app
    if err := app.Start(); err != nil {
        log.Fatal(err)
    }
}

4. Run the Application

go run main.go pocketbase.go serve --http=0.0.0.0:8090

Core Concepts

Event Hooks

Execute code on specific events:

// On record create
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
    log.Println("Post created:", e.Record.GetString("title"))
    return e.Next()
})

// On record update
app.OnRecordUpdate("posts").BindFunc(func(e *core.RecordUpdateEvent) error {
    log.Println("Post updated:", e.Record.GetString("title"))
    return e.Next()
})

// On record delete
app.OnRecordDelete("posts").BindFunc(func(e *core.RecordDeleteEvent) error {
    log.Println("Post deleted:", e.Record.GetString("title"))
    return e.Next()
})

// On authentication
app.OnRecordAuth().BindFunc(func(e *core.RecordAuthEvent) error {
    log.Println("User authenticated:", e.Record.GetString("email"))
    return e.Next()
})

Event Arguments

PocketBase exposes a different event struct for each hook (see the official event hooks reference). Common fields you will interact with include:

  • e.App the running PocketBase instance (database access, configuration, cron, etc.).
  • e.Record the record being created, updated, deleted, or authenticated.
  • e.RecordOriginal the previous value during update hooks.
  • e.Next() call to continue the handler chain after your logic.

Refer to the linked docs for the complete list of fields exposed by each event type.

Custom API Endpoints

Create GET Endpoint

app.OnServe().BindFunc(func(se *core.ServeEvent) error {
    se.Router.GET("/api/stats", func(e *core.RequestEvent) error {
        totalPosts, err := e.App.CountRecords("posts")
        if err != nil {
            return e.InternalServerError("failed to count posts", err)
        }

        totalUsers, err := e.App.CountRecords("users")
        if err != nil {
            return e.InternalServerError("failed to count users", err)
        }

        return e.JSON(200, map[string]any{
            "total_posts":    totalPosts,
            "total_users":    totalUsers,
            "authenticated":  e.Auth != nil,
        })
    })

    return se.Next()
})

Create POST Endpoint

import "github.com/pocketbase/dbx"

app.OnServe().BindFunc(func(se *core.ServeEvent) error {
    se.Router.POST("/api/search", func(e *core.RequestEvent) error {
        // Parse request body
        var req struct {
            Query string `json:"query"`
        }
        if err := e.BindBody(&req); err != nil {
            return e.BadRequestError("invalid body", err)
        }

        // Search posts with a safe filter
        records, err := e.App.FindRecordsByFilter(
            "posts",
            "title ~ {:query}",
            "-created",
            50,
            0,
            dbx.Params{"query": req.Query},
        )
        if err != nil {
            return e.InternalServerError("Search failed", err)
        }

        return e.JSON(200, map[string]any{
            "results": records,
            "count":   len(records),
        })
    })

    return se.Next()
})

Custom Middleware

app.OnServe().BindFunc(func(se *core.ServeEvent) error {
    se.Router.BindFunc(func(e *core.RequestEvent) error {
        // Add CORS headers
        e.Response.Header().Set("Access-Control-Allow-Origin", "*")
        e.Response.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
        e.Response.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

        if e.Request.Method == http.MethodOptions {
            return e.NoContent(http.StatusOK)
        }

        return e.Next()
    })

    return se.Next()
})

Database Operations

Find Records

import "github.com/pocketbase/dbx"

// Find single record
record, err := app.FindRecordById("posts", "RECORD_ID")

// Find multiple records with a filter and pagination
records, err := app.FindRecordsByFilter(
    "posts",
    "status = {:status}",
    "-created",
    50,
    0,
    dbx.Params{"status": "published"},
)

// Custom query with the record query builder
records := []*core.Record{}
err := app.RecordQuery("posts").
    AndWhere(dbx.Like("title", "pocketbase")).
    OrderBy("created DESC").
    Limit(50).
    All(&records)

// Find with relations
record, err = app.FindRecordById("posts", "id")
if err == nil {
    if errs := app.ExpandRecord(record, []string{"author", "comments"}, nil); len(errs) > 0 {
        // handle expand error(s)
    }
}

Create Records

collection, err := app.FindCollectionByNameOrId("posts")
if err != nil {
    return err
}

record := core.NewRecord(collection)
record.Set("title", "My Post")
record.Set("content", "Post content")
record.Set("author", "USER_ID")

if err := app.Save(record); err != nil {
    return err
}

Update Records

record, err := app.FindRecordById("posts", "id")
if err != nil {
    return err
}

record.Set("title", "Updated Title")
record.Set("content", "Updated content")

if err := app.Save(record); err != nil {
    return err
}

Delete Records

record, err := app.FindRecordById("posts", "id")
if err != nil {
    return err
}

if err := app.Delete(record); err != nil {
    return err
}

Query Builder

records := []*core.Record{}
err := app.RecordQuery("posts").
    AndWhere(dbx.HashExp{"status": "published"}).
    AndWhere(dbx.NewExp("created >= {:date}", dbx.Params{"date": "2024-01-01"})).
    OrderBy("created DESC").
    Offset(0).
    Limit(50).
    All(&records)

// Combine conditions with OR
records = []*core.Record{}
err = app.RecordQuery("posts").
    AndWhere(dbx.Or(
        dbx.HashExp{"status": "published"},
        dbx.HashExp{"author": userId},
    )).
    All(&records)

Event Hooks Examples

Auto-populate Fields

// Auto-set author on post create
app.OnRecordCreateRequest("posts").BindFunc(func(e *core.RecordRequestEvent) error {
    if e.Auth != nil {
        e.Record.Set("author", e.Auth.Id)
    }
    return e.Next()
})

// Auto-set slug from title
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
    title := e.Record.GetString("title")
    slug := strings.ToLower(strings.ReplaceAll(title, " ", "-"))
    e.Record.Set("slug", slug)
    return e.Next()
})

Validation

// Custom validation
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
    title := e.Record.GetString("title")
    if len(title) < 5 {
        return errors.New("title must be at least 5 characters")
    }
    return e.Next()
})

// Check permissions
app.OnRecordCreateRequest("posts").BindFunc(func(e *core.RecordRequestEvent) error {
    if e.Auth == nil {
        return e.ForbiddenError("authentication required", nil)
    }

    role := e.Auth.GetString("role")
    if role != "admin" && role != "author" {
        return e.ForbiddenError("insufficient permissions", nil)
    }

    return e.Next()
})

Cascading Updates

// When post is updated, update related comments
app.OnRecordUpdate("posts").BindFunc(func(e *core.RecordUpdateEvent) error {
    // Check if status changed
    oldStatus := e.RecordOriginal.GetString("status")
    newStatus := e.Record.GetString("status")

    if oldStatus != newStatus && newStatus == "published" {
        comments, err := e.App.FindRecordsByFilter(
            "comments",
            "post = {:postId}",
            "",
            0,
            0,
            dbx.Params{"postId": e.Record.Id},
        )
        if err != nil {
            return err
        }

        for _, comment := range comments {
            comment.Set("status", "approved")
            if err := e.App.Save(comment); err != nil {
                return err
            }
        }
    }

    return e.Next()
})

Send Notifications

// Send email when post is published
app.OnRecordUpdate("posts").BindFunc(func(e *core.RecordUpdateEvent) error {
    oldStatus := e.RecordOriginal.GetString("status")
    newStatus := e.Record.GetString("status")

    if oldStatus != "published" && newStatus == "published" {
        author, err := e.App.FindRecordById("users", e.Record.GetString("author"))
        if err == nil {
            log.Println("Sending notification to:", author.GetString("email"))
        }
    }

    return e.Next()
})

Log Activities

// Log all record changes using the builtin logger
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
    e.App.Logger().Info("post created", "recordId", e.Record.Id)
    return e.Next()
})

app.OnRecordUpdate("posts").BindFunc(func(e *core.RecordUpdateEvent) error {
    e.App.Logger().Info(
        "post updated",
        "recordId", e.Record.Id,
        "statusFrom", e.RecordOriginal.GetString("status"),
        "statusTo", e.Record.GetString("status"),
    )
    return e.Next()
})

app.OnRecordDelete("posts").BindFunc(func(e *core.RecordDeleteEvent) error {
    e.App.Logger().Info("post deleted", "recordId", e.Record.Id)
    return e.Next()
})

Scheduled Jobs

Create Background Job

// Register job
app.Cron().MustAdd("daily-backup", "0 2 * * *", func() {
    log.Println("Running daily backup...")

    backupDir := "./backups"
    if err := os.MkdirAll(backupDir, 0o755); err != nil {
        log.Println("Backup failed:", err)
        return
    }

    // Your backup logic here
    log.Println("Backup completed")
})

// Or add a job during serve
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
    se.App.Cron().MustAdd("cleanup", "@every 5m", func() {
        log.Println("Running cleanup task...")
        // Cleanup logic
    })

    return se.Next()
})

File Handling

Custom File Upload

app.OnServe().BindFunc(func(se *core.ServeEvent) error {
    se.Router.POST("/api/upload", func(e *core.RequestEvent) error {
        files, err := e.FindUploadedFiles("file")
        if err != nil {
            return e.BadRequestError("no file uploaded", err)
        }

        collection, err := e.App.FindCollectionByNameOrId("uploads")
        if err != nil {
            return e.NotFoundError("uploads collection not found", err)
        }

        record := core.NewRecord(collection)
        // Attach the uploaded file(s) to a file field
        record.Set("document", files)

        if err := e.App.Save(record, files...); err != nil {
            return e.InternalServerError("failed to save file", err)
        }

        return e.JSON(http.StatusOK, record)
    })

    return se.Next()
})

Custom Auth Provider

// Custom OAuth provider
app.OnRecordAuthWithOAuth2().BindFunc(func(e *core.RecordAuthWithOAuth2Event) error {
    if e.Provider != "custom" {
        return e.Next()
    }

    // Fetch user info from custom provider
    userInfo, err := fetchCustomUserInfo(e.OAuth2UserData)
    if err != nil {
        return err
    }

    // Find or create user
    user, err := e.App.FindAuthRecordByData("users", "email", userInfo.Email)
    if err != nil {
        collection, err := e.App.FindCollectionByNameOrId("users")
        if err != nil {
            return err
        }

        user = core.NewRecord(collection)
        user.Set("email", userInfo.Email)
        user.Set("password", "") // OAuth users don't need password
        user.Set("emailVisibility", false)
        user.Set("verified", true)
        user.Set("name", userInfo.Name)
        if err := e.App.Save(user); err != nil {
            return err
        }
    }

    e.Record = user
    return e.Next()
})

Testing

Unit Tests

package main

import (
    "testing"

    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/core"
    "github.com/pocketbase/pocketbase/tests"
)

func TestCustomEndpoint(t *testing.T) {
    app := pocketbase.NewWithConfig(config{})

    // Add test endpoint
    app.OnServe().BindFunc(func(se *core.ServeEvent) error {
        se.Router.GET("/api/test", func(e *core.RequestEvent) error {
            return e.JSON(200, map[string]string{
                "status": "ok",
            })
        })
        return se.Next()
    })

    e := tests.NewRequestEvent(app, nil)
    // Test endpoint
    e.GET("/api/test").Expect(t).Status(200).JSON().Equal(map[string]interface{}{
        "status": "ok",
    })
}

Integration Tests

func TestRecordCreation(t *testing.T) {
    app := pocketbase.New()
    app.MustSeed()

    client := tests.NewClient(app)

    // Test authenticated request
    auth := client.AuthRecord("users", "test@example.com", "password")
    post := client.CreateRecord("posts", map[string]interface{}{
        "title":   "Test Post",
        "content": "Test content",
    }, auth.Token)

    if post.GetString("title") != "Test Post" {
        t.Errorf("Expected title 'Test Post', got %s", post.GetString("title"))
    }
}

Deployment

Build and Run

# Build
go build -o myapp main.go

# Run
./myapp serve --http=0.0.0.0:8090

Docker Deployment

FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o myapp main.go

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/

COPY --from=builder /app/myapp .
COPY --from=builder /app/pocketbase ./

CMD ["./myapp", "serve", "--http=0.0.0.0:8090"]

Best Practices

1. Error Handling

app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
    if err := validatePost(e.Record); err != nil {
        return err
    }
    return e.Next()
})

func validatePost(record *core.Record) error {
    title := record.GetString("title")
    if len(title) == 0 {
        return errors.New("title is required")
    }
    if len(title) > 200 {
        return errors.New("title too long")
    }
    return nil
}

2. Logging

app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
    e.App.Logger().Info("Post created",
        "id", e.Record.Id,
        "title", e.Record.GetString("title"),
    )
    return e.Next()
})

3. Security

app.OnServe().BindFunc(func(se *core.ServeEvent) error {
    limiter := rate.NewLimiter(10, 20) // 10 req/sec, burst 20

    se.Router.BindFunc(func(e *core.RequestEvent) error {
        if !limiter.Allow() {
            return e.TooManyRequestsError("rate limit exceeded", nil)
        }
        return e.Next()
    })

    return se.Next()
})

4. Configuration

type Config struct {
    ExternalAPIKey string
    EmailFrom      string
}

func (c Config) Name() string {
    return "myapp"
}

func main() {
    app := pocketbase.NewWithConfig(Config{
        ExternalAPIKey: os.Getenv("API_KEY"),
        EmailFrom:      "noreply@example.com",
    })

    // Use config in hooks
    app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
        cfg := e.App.Config().(*Config)
        // Use cfg.ExternalAPIKey
        return e.Next()
    })
}

Common Patterns

1. Soft Delete

app.OnRecordDelete("posts").BindFunc(func(e *core.RecordDeleteEvent) error {
    // Instead of deleting, mark as deleted
    e.Record.Set("status", "deleted")
    e.Record.Set("deleted_at", time.Now())
    if err := e.App.Save(e.Record); err != nil {
        return err
    }
    return e.Next()
})

2. Audit Trail

app.OnRecordCreateRequest("").BindFunc(func(e *core.RecordRequestEvent) error {
    if e.Collection.Name == "posts" || e.Collection.Name == "comments" {
        if e.Auth != nil {
            e.Record.Set("created_by", e.Auth.Id)
        }

        if info, err := e.RequestInfo(); err == nil {
            e.Record.Set("created_ip", info.RealIP)
        }
    }
    return e.Next()
})

app.OnRecordUpdateRequest("").BindFunc(func(e *core.RecordRequestEvent) error {
    if e.Collection.Name == "posts" || e.Collection.Name == "comments" {
        if e.Auth != nil {
            e.Record.Set("updated_by", e.Auth.Id)
        }
        e.Record.Set("updated_at", time.Now())
    }
    return e.Next()
})

3. Data Synchronization

app.OnRecordCreate("posts").BindFunc(func(e *core.RecordCreateEvent) error {
    // Sync with external service
    if err := syncToExternalAPI(e.Record); err != nil {
        e.App.Logger().Warn("sync failed", "error", err)
    }
    return e.Next()
})

func syncToExternalAPI(record *core.Record) error {
    // Implement external API sync
    return nil
}