@pimbay/search-query
v2.1.0
Published
Framework-agnostic, strict search/pagination contracts (page, slice, cursor) with no runtime magic — a foundation for datasource-specific extensions, starting with Drizzle.
Maintainers
Readme
@pimbay/search-query
Framework-agnostic pagination and search-terms parsing for TypeScript/JavaScript.
No query-builder or ORM dependency — you implement one small adapter interface (pageView/pageSlice/pageAfter/count/ids/head/all, whichever you need) against your own data source, and this library gives you back a typed, immutable result object.
Three pagination styles are supported, each with its own result type and adapter interface:
- Page (
paginatePage) — classic page-numbered navigation withtotalCount/pageCount. Needs a count query. - Slice (
paginateSlice) — page-numbered navigation without a count query (hasMoreis determined by over-fetching by one row). Cheaper than Page when you don't needtotalCount. - Cursor (
paginateCursor) — forward-only cursor navigation, nototalCount/pageCount/currentPageconcepts at all.
Plus parseSearchTerms/parseSearchTermsString — pure string parsing of a search-terms input into equals/notEquals/likes/notLikes buckets (leading negation marker, embedded wildcard marker), with no SQL or column awareness of its own. A datasource-specific adapter package (e.g. @pimbay/search-query-drizzle) turns the parsed result into actual query conditions.
Installation
npm install @pimbay/search-queryUsage
paginatePage
Classic page-numbered navigation. Needs a count query.
import { paginatePage, type PageAdapter, type PageChunk } from '@pimbay/search-query';
class MyAdapter implements PageAdapter<MyRow> {
async pageView(offset: number, size: number): Promise<PageChunk<MyRow>> {
const [results, totalCount] = await Promise.all([myQuery(offset, size), myCount()]);
return { results, totalCount };
}
}
const page = await paginatePage(new MyAdapter(), /* page */ 1, /* size */ 20);
page.getData(); // MyRow[]
page.getTotalCount();
page.getPageCount();
page.hasNextPage();paginateSlice/paginateCursor follow the same shape against SliceAdapter/CursorAdapter instead — see docs/usage.md for a Slice example against a real datasource.
If a hard failure is exactly what you want, paginatePageOrThrow/paginateSliceOrThrow do the isOutOfRange() check for you and throw SearchQueryError (outOfRange) instead of returning a result you'd have to check yourself.
An InMemoryArrayAdapter is included — it implements every adapter interface at once, useful for tests and small, already-loaded collections without writing your own adapter.
parseSearchTermsString
Parses a search-terms input string into equals/notEquals/likes/notLikes buckets, with no SQL or column awareness of its own.
import { createSearchTermsConfig, parseSearchTermsString } from '@pimbay/search-query';
const config = createSearchTermsConfig();
const parsed = parseSearchTermsString('dog hors* -cow', config);
parsed.equals; // ['dog']
parsed.likes; // ['hors*']
parsed.notEquals; // ['cow']createSearchTermsConfig validates its markers (non-empty, unique, the two sets disjoint) and throws SearchQueryError.invalidSearchTermsConfig() on a bad combination.
| Option | Default | Notes |
| :---------------------- | :----------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| anywhere | true | Pass-through hint only — this package never reads it; a datasource package decides what it means for its own LIKE pattern. |
| minLength | 3 | Measured on the raw term, so a negation marker counts towards it: -ab needs minLength <= 3. |
| likeMarkers | ['*'] | Markers a term may embed to become a wildcard match; every one of them maps to the same wildcard. [] disables wildcards. |
| ignoreMarkers | ['-', '!'] | Markers a term may start with to become a negation. Longest first wins, so -- beats -. [] disables negation, which is what data with legitimately leading -/! needs. |
| ignoredTermsMatchNull | true | Pass-through hint only, like anywhere. Whether a negated term should also keep records whose value is absent: -red means "not red", and under SQL's three-valued logic a bare value != 'red' drops every NULL row silently. false restores that stricter reading. |
Full usage reference (real-datasource example, advanced usage, edge cases): docs/usage.md.
Adapters for a specific datasource
This package only defines the adapter interfaces — it has zero runtime dependencies. Implementing PageAdapter/SliceAdapter/etc. against your own query layer is normal and expected. For Drizzle ORM specifically, see @pimbay/search-query-drizzle, which provides DrizzleSimpleAdapter/DrizzleIdentityAdapter plus a search-terms-to-SQL-condition builder.
Errors
Every failure mode is a named constructor on SearchQueryError.
| Error | Raised by |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| SearchQueryError.invalidPageIndex() | paginatePage()/paginateSlice() — page not a positive integer |
| SearchQueryError.invalidSize() | paginatePage()/paginateSlice()/paginateCursor() — size not a positive integer |
| SearchQueryError.emptyCursor() | paginateCursor() — empty string passed as cursor (use null instead) |
| SearchQueryError.outOfRange() | paginatePageOrThrow()/paginateSliceOrThrow() — requested page past the last available page |
| SearchQueryError.invalidSearchTermsConfig() | parseSearchTerms()/parseSearchTermsString() — invalid SearchTermsConfig |
Extended notes: docs/errors.md.
Testing
npm run test:22 # docker compose run node22 — npm install + build + unit tests, no coverage
npm run test:24 # docker compose run node24 — npm install + build + unit tests, no coverage
npm run test:26 # docker compose run node26 — npm install + build + unit tests, no coverage
npm run test:all # test:22 + test:24 + test:26
npm run test:coverage # vitest run --coverage test/unit
npm run test:mutation # stryker run — min MSI 100%npm install is sufficient — no external services to start first.
| Node | Status | | :----- | :----- | | 22 | ✅ | | 24 | ✅ | | 26 | ✅ |
Development Helpers
npm run js:lint # eslint (check only)
npm run js:lint:fix # same, applies the fix
npm run js:format # prettier --check .
npm run js:format:fix # same, applies the fix
npm run js:typecheck # tsc --noEmitArchitecture & Decisions
- docs/context.md — current working state: what's in progress, what's next.
- docs/DECISIONS.md — why things are built the way they are, in the order the decisions were made.
- docs/CHANGELOG.md — version history.
Packages in the stack
| Package | Description |
| ------------------------------ | ----------------------------------------------------------------------------------------- |
| @pimbay/search-query | This package — framework-agnostic pagination and search-terms parsing, no ORM dependency. |
| @pimbay/search-query-drizzle | Drizzle ORM adapters and a search-terms-to-SQL condition builder built on this package. |
License
Unlicense — public domain. Part of the PimBay ecosystem.
Bundled third-party dependencies and their licenses: docs/THIRD-PARTY-NOTICES.md.
