@vibeorm/extensions
v4.2.0
Published
Tier-1 VibeORM extensions — full-text search, pgvector similarity and pg_trgm fuzzy matching
Maintainers
Readme
@vibeorm/extensions
A type-safe TypeScript ORM for Bun and Node. Prisma-schema or TypeScript-DSL input, a canonical schema IR, a generated client that your editor never re-infers, and a dialect-aware SQL layer over PostgreSQL, PGlite, SQLite and MySQL.
bun add @vibeorm/extensionsStable.
2.0.0, published under thelatestdist-tag. The v1 line stays installable asvibeorm@1, and is a different API — see the migration guide below.
What it provides
Three extensions, each a factory returning a VibeExtension that you pass to vibeorm.config.ts (so generate, db push, migrate and doctor see it) and to VibeClient (so the runtime mounts it).
| Import | Factory | Databases |
|---|---|---|
| @vibeorm/extensions/search | search(options?) | PostgreSQL, PGlite, SQLite, MySQL |
| @vibeorm/extensions/vector | vector(options?) | PostgreSQL, PGlite (pgvector) |
| @vibeorm/extensions/trgm | trgm(options?) | PostgreSQL, PGlite (pg_trgm) |
The package root (@vibeorm/extensions) re-exports all three for convenience.
// vibeorm.config.ts
import { search } from "@vibeorm/extensions/search";
import { trgm } from "@vibeorm/extensions/trgm";
import { vector } from "@vibeorm/extensions/vector";
export default {
schema: "./prisma/schema.prisma",
output: "./generated/vibeorm",
extensions: [search(), trgm({ threshold: 0.3 }), vector()],
};Each extension declares its schema attributes, the DDL artifacts db push / migrate apply, and the surface the generator writes into the client's .d.ts. You declare usage in the schema; the client method or namespace appears only on the models that declare it.
Search
Declare the searched fields with @@search. Optional named arguments: name (artifact base name, a plain identifier), weights (one finite number per field, tsvector and bm25 engines), language (a text-search config name, tsvector engine only), plus the bm25 index options below.
On tsvector, weights express ordinal priority, not numeric multipliers: distinct values descending map to PostgreSQL's A, B, C, D classes, with further classes capped at D. Equal weights share a class. On bm25 they are used verbatim as per-field pdb.boost factors (multiplicative, within ±2048; 1 emits nothing). FTS5 and MySQL FULLTEXT refuse declared weights with VIBE_UNSUPPORTED_CAPABILITY; omit weights on those engines.
model Post {
id Int @id @default(autoincrement())
title String
body String
published Boolean @default(true)
@@search([title, body], weights: [2, 1])
}import { VibeClient } from "./generated/vibeorm/index.js";
import { search } from "@vibeorm/extensions/search";
const db = VibeClient({ adapter, extensions: [search()] });
const hits = await db.post.search({
query: "database indexes",
where: { published: true },
take: 10,
skip: 0,
minScore: 0.1,
highlight: { field: "body" },
});
// hits: SearchHit<Post>[] — { row, score, highlights }take must be a positive safe integer and skip a non-negative safe integer. Negative, fractional and non-finite pagination refuses with VIBE_VALIDATION rather than being clamped. minScore and vector maxDistance must be finite numbers; a negative inner-product distance is valid. Trigram thresholds must be finite numbers in [0, 1]. Vector index tuning requires integers: m 2..100, efConstruction 4..1000, lists 1..32768. The database may impose additional constraints between options.
highlights is readonly string[] | null; it is null when no highlight was requested and on engines without highlighting.
row holds the model's columns, read exactly as findMany reads them, for search and for vector nearest. Generated artifact columns (the tsvector column) and Unsupported(...) columns such as the vector itself are not model fields and are not returned.
Engines per dialect
engine: "auto" (the default) picks the dialect's native engine. resolveSearchEngine({ dialect, provider, requested }) is the exported resolution rule.
| Dialect | Engine | SQL used | Highlights |
|---|---|---|---|
| postgres / pglite | tsvector | generated STORED tsvector column + GIN index, websearch_to_tsquery / ts_rank_cd / ts_headline | yes |
| postgres (ParadeDB pg_search, opt in with search({ engine: "bm25" })) | bm25 | bm25 index with declared tokenizer casts, @@@ (or &&& with pdb.fuzzy for prefix/fuzzy), pdb.boost per field, paradedb.score / paradedb.snippet | yes |
| sqlite | fts5 | external-content virtual table + sync triggers, MATCH / bm25() / snippet() | yes |
| mysql | fulltext | FULLTEXT index, MATCH … AGAINST in natural language mode | no, typed null |
Two gates worth knowing before you pick a dialect:
engine: "bm25"is refused onpglite(it does not bundlepg_search) and on non-postgres dialects."auto"never selects it, because ParadeDB availability is not knowable at generate time.- The
fts5engine requires the searched model to have a singleIntorBigInt@id. That is the only primary key that aliases and pins the SQLite rowid the external-content table keys on; anything else is refused at generate time.
FTS5 installation on populated tables
The installation statements create the external-content table, install its synchronization triggers, then run FTS5's rebuild command to index historical rows. Apply the full installation in one transaction. This scans the host table and takes SQLite's write lock; allow for that cost in a migration window. It is installation work, not an application-startup hook. Existing installations are not automatically repaired by upgrading the package; review and run an explicit rebuild migration if historical rows were omitted.
Type-ahead (prefix) matching
All engines match complete words by default — a user still typing gets nothing for "piz". For search-as-you-type boxes pass prefix: true:
const hits = await db.post.search({ query: "piz", prefix: true, take: 8 });The query is split into unicode letter/number tokens (punctuation separates and never matches) and every token matches as a prefix. Per engine: tsvector builds to_tsquery('lang', 'piz:*') (still one bound parameter — websearch_to_tsquery has no prefix syntax), fts5 uses "piz"* in the MATCH string, mysql switches to +piz* in BOOLEAN MODE. A query with no tokens resolves to [] without touching the database. Three caveats:
- tsvector prefixes run against stemmed lexemes, so a stemming
language("english", "norwegian") can miss mid-stem prefixes. Declarelanguage: "simple"on models meant for type-ahead. - On
engine: "bm25"the index decides: declaretokenizer: "edge_ngram"(below) and every token of the raw query matches as a prefix through&&&; without that index,prefixneedsfuzzy(pdb.fuzzy(n, t)) or is refused withVIBE_UNSUPPORTED_CAPABILITY. - mysql InnoDB ignores tokens shorter than
innodb_ft_min_token_size(default 3), so one- and two-letter prefixes match nothing there.
ParadeDB bm25 index options
The bm25 engine is the one engine whose index configuration decides matching behavior, so @@search carries it (every key below is bm25-only and refused on other engines). The address-picker shape — type-ahead over Norwegian street names typed without ø, scoped to a municipality:
model Address {
id Int @id @default(autoincrement())
street String
city String
municipalityNumber String
active Boolean @default(true)
@@search([street, city], weights: [2, 1],
tokenizer: "edge_ngram", minGram: 1, maxGram: 12, asciiFolding: true,
filterFields: [municipalityNumber, active])
}CREATE INDEX IF NOT EXISTS "Address_search_idx" ON "Address" USING bm25 ("id",
("street"::pdb.edge_ngram(1, 12, 'ascii_folding=true')), ("city"::pdb.edge_ngram(1, 12, 'ascii_folding=true')),
("municipalityNumber"::pdb.literal), "active")
WITH (key_field='id', search_tokenizer='unicode_words(ascii_folding=true)');tokenizer:unicode_words(ParadeDB's default),simple,whitespace,ngram,edge_ngram,icu,literal_normalized,source_code,chinese_compatible,jieba. One tokenizer per model, applied to every searched field.ngram/edge_ngramrequire integerminGramandmaxGram; words shorter thanminGramare never indexed (minGram: 1if house numbers must match).- Token filters, appended to the tokenizer:
asciiFolding: true(ø→o,é→e),stemmer: "norwegian"(a Snowball language),lowercase: false,stopwordsLanguage: "english"(one language). Filters alone implyunicode_words. - For
edge_ngramthe extension also setssearch_tokenizer = unicode_words(<same filters>), so a query is tokenized into whole words while the index holds prefixes — without it"sho"would becomes,sh,shoand match every s-word. filterFields: non-searched scalar columns added to the index sowherefilters on them are pushed into the ParadeDB scan instead of filtering the heap afterwards.Stringcolumns are indexed withpdb.literal(exact equality andin),Boolean/Int/BigInt/Float/Decimal/DateTimeand@db.Uuidcolumns as-is; enums, JSON and list columns are refused. ParadeDB allows one index per table, so this is the index any hand-written$querySqlover the table uses too.- Column storage: searched fields must be
textorvarchar(String,@db.Text,@db.VarChar(n));filterFieldsadditionally accept@db.Uuid.@db.Char(n)(ParadeDB 0.25: "Type 1042 is not yet supported"),@db.Inet,@db.Xml,@db.Citext,@db.BitandUnsupported(…)columns are refused withVIBE_UNSUPPORTED_CAPABILITYbyvibeorm generateand by migrate alike. - Changing any of these options (or the searched/filter field lists) after the index exists rebuilds the index:
migrate generatelets the shadow server compare the replayed index with the declared one and emitsDROP INDEX+CREATE INDEX(the down migration restores the previous definition);db pushdoes the same when the migration history carries a desired-state snapshot. A bm25 rebuild re-tokenizes the whole table — budget for it in the migration window. Adb pushon a project without migration snapshots checks the index for presence by name only — passdb push --rebuild-artifacts(PostgreSQL) to drop and re-create every extension object from the current declarations, or drop the index by hand.weightsare query-time boosts and never touch the index.
Query-time arguments on bm25:
const hits = await db.address.search({
query: "stro",
prefix: true, // edge_ngram index: every token is a prefix
fuzzy: 1, // and may carry one typo (pdb.fuzzy(1, t)); ParadeDB caps it at 2
where: { municipalityNumber: "3005", active: true },
minScore: 0.5, // paradedb.score(id) >= 0.5 inside the index scan, before LIMIT
take: 8,
});fuzzy (1 or 2) is the Levenshtein edit distance per token; it switches the match from the @@@ query parser (OR between words) to &&& (every token must match). It exists only on bm25 — on the other engines the typed search signature does not offer it and the runtime refuses it; use the trgm extension's similarTo there. @@@ without prefix/fuzzy keeps ParadeDB's query-parser syntax (quoted phrases, AND/OR), exactly as before.
Filtering hits: where composes with the core filter language
search({ where }) compiles through the same filter compiler as every core query — scalar
filters, relation filters and dbNow() comparisons all AND into the match. The menu
composition (type-ahead over items that are orderable right now):
model MenuItem {
id Int @id @default(autoincrement())
name String
description String
availableFrom DateTime?
availableTo DateTime?
category Category? @relation(fields: [categoryId], references: [id])
categoryId Int?
@@search([name, description], weights: [2, 1], language: "simple")
}import { dbNow } from "@vibeorm/runtime";
const hits = await db.menuItem.search({
query: "piz", // mid-keystroke
prefix: true,
take: 8,
where: {
category: { is: { active: true } },
availableFrom: { lte: dbNow() }, // the DATABASE's clock, not the process's
availableTo: { gte: dbNow() },
},
});For typo tolerance ("piza") on bm25 pass fuzzy: 1; on the other postgres engines declare @@trgm([name]) too and fall back to
findMany({ where: { name: { similarTo: query } } }) when a prefix search returns nothing
(postgres/pglite only).
Hydrating hits with relations
search() returns bare rows — it has no include/select of relations. The intended pattern is the select: "id" projection plus a re-read through your own typed query, which carries relations, omit, computed fields and everything else the core API does:
const hits = await db.post.search({ query: "piz", prefix: true, select: "id", take: 8 });
const rows = await db.post.findMany({
where: { id: { in: hits.map((hit) => hit.row.id) } },
include: { author: { select: { id: true, name: true } } },
});
// findMany does not preserve rank — re-order by score:
const byId = new Map(rows.map((row) => [row.id, row]));
const ranked = hits.flatMap((hit) => byId.get(hit.row.id) ?? []);createSearchDelegate(config) is the runtime glue the generator emits into the client. You do not call it by hand.
Vector
pgvector similarity search, on PostgreSQL and PGlite only. Declare a Vector(n) field and an index with @@vector. Named arguments: metric ("cosine" default, "l2", "ip"), index ("hnsw" default or "ivfflat"), plus m / efConstruction for hnsw and lists for ivfflat.
model Doc {
id Int @id @default(autoincrement())
tenantId String
embedding Vector(3)?
@@vector([embedding], metric: "cosine", index: "hnsw")
}import { vector } from "@vibeorm/extensions/vector";
const db = VibeClient({ adapter, extensions: [vector()] });
await db.doc.vector.set({ where: { id: 1 }, by: "embedding", to: [1, 0, 0] });
const near = await db.doc.vector.nearest({
by: "embedding",
to: [1, 0, 0],
take: 10,
where: { tenantId: "t2" },
maxDistance: 0.4,
});
// near: VectorHit<Doc>[] — { row, distance }, smaller distance is closerset returns the number of updated rows. nearest takes an optional metric that overrides the schema's default per query. A to array whose length does not match the column's declared dimensions is a VibeError with code VIBE_VALIDATION. createVectorNamespace(config) is the generated glue; VectorMetric and VectorHit are the exported types the client's .d.ts imports.
Trgm
pg_trgm fuzzy matching, on PostgreSQL and PGlite only. It claims no client surface: it adds the similarTo where-operator and gin_trgm_ops indexes over the fields listed in @@trgm.
model User {
id Int @id @default(autoincrement())
name String
@@trgm([name])
}import { trgm } from "@vibeorm/extensions/trgm";
const db = VibeClient({ adapter, extensions: [trgm({ threshold: 0.3 })] });
await db.user.findMany({ where: { name: { similarTo: "jonatan" } } });
await db.user.findMany({ where: { name: { similarTo: { text: "jonatan", threshold: 0.95 } } } });The operand is a SimilarToOperand: a string, or { text, threshold? } for a per-use threshold. threshold in TrgmOptions sets the default (0.3) and is applied to the session's pg_trgm.similarity_threshold on connect.
The compiled predicate has two forms. When the effective threshold is at least max(configured, 0.3) it emits col % $q AND similarity(col, $q) >= $t, so the trigram index prunes. Below that floor it emits similarity(col, $q) >= $t alone, which is a full scan but returns every row the caller asked for, because a pooled connection may still carry the server's 0.3 default. vibeorm doctor reports whether the server's pg_trgm.similarity_threshold sits above the gate floor.
Capability gating
A feature a dialect cannot express throws a VibeError with code VIBE_UNSUPPORTED_CAPABILITY at the earliest knowable moment: generate or migrate time when the schema alone decides it, execute time otherwise. Nothing degrades silently.
vector()on sqlite or mysql fails when artifacts are built and when the client is generated, before any DDL reaches the database.trgm()fails the same way, andsimilarTorefuses to compile on a non-postgres dialect.search({ engine })fails at engine resolution when the requested engine is not the dialect's native one (and is notbm25on a non-pglite postgres).
vibeorm doctor reports per-extension checks: pg_search, vector and pg_trgm availability from pg_available_extensions, FTS5 compilation on SQLite, and the trgm threshold check described above.
