← All @molecule/* packages · App templates

@molecule/api-search-postgres

Provider bond · search · API (Node) · v1.0.1 · Apache-2.0

PostgreSQL full-text search provider for molecule.dev

npm install @molecule/api-search-postgres

npm · Source on GitHub · Implements @molecule/api-search

How it works

@molecule/api-search-postgres is a provider bond on the API (Node) side: it implements the search core interface (@molecule/api-search) with a concrete vendor or library behind it.

Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.

import { setProvider } from '@molecule/api-search'
import { provider } from '@molecule/api-search-postgres'

setProvider(provider)

Works with: @molecule/api-database, @molecule/api-search

Reference

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

PostgreSQL full-text search provider for molecule.dev.

Implements the SearchProvider interface using PostgreSQL's built-in tsvector/tsquery full-text search capabilities. No external search engine required — uses the existing database bond.

Quick Start

import { setProvider } from '@molecule/api-search'
import { provider } from '@molecule/api-search-postgres'

setProvider(provider)

Type

provider

Installation

npm install @molecule/api-search-postgres @molecule/api-database @molecule/api-search

API

Interfaces

BulkIndexResult

Result of a bulk index operation.

interface BulkIndexResult {
  /**
   * Number of documents successfully indexed.
   */
  indexed: number
  /**
   * Number of documents that failed to index.
   */
  failed: number
  /**
   * Errors encountered during bulk indexing, keyed by document id.
   */
  errors: Record<string, string>
}

FacetCount

A single facet count entry.

interface FacetCount {
  /**
   * The facet value.
   */
  value: string
  /**
   * Number of documents matching this facet value.
   */
  count: number
}

IndexDocument

A document to be indexed in a bulk operation.

interface IndexDocument {
  /**
   * Unique identifier for the document.
   */
  id: string
  /**
   * The document fields and values.
   */
  document: Record<string, unknown>
}

IndexSchema

Schema definition for a search index, describing the fields and their roles.

interface IndexSchema {
  /**
   * Map of field names to their types.
   */
  fields: Record<string, FieldType>
  /**
   * Fields that are searchable via full-text queries.
   */
  searchableFields?: string[]
  /**
   * Fields that can be used in filter expressions.
   */
  filterableFields?: string[]
  /**
   * Fields that can be used for sorting results.
   */
  sortableFields?: string[]
}

PostgresSearchOptions

Configuration options for the PostgreSQL search provider.

interface PostgresSearchOptions {
  /**
   * PostgreSQL text search configuration (language).
   *
   * @default 'english'
   */
  searchConfig?: string

  /**
   * Table prefix for search index tables.
   *
   * @default 'search_'
   */
  tablePrefix?: string

  /**
   * Whether to use GIN indexes for faster text search.
   *
   * @default true
   */
  useGinIndex?: boolean
}

SearchHit

A single search result hit.

interface SearchHit {
  /**
   * Document identifier.
   */
  id: string
  /**
   * Relevance score.
   */
  score: number
  /**
   * The matched document fields.
   */
  document: Record<string, unknown>
  /**
   * Highlighted field snippets, keyed by field name.
   */
  highlights?: Record<string, string[]>
}

SearchProvider

Search provider interface.

All search providers must implement this interface to provide full-text search, indexing, and suggestion capabilities.

interface SearchProvider {
  /**
   * Creates a search index with an optional schema.
   *
   * @param name - Index name.
   * @param schema - Optional schema describing field types and roles.
   */
  createIndex(name: string, schema?: IndexSchema): Promise<void>
  /**
   * Deletes a search index and all its documents.
   *
   * @param name - Index name to delete.
   */
  deleteIndex(name: string): Promise<void>
  /**
   * Indexes a single document.
   *
   * @param indexName - Target index name.
   * @param id - Unique document identifier.
   * @param document - The document fields and values.
   */
  index(indexName: string, id: string, document: Record<string, unknown>): Promise<void>
  /**
   * Indexes multiple documents in a single operation.
   *
   * @param indexName - Target index name.
   * @param documents - Array of documents to index.
   * @returns Result with indexed/failed counts and errors.
   */
  bulkIndex(indexName: string, documents: IndexDocument[]): Promise<BulkIndexResult>
  /**
   * Executes a full-text search query against an index.
   *
   * @param indexName - Index to search.
   * @param query - Search query with text, filters, pagination, etc.
   * @returns Search results with hits, total count, facets, and timing.
   */
  search(indexName: string, query: SearchQuery): Promise<SearchResult>
  /**
   * Deletes a document from an index by id.
   *
   * @param indexName - Index containing the document.
   * @param id - Document identifier to delete.
   */
  delete(indexName: string, id: string): Promise<void>
  /**
   * Returns typeahead/autocomplete suggestions for a partial query.
   *
   * @param indexName - Index to generate suggestions from.
   * @param query - Partial text to complete.
   * @param options - Suggestion options (limit, fields, fuzzy).
   * @returns Array of suggestions sorted by relevance.
   */
  suggest(indexName: string, query: string, options?: SuggestOptions): Promise<Suggestion[]>
  /**
   * Retrieves a single document from an index by id.
   *
   * @param indexName - Index containing the document.
   * @param id - Document identifier.
   * @returns The document fields, or `null` if not found.
   */
  getDocument(indexName: string, id: string): Promise<Record<string, unknown> | null>
}

SearchQuery

A full-text search query with optional filters, facets, sorting, and pagination.

