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,152 @@
# beachcomber
Search index sync for GORM models: after-commit upserts and deletes through a pluggable engine, gated by an application kill-switch.
`import "git.golem15.com/golem15/summercms/modules/beachcomber"`
`import _ "git.golem15.com/golem15/summercms/modules/beachcomber/typesense"`
## Overview
beachcomber is the SummerCMS counterpart of Laravel Scout as WinterCMS applications use it with `queue=false`. A model opts in by implementing `beachcomber.Searchable`. After a create, update or delete of such a model commits, the row is reloaded by primary key and its document is upserted into the engine's index, or removed from it. Sync runs inline in the writing goroutine, bounded by the engine's request timeout, and is never fatal: a failure is logged and the write stays committed.
The package itself knows no search server. An engine package registers itself from its `init` function, the way `database/sql` drivers do, and the application picks one with `search.driver`. The built-in `null` engine indexes nothing. The `typesense` sub-package is a hand-rolled `net/http` client for Typesense that follows the Scout `TypesenseEngine` wire contract.
`beachcomber.From` builds the app-scoped `beachcomber.Service` on first use and publishes it on the app. It installs the sync GORM callbacks through `lagoon.OnDatabase`, so they reach the production handle even though plugins boot before `serve` publishes the database. The application installs a `beachcomber.Gate`, its kill-switch, with `beachcomber.Service.SetGate`.
## Features
- Engine selection by `search.driver`: `null` (the default) or a registered engine such as `typesense`. An unknown name is a boot error that lists the registered engines. Third-party engines register with `beachcomber.RegisterEngine` and a `beachcomber.EngineFactory`; a duplicate name panics at init.
- The `beachcomber.Searchable` model contract: `SearchableAs` (the index name, prefixed with `search.prefix`), `ToSearchableArray(ctx, db)` (the document, built from the committed row and free to query related rows) and `ShouldBeSearchable`. A model can also implement `beachcomber.IndexSchemaProvider` (the schema the engine creates a missing index with) and `beachcomber.SearchKeyer` (a document key other than the decimal primary key).
- The `beachcomber.Engine` driver contract: `Name`, `Configured`, `Upsert`, `Delete`, `Flush` and `SearchIDs` with a `beachcomber.Query`.
- GORM callbacks `beachcomber.CallbackAfterCreate`, `beachcomber.CallbackAfterUpdate` and `beachcomber.CallbackAfterDelete`, installed once per `*gorm.DB`, register the sync with `lagoon.AfterCommit`.
- `beachcomber.Service.Sync` and `beachcomber.Service.Remove` run the same gated path on demand, for reindex tooling, and return the error instead of logging it.
- Typesense engine (`typesense.Engine`, engine name `typesense`): the `X-TYPESENSE-API-KEY` header on every request; `Upsert` reads the collection and creates it from the schema on 404, then imports JSON lines with `action=upsert`; `Delete` and `Flush` treat 404 as success; `SearchIDs` returns `hits[].document.id`.
## Usage
An application selects the engine in `config/search.yaml`:
```yaml
driver: typesense
typesense:
api_key: "" # set with SUMMER_SEARCH__TYPESENSE__API_KEY
```
A model implements `beachcomber.Searchable` without importing beachcomber:
```go
package models
func (Post) SearchableAs() string { return "acme_blog_posts" }
func (Post) ShouldBeSearchable() bool { return true }
func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]any, error) {
return map[string]any{
"id": strconv.FormatUint(uint64(p.ID), 10),
"blog_id": int64(p.BlogID),
"title": p.Title,
}, nil
}
func (Post) SearchIndexSchema() map[string]any {
return map[string]any{
"fields": []map[string]any{
{"name": "id", "type": "string"},
{"name": "blog_id", "type": "int64"},
{"name": "title", "type": "string"},
},
}
}
```
The plugin imports the engine package for its side effect, builds the service at Boot and installs its kill-switch:
```go
package acme
import (
"context"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/beachcomber"
_ "git.golem15.com/golem15/summercms/modules/beachcomber/typesense"
"gorm.io/gorm"
)
func (p *Plugin) Boot(app *backpack.App) error {
svc, err := beachcomber.From(app)
if err != nil {
return err
}
svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool {
return acmeSearchEnabled(ctx, db) // false on any read error
}))
return nil
}
```
A search endpoint asks the engine for candidate ids:
```go
ids, err := svc.Engine().SearchIDs(ctx, svc.IndexName(&models.Post{}), beachcomber.Query{
Q: term,
QueryBy: []string{"title"},
FilterBy: "blog_id:=" + strconv.FormatUint(uint64(blogID), 10),
})
```
## API reference
### beachcomber
| Identifier | Description |
|------------|-------------|
| `beachcomber.From(app)` | The app's `*beachcomber.Service`, built and published on first use. |
| `beachcomber.Service` | The search service: `SetGate`, `Engine`, `Prefix`, `IndexName`, `Logger`, `Sync`, `Remove`. |
| `beachcomber.Searchable` | `SearchableAs()`, `ToSearchableArray(ctx, db)`, `ShouldBeSearchable()`. |
| `beachcomber.IndexSchemaProvider` | `SearchIndexSchema()`: the schema a missing index is created with. |
| `beachcomber.SearchKeyer` | `SearchKey()`: replaces the decimal primary key as the document key. |
| `beachcomber.Engine` | `Name`, `Configured`, `Upsert`, `Delete`, `Flush`, `SearchIDs`. |
| `beachcomber.Query` | `Q`, `QueryBy`, `FilterBy`, `SortBy`, `Page`, `PerPage`. |
| `beachcomber.Gate`, `beachcomber.GateFunc` | The application kill-switch: `Enabled(ctx, db) bool`. |
| `beachcomber.EngineFactory`, `beachcomber.RegisterEngine(name, factory)` | Registers an engine from an `init` function. |
| `beachcomber.NullEngine`, `beachcomber.DefaultDriver` | The name of the built-in engine that indexes nothing, and the default of `search.driver`. |
| `beachcomber.CallbackAfterCreate`, `beachcomber.CallbackAfterUpdate`, `beachcomber.CallbackAfterDelete` | Names of the GORM callbacks. |
### beachcomber/typesense
| Identifier | Description |
|------------|-------------|
| `typesense.Config`, `typesense.LoadConfig` | The `search.typesense.*` settings with their defaults; `BaseURL` is `{protocol}://{host}:{port}{path}`. |
| `typesense.Engine`, `typesense.New` | The `beachcomber.Engine`, with `Config`. |
| `typesense.DriverName` | `typesense`. |
| `typesense.DefaultHost`, `typesense.DefaultPort`, `typesense.DefaultProtocol`, `typesense.DefaultConnectionTimeout`, `typesense.DefaultImportAction` | Defaults of the configuration keys. |
## Configuration
| Key | Default | Description |
|-----|---------|-------------|
| `search.driver` | `null` | `null`, or a registered engine such as `typesense`. |
| `search.prefix` | `""` | Prefix applied to every index name. |
| `search.typesense.api_key` | `""` | API key; empty means nothing is ever sent. |
| `search.typesense.host` | `localhost` | Typesense node host. |
| `search.typesense.port` | `8181` | Typesense node port. |
| `search.typesense.protocol` | `http` | `http` or `https`. |
| `search.typesense.path` | `""` | Path prefix of the node. |
| `search.typesense.connection_timeout_seconds` | `2` | Per-request timeout, in seconds or as a duration string. |
| `search.typesense.import_action` | `upsert` | The `action` of document imports. |
## Dependencies
- `backpack`, `compass` and `lagoon` (callback installation and `lagoon.AfterCommit`) from this repository.
- `gorm.io/gorm` (sync callbacks and reloads).
- The Typesense client is plain `net/http`; no Typesense SDK is used.
## Testing
```bash
go test ./modules/beachcomber/...
```
A test points `search.typesense.host` and `search.typesense.port` at an `httptest` server to see the exact Typesense requests. Sync is inline, so the requests have arrived when the write returns.

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()
}

