Search

The battery/search package defines a pluggable full-text search interface.
The framework ships three backends: an in-memory backend for dev/tests, a
Postgres full-text-search backend for production, and a SQLite FTS5 backend
for SQLite-first apps. Other engines (Bleve, Meilisearch, etc.) plug in
behind the same Backend interface.

Quickstart

18 lines
import "github.com/DonaldMurillo/gofastr/battery/search"index := search.NewMemory()// Index a document._ = index.Index(ctx, search.Document{    ID:   "post-1",    Type: "posts",    Text: "GoFastr framework release notes",    Fields: map[string]any{"author": "carol", "tags": []string{"go"}},})// Query it.results, err := index.Search(ctx, search.Query{    Text:  "framework",    Type:  "posts",    Limit: 10,})

The blog example wires this into GET /posts/search?q=.... See
examples/blog/main.go.

Types

Document

FieldTypeNotes
IDstringRequired. Unique per backend.
TypestringOptional. Filterable namespace ("posts", etc.).
TextstringFree text body the engine tokenises.
Fieldsmap[string]anyOptional structured fields returned with hits.

Query

FieldTypeNotes
TextstringQuery text. Empty string matches all documents.
TypestringOptional. Restricts results to one Document.Type.
LimitintMax hits. 0 means no limit (engine default).
OffsetintSkip first N hits — useful for pagination.
FieldEqualsmap[string]stringOptional. Exact-match filter on Document.Fields — the scope hook (see below).

Result

4 lines
type Result struct {    Document Document    Score    float64}

Score is engine-specific. The memory backend uses a naive term-frequency
score; a real engine returns BM25 or similar. Don't compare scores
across backends.

The Backend interface

5 lines
type Backend interface {    Index(ctx context.Context, doc Document) error    Delete(ctx context.Context, id string) error    Search(ctx context.Context, query Query) ([]Result, error)}

Anything that implements all three methods is a valid backend. There
is no registration step — wire your backend wherever you would have
used search.NewMemory().

The in-memory backend

search.NewMemory() returns a goroutine-safe in-process store. It is
appropriate for tests, single-binary demos, and small read-only sites.
Documents are stored in a map, so:

  • Restarting the process drops the index.
  • Memory usage is O(total text size).
  • Search is O(n) over the corpus.

The implementation is in battery/search/memory.go — read it before
using it in anything user-facing.

The Postgres backend

search.NewPostgres(db, cfg) returns a backend that stores documents in a
single Postgres table with a generated TSVECTOR column, so a Postgres-first
app gets ranked full-text search without standing up separate search
infrastructure. Call EnsureSchema once on boot, then Index/Search like
any backend:

15 lines
idx, err := search.NewPostgres(db, search.PostgresConfig{})if err != nil {    return err}if err := idx.EnsureSchema(ctx); err != nil {    return err}_ = idx.Index(ctx, search.Document{    ID:   "post-1",    Type: "posts",    Text: "GoFastr framework release notes",})results, err := idx.Search(ctx, search.Query{Text: "framework", Limit: 10})

Configuration

PostgresConfig:

FieldTypeNotes
TablestringDestination table. Defaults to search_documents.
Languagestringtsvector config. Defaults to english; validated against ^[a-z_]+$.
WeightedFieldsmap[string]byteMaps Document.Fields keys (string values) to a weight 'A'..'D'.

EnsureSchema is idempotent: it creates the table and a GIN index on the
tsvector if they don't already exist, so it's safe to call on every startup.

Weighted fields

The document body (Document.Text) is always indexed at weight 'A'. Promote
structured fields so a hit in a title outranks one in the body:

<!-- gofastr:compile
stmt: _ = idx
import "github.com/DonaldMurillo/gofastr/battery/search"
import "database/sql"
var db *sql.DB
-->

3 lines
idx, _ := search.NewPostgres(db, search.PostgresConfig{    WeightedFields: map[string]byte{"title": 'A', "summary": 'B'},})

A field configured in WeightedFields is indexed only when its value is a
string; non-string values are skipped. The default ts_rank weight multipliers
(D < C < B < A) mean a weight-'A' hit scores higher than a weight-'C' hit.

Scoping with FieldEquals

Query.FieldEquals is the multi-tenant / per-owner / permission scope hook.
Put the scope value in Document.Fields at index time and filter on it in
the query — in-query, not post-filtered:

12 lines
_ = idx.Index(ctx, search.Document{    ID:     "post-1",    Type:   "posts",    Text:   "Tenant-scoped content",    Fields: map[string]any{"tenant": "acme"},})// Only documents whose Fields["tenant"] == "acme" can match.results, _ := idx.Search(ctx, search.Query{    Text:        "content",    FieldEquals: map[string]string{"tenant": "acme"},})

Matching is string-only and identical across the memory and Postgres backends:
a document matches iff, for every key/value pair, Fields[key] is present and
its value is a string equal to value. A field whose value is not a string
never matches. The Postgres backend encodes this as JSONB containment
(fields @> '{"tenant":"acme"}'); the memory backend applies the same rule in Go.

Prefix matching

