npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vibeorm/extensions

v4.2.0

Published

Tier-1 VibeORM extensions — full-text search, pgvector similarity and pg_trgm fuzzy matching

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/extensions

Stable. 2.0.0, published under the latest dist-tag. The v1 line stays installable as vibeorm@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 on pglite (it does not bundle pg_search) and on non-postgres dialects. "auto" never selects it, because ParadeDB availability is not knowable at generate time.
  • The fts5 engine requires the searched model to have a single Int or BigInt @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. Declare language: "simple" on models meant for type-ahead.
  • On engine: "bm25" the index decides: declare tokenizer: "edge_ngram" (below) and every token of the raw query matches as a prefix through &&&; without that index, prefix needs fuzzy (pdb.fuzzy(n, t)) or is refused with VIBE_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_ngram require integer minGram and maxGram; words shorter than minGram are never indexed (minGram: 1 if 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 imply unicode_words.
  • For edge_ngram the extension also sets search_tokenizer = unicode_words(<same filters>), so a query is tokenized into whole words while the index holds prefixes — without it "sho" would become s, sh, sho and match every s-word.
  • filterFields: non-searched scalar columns added to the index so where filters on them are pushed into the ParadeDB scan instead of filtering the heap afterwards. String columns are indexed with pdb.literal (exact equality and in), Boolean/Int/BigInt/Float/Decimal/DateTime and @db.Uuid columns as-is; enums, JSON and list columns are refused. ParadeDB allows one index per table, so this is the index any hand-written $querySql over the table uses too.
  • Column storage: searched fields must be text or varchar (String, @db.Text, @db.VarChar(n)); filterFields additionally accept @db.Uuid. @db.Char(n) (ParadeDB 0.25: "Type 1042 is not yet supported"), @db.Inet, @db.Xml, @db.Citext, @db.Bit and Unsupported(…) columns are refused with VIBE_UNSUPPORTED_CAPABILITY by vibeorm generate and by migrate alike.
  • Changing any of these options (or the searched/filter field lists) after the index exists rebuilds the index: migrate generate lets the shadow server compare the replayed index with the declared one and emits DROP INDEX + CREATE INDEX (the down migration restores the previous definition); db push does 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. A db push on a project without migration snapshots checks the index for presence by name only — pass db push --rebuild-artifacts (PostgreSQL) to drop and re-create every extension object from the current declarations, or drop the index by hand. weights are 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 closer

set 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, and similarTo refuses 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 not bm25 on 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.

Links