View File

@@ -0,0 +1,77 @@
package beachcomber
import (
"context"
"sort"
"sync"
"git.golem15.com/golem15/summercms/modules/backpack"
)
// NullEngine is the name of the built-in engine that indexes nothing.
const NullEngine = "null"
// EngineFactory builds an engine for an app.
type EngineFactory func(app *backpack.App) (Engine, error)
// engineTable is the init-time engine registry, like database/sql's: it is
// written only from package init functions and read when a Service is built.
var engineTable = struct {
mu sync.RWMutex
factories map[string]EngineFactory
}{factories: map[string]EngineFactory{}}
// RegisterEngine makes an engine available under name. Call it from the
// engine package's init function. A duplicate name, an empty name or a nil
// factory panics.
func RegisterEngine(name string, f EngineFactory) {
if name == "" {
panic("beachcomber: RegisterEngine with an empty name")
}
if f == nil {
panic("beachcomber: RegisterEngine " + name + " with a nil factory")
}
engineTable.mu.Lock()
defer engineTable.mu.Unlock()
if _, dup := engineTable.factories[name]; dup {
panic("beachcomber: RegisterEngine called twice for engine " + name)
}
engineTable.factories[name] = f
}
func engineFactory(name string) (EngineFactory, bool) {
engineTable.mu.RLock()
defer engineTable.mu.RUnlock()
f, ok := engineTable.factories[name]
return f, ok
}
func engineNames() []string {
engineTable.mu.RLock()
defer engineTable.mu.RUnlock()
names := make([]string, 0, len(engineTable.factories))
for n := range engineTable.factories {
names = append(names, n)
}
sort.Strings(names)
return names
}
func init() {
RegisterEngine(NullEngine, func(*backpack.App) (Engine, error) { return nullEngine{}, nil })
}
// nullEngine is never configured, so sync is skipped before it is called.
// Every method is a no-op; SearchIDs returns an empty list.
type nullEngine struct{}
func (nullEngine) Name() string { return NullEngine }
func (nullEngine) Configured() bool { return false }
func (nullEngine) Upsert(context.Context, string, map[string]any, []map[string]any) error {
return nil
}
func (nullEngine) Delete(context.Context, string, []string) error { return nil }
func (nullEngine) Flush(context.Context, string) error { return nil }
func (nullEngine) SearchIDs(context.Context, string, Query) ([]string, error) {
return []string{}, nil
}