interface SearchQuery {
  /**
   * The search text.
   *
   * Empty or whitespace-only text is "browse" mode: bond implementations
   * MUST match ALL documents (subject to `filters`, `sort`, and pagination)
   * rather than erroring or returning zero hits. Every bundled bond
   * (Elasticsearch, Meilisearch, Typesense, PostgreSQL) follows this
   * contract, so swapping providers doesn't silently change what an empty
   * search box shows.
   */
  text: string
  /**
   * Filter expressions to narrow results.
   */
  filters?: Record<string, unknown>
  /**
   * Fields to compute facet counts for.
   */
  facets?: string[]
  /**
   * Sort fields and directions.
   */
  sort?: SortField[]
  /**
   * Page number (1-based).
   */
  page?: number
  /**
   * Number of results per page.
   */
  perPage?: number
  /**
   * Whether to include highlighted snippets in results.
   */
  highlight?: boolean
}

SearchResult

The result of a search query, including hits, pagination, facets, and timing.

interface SearchResult {
  /**
   * Matched documents.
   */
  hits: SearchHit[]
  /**
   * Total number of matching documents.
   */
  total: number
  /**
   * Current page number.
   */
  page: number
  /**
   * Number of results per page.
   */
  perPage: number
  /**
   * Facet counts keyed by field name.
   */
  facets?: Record<string, FacetCount[]>
  /**
   * Time taken to process the query in milliseconds.
   */
  processingTimeMs: number
}

SortField

A field to sort search results by.

interface SortField {
  /**
   * The field name to sort on.
   */
  field: string
  /**
   * Sort direction.
   */
  direction: SortDirection
}

Suggestion

A single autocomplete suggestion.

interface Suggestion {
  /**
   * The suggested text.
   */
  text: string
  /**
   * Relevance score for ranking suggestions.
   */
  score: number
  /**
   * Optional highlighted version of the suggestion.
   */
  highlighted?: string
}

SuggestOptions

Options for typeahead / autocomplete suggestions.

interface SuggestOptions {
  /**
   * Maximum number of suggestions to return.
   */
  limit?: number
  /**
   * Fields to generate suggestions from.
   */
  fields?: string[]
  /**
   * Whether to apply fuzzy matching.
   */
  fuzzy?: boolean
}

Types

FieldType

Field type for index schema definitions.

type FieldType = 'text' | 'keyword' | 'number' | 'boolean' | 'date' | 'geo'

SortDirection

Sort direction for search results.

type SortDirection = 'asc' | 'desc'

Functions

createProvider(options)

Creates a PostgreSQL full-text search provider instance.

function createProvider(options?: PostgresSearchOptions): SearchProvider
  • options — Provider configuration options.

Returns: A fully configured SearchProvider implementation.

Constants

provider

Default lazily-initialized PostgreSQL search provider. Uses the bonded database pool for queries.

const provider: SearchProvider

Core Interface

Implements @molecule/api-search interface.

Bond Wiring

Setup function to register this provider with the core interface:

import { setProvider } from '@molecule/api-search'
import { provider } from '@molecule/api-search-postgres'

export function setupSearchPostgres(): void {
  setProvider(provider)
}

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-database ^1.0.1
  • @molecule/api-search ^1.0.1

Runtime Dependencies

  • @molecule/api-database
  • @molecule/api-search

Provider-specific behavior to know before debugging:

  • Empty/whitespace-only search text is "browse" mode — matches ALL documents (filters/sort/pagination still apply), per the core SearchQuery.text contract, consistent with the meilisearch/typesense bonds. suggest()'s partial-query parameter is different: empty input there still returns [] (no autocomplete suggestions), not every document.
  • Search text is punctuation-safe prefix matching. Every whitespace-separated token must match as a prefix (widget cas matches "widget case"). Operators typed by users (!, (, :, &, quotes) are treated as literal text, never as tsquery syntax.
  • Facets run one extra GROUP BY document->>field query per requested facet field (values coerced to text, capped at the top 100 by count — SearchResult.facets[field]). Budget for that extra round trip when requesting facets on a hot path or many fields at once.
  • Filters compare top-level document keys as text equality (document->>field = value, field bound as a query parameter — not a sanitized identifier, so punctuation in field names like 'product-type' works). Nested paths and range filters are not supported.
  • Sorting casts to the field's declared type when that field appears in the IndexSchema.fields passed to createIndex() (numberdouble precision, datetimestamptz, booleanboolean) — the type map is recorded in a companion <prefix>schema_meta table at createIndex() time. Fields NOT in the schema (or indices created without one) still compare as text, so lexicographic ordering ("10" < "9") is still possible for undeclared fields.
  • Highlights run over a dedicated content column (the same plain text index()/bulkIndex() extract for full-text matching), not the raw JSON document — so snippets never contain JSON braces/keys/quotes. They still come back under a single key, _content, rather than per-field like the engine-backed bonds. Rows written before this column existed backfill it (empty string) on the next createIndex() call and get real content on their next index()/bulkIndex() write.
  • Only top-level string values (and strings inside arrays) are indexed for full-text matching; numbers/booleans/nested objects are stored but not searchable.

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:

  • Searching a term that exists in seeded data returns the matching records in the results UI.
  • An empty search box shows the browse-everything view (empty text matches ALL documents by contract) — not zero results and not an error.
  • A term with no matches shows a clear "no results" state.
  • Index-on-write is wired: create a new record through the UI, then search for it — it must be findable without a manual reindex.
  • If autocomplete/suggestions are surfaced, typing a prefix of a known record shows relevant suggestions.
  • Search is scoped to the caller: one user's search never returns another user's private records.