davepi-plugin-search
v0.1.1
Published
Full-text search for dAvePi. A schema declares `search: { fields, facets }` and the plugin creates the index on boot, keeps it in sync off the record event bus, and mounts REST `GET /api/{v}/{path}/search` plus a GraphQL `{path}Search` query — all tenant-
Maintainers
Readme
davepi-plugin-search
Full-text search for dAvePi. A schema opts
in with a search block; the plugin builds the backend index at boot, keeps it
in sync off the record event bus, and exposes tenant-scoped REST and GraphQL
search surfaces — no route code to write. Pluggable backend: Typesense
(default), Meilisearch, or Algolia.
Reach for this instead of Mongo $text the moment you need typo-tolerance,
faceted filtering, weighted ranking, or sub-50ms latency at scale.
Install
npm install davepi-plugin-searchAdd it to your project's package.json:
{ "davepi": { "plugins": ["davepi-plugin-search"] } }The plugin stays dormant (logs a warning and does nothing) until
SEARCH_API_KEY is set, so it's safe to declare before provisioning the search
service.
Opt a schema in
// schema/versions/v1/product.js
module.exports = {
path: 'product',
collection: 'product',
fields: [
{ name: 'userId', type: String, required: true },
{ name: 'title', type: String },
{ name: 'description', type: String },
{ name: 'tags', type: [String] },
{ name: 'category', type: String },
{ name: 'inStock', type: Boolean },
],
search: {
fields: [
{ name: 'title', weight: 3 }, // higher weight ranks higher
{ name: 'description' }, // bare string == weight 1
'tags',
],
facets: ['category', 'inStock'],
sortDefaults: ['createdAt:desc'],
},
};Schemas without a search block are untouched.
Use it
REST (added automatically, no route code):
GET /api/v1/product/search?q=red+shoes&facet=category&filter=inStock:true{
"hits": [{ "_id": "…", "title": "Red running shoes", "score": 99 }],
"facets": { "category": [{ "value": "footwear", "count": 12 }] },
"found": 12
}GraphQL:
query {
productSearch(q: "red shoes", facet: ["category"], filter: "inStock:true") {
hits
facets
found
}
}Both surfaces AND a userId:{caller} predicate (from the JWT / API key / client
id) into every query server-side, so a caller can never read another tenant's
rows — and that predicate can't be widened via the filter arg.
Programmatic (advanced / cross-schema):
const search = require('davepi-plugin-search');
const result = await search.query({ resource: 'product', q: 'red shoes', user });Backfill / recovery
After declaring search on an already-populated schema — or after a backend
outage that dropped sync events, or after a bulk write (bulk paths don't fan out
per-record events) — backfill the index:
npx davepi-plugin-search reindex productThe plugin also detects a changed search config by hashing it; a mismatch on
boot re-asserts the index settings and backfills automatically (with a warning).
Configuration
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| SEARCH_BACKEND | no | typesense | One of typesense / meilisearch / algolia. |
| SEARCH_API_URL | typesense / meilisearch | — | E.g. http://localhost:8108. |
| SEARCH_API_KEY | yes (else dormant) | — | Admin API key used for indexing. |
| SEARCH_PUBLIC_KEY | no | falls back to SEARCH_API_KEY | Restricted key used to mint per-request scoped search keys (Typesense / Algolia). |
| ALGOLIA_APP_ID | algolia | — | Algolia application ID. |
| SEARCH_INDEX_PREFIX | no | davepi | Index/collection name prefix (davepi_product). |
| SEARCH_SYNC_DEBOUNCE_MS | no | 0 | If >0, batches index upserts within this window (helps under heavy write load). |
Scoped search keys (advanced)
When SEARCH_PUBLIC_KEY is set, POST /api/v1/{path}/search/token returns a
tenant-scoped, search-only key a browser client can use to talk to the search
service directly. Supported on Typesense and Algolia; Meilisearch returns a
clear "unsupported" error in v1.
Backend setup
Typesense (default — modern, fast, self-hostable)
# docker-compose.yml
services:
typesense:
image: typesense/typesense:27.1
ports: ["8108:8108"]
command: --data-dir /data --api-key=xyz --enable-cors
volumes: ["typesense-data:/data"]
volumes: { typesense-data: {} }SEARCH_BACKEND=typesense
SEARCH_API_URL=http://localhost:8108
SEARCH_API_KEY=xyzMeilisearch (self-hostable)
# docker-compose.yml
services:
meilisearch:
image: getmeili/meilisearch:v1.10
ports: ["7700:7700"]
environment: { MEILI_MASTER_KEY: masterKey }
volumes: ["meili-data:/meili_data"]
volumes: { meili-data: {} }SEARCH_BACKEND=meilisearch
SEARCH_API_URL=http://localhost:7700
SEARCH_API_KEY=masterKeyAlgolia (hosted; paid beyond a small free tier)
Sign up at algolia.com, create an app, and grab the Application ID and an Admin API Key (Dashboard → Settings → API Keys).
SEARCH_BACKEND=algolia
ALGOLIA_APP_ID=YourAppId
SEARCH_API_KEY=YourAdminApiKey
SEARCH_PUBLIC_KEY=YourSearchOnlyApiKey # optional, for scoped client keysNotes
- Default to Typesense or Meilisearch unless you specifically want Algolia's hosted service — both are free and self-hostable. Algolia is paid beyond a small tier.
- Indexes can be larger than the source documents; the projection step (only
declared
search.fields+facetsare indexed) is the main mitigation. - The package has zero runtime dependencies — adapters talk the backends'
HTTP APIs via the global
fetch(Node 18+).
License
ISC