View File

@@ -0,0 +1,91 @@
package beachcomber
import (
"context"
"gorm.io/gorm"
)
// Searchable is implemented (on the pointer receiver or the value) by a
// model whose rows are kept in a search index. The GORM callbacks sync a
// Searchable model after every create, update and delete.
type Searchable interface {
// SearchableAs is the index name without the search.prefix.
SearchableAs() string
// ToSearchableArray builds the document of the row. It runs after the
// write committed, on a fresh copy reloaded by primary key, and may
// query related rows through db. An error means nothing is indexed.
ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]any, error)
// ShouldBeSearchable reports whether the row belongs in the index. A
// row that should not be searchable has its document removed.
ShouldBeSearchable() bool
}
// IndexSchemaProvider supplies the schema an engine creates the index with
// when the index does not exist yet. For Typesense it is the collection
// schema (fields, default_sorting_field); the engine adds the name.
type IndexSchemaProvider interface {
SearchIndexSchema() map[string]any
}
// SearchKeyer replaces the document key, which defaults to the decimal
// primary key. It is called on the written model, which may hold only its
// primary key, so it must derive the key from the primary key.
type SearchKeyer interface {
SearchKey() string
}
// Engine is a search index driver.
type Engine interface {
// Name is the search.driver value that selects the engine.
Name() string
// Configured reports whether the engine may send anything. When it is
// false, sync is skipped without a request.
Configured() bool
// Upsert inserts or replaces docs in index, creating the index from
// schema first when it does not exist.
Upsert(ctx context.Context, index string, schema map[string]any, docs []map[string]any) error
// Delete removes the documents with ids from index. A document that
// is already gone is not an error.
Delete(ctx context.Context, index string, ids []string) error
// Flush drops the whole index. A missing index is not an error.
Flush(ctx context.Context, index string) error
// SearchIDs returns the ids of the documents matching q, in the
// engine's order. They are candidates only: callers must re-check
// every id against the database before exposing it.
SearchIDs(ctx context.Context, index string, q Query) ([]string, error)
}
// Query is an engine search request.
type Query struct {
// Q is the query text; empty means match everything ("*").
Q string
// QueryBy lists the fields Q is matched against.
QueryBy []string
// FilterBy is an engine filter expression, for example
// "collection_id:=5".
FilterBy string
// SortBy is an engine sort expression, for example "created_at:desc".
SortBy string
// Page is 1-based; 0 leaves the engine default.
Page int
// PerPage is the page size; 0 leaves the engine default.
PerPage int
}
// Gate is the application kill-switch consulted before every sync. It
// reads its setting through db; any error must count as off.
type Gate interface {
Enabled(ctx context.Context, db *gorm.DB) bool
}
// GateFunc adapts a function to Gate.
type GateFunc func(ctx context.Context, db *gorm.DB) bool
// Enabled calls f.
func (f GateFunc) Enabled(ctx context.Context, db *gorm.DB) bool {
if f == nil {
return false
}
return f(ctx, db)
}

