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

search-cop

v0.6.0

Published

A powerful search DSL that compiles to TypeORM SelectQueryBuilder queries

Readme

search-cop

A powerful search DSL that compiles to a TypeORM SelectQueryBuilder.

import { search } from 'search-cop';

const qb = search({
  repository: ProductRepository,
  query: 'status:online AND price:>100',
  attributes: {
    status: { type: 'enum', values: ['online', 'offline'] },
    price: { type: 'number' },
    createdAt: { type: 'datetime' },
  },
});

const products = await qb.getMany();

search() returns a plain TypeORM SelectQueryBuilder — nothing is wrapped.

repository, query, and attributes are required. alias is optional and defaults to the entity's table name — pass it explicitly if you need the generated query to use a specific alias (e.g. to match an alias already used elsewhere in a larger query). It only affects the FROM/SELECT clauses TypeORM generates on its own; it has no effect on the WHERE clause, since fields are always inserted verbatim.

Combining with your own queryBuilder

search() is a convenience for the common case: give it a repository and get back a ready-to-go SelectQueryBuilder. If you've already built your own queryBuilder — with joins, a specific alias, or other where conditions already in place — use searchCondition() instead. It compiles a query to a standalone Brackets fragment rather than a full query, which you merge in yourself via andWhere()/orWhere():

import { searchCondition } from 'search-cop';

const queryBuilder = ProductRepository.createQueryBuilder('product').leftJoinAndSelect('product.author', 'author');

queryBuilder.andWhere(searchCondition({
  query: 'author.name:joe',
  attributes: {
    // A join means there's now more than one table, so this field is qualified —
    // exactly as with any other field, search-cop inserts it verbatim (see below).
    author: { type: 'string', fields: ['author.name'] },
  },
}));

const products = await queryBuilder.getMany();

Brackets (and its negated counterpart NotBrackets, used internally for NOT) is TypeORM's own portable where-clause primitive — its callback isn't evaluated until it's handed to andWhere()/orWhere()/where() on a real queryBuilder, so searchCondition() needs no repository or queryBuilder of its own, and doesn't touch or know about the rest of your query. Using andWhere() (rather than where()) to merge it in means any conditions you already added stay in place — search-cop only ever adds to your WHERE clause, never replaces it.

Attributes

Only attributes declared in attributes may be queried. Supported types:

| Type | Runtime value | Allowed operators | | ---------- | -------------- | --------------------------------- | | string | string | = > >= < <= | | number | number | = > >= < <= | | boolean | boolean | = | | date | Date | = > >= < <= | | datetime | Date | = > >= < <= | | enum | string | = | | uuid | string | = | | null | none — compiles to IS NULL/IS NOT NULL | = | | fulltext | string | : only | | tag | string — redirected into a fulltext attribute | : only |

enum attributes also require a values: string[] list, or a values: Record<string, string> map to translate the query-facing value into a different underlying value, e.g. { type: 'enum', values: { pending: 'waiting', completed: 'finished' } }.

null attributes require isNull: string[] and isNotNull: string[] — see Null checks.

string attributes accept an optional caseSensitive: boolean | 'lower' | 'upper' (default true) — see Case sensitivity — and optional wildcards/ leftWildcard/rightWildcard: boolean (all default false) — see Implicit wildcards.

uuid values are validated against RFC 9562 (version 1-8 and variant nibbles, plus the nil and max UUIDs) using the uuid package, and are lowercased on the way out.

Any attribute type accepts an optional fields to match multiple underlying columns instead of the attribute's own key — see Multi-field attributes.

A query term with no field: prefix is matched against the conventional _all attribute key — see Default field.

Query syntax

A predicate is field<op>value, or just a bare value to search the default field (see Default field). : means equality:

status:online        // equivalent to status:=online
price:>100
price:>=100
price:<100
price:<=100
createdAt:>=2026-01-01

For most attribute types, status:online and status:=online compile identically. For a string attribute they don't: the bare : form always compiles to a LIKE predicate (so it can support wildcard syntax — see Wildcards) and enables the wildcards option (see Implicit wildcards); an explicit = always compiles to a plain = comparison and opts out of both.

Combine predicates with AND / OR (case-sensitive — lowercase and/or are rejected) and parentheses. AND binds tighter than OR:

status:online AND price:>100
status:online OR status:pending
status:a OR status:b AND status:c        // == status:a OR (status:b AND status:c)
(status:online OR status:pending) AND price:>100