The query builder joins terms with AND and suffixes the last term with
:*, so partial input matches as the user types — useful for command palettes
and autocomplete. A query for "pagin" matches documents containing
"pagination".

What it deliberately doesn't do

This backend does not use pg_trgm. That extension needs CREATE EXTENSION
(a superuser privilege many managed-database roles lack), so fuzzy/trigram
matching is left to a future backend that can opt into the extension explicitly.
All query text is sanitized before reaching to_tsquery (operators and SQL
metacharacters are stripped), so hostile input neither errors nor matches
every document.

The SQLite FTS5 backend

search.NewSQLiteFTS(db, cfg) returns a backend backed by a single SQLite FTS5
virtual table, so a SQLite-first app gets ranked BM25 full-text search without
standing up separate search infrastructure. Call EnsureSchema once on boot,
then Index/Search like any backend:

15 lines
idx, err := search.NewSQLiteFTS(db, search.SQLiteFTSConfig{})if err != nil {    return err}if err := idx.EnsureSchema(ctx); err != nil {    return err}_ = idx.Index(ctx, search.Document{    ID:   "post-1",    Type: "posts",    Text: "GoFastr framework release notes",})results, err := idx.Search(ctx, search.Query{Text: "framework", Limit: 10})

Build tag

FTS5 is an SQLite compile-time option. The mattn/go-sqlite3 driver bundles
FTS5 only when built with the -tags sqlite_fts5 build tag:

2 lines
go build -tags sqlite_fts5 .go test -tags sqlite_fts5 ./...

Without the tag, EnsureSchema returns an actionable error naming the tag.
The test suite probes FTS5 availability at runtime and t.Skips when absent,
so the default go test ./... stays green.

Configuration

SQLiteFTSConfig:

FieldTypeNotes
TablestringDestination FTS5 virtual table. Defaults to search_documents.

The schema is fixed: CREATE VIRTUAL TABLE ... USING fts5(id UNINDEXED, type UNINDEXED, fields UNINDEXED, text, tokenize='porter unicode61'). Only Document.Text is tokenised; structured fields are stored (UNINDEXED) for FieldEquals filtering via json_extract.

Ranking

Results are ranked by BM25. The SQLite FTS5 bm25() function returns negative
values (more negative = better match), so the backend exposes
Score = -bm25 — callers sort descending consistently with the other backends.
The tiebreak is id ASC.

Scoping with FieldEquals

FieldEquals uses json_extract(fields, '$."key"') = ? — the key is validated
against ^[A-Za-z0-9_]+$ before the JSON path is interpolated, and the value
is parameterised. String-only matching is enforced naturally by SQLite's type
system: a JSON number and a TEXT parameter are different storage classes and
never compare equal.

Indexing

FTS5 has no upsert, so Index is DELETE by id + INSERT in a single
transaction — re-indexing the same id replaces in place with no duplicate rows.

Choosing a backend

BackendDurableRankedWeighted fieldsInfrastructureBuild tag
MemoryNo (in-process)Term frequencyNoNoneNone
PostgresSearchYests_rankYes (A..D)PostgresNone
SQLiteFTSYesBM25NoSQLitesqlite_fts5

When to pick:

  • Memory — tests, single-binary demos, small read-only sites. Loses
    everything on restart.
  • PostgresSearch — a Postgres-first production app. Ranked search
    without standing up separate search infrastructure, plus weighted fields
    for title-vs-body ranking.
  • SQLiteFTS — a SQLite-first app (embedded, single-file, edge). Ranked
    BM25 search without standing up Postgres. Needs the sqlite_fts5 build tag.

All three share AND-of-terms semantics, prefix matching on the last term,
the same empty-query (match-all) and pure-punctuation (match-none) edge
cases, and the string-only FieldEquals scoping contract.

Battery wrapper

search.NewBattery(backend) wraps any Backend in a
framework.Battery so the index participates in the App's
dependency-resolved lifecycle. This lets other batteries declare
"search" as a dependency, guaranteeing the index is available before
their own Init runs.

7 lines
idx := search.NewMemory()app.Batteries.Register(search.NewBattery(idx))// Retrieve the backend from within another battery:b, _ := app.Batteries.Get("search")sb := b.(*search.Battery)results, _ := sb.Backend().Search(ctx, search.Query{Text: "..."})

search.Memory has no background goroutine, so Battery implements
framework.Battery only — Name and Init (a no-op); there is no
OnStop. A future backend that starts a background goroutine will need
an OnStop added to this wrapper (it does not exist today): implement
io.Closer on that backend type and call Close from the new hook.

Common mistakes

  • Indexing inside a hook without context. Pass the request context
    to Index so cancellations propagate. Don't use context.Background()
    from inside a request-scoped hook — you'll leak goroutines on client
    disconnect.
  • Expecting Type to be required. It isn't. Searches without a
    Type filter scan all documents. Set Type consistently when you
    index, or be ready for cross-entity results.
  • Treating the memory backend as durable. It isn't. Wire a real
    backend before relying on the index surviving restart.
  • Forgetting the sqlite_fts5 build tag. The SQLiteFTS backend
    needs the driver compiled with FTS5 support. Without -tags sqlite_fts5,
    EnsureSchema fails with an actionable error; the test suite skips.