348
modules/beachcomber/sync.go Normal file
View File

@@ -0,0 +1,348 @@
package beachcomber
import (
"context"
"errors"
"fmt"
"log/slog"
"reflect"
"strconv"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
// operation is what a sync does with the written row's document.
type operation string
const (
opUpsert operation = "upsert"
opDelete operation = "delete"
)
const syncSavepoint = "beachcomber_sync"
var (
searchableType = reflect.TypeFor[Searchable]()
deletedAtType = reflect.TypeFor[gorm.DeletedAt]()
)
// pending is one written row waiting for its after-commit sync. It holds
// the primary key, never the model, so the sync always reads the
// committed row.
type pending struct {
sch *schema.Schema
typ reflect.Type
pk any
key string
index string
op operation
}
func (s *Service) afterCreate(db *gorm.DB) { s.afterWrite(db, opUpsert) }
func (s *Service) afterUpdate(db *gorm.DB) { s.afterWrite(db, opUpsert) }
func (s *Service) afterDelete(db *gorm.DB) { s.afterWrite(db, opDelete) }
// afterWrite registers one after-commit sync per Searchable row of the
// statement. A statement without a primary key value (a batch update or
// delete through an empty model) is skipped: no document can be built for
// it. Nothing is registered while the engine is not configured.
func (s *Service) afterWrite(db *gorm.DB, op operation) {
if db.Error != nil || db.Statement == nil || db.Statement.Schema == nil {
return
}
if s.engine == nil || !s.engine.Configured() {
return
}
sch := db.Statement.Schema
if !reflect.PointerTo(sch.ModelType).Implements(searchableType) {
return
}
pkField := sch.PrioritizedPrimaryField
if pkField == nil {
return
}
ctx := db.Statement.Context
if ctx == nil {
ctx = context.Background()
}
index := s.IndexName(reflect.New(sch.ModelType).Interface().(Searchable))
add := func(v reflect.Value) {
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return
}
v = v.Elem()
}
if v.Kind() != reflect.Struct || v.Type() != sch.ModelType {
return
}
pk, zero := pkField.ValueOf(ctx, v)
if zero {
return
}
p := pending{sch: sch, typ: sch.ModelType, pk: pk, key: searchKey(v, pk), index: index, op: op}
lagoon.AfterCommit(ctx, db, func(ctx context.Context, db *gorm.DB) {
if err := s.syncOne(ctx, db, p); err != nil {
s.warn(p, err)
}
})
}
rv := db.Statement.ReflectValue
switch rv.Kind() {
case reflect.Slice, reflect.Array:
for i := 0; i < rv.Len(); i++ {
add(rv.Index(i))
}
default:
add(rv)
}
}
// Sync upserts the document of model (a pointer to a Searchable struct
// with its primary key set) through the same path as the callbacks: the
// gates, a reload by primary key, and a delete when the row is gone, soft
// deleted or not searchable. db may be nil to use the published handle. A
// skipped sync returns nil.
func (s *Service) Sync(ctx context.Context, db *gorm.DB, model any) error {
return s.explicit(ctx, db, model, opUpsert)
}
// Remove deletes the document of model (a pointer to a Searchable struct
// with its primary key set) through the same gated path as Sync.
func (s *Service) Remove(ctx context.Context, db *gorm.DB, model any) error {
return s.explicit(ctx, db, model, opDelete)
}
func (s *Service) explicit(ctx context.Context, db *gorm.DB, model any, op operation) error {
if s == nil {
return fmt.Errorf("beachcomber: nil service")
}
if ctx == nil {
ctx = context.Background()
}
if db == nil {
published, ok := s.publishedDB()
if !ok {
return nil
}
db = published
}
rv := reflect.ValueOf(model)
if rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
return fmt.Errorf("beachcomber: %T is not a pointer to a model struct", model)
}
if _, ok := model.(Searchable); !ok {
return fmt.Errorf("beachcomber: %T does not implement Searchable", model)
}
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(model); err != nil {
return fmt.Errorf("beachcomber: parse %T: %w", model, err)
}
pkField := stmt.Schema.PrioritizedPrimaryField
if pkField == nil {
return fmt.Errorf("beachcomber: %T has no primary key", model)
}
v := rv.Elem()
pk, zero := pkField.ValueOf(ctx, v)
if zero {
return fmt.Errorf("beachcomber: %T has a zero primary key", model)
}
p := pending{
sch: stmt.Schema,
typ: stmt.Schema.ModelType,
pk: pk,
key: searchKey(v, pk),
index: s.IndexName(model.(Searchable)),
op: op,
}
return s.syncOne(ctx, db, p)
}
// syncOne runs the gates, in order and without a request: the engine is
// configured, a database is published, the application Gate is on. It then
// reloads the row by primary key and upserts its document, or deletes it
// when the operation is a delete or the row is gone, soft deleted or not
// searchable. The engine call is bounded by the engine's own timeout; the
// caller's cancellation does not abandon it.
func (s *Service) syncOne(ctx context.Context, db *gorm.DB, p pending) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
if s.engine == nil || !s.engine.Configured() {
return nil
}
if _, ok := s.publishedDB(); !ok {
return nil
}
if db == nil {
return nil
}
ctx = context.WithoutCancel(ctx)
sess := db.Session(&gorm.Session{NewDB: true, Context: ctx})
var (
skip bool
remove bool
doc map[string]any
idx map[string]any
)
err = inSavepoint(sess, func(tx *gorm.DB) error {
if g := s.currentGate(); g != nil && !g.Enabled(ctx, tx) {
skip = true
return nil
}
if p.op == opDelete {
remove = true
return nil
}
model := reflect.New(p.typ)
pkCol := tx.Statement.Quote(p.sch.PrioritizedPrimaryField.DBName)
err := tx.Unscoped().Where(pkCol+" = ?", p.pk).Take(model.Interface()).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
remove = true
return nil
}
if err != nil {
return fmt.Errorf("reload: %w", err)
}
if softDeleted(ctx, p.sch, model.Elem()) {
remove = true
return nil
}
m := model.Interface().(Searchable)
if !m.ShouldBeSearchable() {
remove = true
return nil
}
doc, err = m.ToSearchableArray(ctx, tx)
if err != nil {
return fmt.Errorf("build document: %w", err)
}
if len(doc) == 0 {
skip = true
return nil
}
if _, ok := doc["id"]; !ok {
doc["id"] = p.key
}
if sp, ok := model.Interface().(IndexSchemaProvider); ok {
idx = sp.SearchIndexSchema()
}
return nil
})
if err != nil || skip {
return err
}
if remove {
if err := s.engine.Delete(ctx, p.index, []string{p.key}); err != nil {
return fmt.Errorf("delete: %w", err)
}
return nil
}
if err := s.engine.Upsert(ctx, p.index, idx, []map[string]any{doc}); err != nil {
return fmt.Errorf("upsert: %w", err)
}
return nil
}
// inSavepoint runs fn on db. Inside a transaction fn runs in a savepoint,
// so a failed read (a table that does not exist yet, say) is rolled back
// to it and never aborts the caller's transaction.
func inSavepoint(db *gorm.DB, fn func(tx *gorm.DB) error) error {
if _, inTx := db.Statement.ConnPool.(gorm.TxCommitter); !inTx {
return fn(db)
}
if err := db.SavePoint(syncSavepoint).Error; err != nil {
return fmt.Errorf("savepoint: %w", err)
}
var err error
func() {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
err = fn(db)
}()
if err != nil {
db.RollbackTo(syncSavepoint)
return err
}
db.Exec("RELEASE SAVEPOINT " + syncSavepoint)
return nil
}
// softDeleted reports whether any gorm.DeletedAt field of v is set.
func softDeleted(ctx context.Context, sch *schema.Schema, v reflect.Value) bool {
for _, f := range sch.Fields {
if f.FieldType != deletedAtType {
continue
}
val, zero := f.ValueOf(ctx, v)
if zero {
continue
}
if d, ok := val.(gorm.DeletedAt); ok && d.Valid {
return true
}
}
return false
}
// searchKey is SearchKeyer.SearchKey when the model implements it and
// returns a key, else the decimal primary key.
func searchKey(v reflect.Value, pk any) string {
if v.CanAddr() {
if k, ok := v.Addr().Interface().(SearchKeyer); ok {
if key := k.SearchKey(); key != "" {
return key
}
}
} else if k, ok := v.Interface().(SearchKeyer); ok {
if key := k.SearchKey(); key != "" {
return key
}
}
switch n := pk.(type) {
case uint:
return strconv.FormatUint(uint64(n), 10)
case uint32:
return strconv.FormatUint(uint64(n), 10)
case uint64:
return strconv.FormatUint(n, 10)
case int:
return strconv.Itoa(n)
case int32:
return strconv.FormatInt(int64(n), 10)
case int64:
return strconv.FormatInt(n, 10)
default:
return fmt.Sprint(pk)
}
}
func (s *Service) publishedDB() (*gorm.DB, bool) {
if s.app == nil {
return nil, false
}
gdb, ok := s.app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
return nil, false
}
return gdb, true
}
// warn logs a failed sync with the index, key and operation. It never logs
// the document or the engine's credentials.
func (s *Service) warn(p pending, err error) {
s.Logger().Warn("search: sync failed",
slog.String("index", p.index),
slog.String("key", p.key),
slog.String("operation", string(p.op)),
slog.String("error", err.Error()),
)
}