AND is the default combinator and may be omitted — juxtaposing predicates with whitespace implies AND:

status:online price:>100                 // == status:online AND price:>100

Prefix a predicate or parenthesized group with NOT to negate it. NOT binds tighter than AND/OR, so it only negates the next term or group — parenthesize to negate more than one:

NOT status:online                        // negates just this predicate
NOT status:online price:>100             // == (NOT status:online) AND price:>100
NOT (status:online OR status:pending)    // negates the whole group
NOT NOT status:online                    // double negation cancels out

NOT must be immediately followed by whitespace or ( — NOTstatus:online is a field literally named NOTstatus, not a negation. NOT is reserved the same way AND/OR are (see below) — double-quote it ("NOT") to search for the literal word.

- is shorthand for NOT, including on a bare term against _all (see Default field):

-status:online                           // == NOT status:online
-cheap                                   // negates a bare term
red -cheap                               // == red AND (NOT cheap)
-(status:online OR status:pending)       // negates the whole group

Unlike NOT, - only counts as negation when directly attached to what follows — no space. - cheap (a space after -) parses - itself as the literal value -, followed by a separate cheap term; a lone - is likewise just the literal value -. This also means - is never mistaken for negation inside a value — a negative number (price:-5) or a hyphenated word (well-known) are unaffected, since this rule only ever applies at the very start of a term. Searching for a literal leading - needs quoting (-"AND" negates the literal word AND; "-test" searches for the literal string -test).

Quoted values

Unquoted values end at the first (unescaped) whitespace or parenthesis, so a value containing either must be double-quoted:

name:"foo bar"
name:"(foo)"

\ escapes the following character the same way in both quoted and unquoted values: \" unescapes to a literal " (only meaningful inside quotes, to embed one without ending the value early), \\ to a literal \, and \* to a literal * (see Wildcards — the one case that matters outside quotes too). Any other \<char> just drops the backslash, so a literal backslash needs doubling:

name:"foo \"bar\" baz"                   // foo "bar" baz
name:back\\slash                         // back\slash
name:back\slash                          // backslash (a single "\" is just dropped)

Escaping in an unquoted value can't swallow a terminator — \ followed by a space or parenthesis still ends the value there; quote the value instead if you need one included.

Wildcards

Wildcard syntax exists only for the bare : shorthand on a string attribute — every explicit operator (=, >, >=, <, <=) always treats * as a plain literal character, never a wildcard (see Query syntax). Because of that, : on a string attribute always compiles to a LIKE predicate — even when the value has no * at all — while every explicit operator always compiles to a plain comparison. Wildcards don't apply to enum/uuid/other attribute types.

A * translates to SQL's %; any literal % or _ in the value is escaped so it's matched literally rather than as a LIKE wildcard. A bare * is only valid at the very start and/or end of the value — one anywhere else throws INVALID_WILDCARD rather than silently falling back to a literal match:

name:Pet*                                   // starts with "Pet"
name:*fred                                  // ends with "fred"
name:*pet*                                  // contains "pet"

Escape a literal * with \* (see Quoted values):

name:Pet\*                                  // literal value "Pet*", not a wildcard
name:*Pet\*Other                            // starts with "Pet*Other" (real wildcard + literal "*")

Because : always compiles to LIKE, case-sensitivity of every bare-colon string match (wildcarded or not) depends on the database's LIKE collation by default (e.g. Postgres' LIKE is case-sensitive; SQLite's is case-insensitive for ASCII regardless of collation) — see Case sensitivity for a portable, explicit alternative.

Implicit wildcards

Set wildcards: true on a string attribute to make the bare : shorthand a contains match by default, without writing * yourself:

attributes: {
  name: { type: 'string', wildcards: true },
}
name:pet                                    // contains "pet" — same as name:*pet*
name:pet*                                   // explicit "*" is left exactly as written
name:=pet                                   // explicit "=" always stays an exact match

wildcards: true is shorthand for setting both leftWildcard and rightWildcard, which you can also set independently for a one-sided match:

attributes: {
  endsWithName: { type: 'string', leftWildcard: true },     // endsWithName:pet   -> *pet  (ends with)
  startsWithName: { type: 'string', rightWildcard: true },  // startsWithName:pet -> pet*  (starts with)
}

Precedence, in order: an explicit * anywhere in the value is always respected as-is (no double-wrapping); otherwise an explicit = is always an exact match; only a bare : with neither falls back to the implicit wrap. This also applies to a bare term against _all (see Default field), since bare terms are : too.

Case sensitivity

By default, string attributes are matched case-sensitively (subject to the database's own collation rules, as noted above for wildcards). Set caseSensitive: false on the attribute definition to make =, < <= > >=, and wildcard matches case-insensitive:

attributes: {
  name: { type: 'string', caseSensitive: false },
}

This compiles to LOWER(column) <op> LOWER(value), using the standard SQL LOWER() function so behavior is identical across Postgres, MySQL, and SQLite — rather than a database-specific mechanism (e.g. Postgres' ILIKE or citext, or a COLLATE clause).

Set caseSensitive: 'upper' instead to fold through UPPER() rather than LOWER() — for example, to match an existing functional index built on UPPER(column):

attributes: {
  name: { type: 'string', caseSensitive: 'upper' },
}
name:=Fred     // UPPER(name) = 'FRED'

caseSensitive: 'lower' is also accepted, and behaves exactly like false — it's just the explicit spelling, useful if you'd rather not read false as "off".

Multi-field attributes

An attribute can match against multiple underlying columns instead of a single one — for example, searching a "name" query across both firstName and lastName columns:

attributes: {
  name: { type: 'string', fields: ['firstName', 'lastName'] },
}
name:Fred          // (firstName LIKE 'Fred' ...) OR (lastName LIKE 'Fred' ...)
name:Fred*         // (firstName LIKE 'Fred%' ...) OR (lastName LIKE 'Fred%' ...)
name:=Fred         // firstName = 'Fred' OR lastName = 'Fred'

Only = is supported (including its wildcard form) — ordering operators (> >= < <=) are rejected, since combining multiple columns with OR under an ordering comparison doesn't have an unambiguous meaning.

Fields are raw SQL

Every fields entry — whether a plain string, or the implicit default when fields is omitted (the attribute's own key) — is inserted into the generated SQL verbatim: no escaping, and no alias-qualification. search-cop doesn't know whether your column name is a safe unquoted identifier, doesn't know your database's quote character, and doesn't create or know about any joins — you do, so quoting (e.g. '"createdAt"' to preserve case on Postgres) and qualification (e.g. 'author.name' if you've added your own leftJoin) are entirely your responsibility whenever you need them. A plain 'firstName' is exactly as raw as any other entry; it's just already a valid bare identifier, so it needs nothing extra. Querying a joined column works exactly like any other column — see the searchCondition() example above — search-cop just never adds the join itself.

This also means a field entry doesn't have to be a column name at all — anything valid in SQL works: a cast, a computed expression, string concatenation, and so on. A string-typed multi-field attribute always binds the search value as a plain string parameter for every field. If one of the underlying columns is actually a stricter type — a uuid or integer primary key, say — comparing it directly against an arbitrary string can make the database reject the query outright (e.g. Postgres: invalid input syntax for type uuid: "foo") instead of the field simply not matching. Cast that field to text instead:

attributes: {
  name: {
    type: 'string',
    fields: ['firstName', 'lastName', 'CAST(id AS TEXT)'],
  },
}
name:Fred     // (firstName LIKE 'Fred' ...) OR (lastName LIKE 'Fred' ...) OR (CAST(id AS TEXT) LIKE 'Fred' ...)

CAST itself is standard SQL and works identically everywhere, but the type-name argument is dialect-specific: TEXT works on Postgres and SQLite, but MySQL's CAST doesn't accept TEXT (use CHAR there instead); conversely a bare CHAR silently truncates to a single character on Postgres. search-cop never inspects or validates a fields entry — it's entirely your responsibility: you know your database; search-cop deliberately doesn't try to.

Field-level type overrides

If you'd rather not write SQL at all, give a field its own type (and, for enum, its own values) instead of casting. search-cop then validates that field independently, using its own type's normal conversion — reusing the exact same logic as a regular attribute — rather than the outer attribute's string type:

attributes: {
  name: {
    type: 'string',
    fields: ['firstName', 'lastName', { field: 'id', type: 'uuid' }],
  },
}
name:Fred                                    // (firstName LIKE 'Fred' ...) OR (lastName LIKE 'Fred' ...)
                                              //   OR (id is skipped: "Fred" isn't a valid uuid)
name:550e8400-e29b-41d4-a716-446655440000     // ...OR id = '550e8400-e29b-41d4-a716-446655440000'

Unlike casting, a field-level type override needs no dialect knowledge and no CAST at all — a value that doesn't fit the override's type just makes that one field not match (see Unparseable values never error), while a value that does fit gets bound normally, letting the database do its own natural type coercion. The tradeoff: it only ever produces an exact = match, so (unlike casting to text) it can't support wildcards against that field — a wildcarded query always fails validation for anything other than string (there's always a literal * in the raw value, and no uuid/number/date/etc. can ever contain one).

Trigram indexes (Postgres)

A bare-: string match compiles to LIKE '%...%' when wildcards are used on both sides — a pattern a plain B-tree index can't accelerate at all, since the leading % rules out any prefix-based lookup. Postgres' pg_trgm extension provides a GIN/GiST index type built for exactly this case:

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_name_trgm ON products USING gin (name gin_trgm_ops);
attributes: {
  name: { type: 'string', wildcards: true },
}
name:pet     // name LIKE '%pet%' — now served by idx_name_trgm instead of a seq scan

This is entirely a database-side concern — search-cop doesn't create or know about indexes, it just needs the compiled SQL expression to match whatever expression the index was built on, exactly. Two ways that expression can drift out from under an index without erroring (the query still runs, just without using the index):

  • A multi-field attribute compiles to separate OR-ed LIKE conditions per field (name LIKE ... OR description LIKE ...), which doesn't match an index built on a concatenated expression. To use an index on (name || ' ' || description), give that same concatenation as a single raw fields entry instead of two separate ones, so the compiled condition is one LIKE against that exact expression:

    attributes: {
      search: { type: 'string', wildcards: true, fields: ["name || ' ' || description"] },
    }
    CREATE INDEX idx_search_trgm ON products USING gin ((name || ' ' || description) gin_trgm_ops);
  • caseSensitive: false/'upper' (see Case sensitivity) wraps the column in LOWER()/UPPER(), changing the compiled expression — build the index on the same wrapped expression (e.g. USING gin ((LOWER(name)) gin_trgm_ops)) to keep it matching.

Default field

A query term with no field: prefix compiles to a predicate against the conventional _all attribute key (exported as DEFAULT_FIELD). Define it like any other attribute — typically as a multi-field attribute — to opt in:

import { search, DEFAULT_FIELD } from 'search-cop';

const qb = search({
  repository: ProductRepository,
  query: 'red shoes status:online',
  attributes: {
    status: { type: 'enum', values: ['online', 'offline'] },
    // Fold a uuid primary key into the default field too — casting it to text (see
    // above) keeps a non-uuid-shaped term from erroring against that column.
    [DEFAULT_FIELD]: { type: 'string', fields: ['name', 'description', 'CAST(id AS TEXT)'] },
  },
});

Bare terms can be freely combined with explicit field:value predicates and with each other. Since AND is already implicit between predicates, multiple bare terms behave like free-text search — each term ORs across the configured fields, and terms AND together:

red shoes             // (name LIKE 'red' ... OR description LIKE 'red' ...)
                      //   AND (name LIKE 'shoes' ... OR description LIKE 'shoes' ...)
red* shoes*           // same, but with wildcards on each term
red status:online     // (name LIKE 'red' ... OR description LIKE 'red' ...) AND status = 'online'

Unlike any other undeclared field, a bare query never errors when _all isn't declared in attributes — it's opt-in, so it just never matches (see Unparseable values never error) rather than being treated as a typo. AND/OR/NOT are always reserved as keywords, even as a bare term on their own — double-quote them ("AND", "OR", "NOT") to search for the literal word.

UUIDs

id:550e8400-e29b-41d4-a716-446655440000
id:550E8400-E29B-41D4-A716-446655440000     // case-insensitive, lowercased on output

Booleans

active:true
active:false

Null checks

A null attribute compiles to an IS NULL/IS NOT NULL check on the underlying column instead of a value comparison — no parameter is ever bound. isNull/isNotNull are each a list of DSL values that trigger that check, letting you accept multiple synonyms for the same check:

attributes: {
  assigned: { type: 'null', isNull: ['false', 'no'], isNotNull: ['true', 'yes'], fields: ['assignedTo'] },
}
assigned:no      // assignedTo IS NULL
assigned:yes     // assignedTo IS NOT NULL

A value that's in neither list never errors — like any other attribute type, it just never matches (see Unparseable values never error). Only = is supported, same as enum/boolean/uuid.

Dates and datetimes

Date-only values (YYYY-MM-DD) are interpreted as UTC midnight. Datetime values accept YYYY-MM-DDTHH:mm:ss[.sss](Z|±HH:mm); when no offset is given, the value is interpreted as UTC, not the host machine's local timezone.

releaseDate:>=2026-01-01                    // 2026-01-01T00:00:00.000Z
createdAt:>=2026-01-01T10:00:00             // 2026-01-01T10:00:00.000Z (UTC)
createdAt:>=2026-01-01T10:00:00+02:00       // 2026-01-01T08:00:00.000Z

Full-text search (Postgres)

A fulltext attribute matches against a tsvector. fields must already evaluate to one — either a precomputed/indexed column, or a SQL expression that builds one — search-cop never wraps or casts it for you, same as any other raw fields entry. A required dialect controls how a query term becomes the tsquery value compared against it: 'to_tsquery' hands the term to Postgres's own text-search parser and dictionaries; 'tsquery' bypasses them entirely for literal, tokenizer-controlled matching. There's no default — dialect must always be set explicitly, since the two behave differently enough that picking one silently on a caller's behalf would be surprising. Only : is supported for either dialect (no =, no ordering operators).

dialect: 'to_tsquery'

Compiles to @@ to_tsquery(...):

attributes: {
  _all: {
    type: 'fulltext',
    dialect: 'to_tsquery',
    fields: ["to_tsvector('simple', title || ' ' || body)"],
  },
}
pizza                 // to_tsvector(...) @@ to_tsquery('simple', '''pizza''')
pizza pasta           // ...@@ to_tsquery('simple', '''pizza'' & ''pasta''')       (AND)
pizza OR pasta        // ...@@ to_tsquery('simple', '''pizza'' | ''pasta''')       (OR)
pizza -pasta          // ...@@ to_tsquery('simple', '''pizza'' & !''pasta''')      (NOT)
pizza*                // ...@@ to_tsquery('simple', '''pizza'':*')                (prefix wildcard)

Every term is bound as a plain parameter and quoted as its own tsquery lexeme (any embedded ' is doubled, per tsquery's own quoting rule) before being spliced into the query text — a term is never concatenated into that text raw. That matters for correctness, not just safety: to_tsquery requires its own operators (&/|/!) between lexemes, so a value containing one of those characters would otherwise either silently change the query's meaning or throw a syntax error outright, depending on where it landed.

A trailing * on a bare term makes it a prefix match (to_tsquery's :*, applied after the closing quote) — the only wildcard shape tsquery supports; there's no suffix/contains equivalent. A * anywhere other than the end of a value throws INVALID_WILDCARD, same as for a string attribute.

An optional language (default 'simple' — no stemming, no stopwords) sets the regconfig passed to to_tsquery — it should match whatever produced the tsvector in fields. Set it to 'english' (or another language config) for stemming/stopword handling.

A multi-word quoted value (e.g. "red shoes") is left as one quoted lexeme and joined into a phrase (<->) by to_tsquery's own parser — set phrases: false to join it with & (plain AND, ignoring word order/adjacency) instead.

dialect: 'tsquery' — literal, tokenizer-controlled matching

to_tsquery always re-runs Postgres's text-search parser and dictionary over every lexeme it's given — even a quoted one — so a term like status=online or foo&bar always gets split into two lexemes no matter how it's escaped. dialect: 'tsquery' avoids the parser entirely by casting straight to tsquery ((:query)::tsquery instead of to_tsquery(:language, :query)) — a raw cast takes each quoted lexeme completely literally, so whatever tokenized the tsvector in fields is the only thing that decides what a token is:

attributes: {
  tags: {
    type: 'fulltext',
    dialect: 'tsquery',
    fields: ["array_to_tsvector(array_remove(regexp_split_to_array(tags, '\\s+'), ''))"],
  },
}
tags:"status=online"   // array_to_tsvector(...) @@ ('''status=online''')::tsquery

array_to_tsvector(regexp_split_to_array(...)) stores each array element as a literal lexeme with no parsing or normalization at all, so status=online survives as one token instead of being split on =. Since there's no config/dictionary involved, there's no language option for this dialect — it's simply ignored if set.

An optional tokenize: (value: string) => string[] controls how a query term is split into lexemes before being matched — it should mirror whatever produced the tokens in fields. It defaults to splitting on whitespace, matching the regexp_split_to_array(..., '\s+') recipe above. Because it's a function rather than a regex, it can also normalize case in the same place, mirroring a transform applied on the SQL side (e.g. lower(...) before the split):

attributes: {
  tags: {
    type: 'fulltext',
    dialect: 'tsquery',
    fields: ["array_to_tsvector(array_remove(regexp_split_to_array(lower(tags), '\\s+'), ''))"],
    tokenize: (value) => value.toLowerCase().split(/\s+/).filter(Boolean),
  },
}

array_to_tsvector does not store lexeme positions, so a multi-token term always joins with & (plain AND) by default — phrases: true, which joins with <-> instead, silently never matches anything against a position-less vector like the one above (not an error — see Unparseable values never error for the general precedent). Positions — and so real phrase search — need a different fields recipe, building the tsvector's own literal syntax (which accepts explicit positions) from a tokenized array:

CREATE FUNCTION literal_tsvector(input text, sep text DEFAULT '\s+') RETURNS tsvector AS $$
  SELECT COALESCE(
    string_agg(quote_literal(tok) || ':' || ord::text, ' ' ORDER BY ord),
    ''
  )::tsvector
  FROM unnest(array_remove(regexp_split_to_array(input, sep), '')) WITH ORDINALITY AS t(tok, ord)
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
attributes: {
  tags: {
    type: 'fulltext',
    dialect: 'tsquery',
    phrases: true,
    fields: ["literal_tsvector(tags)"],
  },
}

This still needs no extension — IMMUTABLE SQL functions like this one are indexable (CREATE INDEX ... USING gin (literal_tsvector(tags)), same functional-index pattern as Trigram indexes) — but it costs roughly 2x a plain array_to_tsvector/to_tsvector call per row, since a function with a FROM/aggregate in its body can't be inlined into the surrounding query the way a single-expression function can. Postgres also flatly rejects a bare subquery as an index expression, which is why this needs a wrapper function rather than an inline expression.

type: 'tag' — key:value syntax against a literal fulltext attribute

Typing status:online directly against a tsquery-dialect attribute doesn't do what it looks like it should — search-cop's own grammar parses field<op>value first, so status:online as a top-level query term means "attribute status, operator :, value online", not a literal string to match. Getting the literal string status:online matched requires writing it explicitly as a quoted value against the actual attribute, e.g. tags:"status:online".

A tag attribute makes the natural syntax work by rewriting the predicate before it's resolved: status:online compiles exactly as if the query had been tags:"status:online" against the attribute it points to (normally a dialect: 'tsquery' fulltext attribute, so the literal : survives):

attributes: {
  status: { type: 'tag', attribute: 'tags' },
  priority: { type: 'tag', attribute: 'tags' },
  tags: {
    type: 'fulltext',
    dialect: 'tsquery',
    fields: ["array_to_tsvector(array_remove(regexp_split_to_array(tags, '\\s+'), ''))"],
  },
}
status:online                    // tags @@ ('''status:online''')::tsquery
status:online priority:high      // tags @@ ('''status:online'' & ''priority:high''')::tsquery   (fused)

Only : is supported (no =, no ordering operators), same as fulltext. Wildcards, negation, and escaping all work exactly as they do for any other bare-colon term — a trailing * (e.g. status:on*) makes the whole reconstructed token a prefix match, not just the part after the colon. Since the rewritten predicate is indistinguishable from an ordinary predicate against the target attribute, it goes through the exact same resolution and fusion as any other fulltext term — several tag attributes pointing at the same target (like status and priority above) fuse into a single @@ call when combined in a query, exactly like two predicates against the same named fulltext attribute already do.

search-cop doesn't validate that attribute points at a fulltext attribute (or that its dialect is 'tsquery') — same "config is your responsibility" contract as fields being raw SQL. Pointing it at a nonexistent attribute throws UNKNOWN_ATTRIBUTE (the rewritten predicate is validated exactly like any other), but pointing it at, say, a dialect: 'to_tsquery' attribute won't error — it'll just silently re-split the literal : via Postgres's own parser, the same as using that dialect directly.

Fusion

Several bare terms against the same fulltext attribute, combined at the same AND/OR level (including a single negated term), are fused into one @@ call instead of one call per term — each term is rendered independently and then joined with &/|/!, never by concatenating raw values into a shared string. This keeps a free-text query from re-evaluating the tsvector expression (and re-scanning its index) once per word.

Fusion also folds an already-fused nested AND/OR group back up into an enclosing group when both target the same attribute shape (field, language, dialect) — implicit AND binds tighter than OR, so a b OR c parses as (a AND b) OR c, but still compiles to a single @@ call against ('a' & 'b') | 'c' rather than two separate ones. A NOT wrapping a whole group, or a nested group whose attribute shape doesn't match the enclosing level (a different field, language, or dialect, or mixed in with a non-fulltext attribute), still compiles to its own separate @@ call instead (still correct, just not fused). Fusion itself has no Postgres-specific knowledge — the rendering it calls into (per-term quoting, &/|/! glue, :* for wildcards, and the outer @@ shape) is looked up by dialect. MySQL (MATCH ... AGAINST) and SQLite (FTS5) full-text — which would need a different combined-query syntax and outer SQL shape for the same fused terms — are not implemented yet, but would slot into that same per-dialect lookup rather than changing how fusion decides what to combine. See Out of scope.

Unparseable values never error

A value that doesn't fit its attribute's type — id:foo where id is a uuid, status:banana where status is an enum, price:>banana where price is a number, and so on — never throws. It simply can't match anything, so it compiles to an unconditional 1 = 0 instead of a real comparison. This applies to every attribute type, not just multi-field ones or field-level type overrides — it's the same reason those two features can gracefully skip a field instead of erroring the whole query.

This is deliberate: search-cop treats "this value can never match this type" as a normal, expected outcome of a search — the same as any other query that happens to match zero rows — rather than a client error. If you want to reject malformed input before it reaches search() (e.g. to return a 400 to an API caller), validate it yourself first.

Errors

Invalid queries throw a SearchCopError with a code:

  • INVALID_SYNTAX — the query does not parse (includes an approximate character position)
  • UNKNOWN_ATTRIBUTE — the field is not declared in attributes (except a bare query against an undeclared _all — see Default field)
  • INVALID_OPERATOR — the operator is not supported for the attribute's type (e.g. status:>online for an enum)
  • INVALID_WILDCARD — a bare * appears somewhere other than the start/end of a bare-colon string value (e.g. name:Pet*Other; see Wildcards)
  • CIRCULAR_TAG_REFERENCE — a tag attribute's attribute points at itself (see type: 'tag')

Catch them with instanceof:

import { SearchCopError } from 'search-cop';

try {
  search({ query, attributes, queryBuilder });
} catch (error) {
  if (error instanceof SearchCopError) {
    // error.code, error.message, error.position
  }

  throw error;
}

instanceof is safe even when more than one copy of search-cop is loaded in the same process — duplicate installs, or a bundler emitting its own copy alongside the one in node_modules. The class is resolved through a version-keyed global singleton (Symbol.for('search-cop@<version>:SearchCopError')), so every copy of the same version hands out one and the same class, and an error thrown by one copy is instanceof the SearchCopError imported from another. No type guard needed. Copies of different versions are deliberately distinct classes, since their code sets may differ.

Note there's no error for a value that doesn't fit its type (an invalid uuid, an unknown enum value, ...) — see Unparseable values never error.

Out of scope (for now)

Automatically generating joins for associations — search-cop never creates a join itself; build it on your own queryBuilder and qualify the field (see Combining with your own queryBuilder and Fields are raw SQL) — full-text search on MySQL (MATCH ... AGAINST) or SQLite (FTS5) — only Postgres full-text is implemented so far — a != operator (negate with NOT instead), range syntax (1..100), query optimization/planning beyond the full-text fusion already described, pagination/sorting, raw SQL as the top-level query language (a fields entry is raw SQL, but the query string itself is always the DSL, never passed through), and additional database adapters are intentionally not implemented. The AST is designed so these can be added later.

Development

pnpm install
pnpm test        # build the grammar and run the test suite (sqlite)
pnpm run lint
pnpm run typecheck
pnpm run build

Postgres-specific tests (full-text search, and the general integration suite run against Postgres instead of sqlite) need a real database:

docker compose up -d
DATABASE=postgres pnpm test