feat(11-05): add the beachcomber search sync package and its Typesense engine

- Searchable, Engine, Gate and an init-time engine registry with the null engine
- GORM callbacks installed through lagoon.OnDatabase register an after-commit
  sync that reloads the row and upserts or deletes its document
- Gates run before any request: engine configured, database published, app Gate
- hand-rolled net/http Typesense engine following the Scout wire contract
- module README and root modules row
This commit is contained in:
Jakub Zych
2026-09-30 12:50:54 +02:00
parent 0d45698ad7
commit 3e1f3e6a1b
8 changed files with 1257 additions and 0 deletions

View File

@@ -0,0 +1,177 @@
// Package beachcomber keeps search indexes in step with GORM models: after
// a write commits, the written row is reloaded and its document upserted
// into, or deleted from, the index of a pluggable engine. A failed sync is
// logged and never touches the write.
//
// Models implement Searchable. An engine package (for example
// beachcomber/typesense) is imported for its side effect of registering
// itself, and is chosen with search.driver.
package beachcomber
import (
"database/sql"
"fmt"
"log/slog"
"strings"
"sync"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// DefaultDriver is the engine used when search.driver is empty.
const DefaultDriver = NullEngine
// Names of the GORM callbacks that sync Searchable models.
const (
CallbackAfterCreate = "beachcomber:after_create"
CallbackAfterUpdate = "beachcomber:after_update"
CallbackAfterDelete = "beachcomber:after_delete"
)
// Service is the app-scoped search service. Get it with From.
type Service struct {
app *backpack.App
engine Engine
prefix string
log *slog.Logger
mu sync.RWMutex
gate Gate
}
// From returns the app's Service, building and publishing it on first use.
// The first call reads search.driver (default "null") and search.prefix
// (default ""), builds the engine through its registered factory, and
// installs the sync GORM callbacks through lagoon.OnDatabase. An unknown
// driver name is an error.
func From(app *backpack.App) (*Service, error) {
if app == nil {
return nil, fmt.Errorf("beachcomber: app is nil")
}
if s, ok := app.Lookup[*Service](); ok && s != nil {
return s, nil
}
svc := &Service{app: app, log: loggerFromApp(app)}
name := DefaultDriver
if app.Config != nil {
if v := strings.TrimSpace(app.Config.String("search.driver")); v != "" {
name = strings.ToLower(v)
}
svc.prefix = strings.TrimSpace(app.Config.String("search.prefix"))
}
factory, ok := engineFactory(name)
if !ok {
return nil, fmt.Errorf("beachcomber: unknown search.driver %q (registered: %s; an engine package must be imported to register itself)", name, strings.Join(engineNames(), ", "))
}
engine, err := factory(app)
if err != nil {
return nil, fmt.Errorf("beachcomber: engine %s: %w", name, err)
}
if engine == nil {
return nil, fmt.Errorf("beachcomber: engine %s returned nil", name)
}
svc.engine = engine
// The callbacks need the GORM handle, which serve publishes after
// plugins boot.
if err := lagoon.OnDatabase(app, func(_ *sql.DB, gdb *gorm.DB) error {
return svc.installCallbacks(gdb)
}); err != nil {
return nil, fmt.Errorf("beachcomber: install sync callbacks: %w", err)
}
if err := app.Publish(svc); err != nil {
if existing, ok := app.Lookup[*Service](); ok && existing != nil {
return existing, nil
}
return nil, fmt.Errorf("beachcomber: %w", err)
}
return svc, nil
}
// SetGate installs the application kill-switch. With no gate, sync runs
// whenever the engine is configured and a database is published.
func (s *Service) SetGate(g Gate) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.gate = g
}
func (s *Service) currentGate() Gate {
s.mu.RLock()
defer s.mu.RUnlock()
return s.gate
}
// Engine returns the engine selected by search.driver.
func (s *Service) Engine() Engine {
if s == nil {
return nil
}
return s.engine
}
// Prefix returns search.prefix.
func (s *Service) Prefix() string {
if s == nil {
return ""
}
return s.prefix
}
// IndexName is search.prefix followed by m.SearchableAs().
func (s *Service) IndexName(m Searchable) string {
if m == nil {
return s.Prefix()
}
return s.Prefix() + m.SearchableAs()
}
// Logger returns the app logger sync failures are logged through.
func (s *Service) Logger() *slog.Logger {
if s == nil || s.log == nil {
return slog.Default()
}
return s.log
}
// installCallbacks registers the sync callbacks on gdb, replacing earlier
// ones so a handle shared by several apps syncs through the most recent
// service.
func (s *Service) installCallbacks(gdb *gorm.DB) error {
cb := gdb.Callback()
if cb.Create().Get(CallbackAfterCreate) == nil {
if err := cb.Create().After("gorm:after_create").Register(CallbackAfterCreate, s.afterCreate); err != nil {
return err
}
} else if err := cb.Create().Replace(CallbackAfterCreate, s.afterCreate); err != nil {
return err
}
if cb.Update().Get(CallbackAfterUpdate) == nil {
if err := cb.Update().After("gorm:after_update").Register(CallbackAfterUpdate, s.afterUpdate); err != nil {
return err
}
} else if err := cb.Update().Replace(CallbackAfterUpdate, s.afterUpdate); err != nil {
return err
}
if cb.Delete().Get(CallbackAfterDelete) == nil {
if err := cb.Delete().After("gorm:after_delete").Register(CallbackAfterDelete, s.afterDelete); err != nil {
return err
}
} else if err := cb.Delete().Replace(CallbackAfterDelete, s.afterDelete); err != nil {
return err
}
return nil
}
func loggerFromApp(app *backpack.App) *slog.Logger {
if app != nil {
if log, ok := app.Lookup[*slog.Logger](); ok && log != nil {
return log
}
}
return slog.Default()
}