View File

@@ -0,0 +1,89 @@
// Package typesense is the Typesense engine of beachcomber: a hand-rolled
// net/http client for the collection, import, delete and search endpoints.
// Import it for its side effect to register the "typesense" search.driver.
package typesense
import (
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/compass"
)
// Default values of the search.typesense.* keys.
const (
DefaultHost = "localhost"
DefaultPort = 8181
DefaultProtocol = "http"
DefaultConnectionTimeout = 2 * time.Second
DefaultImportAction = "upsert"
)
// Config is the search.typesense.* configuration.
type Config struct {
// APIKey is sent as X-TYPESENSE-API-KEY; empty means not configured,
// and nothing is ever sent.
APIKey string
// Host, Port, Protocol and Path form the base URL
// {protocol}://{host}:{port}{path}.
Host string
Port int
Protocol string
Path string
// ConnectionTimeout bounds each request, including reading the answer.
ConnectionTimeout time.Duration
// ImportAction is the action query parameter of document imports.
ImportAction string
}
// LoadConfig reads search.typesense.* from c, filling the defaults.
// connection_timeout_seconds is an integer number of seconds or a duration
// string.
func LoadConfig(c *compass.Config) Config {
cfg := Config{
Host: DefaultHost,
Port: DefaultPort,
Protocol: DefaultProtocol,
ConnectionTimeout: DefaultConnectionTimeout,
ImportAction: DefaultImportAction,
}
if c == nil {
return cfg
}
str := func(key string) string { return strings.TrimSpace(c.String("search.typesense." + key)) }
cfg.APIKey = str("api_key")
if v := str("host"); v != "" {
cfg.Host = v
}
if v := str("port"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
cfg.Port = n
}
}
if v := str("protocol"); v != "" {
cfg.Protocol = strings.ToLower(v)
}
if v := strings.TrimSuffix(str("path"), "/"); v != "" {
if !strings.HasPrefix(v, "/") {
v = "/" + v
}
cfg.Path = v
}
if v := str("connection_timeout_seconds"); v != "" {
if n, err := strconv.ParseFloat(v, 64); err == nil && n > 0 {
cfg.ConnectionTimeout = time.Duration(n * float64(time.Second))
} else if d, err := time.ParseDuration(v); err == nil && d > 0 {
cfg.ConnectionTimeout = d
}
}
if v := str("import_action"); v != "" {
cfg.ImportAction = v
}
return cfg
}
// BaseURL is {protocol}://{host}:{port}{path}.
func (c Config) BaseURL() string {
return c.Protocol + "://" + c.Host + ":" + strconv.Itoa(c.Port) + c.Path
}

View File

@@ -0,0 +1,322 @@
package typesense
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/beachcomber"
)
// DriverName is the search.driver value of this engine.
const DriverName = "typesense"
// apiKeyHeader authenticates every request.
const apiKeyHeader = "X-TYPESENSE-API-KEY"
// maxResponseBytes caps how much of an answer is read.
const maxResponseBytes = 32 << 20
func init() {
beachcomber.RegisterEngine(DriverName, func(app *backpack.App) (beachcomber.Engine, error) {
var cfg Config
if app != nil {
cfg = LoadConfig(app.Config)
} else {
cfg = LoadConfig(nil)
}
return New(cfg), nil
})
}
// Engine is the Typesense beachcomber.Engine. It follows the Laravel Scout
// TypesenseEngine wire contract.
type Engine struct {
cfg Config
base string
client *http.Client
}
var _ beachcomber.Engine = (*Engine)(nil)
// New returns an engine for cfg. Every request is bounded by
// cfg.ConnectionTimeout.
func New(cfg Config) *Engine {
if cfg.ConnectionTimeout <= 0 {
cfg.ConnectionTimeout = DefaultConnectionTimeout
}
if cfg.ImportAction == "" {
cfg.ImportAction = DefaultImportAction
}
return &Engine{
cfg: cfg,
base: strings.TrimSuffix(cfg.BaseURL(), "/"),
client: &http.Client{Timeout: cfg.ConnectionTimeout},
}
}
// Name returns "typesense".
func (e *Engine) Name() string { return DriverName }
// Configured reports whether an API key is set. Without one the engine is
// never called.
func (e *Engine) Configured() bool { return e != nil && e.cfg.APIKey != "" }
// Config returns the engine's configuration.
func (e *Engine) Config() Config { return e.cfg }
// Upsert makes sure the collection exists (GET /collections/{index}, then
// POST /collections with schema and the name when it answers 404), then
// imports docs as JSON lines with POST
// /collections/{index}/documents/import?action={import_action}. Typesense
// answers 200 even when documents fail, so every answer line is checked
// and any "success":false line is an error.
func (e *Engine) Upsert(ctx context.Context, index string, schema map[string]any, docs []map[string]any) error {
if len(docs) == 0 {
return nil
}
if err := e.ensureCollection(ctx, index, schema); err != nil {
return err
}
var body bytes.Buffer
for _, doc := range docs {
line, err := json.Marshal(doc)
if err != nil {
return fmt.Errorf("typesense: encode document: %w", err)
}
body.Write(line)
body.WriteByte('\n')
}
q := url.Values{"action": {e.cfg.ImportAction}}
path := "/collections/" + url.PathEscape(index) + "/documents/import"
code, answer, err := e.do(ctx, http.MethodPost, path, q, &body, "text/plain")
if err != nil {
return err
}
if !ok2xx(code) {
return statusError(http.MethodPost, path, code)
}
return checkImport(index, answer, len(docs))
}
// ensureCollection creates index from schema when Typesense does not have
// it. A missing schema creates an auto-typed collection.
func (e *Engine) ensureCollection(ctx context.Context, index string, schema map[string]any) error {
path := "/collections/" + url.PathEscape(index)
code, _, err := e.do(ctx, http.MethodGet, path, nil, nil, "")
if err != nil {
return err
}
if ok2xx(code) {
return nil
}
if code != http.StatusNotFound {
return statusError(http.MethodGet, path, code)
}
create := map[string]any{}
maps.Copy(create, schema)
if _, ok := create["fields"]; !ok {
create["fields"] = []map[string]any{{"name": ".*", "type": "auto"}}
}
create["name"] = index
raw, err := json.Marshal(create)
if err != nil {
return fmt.Errorf("typesense: encode collection schema: %w", err)
}
code, _, err = e.do(ctx, http.MethodPost, "/collections", nil, bytes.NewReader(raw), "application/json")
if err != nil {
return err
}
// 409: created concurrently by another writer.
if ok2xx(code) || code == http.StatusConflict {
return nil
}
return statusError(http.MethodPost, "/collections", code)
}
// importLine is one line of an import answer.
type importLine struct {
Success bool `json:"success"`
Error string `json:"error"`
}
// checkImport returns an error when any answer line reports a failure. The
// error carries the Typesense message, never the document.
func checkImport(index string, answer []byte, total int) error {
failed := 0
first := ""
sc := bufio.NewScanner(bytes.NewReader(answer))
sc.Buffer(make([]byte, 0, 64*1024), maxResponseBytes)
for sc.Scan() {
line := bytes.TrimSpace(sc.Bytes())
if len(line) == 0 {
continue
}
var l importLine
if err := json.Unmarshal(line, &l); err != nil {
return fmt.Errorf("typesense: import into %s: unreadable answer line", index)
}
if !l.Success {
failed++
if first == "" {
first = l.Error
}
}
}
if err := sc.Err(); err != nil {
return fmt.Errorf("typesense: import into %s: read answer: %w", index, err)
}
if failed == 0 {
return nil
}
if len(first) > 200 {
first = first[:200]
}
return fmt.Errorf("typesense: import into %s: %d of %d documents failed: %s", index, failed, total, first)
}
// Delete sends DELETE /collections/{index}/documents/{id} per id. A 404
// (document or collection already gone) counts as success.
func (e *Engine) Delete(ctx context.Context, index string, ids []string) error {
for _, id := range ids {
path := "/collections/" + url.PathEscape(index) + "/documents/" + url.PathEscape(id)
code, _, err := e.do(ctx, http.MethodDelete, path, nil, nil, "")
if err != nil {
return err
}
if !ok2xx(code) && code != http.StatusNotFound {
return statusError(http.MethodDelete, path, code)
}
}
return nil
}
// Flush drops the collection with DELETE /collections/{index}. A 404
// counts as success.
func (e *Engine) Flush(ctx context.Context, index string) error {
path := "/collections/" + url.PathEscape(index)
code, _, err := e.do(ctx, http.MethodDelete, path, nil, nil, "")
if err != nil {
return err
}
if !ok2xx(code) && code != http.StatusNotFound {
return statusError(http.MethodDelete, path, code)
}
return nil
}
// searchAnswer is the part of a search answer SearchIDs reads.
type searchAnswer struct {
Hits []struct {
Document struct {
ID json.RawMessage `json:"id"`
} `json:"document"`
} `json:"hits"`
}
// SearchIDs sends GET /collections/{index}/documents/search with q
// (default "*"), query_by, filter_by, sort_by, page and per_page, and
// returns hits[].document.id in order. The ids are candidates: the caller
// must re-check each one in SQL before exposing it. No hits is an empty
// list.
func (e *Engine) SearchIDs(ctx context.Context, index string, q beachcomber.Query) ([]string, error) {
params := url.Values{}
text := q.Q
if text == "" {
text = "*"
}
params.Set("q", text)
if len(q.QueryBy) > 0 {
params.Set("query_by", strings.Join(q.QueryBy, ","))
}
if q.FilterBy != "" {
params.Set("filter_by", q.FilterBy)
}
if q.SortBy != "" {
params.Set("sort_by", q.SortBy)
}
if q.Page > 0 {
params.Set("page", strconv.Itoa(q.Page))
}
if q.PerPage > 0 {
params.Set("per_page", strconv.Itoa(q.PerPage))
}
path := "/collections/" + url.PathEscape(index) + "/documents/search"
code, answer, err := e.do(ctx, http.MethodGet, path, params, nil, "")
if err != nil {
return nil, err
}
if !ok2xx(code) {
return nil, statusError(http.MethodGet, path, code)
}
var res searchAnswer
if err := json.Unmarshal(answer, &res); err != nil {
return nil, fmt.Errorf("typesense: search %s: unreadable answer", index)
}
ids := make([]string, 0, len(res.Hits))
for _, h := range res.Hits {
raw := bytes.TrimSpace(h.Document.ID)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
continue
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
ids = append(ids, s)
continue
}
ids = append(ids, string(raw))
}
return ids, nil
}
// do sends one request with the API key header and returns the status and
// the (capped) body.
func (e *Engine) do(ctx context.Context, method, path string, query url.Values, body io.Reader, contentType string) (int, []byte, error) {
u := e.base + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u, body)
if err != nil {
return 0, nil, fmt.Errorf("typesense: %s %s: build request: %w", method, path, err)
}
req.Header.Set(apiKeyHeader, e.cfg.APIKey)
req.Header.Set("Accept", "application/json")
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := e.client.Do(req)
if err != nil {
return 0, nil, fmt.Errorf("typesense: %s %s: %w", method, path, unwrapURLError(err))
}
defer resp.Body.Close()
answer, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return resp.StatusCode, nil, fmt.Errorf("typesense: %s %s: read answer: %w", method, path, err)
}
return resp.StatusCode, answer, nil
}
// unwrapURLError drops the *url.Error wrapper, whose message repeats the
// full URL, and keeps the cause (a timeout, a refused connection).
func unwrapURLError(err error) error {
if ue, ok := err.(*url.Error); ok && ue.Err != nil {
return ue.Err
}
return err
}
func ok2xx(code int) bool { return code >= 200 && code < 300 }
func statusError(method, path string, code int) error {
return fmt.Errorf("typesense: %s %s: status %d", method, path, code)
}