Repository files navigation

@jantstack/adonis-searchable

Generic service layer for AdonisJS 7 + Lucid ORM: paginated listing, multi-column fulltext search, declarative filters with a safe-by-default whitelist, date-range filters, sorting, preloads and Lucid scopes — all driven from the query string, none of it touching HttpContext.

Services stay testable in isolation and reusable from jobs, commands and other services. Controllers shrink to a few lines.

import{BaseService}from'@jantstack/adonis-searchable'importProductfrom'#models/product'exportdefaultclassProductsServiceextendsBaseService<typeofProduct>{protectedmodel=ProductprotectedsearchableColumns=['name','sku']protectedallowedFilters=['status','category_id','created_at']protectedindexWith=['category']}
import{parseQueryParams}from'@jantstack/adonis-searchable'exportdefaultclassProductsController{constructor(privateservice=newProductsService()){}asyncindex({ request }: HttpContext){returnthis.service.index(parseQueryParams(request))}}

That's a full listing endpoint with pagination, search, filters and preloads.


Table of contents


Install

npm i @jantstack/adonis-searchable

Peer dependencies: @adonisjs/core ^7 and @adonisjs/lucid ^22. No provider to register and no config file — you extend a class and you're done.

Engine-agnostic: the package emits no engine-specific SQL, so Postgres, MySQL and SQLite all work. (Fulltext search uses ILIKE on Postgres and LIKE elsewhere; column introspection uses each engine's standard catalog.)


Configuring a service

Every knob is a protected property on the subclass:

PropertyDefaultWhat it does
model(required)The Lucid model the service operates on.
searchableColumns[]Columns scanned by search. Empty = search does nothing.
allowedFiltersserialized columnsFields the client may filter by — see below.
periodColumns['created_at', 'updated_at']Columns allowed in date-range filters.
indexWith[]Relations preloaded by default in index().
showWith[]Relations preloaded by default in findOne().
allowedIncludesper method: what that method preloadsWhat the client may request on top — see below.
defaultPerPage25Page size when the client doesn't send one.
maxPerPage100Ceiling for perPage — protects against ?perPage=100000.
maxUnpaginatedLimit1000Row ceiling for paginate: false — the client's limit narrows it, never widens it.
filterLimitsdepth 5, 100 conditions, 500 whereIn valuesComplexity ceilings for client-supplied filters.

index() and the query string

index(params) accepts a QueryParams object. parseQueryParams(request) builds it from the HTTP request — it is the only piece of the package that knows about HttpContext, so services stay transport-agnostic (write another adapter for GraphQL or gRPC and nothing else changes). It reads the query string on GET and merges the body on POST/PUT, supporting these conventions:

?page=2&per_page=50
&search=acme&search_columns[]=name&search_columns[]=sku
&filters[where][0][field]=status&filters[where][0][op]==&filters[where][0][value]=active
&period_filters[0][column]=created_at&period_filters[0][start]=2026-01-01&period_filters[0][end]=2026-03-31
&order_by=created_at&order_direction=desc
&with=category,category.parent
&scopes[withStatus]=delivered
&count=true
&paginate=false&limit=500

Query-string key → QueryParams property: per_pageperPage, search_columns[]searchColumns, order_by/order_directionorderBy/orderDirection, with (CSV or array) → includes, period_filtersperiod. search_input is accepted as a legacy alias of search.

ParamTypeNotes
page / perPagenumberperPage is clamped to maxPerPage.
countbooleanReturns { count } only — no rows, no meta.
paginatebooleanfalse returns { data } unpaginated (bounded by limit).
limitnumberRow cap when paginate: false.
searchstringFulltext across searchableColumns.
searchColumnsstring[]Narrows the search to a subset (intersected with the whitelist).
filtersobject | object[]See Filters.
periodobject[]{ column, start?, end? }; column must be in periodColumns and the dates must be real YYYY-MM-DD.
orderBy / orderDirectionstringorderBy must be a real and visible column — see Sorting.
includesstring[]Relations to preload; dot notation for nested (profile.wallets). Filtered by allowedIncludes.
scopesobjectLucid scopes to apply: { withStatus: 'delivered' }.
paginationBaseUrl / paginationExtraQsstring / objectBuild absolute pagination links in meta.

Return shape:

// default{ data: Model[],meta: { total, perPage, currentPage, lastPage, firstPage, ...links}}// count: true{count: number}// paginate: false{ data: Model[]}

Filters

Filters are declarative and arrive from the client, so the package is deny-by-default in the two places that matter: which fields can be filtered, and which operators are allowed.

Filter methods

Each key of a filter block is a method, each value an array of conditions:

FamilyMethods
Comparisonwhere, orWhere
SetswhereIn, orWhereIn, whereNotIn, orWhereNotIn
RangeswhereBetween, orWhereBetween, whereNotBetween, orWhereNotBetween
NullabilitywhereNull, orWhereNull, whereNotNull, orWhereNotNull
JSONwhereJsonContains, orWhereJsonContains, whereJsonLength, orWhereJsonLength

A condition is { field, op?, value?, values? }. Anything unrecognized is skipped silently — by design, since the input is untrusted.

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],whereIn: [{field: 'category_id',values: [1,2,3]}],whereNotNull: [{field: 'published_at'}],},})

Field whitelist (safe by default)

The guiding principle is filterable ⊆ visible: if a column already travels in the API response, filtering by it reveals nothing new.

allowedFiltersFilterableUse it when
(not declared)columns the model serializesinternal CRUD, prototypes — safe with zero config
['name', 'status']only thosepublic APIs: the filter contract stops following the schema
[]nothingendpoints that must not accept filters at all
ALLOW_ALL_FILTERSevery column, hidden ones includedinternal tooling over non-sensitive models

The default excludes anything marked @column({ serializeAs: null }) — a password hash, for instance. This matters: a like filter over a hidden column is a blind exfiltration oracle. An attacker probes character by character ($scrypt$a%, $scrypt$b%…) and reads the answer from which rows come back. Excluding non-serialized columns closes that without any configuration on your part.

import{SearchableService,ALLOW_ALL_FILTERS}from'@jantstack/adonis-searchable'importtype{AllowedFilters}from'@jantstack/adonis-searchable'classInternalAuditServiceextendsSearchableService<typeofAuditRow>{protectedmodel=AuditRowprotectedallowedFilters: AllowedFilters=ALLOW_ALL_FILTERS// explicit opt-in}

The : AllowedFilters annotation is required — without it TypeScript widens the symbol and the assignment won't compile. Useful side effect: the opt-in is impossible to miss in code review.

Operator whitelist

The op of a condition is interpolated raw into SQL by Knex, so only these pass: =, !=, <>, >, >=, <, <=, like, ilike, not like, not ilike. Anything else (op = "IS NULL OR 1=1 --") drops the condition instead of injecting it.

Nesting with orGroup / andGroup

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],orGroup: [{where: [{field: 'priority',op: '>=',value: 8}]},{where: [{field: 'flagged',op: '=',value: true}]},],},})// WHERE status = 'active' AND (priority >= 8 OR flagged = true)

Inside an orGroup, where conditions are rewritten to orWhere automatically. Nesting is recursive, and the field whitelist applies at every level.

JSON columns

Use -> to reach into a JSON path; the whitelist checks the root field:

{where: [{field: 'metadata->plan',op: '=',value: 'pro'}]}// needs 'metadata' allowed{whereJsonLength: [{field: 'tags',op: '>',value: 3}]}

Complexity ceilings

Filters are recursive and, on POST/PUT, they arrive in a JSON body with no depth limit of its own. Three ceilings bound the damage: depth 5, 100 applied conditions per request, and 500 values in a single whereIn (a condition count alone doesn't help — one whereIn with 100 000 values is still one condition). What exceeds them is dropped silently, like everything else in the filter pipeline. Raise or lower them per service:

protectedfilterLimits={maxDepth: 8,maxConditions: 250,maxInValues: 1000}

An oversized whereIn is dropped whole rather than truncated: a truncated set answers a question the client didn't ask, and does it silently.


Preload whitelist

?with= is client input, so it gets the same treatment as filters. Two properties split the job:

  • indexWith / showWith say what the service loads. They always apply — whether the client asks or not.
  • allowedIncludes says what the client may ask for on top. Per method, and additive.
allowedIncludesClient may preloadUse it when
(not declared)exactly what that method preloadsthe common case — zero extra config
{ index: ['tags'], show: ['tags', 'audit'] }that, plus the method's own preloadsopening the listing without opening the detail, or the reverse
['tags']that, on both methodsthe same extra everywhere
ALLOW_ALL_INCLUDESany relation on the modelinternal tooling; this was the implicit behaviour before 2.0.0

The default is per method on purpose. A relation listed only in showWith doesn't become requestable on the listing: fetching one organization's invitations is proportionate; fetching them for the 25 rows of a page — every invited person's email — is a different thing. Nothing stops you from opening it, but opening it has to be something you wrote.

The explicit forms are additive: you never repeat in allowedIncludes what is already in indexWith or showWith. A relation preloaded by default travels in the response whether the client asks for it or not, so "denying" it would mean nothing.

A requested path also passes if it is a prefix of an allowed one (owner when owner.profile is allowed) — asking for less is always fine. The reverse is not: owner.profile when only owner is allowed is one level deeper than anyone authorized.

Without any of this, ?with=owner on a public listing pulls the entire related model into the response — including whatever that model serializes — and nested paths multiply the queries behind it.


Sorting

orderBy must name a column that exists and that the model serializes. The first half is the SQL-injection guard; the second closes an oracle: sorting by a hidden column and paging through the results lets an attacker compare that value across rows and reconstruct it by position. Same principle as filters — sortable ⊆ visible — and ALLOW_ALL_FILTERS lifts both restrictions together.

orderDirection only ever reaches Knex as asc or desc.


CRUD and lifecycle hooks

BaseService adds create, update and destroy on top of SearchableService, each with optional hooks and transaction support:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncbeforeCreate(data: Partial<Order>){data.reference??=generateReference()}protectedasyncafterCreate(record: Order,trx?: TransactionClientContract){awaitthis.notify(record,trx)}}

Available hooks: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDestroy, afterDestroy. All optional, all awaited, all receiving the transaction when one is passed.

findOne(uuid, includes?) returns null when the row doesn't exist; findOneOrFail, update and destroy throw RecordNotFoundError instead. It carries status = 404, so a standard AdonisJS exception handler maps it without extra wiring.


Escape hatch: applyCustomFilters

For conditions the declarative system can't express — rich jsonb, joins, subqueries, tenant scoping — override the hook. It runs inside the same query builder, so it also constrains count and pagination:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncapplyCustomFilters(query: ModelQueryBuilder,params: QueryParams){query.whereRaw("metadata @> ?",[JSON.stringify({region: this.region})])}}

This is your code, not client input, so the field whitelist doesn't apply here — that's the point of the hatch. Keep any client-supplied value parameterized.


API reference

// FunctionsfunctionparseQueryParams(request: HttpContext['request']): QueryParams// ClassesclassSearchableService<TModel>{index(params?: QueryParams): Promise<PaginatedResult<TModel>|{count: number}|{data: []}>findOne(uuid: string,includes?: string[]): Promise<InstanceType<TModel>|null>findOneOrFail(uuid: string,includes?: string[]): Promise<InstanceType<TModel>>protectedbuildQuery(params: QueryParams): Promise<{query: ModelQueryBuilder}>protectedapplyCustomFilters?(query,params): void|Promise<void>}classBaseService<TModel>extendsSearchableService<TModel>{create(data,trx?): Promise<InstanceType<TModel>>update(uuid,data,trx?): Promise<InstanceType<TModel>>destroy(uuid,trx?): Promise<void>}classRecordNotFoundErrorextendsError{status=404}// SentinelsconstALLOW_ALL_FILTERS: unique symbolconstFILTERABLE_FROM_MODEL: unique symbol// the default for allowedFiltersconstALLOW_ALL_INCLUDES: unique symbolconstINCLUDES_FROM_SERVICE: unique symbol// the default for allowedIncludes// ConstantsconstMAX_FILTER_DEPTH=5constMAX_FILTER_CONDITIONS=100constMAX_FILTER_IN_VALUES=500// TypestypeAllowedFilters,AllowedIncludes,AllowedIncludesByMethod,IncludesMethod,FilterLimits,QueryParams,PaginatedResult,PaginationMeta,PeriodFilter,FilterBlock,FilterCondition,FilterMethod

Design notes

No HttpContext. The service takes a plain object, so the same code serves an HTTP endpoint, a queue job, an ace command or another service. It's also what makes it testable without booting a server.

Silent skipping over errors. Unknown filter methods, non-whitelisted fields and unsafe operators are dropped, not rejected. Filters come from untrusted input; failing loudly turns every bad-faith query string into a 500. If your API needs explicit feedback, validate at the controller/validator layer.

Column introspection is cached per service instance: the orderBy guard reads the real table columns once; the default filter whitelist reads the model's own metadata and never touches the database.

Every client-controlled surface has a whitelist. Fields, operators, sort columns, preloads, scopes, search columns and every numeric bound. That symmetry is the point: a gap in one of them is worth more to an attacker than hardening the others further.

The package tests itself.npm test runs the full suite against in-memory SQLite — no Postgres, no migrations, no host application. CI runs it on Node 20, 22 and 24 before anything ships.


Compatibility

Node≥ 20.6
AdonisJS^7 (peer)
Lucid^22 (peer)
DatabasesPostgreSQL, MySQL, SQLite
Module formatESM only

Scope and maintenance

Extracted from the adonis7-base chassis, where it runs in production-shaped projects. It is maintained according to that chassis's needs: bug fixes and small additions are welcome, larger feature requests may not fit the roadmap.

License

MIT

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

@jantstack/adonis-searchable

Generic service layer for AdonisJS 7 + Lucid ORM: paginated listing, multi-column fulltext search, declarative filters with a safe-by-default whitelist, date-range filters, sorting, preloads and Lucid scopes — all driven from the query string, none of it touching HttpContext.

Services stay testable in isolation and reusable from jobs, commands and other services. Controllers shrink to a few lines.

import{BaseService}from'@jantstack/adonis-searchable'importProductfrom'#models/product'exportdefaultclassProductsServiceextendsBaseService<typeofProduct>{protectedmodel=ProductprotectedsearchableColumns=['name','sku']protectedallowedFilters=['status','category_id','created_at']protectedindexWith=['category']}
import{parseQueryParams}from'@jantstack/adonis-searchable'exportdefaultclassProductsController{constructor(privateservice=newProductsService()){}asyncindex({ request }: HttpContext){returnthis.service.index(parseQueryParams(request))}}

That's a full listing endpoint with pagination, search, filters and preloads.


Table of contents


Install

npm i @jantstack/adonis-searchable

Peer dependencies: @adonisjs/core ^7 and @adonisjs/lucid ^22. No provider to register and no config file — you extend a class and you're done.

Engine-agnostic: the package emits no engine-specific SQL, so Postgres, MySQL and SQLite all work. (Fulltext search uses ILIKE on Postgres and LIKE elsewhere; column introspection uses each engine's standard catalog.)


Configuring a service

Every knob is a protected property on the subclass:

PropertyDefaultWhat it does
model(required)The Lucid model the service operates on.
searchableColumns[]Columns scanned by search. Empty = search does nothing.
allowedFiltersserialized columnsFields the client may filter by — see below.
periodColumns['created_at', 'updated_at']Columns allowed in date-range filters.
indexWith[]Relations preloaded by default in index().
showWith[]Relations preloaded by default in findOne().
allowedIncludesper method: what that method preloadsWhat the client may request on top — see below.
defaultPerPage25Page size when the client doesn't send one.
maxPerPage100Ceiling for perPage — protects against ?perPage=100000.
maxUnpaginatedLimit1000Row ceiling for paginate: false — the client's limit narrows it, never widens it.
filterLimitsdepth 5, 100 conditions, 500 whereIn valuesComplexity ceilings for client-supplied filters.

index() and the query string

index(params) accepts a QueryParams object. parseQueryParams(request) builds it from the HTTP request — it is the only piece of the package that knows about HttpContext, so services stay transport-agnostic (write another adapter for GraphQL or gRPC and nothing else changes). It reads the query string on GET and merges the body on POST/PUT, supporting these conventions:

?page=2&per_page=50
&search=acme&search_columns[]=name&search_columns[]=sku
&filters[where][0][field]=status&filters[where][0][op]==&filters[where][0][value]=active
&period_filters[0][column]=created_at&period_filters[0][start]=2026-01-01&period_filters[0][end]=2026-03-31
&order_by=created_at&order_direction=desc
&with=category,category.parent
&scopes[withStatus]=delivered
&count=true
&paginate=false&limit=500

Query-string key → QueryParams property: per_pageperPage, search_columns[]searchColumns, order_by/order_directionorderBy/orderDirection, with (CSV or array) → includes, period_filtersperiod. search_input is accepted as a legacy alias of search.

ParamTypeNotes
page / perPagenumberperPage is clamped to maxPerPage.
countbooleanReturns { count } only — no rows, no meta.
paginatebooleanfalse returns { data } unpaginated (bounded by limit).
limitnumberRow cap when paginate: false.
searchstringFulltext across searchableColumns.
searchColumnsstring[]Narrows the search to a subset (intersected with the whitelist).
filtersobject | object[]See Filters.
periodobject[]{ column, start?, end? }; column must be in periodColumns and the dates must be real YYYY-MM-DD.
orderBy / orderDirectionstringorderBy must be a real and visible column — see Sorting.
includesstring[]Relations to preload; dot notation for nested (profile.wallets). Filtered by allowedIncludes.
scopesobjectLucid scopes to apply: { withStatus: 'delivered' }.
paginationBaseUrl / paginationExtraQsstring / objectBuild absolute pagination links in meta.

Return shape:

// default{ data: Model[],meta: { total, perPage, currentPage, lastPage, firstPage, ...links}}// count: true{count: number}// paginate: false{ data: Model[]}

Filters

Filters are declarative and arrive from the client, so the package is deny-by-default in the two places that matter: which fields can be filtered, and which operators are allowed.

Filter methods

Each key of a filter block is a method, each value an array of conditions:

FamilyMethods
Comparisonwhere, orWhere
SetswhereIn, orWhereIn, whereNotIn, orWhereNotIn
RangeswhereBetween, orWhereBetween, whereNotBetween, orWhereNotBetween
NullabilitywhereNull, orWhereNull, whereNotNull, orWhereNotNull
JSONwhereJsonContains, orWhereJsonContains, whereJsonLength, orWhereJsonLength

A condition is { field, op?, value?, values? }. Anything unrecognized is skipped silently — by design, since the input is untrusted.

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],whereIn: [{field: 'category_id',values: [1,2,3]}],whereNotNull: [{field: 'published_at'}],},})

Field whitelist (safe by default)

The guiding principle is filterable ⊆ visible: if a column already travels in the API response, filtering by it reveals nothing new.

allowedFiltersFilterableUse it when
(not declared)columns the model serializesinternal CRUD, prototypes — safe with zero config
['name', 'status']only thosepublic APIs: the filter contract stops following the schema
[]nothingendpoints that must not accept filters at all
ALLOW_ALL_FILTERSevery column, hidden ones includedinternal tooling over non-sensitive models

The default excludes anything marked @column({ serializeAs: null }) — a password hash, for instance. This matters: a like filter over a hidden column is a blind exfiltration oracle. An attacker probes character by character ($scrypt$a%, $scrypt$b%…) and reads the answer from which rows come back. Excluding non-serialized columns closes that without any configuration on your part.

import{SearchableService,ALLOW_ALL_FILTERS}from'@jantstack/adonis-searchable'importtype{AllowedFilters}from'@jantstack/adonis-searchable'classInternalAuditServiceextendsSearchableService<typeofAuditRow>{protectedmodel=AuditRowprotectedallowedFilters: AllowedFilters=ALLOW_ALL_FILTERS// explicit opt-in}

The : AllowedFilters annotation is required — without it TypeScript widens the symbol and the assignment won't compile. Useful side effect: the opt-in is impossible to miss in code review.

Operator whitelist

The op of a condition is interpolated raw into SQL by Knex, so only these pass: =, !=, <>, >, >=, <, <=, like, ilike, not like, not ilike. Anything else (op = "IS NULL OR 1=1 --") drops the condition instead of injecting it.

Nesting with orGroup / andGroup

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],orGroup: [{where: [{field: 'priority',op: '>=',value: 8}]},{where: [{field: 'flagged',op: '=',value: true}]},],},})// WHERE status = 'active' AND (priority >= 8 OR flagged = true)

Inside an orGroup, where conditions are rewritten to orWhere automatically. Nesting is recursive, and the field whitelist applies at every level.

JSON columns

Use -> to reach into a JSON path; the whitelist checks the root field:

{where: [{field: 'metadata->plan',op: '=',value: 'pro'}]}// needs 'metadata' allowed{whereJsonLength: [{field: 'tags',op: '>',value: 3}]}

Complexity ceilings

Filters are recursive and, on POST/PUT, they arrive in a JSON body with no depth limit of its own. Three ceilings bound the damage: depth 5, 100 applied conditions per request, and 500 values in a single whereIn (a condition count alone doesn't help — one whereIn with 100 000 values is still one condition). What exceeds them is dropped silently, like everything else in the filter pipeline. Raise or lower them per service:

protectedfilterLimits={maxDepth: 8,maxConditions: 250,maxInValues: 1000}

An oversized whereIn is dropped whole rather than truncated: a truncated set answers a question the client didn't ask, and does it silently.


Preload whitelist

?with= is client input, so it gets the same treatment as filters. Two properties split the job:

  • indexWith / showWith say what the service loads. They always apply — whether the client asks or not.
  • allowedIncludes says what the client may ask for on top. Per method, and additive.
allowedIncludesClient may preloadUse it when
(not declared)exactly what that method preloadsthe common case — zero extra config
{ index: ['tags'], show: ['tags', 'audit'] }that, plus the method's own preloadsopening the listing without opening the detail, or the reverse
['tags']that, on both methodsthe same extra everywhere
ALLOW_ALL_INCLUDESany relation on the modelinternal tooling; this was the implicit behaviour before 2.0.0

The default is per method on purpose. A relation listed only in showWith doesn't become requestable on the listing: fetching one organization's invitations is proportionate; fetching them for the 25 rows of a page — every invited person's email — is a different thing. Nothing stops you from opening it, but opening it has to be something you wrote.

The explicit forms are additive: you never repeat in allowedIncludes what is already in indexWith or showWith. A relation preloaded by default travels in the response whether the client asks for it or not, so "denying" it would mean nothing.

A requested path also passes if it is a prefix of an allowed one (owner when owner.profile is allowed) — asking for less is always fine. The reverse is not: owner.profile when only owner is allowed is one level deeper than anyone authorized.

Without any of this, ?with=owner on a public listing pulls the entire related model into the response — including whatever that model serializes — and nested paths multiply the queries behind it.


Sorting

orderBy must name a column that exists and that the model serializes. The first half is the SQL-injection guard; the second closes an oracle: sorting by a hidden column and paging through the results lets an attacker compare that value across rows and reconstruct it by position. Same principle as filters — sortable ⊆ visible — and ALLOW_ALL_FILTERS lifts both restrictions together.

orderDirection only ever reaches Knex as asc or desc.


CRUD and lifecycle hooks

BaseService adds create, update and destroy on top of SearchableService, each with optional hooks and transaction support:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncbeforeCreate(data: Partial<Order>){data.reference??=generateReference()}protectedasyncafterCreate(record: Order,trx?: TransactionClientContract){awaitthis.notify(record,trx)}}

Available hooks: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDestroy, afterDestroy. All optional, all awaited, all receiving the transaction when one is passed.

findOne(uuid, includes?) returns null when the row doesn't exist; findOneOrFail, update and destroy throw RecordNotFoundError instead. It carries status = 404, so a standard AdonisJS exception handler maps it without extra wiring.


Escape hatch: applyCustomFilters

For conditions the declarative system can't express — rich jsonb, joins, subqueries, tenant scoping — override the hook. It runs inside the same query builder, so it also constrains count and pagination:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncapplyCustomFilters(query: ModelQueryBuilder,params: QueryParams){query.whereRaw("metadata @> ?",[JSON.stringify({region: this.region})])}}

This is your code, not client input, so the field whitelist doesn't apply here — that's the point of the hatch. Keep any client-supplied value parameterized.


API reference

// FunctionsfunctionparseQueryParams(request: HttpContext['request']): QueryParams// ClassesclassSearchableService<TModel>{index(params?: QueryParams): Promise<PaginatedResult<TModel>|{count: number}|{data: []}>findOne(uuid: string,includes?: string[]): Promise<InstanceType<TModel>|null>findOneOrFail(uuid: string,includes?: string[]): Promise<InstanceType<TModel>>protectedbuildQuery(params: QueryParams): Promise<{query: ModelQueryBuilder}>protectedapplyCustomFilters?(query,params): void|Promise<void>}classBaseService<TModel>extendsSearchableService<TModel>{create(data,trx?): Promise<InstanceType<TModel>>update(uuid,data,trx?): Promise<InstanceType<TModel>>destroy(uuid,trx?): Promise<void>}classRecordNotFoundErrorextendsError{status=404}// SentinelsconstALLOW_ALL_FILTERS: unique symbolconstFILTERABLE_FROM_MODEL: unique symbol// the default for allowedFiltersconstALLOW_ALL_INCLUDES: unique symbolconstINCLUDES_FROM_SERVICE: unique symbol// the default for allowedIncludes// ConstantsconstMAX_FILTER_DEPTH=5constMAX_FILTER_CONDITIONS=100constMAX_FILTER_IN_VALUES=500// TypestypeAllowedFilters,AllowedIncludes,AllowedIncludesByMethod,IncludesMethod,FilterLimits,QueryParams,PaginatedResult,PaginationMeta,PeriodFilter,FilterBlock,FilterCondition,FilterMethod

Design notes

No HttpContext. The service takes a plain object, so the same code serves an HTTP endpoint, a queue job, an ace command or another service. It's also what makes it testable without booting a server.

Silent skipping over errors. Unknown filter methods, non-whitelisted fields and unsafe operators are dropped, not rejected. Filters come from untrusted input; failing loudly turns every bad-faith query string into a 500. If your API needs explicit feedback, validate at the controller/validator layer.

Column introspection is cached per service instance: the orderBy guard reads the real table columns once; the default filter whitelist reads the model's own metadata and never touches the database.

Every client-controlled surface has a whitelist. Fields, operators, sort columns, preloads, scopes, search columns and every numeric bound. That symmetry is the point: a gap in one of them is worth more to an attacker than hardening the others further.

The package tests itself.npm test runs the full suite against in-memory SQLite — no Postgres, no migrations, no host application. CI runs it on Node 20, 22 and 24 before anything ships.


Compatibility

Node≥ 20.6
AdonisJS^7 (peer)
Lucid^22 (peer)
DatabasesPostgreSQL, MySQL, SQLite
Module formatESM only

Scope and maintenance

Extracted from the adonis7-base chassis, where it runs in production-shaped projects. It is maintained according to that chassis's needs: bug fixes and small additions are welcome, larger feature requests may not fit the roadmap.

License

MIT

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@jantstack/adonis-searchable

Generic service layer for AdonisJS 7 + Lucid ORM: paginated listing, multi-column fulltext search, declarative filters with a safe-by-default whitelist, date-range filters, sorting, preloads and Lucid scopes — all driven from the query string, none of it touching HttpContext.

Services stay testable in isolation and reusable from jobs, commands and other services. Controllers shrink to a few lines.

import{BaseService}from'@jantstack/adonis-searchable'importProductfrom'#models/product'exportdefaultclassProductsServiceextendsBaseService<typeofProduct>{protectedmodel=ProductprotectedsearchableColumns=['name','sku']protectedallowedFilters=['status','category_id','created_at']protectedindexWith=['category']}
import{parseQueryParams}from'@jantstack/adonis-searchable'exportdefaultclassProductsController{constructor(privateservice=newProductsService()){}asyncindex({ request }: HttpContext){returnthis.service.index(parseQueryParams(request))}}

That's a full listing endpoint with pagination, search, filters and preloads.


Table of contents


Install

npm i @jantstack/adonis-searchable

Peer dependencies: @adonisjs/core ^7 and @adonisjs/lucid ^22. No provider to register and no config file — you extend a class and you're done.

Engine-agnostic: the package emits no engine-specific SQL, so Postgres, MySQL and SQLite all work. (Fulltext search uses ILIKE on Postgres and LIKE elsewhere; column introspection uses each engine's standard catalog.)


Configuring a service

Every knob is a protected property on the subclass:

PropertyDefaultWhat it does
model(required)The Lucid model the service operates on.
searchableColumns[]Columns scanned by search. Empty = search does nothing.
allowedFiltersserialized columnsFields the client may filter by — see below.
periodColumns['created_at', 'updated_at']Columns allowed in date-range filters.
indexWith[]Relations preloaded by default in index().
showWith[]Relations preloaded by default in findOne().
allowedIncludesper method: what that method preloadsWhat the client may request on top — see below.
defaultPerPage25Page size when the client doesn't send one.
maxPerPage100Ceiling for perPage — protects against ?perPage=100000.
maxUnpaginatedLimit1000Row ceiling for paginate: false — the client's limit narrows it, never widens it.
filterLimitsdepth 5, 100 conditions, 500 whereIn valuesComplexity ceilings for client-supplied filters.

index() and the query string

index(params) accepts a QueryParams object. parseQueryParams(request) builds it from the HTTP request — it is the only piece of the package that knows about HttpContext, so services stay transport-agnostic (write another adapter for GraphQL or gRPC and nothing else changes). It reads the query string on GET and merges the body on POST/PUT, supporting these conventions:

?page=2&per_page=50
&search=acme&search_columns[]=name&search_columns[]=sku
&filters[where][0][field]=status&filters[where][0][op]==&filters[where][0][value]=active
&period_filters[0][column]=created_at&period_filters[0][start]=2026-01-01&period_filters[0][end]=2026-03-31
&order_by=created_at&order_direction=desc
&with=category,category.parent
&scopes[withStatus]=delivered
&count=true
&paginate=false&limit=500

Query-string key → QueryParams property: per_pageperPage, search_columns[]searchColumns, order_by/order_directionorderBy/orderDirection, with (CSV or array) → includes, period_filtersperiod. search_input is accepted as a legacy alias of search.

ParamTypeNotes
page / perPagenumberperPage is clamped to maxPerPage.
countbooleanReturns { count } only — no rows, no meta.
paginatebooleanfalse returns { data } unpaginated (bounded by limit).
limitnumberRow cap when paginate: false.
searchstringFulltext across searchableColumns.
searchColumnsstring[]Narrows the search to a subset (intersected with the whitelist).
filtersobject | object[]See Filters.
periodobject[]{ column, start?, end? }; column must be in periodColumns and the dates must be real YYYY-MM-DD.
orderBy / orderDirectionstringorderBy must be a real and visible column — see Sorting.
includesstring[]Relations to preload; dot notation for nested (profile.wallets). Filtered by allowedIncludes.
scopesobjectLucid scopes to apply: { withStatus: 'delivered' }.
paginationBaseUrl / paginationExtraQsstring / objectBuild absolute pagination links in meta.

Return shape:

// default{ data: Model[],meta: { total, perPage, currentPage, lastPage, firstPage, ...links}}// count: true{count: number}// paginate: false{ data: Model[]}

Filters

Filters are declarative and arrive from the client, so the package is deny-by-default in the two places that matter: which fields can be filtered, and which operators are allowed.

Filter methods

Each key of a filter block is a method, each value an array of conditions:

FamilyMethods
Comparisonwhere, orWhere
SetswhereIn, orWhereIn, whereNotIn, orWhereNotIn
RangeswhereBetween, orWhereBetween, whereNotBetween, orWhereNotBetween
NullabilitywhereNull, orWhereNull, whereNotNull, orWhereNotNull
JSONwhereJsonContains, orWhereJsonContains, whereJsonLength, orWhereJsonLength

A condition is { field, op?, value?, values? }. Anything unrecognized is skipped silently — by design, since the input is untrusted.

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],whereIn: [{field: 'category_id',values: [1,2,3]}],whereNotNull: [{field: 'published_at'}],},})

Field whitelist (safe by default)

The guiding principle is filterable ⊆ visible: if a column already travels in the API response, filtering by it reveals nothing new.

allowedFiltersFilterableUse it when
(not declared)columns the model serializesinternal CRUD, prototypes — safe with zero config
['name', 'status']only thosepublic APIs: the filter contract stops following the schema
[]nothingendpoints that must not accept filters at all
ALLOW_ALL_FILTERSevery column, hidden ones includedinternal tooling over non-sensitive models

The default excludes anything marked @column({ serializeAs: null }) — a password hash, for instance. This matters: a like filter over a hidden column is a blind exfiltration oracle. An attacker probes character by character ($scrypt$a%, $scrypt$b%…) and reads the answer from which rows come back. Excluding non-serialized columns closes that without any configuration on your part.

import{SearchableService,ALLOW_ALL_FILTERS}from'@jantstack/adonis-searchable'importtype{AllowedFilters}from'@jantstack/adonis-searchable'classInternalAuditServiceextendsSearchableService<typeofAuditRow>{protectedmodel=AuditRowprotectedallowedFilters: AllowedFilters=ALLOW_ALL_FILTERS// explicit opt-in}

The : AllowedFilters annotation is required — without it TypeScript widens the symbol and the assignment won't compile. Useful side effect: the opt-in is impossible to miss in code review.

Operator whitelist

The op of a condition is interpolated raw into SQL by Knex, so only these pass: =, !=, <>, >, >=, <, <=, like, ilike, not like, not ilike. Anything else (op = "IS NULL OR 1=1 --") drops the condition instead of injecting it.

Nesting with orGroup / andGroup

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],orGroup: [{where: [{field: 'priority',op: '>=',value: 8}]},{where: [{field: 'flagged',op: '=',value: true}]},],},})// WHERE status = 'active' AND (priority >= 8 OR flagged = true)

Inside an orGroup, where conditions are rewritten to orWhere automatically. Nesting is recursive, and the field whitelist applies at every level.

JSON columns

Use -> to reach into a JSON path; the whitelist checks the root field:

{where: [{field: 'metadata->plan',op: '=',value: 'pro'}]}// needs 'metadata' allowed{whereJsonLength: [{field: 'tags',op: '>',value: 3}]}

Complexity ceilings

Filters are recursive and, on POST/PUT, they arrive in a JSON body with no depth limit of its own. Three ceilings bound the damage: depth 5, 100 applied conditions per request, and 500 values in a single whereIn (a condition count alone doesn't help — one whereIn with 100 000 values is still one condition). What exceeds them is dropped silently, like everything else in the filter pipeline. Raise or lower them per service:

protectedfilterLimits={maxDepth: 8,maxConditions: 250,maxInValues: 1000}

An oversized whereIn is dropped whole rather than truncated: a truncated set answers a question the client didn't ask, and does it silently.


Preload whitelist

?with= is client input, so it gets the same treatment as filters. Two properties split the job:

  • indexWith / showWith say what the service loads. They always apply — whether the client asks or not.
  • allowedIncludes says what the client may ask for on top. Per method, and additive.
allowedIncludesClient may preloadUse it when
(not declared)exactly what that method preloadsthe common case — zero extra config
{ index: ['tags'], show: ['tags', 'audit'] }that, plus the method's own preloadsopening the listing without opening the detail, or the reverse
['tags']that, on both methodsthe same extra everywhere
ALLOW_ALL_INCLUDESany relation on the modelinternal tooling; this was the implicit behaviour before 2.0.0

The default is per method on purpose. A relation listed only in showWith doesn't become requestable on the listing: fetching one organization's invitations is proportionate; fetching them for the 25 rows of a page — every invited person's email — is a different thing. Nothing stops you from opening it, but opening it has to be something you wrote.

The explicit forms are additive: you never repeat in allowedIncludes what is already in indexWith or showWith. A relation preloaded by default travels in the response whether the client asks for it or not, so "denying" it would mean nothing.

A requested path also passes if it is a prefix of an allowed one (owner when owner.profile is allowed) — asking for less is always fine. The reverse is not: owner.profile when only owner is allowed is one level deeper than anyone authorized.

Without any of this, ?with=owner on a public listing pulls the entire related model into the response — including whatever that model serializes — and nested paths multiply the queries behind it.


Sorting

orderBy must name a column that exists and that the model serializes. The first half is the SQL-injection guard; the second closes an oracle: sorting by a hidden column and paging through the results lets an attacker compare that value across rows and reconstruct it by position. Same principle as filters — sortable ⊆ visible — and ALLOW_ALL_FILTERS lifts both restrictions together.

orderDirection only ever reaches Knex as asc or desc.


CRUD and lifecycle hooks

BaseService adds create, update and destroy on top of SearchableService, each with optional hooks and transaction support:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncbeforeCreate(data: Partial<Order>){data.reference??=generateReference()}protectedasyncafterCreate(record: Order,trx?: TransactionClientContract){awaitthis.notify(record,trx)}}

Available hooks: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDestroy, afterDestroy. All optional, all awaited, all receiving the transaction when one is passed.

findOne(uuid, includes?) returns null when the row doesn't exist; findOneOrFail, update and destroy throw RecordNotFoundError instead. It carries status = 404, so a standard AdonisJS exception handler maps it without extra wiring.


Escape hatch: applyCustomFilters

For conditions the declarative system can't express — rich jsonb, joins, subqueries, tenant scoping — override the hook. It runs inside the same query builder, so it also constrains count and pagination:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncapplyCustomFilters(query: ModelQueryBuilder,params: QueryParams){query.whereRaw("metadata @> ?",[JSON.stringify({region: this.region})])}}

This is your code, not client input, so the field whitelist doesn't apply here — that's the point of the hatch. Keep any client-supplied value parameterized.


API reference

// FunctionsfunctionparseQueryParams(request: HttpContext['request']): QueryParams// ClassesclassSearchableService<TModel>{index(params?: QueryParams): Promise<PaginatedResult<TModel>|{count: number}|{data: []}>findOne(uuid: string,includes?: string[]): Promise<InstanceType<TModel>|null>findOneOrFail(uuid: string,includes?: string[]): Promise<InstanceType<TModel>>protectedbuildQuery(params: QueryParams): Promise<{query: ModelQueryBuilder}>protectedapplyCustomFilters?(query,params): void|Promise<void>}classBaseService<TModel>extendsSearchableService<TModel>{create(data,trx?): Promise<InstanceType<TModel>>update(uuid,data,trx?): Promise<InstanceType<TModel>>destroy(uuid,trx?): Promise<void>}classRecordNotFoundErrorextendsError{status=404}// SentinelsconstALLOW_ALL_FILTERS: unique symbolconstFILTERABLE_FROM_MODEL: unique symbol// the default for allowedFiltersconstALLOW_ALL_INCLUDES: unique symbolconstINCLUDES_FROM_SERVICE: unique symbol// the default for allowedIncludes// ConstantsconstMAX_FILTER_DEPTH=5constMAX_FILTER_CONDITIONS=100constMAX_FILTER_IN_VALUES=500// TypestypeAllowedFilters,AllowedIncludes,AllowedIncludesByMethod,IncludesMethod,FilterLimits,QueryParams,PaginatedResult,PaginationMeta,PeriodFilter,FilterBlock,FilterCondition,FilterMethod

Design notes

No HttpContext. The service takes a plain object, so the same code serves an HTTP endpoint, a queue job, an ace command or another service. It's also what makes it testable without booting a server.

Silent skipping over errors. Unknown filter methods, non-whitelisted fields and unsafe operators are dropped, not rejected. Filters come from untrusted input; failing loudly turns every bad-faith query string into a 500. If your API needs explicit feedback, validate at the controller/validator layer.

Column introspection is cached per service instance: the orderBy guard reads the real table columns once; the default filter whitelist reads the model's own metadata and never touches the database.

Every client-controlled surface has a whitelist. Fields, operators, sort columns, preloads, scopes, search columns and every numeric bound. That symmetry is the point: a gap in one of them is worth more to an attacker than hardening the others further.

The package tests itself.npm test runs the full suite against in-memory SQLite — no Postgres, no migrations, no host application. CI runs it on Node 20, 22 and 24 before anything ships.


Compatibility

Node≥ 20.6
AdonisJS^7 (peer)
Lucid^22 (peer)
DatabasesPostgreSQL, MySQL, SQLite
Module formatESM only

Scope and maintenance

Extracted from the adonis7-base chassis, where it runs in production-shaped projects. It is maintained according to that chassis's needs: bug fixes and small additions are welcome, larger feature requests may not fit the roadmap.

License

MIT

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@jantstack/adonis-searchable

Generic service layer for AdonisJS 7 + Lucid ORM: paginated listing, multi-column fulltext search, declarative filters with a safe-by-default whitelist, date-range filters, sorting, preloads and Lucid scopes — all driven from the query string, none of it touching HttpContext.

Services stay testable in isolation and reusable from jobs, commands and other services. Controllers shrink to a few lines.

import{BaseService}from'@jantstack/adonis-searchable'importProductfrom'#models/product'exportdefaultclassProductsServiceextendsBaseService<typeofProduct>{protectedmodel=ProductprotectedsearchableColumns=['name','sku']protectedallowedFilters=['status','category_id','created_at']protectedindexWith=['category']}
import{parseQueryParams}from'@jantstack/adonis-searchable'exportdefaultclassProductsController{constructor(privateservice=newProductsService()){}asyncindex({ request }: HttpContext){returnthis.service.index(parseQueryParams(request))}}

That's a full listing endpoint with pagination, search, filters and preloads.


Table of contents


Install

npm i @jantstack/adonis-searchable

Peer dependencies: @adonisjs/core ^7 and @adonisjs/lucid ^22. No provider to register and no config file — you extend a class and you're done.

Engine-agnostic: the package emits no engine-specific SQL, so Postgres, MySQL and SQLite all work. (Fulltext search uses ILIKE on Postgres and LIKE elsewhere; column introspection uses each engine's standard catalog.)


Configuring a service

Every knob is a protected property on the subclass:

PropertyDefaultWhat it does
model(required)The Lucid model the service operates on.
searchableColumns[]Columns scanned by search. Empty = search does nothing.
allowedFiltersserialized columnsFields the client may filter by — see below.
periodColumns['created_at', 'updated_at']Columns allowed in date-range filters.
indexWith[]Relations preloaded by default in index().
showWith[]Relations preloaded by default in findOne().
allowedIncludesper method: what that method preloadsWhat the client may request on top — see below.
defaultPerPage25Page size when the client doesn't send one.
maxPerPage100Ceiling for perPage — protects against ?perPage=100000.
maxUnpaginatedLimit1000Row ceiling for paginate: false — the client's limit narrows it, never widens it.
filterLimitsdepth 5, 100 conditions, 500 whereIn valuesComplexity ceilings for client-supplied filters.

index() and the query string

index(params) accepts a QueryParams object. parseQueryParams(request) builds it from the HTTP request — it is the only piece of the package that knows about HttpContext, so services stay transport-agnostic (write another adapter for GraphQL or gRPC and nothing else changes). It reads the query string on GET and merges the body on POST/PUT, supporting these conventions:

?page=2&per_page=50
&search=acme&search_columns[]=name&search_columns[]=sku
&filters[where][0][field]=status&filters[where][0][op]==&filters[where][0][value]=active
&period_filters[0][column]=created_at&period_filters[0][start]=2026-01-01&period_filters[0][end]=2026-03-31
&order_by=created_at&order_direction=desc
&with=category,category.parent
&scopes[withStatus]=delivered
&count=true
&paginate=false&limit=500

Query-string key → QueryParams property: per_pageperPage, search_columns[]searchColumns, order_by/order_directionorderBy/orderDirection, with (CSV or array) → includes, period_filtersperiod. search_input is accepted as a legacy alias of search.

ParamTypeNotes
page / perPagenumberperPage is clamped to maxPerPage.
countbooleanReturns { count } only — no rows, no meta.
paginatebooleanfalse returns { data } unpaginated (bounded by limit).
limitnumberRow cap when paginate: false.
searchstringFulltext across searchableColumns.
searchColumnsstring[]Narrows the search to a subset (intersected with the whitelist).
filtersobject | object[]See Filters.
periodobject[]{ column, start?, end? }; column must be in periodColumns and the dates must be real YYYY-MM-DD.
orderBy / orderDirectionstringorderBy must be a real and visible column — see Sorting.
includesstring[]Relations to preload; dot notation for nested (profile.wallets). Filtered by allowedIncludes.
scopesobjectLucid scopes to apply: { withStatus: 'delivered' }.
paginationBaseUrl / paginationExtraQsstring / objectBuild absolute pagination links in meta.

Return shape:

// default{ data: Model[],meta: { total, perPage, currentPage, lastPage, firstPage, ...links}}// count: true{count: number}// paginate: false{ data: Model[]}

Filters

Filters are declarative and arrive from the client, so the package is deny-by-default in the two places that matter: which fields can be filtered, and which operators are allowed.

Filter methods

Each key of a filter block is a method, each value an array of conditions:

FamilyMethods
Comparisonwhere, orWhere
SetswhereIn, orWhereIn, whereNotIn, orWhereNotIn
RangeswhereBetween, orWhereBetween, whereNotBetween, orWhereNotBetween
NullabilitywhereNull, orWhereNull, whereNotNull, orWhereNotNull
JSONwhereJsonContains, orWhereJsonContains, whereJsonLength, orWhereJsonLength

A condition is { field, op?, value?, values? }. Anything unrecognized is skipped silently — by design, since the input is untrusted.

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],whereIn: [{field: 'category_id',values: [1,2,3]}],whereNotNull: [{field: 'published_at'}],},})

Field whitelist (safe by default)

The guiding principle is filterable ⊆ visible: if a column already travels in the API response, filtering by it reveals nothing new.

allowedFiltersFilterableUse it when
(not declared)columns the model serializesinternal CRUD, prototypes — safe with zero config
['name', 'status']only thosepublic APIs: the filter contract stops following the schema
[]nothingendpoints that must not accept filters at all
ALLOW_ALL_FILTERSevery column, hidden ones includedinternal tooling over non-sensitive models

The default excludes anything marked @column({ serializeAs: null }) — a password hash, for instance. This matters: a like filter over a hidden column is a blind exfiltration oracle. An attacker probes character by character ($scrypt$a%, $scrypt$b%…) and reads the answer from which rows come back. Excluding non-serialized columns closes that without any configuration on your part.

import{SearchableService,ALLOW_ALL_FILTERS}from'@jantstack/adonis-searchable'importtype{AllowedFilters}from'@jantstack/adonis-searchable'classInternalAuditServiceextendsSearchableService<typeofAuditRow>{protectedmodel=AuditRowprotectedallowedFilters: AllowedFilters=ALLOW_ALL_FILTERS// explicit opt-in}

The : AllowedFilters annotation is required — without it TypeScript widens the symbol and the assignment won't compile. Useful side effect: the opt-in is impossible to miss in code review.

Operator whitelist

The op of a condition is interpolated raw into SQL by Knex, so only these pass: =, !=, <>, >, >=, <, <=, like, ilike, not like, not ilike. Anything else (op = "IS NULL OR 1=1 --") drops the condition instead of injecting it.

Nesting with orGroup / andGroup

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],orGroup: [{where: [{field: 'priority',op: '>=',value: 8}]},{where: [{field: 'flagged',op: '=',value: true}]},],},})// WHERE status = 'active' AND (priority >= 8 OR flagged = true)

Inside an orGroup, where conditions are rewritten to orWhere automatically. Nesting is recursive, and the field whitelist applies at every level.

JSON columns

Use -> to reach into a JSON path; the whitelist checks the root field:

{where: [{field: 'metadata->plan',op: '=',value: 'pro'}]}// needs 'metadata' allowed{whereJsonLength: [{field: 'tags',op: '>',value: 3}]}

Complexity ceilings

Filters are recursive and, on POST/PUT, they arrive in a JSON body with no depth limit of its own. Three ceilings bound the damage: depth 5, 100 applied conditions per request, and 500 values in a single whereIn (a condition count alone doesn't help — one whereIn with 100 000 values is still one condition). What exceeds them is dropped silently, like everything else in the filter pipeline. Raise or lower them per service:

protectedfilterLimits={maxDepth: 8,maxConditions: 250,maxInValues: 1000}

An oversized whereIn is dropped whole rather than truncated: a truncated set answers a question the client didn't ask, and does it silently.


Preload whitelist

?with= is client input, so it gets the same treatment as filters. Two properties split the job:

  • indexWith / showWith say what the service loads. They always apply — whether the client asks or not.
  • allowedIncludes says what the client may ask for on top. Per method, and additive.
allowedIncludesClient may preloadUse it when
(not declared)exactly what that method preloadsthe common case — zero extra config
{ index: ['tags'], show: ['tags', 'audit'] }that, plus the method's own preloadsopening the listing without opening the detail, or the reverse
['tags']that, on both methodsthe same extra everywhere
ALLOW_ALL_INCLUDESany relation on the modelinternal tooling; this was the implicit behaviour before 2.0.0

The default is per method on purpose. A relation listed only in showWith doesn't become requestable on the listing: fetching one organization's invitations is proportionate; fetching them for the 25 rows of a page — every invited person's email — is a different thing. Nothing stops you from opening it, but opening it has to be something you wrote.

The explicit forms are additive: you never repeat in allowedIncludes what is already in indexWith or showWith. A relation preloaded by default travels in the response whether the client asks for it or not, so "denying" it would mean nothing.

A requested path also passes if it is a prefix of an allowed one (owner when owner.profile is allowed) — asking for less is always fine. The reverse is not: owner.profile when only owner is allowed is one level deeper than anyone authorized.

Without any of this, ?with=owner on a public listing pulls the entire related model into the response — including whatever that model serializes — and nested paths multiply the queries behind it.


Sorting

orderBy must name a column that exists and that the model serializes. The first half is the SQL-injection guard; the second closes an oracle: sorting by a hidden column and paging through the results lets an attacker compare that value across rows and reconstruct it by position. Same principle as filters — sortable ⊆ visible — and ALLOW_ALL_FILTERS lifts both restrictions together.

orderDirection only ever reaches Knex as asc or desc.


CRUD and lifecycle hooks

BaseService adds create, update and destroy on top of SearchableService, each with optional hooks and transaction support:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncbeforeCreate(data: Partial<Order>){data.reference??=generateReference()}protectedasyncafterCreate(record: Order,trx?: TransactionClientContract){awaitthis.notify(record,trx)}}

Available hooks: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDestroy, afterDestroy. All optional, all awaited, all receiving the transaction when one is passed.

findOne(uuid, includes?) returns null when the row doesn't exist; findOneOrFail, update and destroy throw RecordNotFoundError instead. It carries status = 404, so a standard AdonisJS exception handler maps it without extra wiring.


Escape hatch: applyCustomFilters

For conditions the declarative system can't express — rich jsonb, joins, subqueries, tenant scoping — override the hook. It runs inside the same query builder, so it also constrains count and pagination:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncapplyCustomFilters(query: ModelQueryBuilder,params: QueryParams){query.whereRaw("metadata @> ?",[JSON.stringify({region: this.region})])}}

This is your code, not client input, so the field whitelist doesn't apply here — that's the point of the hatch. Keep any client-supplied value parameterized.


API reference

// FunctionsfunctionparseQueryParams(request: HttpContext['request']): QueryParams// ClassesclassSearchableService<TModel>{index(params?: QueryParams): Promise<PaginatedResult<TModel>|{count: number}|{data: []}>findOne(uuid: string,includes?: string[]): Promise<InstanceType<TModel>|null>findOneOrFail(uuid: string,includes?: string[]): Promise<InstanceType<TModel>>protectedbuildQuery(params: QueryParams): Promise<{query: ModelQueryBuilder}>protectedapplyCustomFilters?(query,params): void|Promise<void>}classBaseService<TModel>extendsSearchableService<TModel>{create(data,trx?): Promise<InstanceType<TModel>>update(uuid,data,trx?): Promise<InstanceType<TModel>>destroy(uuid,trx?): Promise<void>}classRecordNotFoundErrorextendsError{status=404}// SentinelsconstALLOW_ALL_FILTERS: unique symbolconstFILTERABLE_FROM_MODEL: unique symbol// the default for allowedFiltersconstALLOW_ALL_INCLUDES: unique symbolconstINCLUDES_FROM_SERVICE: unique symbol// the default for allowedIncludes// ConstantsconstMAX_FILTER_DEPTH=5constMAX_FILTER_CONDITIONS=100constMAX_FILTER_IN_VALUES=500// TypestypeAllowedFilters,AllowedIncludes,AllowedIncludesByMethod,IncludesMethod,FilterLimits,QueryParams,PaginatedResult,PaginationMeta,PeriodFilter,FilterBlock,FilterCondition,FilterMethod

Design notes

No HttpContext. The service takes a plain object, so the same code serves an HTTP endpoint, a queue job, an ace command or another service. It's also what makes it testable without booting a server.

Silent skipping over errors. Unknown filter methods, non-whitelisted fields and unsafe operators are dropped, not rejected. Filters come from untrusted input; failing loudly turns every bad-faith query string into a 500. If your API needs explicit feedback, validate at the controller/validator layer.

Column introspection is cached per service instance: the orderBy guard reads the real table columns once; the default filter whitelist reads the model's own metadata and never touches the database.

Every client-controlled surface has a whitelist. Fields, operators, sort columns, preloads, scopes, search columns and every numeric bound. That symmetry is the point: a gap in one of them is worth more to an attacker than hardening the others further.

The package tests itself.npm test runs the full suite against in-memory SQLite — no Postgres, no migrations, no host application. CI runs it on Node 20, 22 and 24 before anything ships.


Compatibility

Node≥ 20.6
AdonisJS^7 (peer)
Lucid^22 (peer)
DatabasesPostgreSQL, MySQL, SQLite
Module formatESM only

Scope and maintenance

Extracted from the adonis7-base chassis, where it runs in production-shaped projects. It is maintained according to that chassis's needs: bug fixes and small additions are welcome, larger feature requests may not fit the roadmap.

License

MIT

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

@jantstack/adonis-searchable

Generic service layer for AdonisJS 7 + Lucid ORM: paginated listing, multi-column fulltext search, declarative filters with a safe-by-default whitelist, date-range filters, sorting, preloads and Lucid scopes — all driven from the query string, none of it touching HttpContext.

Services stay testable in isolation and reusable from jobs, commands and other services. Controllers shrink to a few lines.

import{BaseService}from'@jantstack/adonis-searchable'importProductfrom'#models/product'exportdefaultclassProductsServiceextendsBaseService<typeofProduct>{protectedmodel=ProductprotectedsearchableColumns=['name','sku']protectedallowedFilters=['status','category_id','created_at']protectedindexWith=['category']}
import{parseQueryParams}from'@jantstack/adonis-searchable'exportdefaultclassProductsController{constructor(privateservice=newProductsService()){}asyncindex({ request }: HttpContext){returnthis.service.index(parseQueryParams(request))}}

That's a full listing endpoint with pagination, search, filters and preloads.


Table of contents


Install

npm i @jantstack/adonis-searchable

Peer dependencies: @adonisjs/core ^7 and @adonisjs/lucid ^22. No provider to register and no config file — you extend a class and you're done.

Engine-agnostic: the package emits no engine-specific SQL, so Postgres, MySQL and SQLite all work. (Fulltext search uses ILIKE on Postgres and LIKE elsewhere; column introspection uses each engine's standard catalog.)


Configuring a service

Every knob is a protected property on the subclass:

PropertyDefaultWhat it does
model(required)The Lucid model the service operates on.
searchableColumns[]Columns scanned by search. Empty = search does nothing.
allowedFiltersserialized columnsFields the client may filter by — see below.
periodColumns['created_at', 'updated_at']Columns allowed in date-range filters.
indexWith[]Relations preloaded by default in index().
showWith[]Relations preloaded by default in findOne().
allowedIncludesper method: what that method preloadsWhat the client may request on top — see below.
defaultPerPage25Page size when the client doesn't send one.
maxPerPage100Ceiling for perPage — protects against ?perPage=100000.
maxUnpaginatedLimit1000Row ceiling for paginate: false — the client's limit narrows it, never widens it.
filterLimitsdepth 5, 100 conditions, 500 whereIn valuesComplexity ceilings for client-supplied filters.

index() and the query string

index(params) accepts a QueryParams object. parseQueryParams(request) builds it from the HTTP request — it is the only piece of the package that knows about HttpContext, so services stay transport-agnostic (write another adapter for GraphQL or gRPC and nothing else changes). It reads the query string on GET and merges the body on POST/PUT, supporting these conventions:

?page=2&per_page=50
&search=acme&search_columns[]=name&search_columns[]=sku
&filters[where][0][field]=status&filters[where][0][op]==&filters[where][0][value]=active
&period_filters[0][column]=created_at&period_filters[0][start]=2026-01-01&period_filters[0][end]=2026-03-31
&order_by=created_at&order_direction=desc
&with=category,category.parent
&scopes[withStatus]=delivered
&count=true
&paginate=false&limit=500

Query-string key → QueryParams property: per_pageperPage, search_columns[]searchColumns, order_by/order_directionorderBy/orderDirection, with (CSV or array) → includes, period_filtersperiod. search_input is accepted as a legacy alias of search.

ParamTypeNotes
page / perPagenumberperPage is clamped to maxPerPage.
countbooleanReturns { count } only — no rows, no meta.
paginatebooleanfalse returns { data } unpaginated (bounded by limit).
limitnumberRow cap when paginate: false.
searchstringFulltext across searchableColumns.
searchColumnsstring[]Narrows the search to a subset (intersected with the whitelist).
filtersobject | object[]See Filters.
periodobject[]{ column, start?, end? }; column must be in periodColumns and the dates must be real YYYY-MM-DD.
orderBy / orderDirectionstringorderBy must be a real and visible column — see Sorting.
includesstring[]Relations to preload; dot notation for nested (profile.wallets). Filtered by allowedIncludes.
scopesobjectLucid scopes to apply: { withStatus: 'delivered' }.
paginationBaseUrl / paginationExtraQsstring / objectBuild absolute pagination links in meta.

Return shape:

// default{ data: Model[],meta: { total, perPage, currentPage, lastPage, firstPage, ...links}}// count: true{count: number}// paginate: false{ data: Model[]}

Filters

Filters are declarative and arrive from the client, so the package is deny-by-default in the two places that matter: which fields can be filtered, and which operators are allowed.

Filter methods

Each key of a filter block is a method, each value an array of conditions:

FamilyMethods
Comparisonwhere, orWhere
SetswhereIn, orWhereIn, whereNotIn, orWhereNotIn
RangeswhereBetween, orWhereBetween, whereNotBetween, orWhereNotBetween
NullabilitywhereNull, orWhereNull, whereNotNull, orWhereNotNull
JSONwhereJsonContains, orWhereJsonContains, whereJsonLength, orWhereJsonLength

A condition is { field, op?, value?, values? }. Anything unrecognized is skipped silently — by design, since the input is untrusted.

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],whereIn: [{field: 'category_id',values: [1,2,3]}],whereNotNull: [{field: 'published_at'}],},})

Field whitelist (safe by default)

The guiding principle is filterable ⊆ visible: if a column already travels in the API response, filtering by it reveals nothing new.

allowedFiltersFilterableUse it when
(not declared)columns the model serializesinternal CRUD, prototypes — safe with zero config
['name', 'status']only thosepublic APIs: the filter contract stops following the schema
[]nothingendpoints that must not accept filters at all
ALLOW_ALL_FILTERSevery column, hidden ones includedinternal tooling over non-sensitive models

The default excludes anything marked @column({ serializeAs: null }) — a password hash, for instance. This matters: a like filter over a hidden column is a blind exfiltration oracle. An attacker probes character by character ($scrypt$a%, $scrypt$b%…) and reads the answer from which rows come back. Excluding non-serialized columns closes that without any configuration on your part.

import{SearchableService,ALLOW_ALL_FILTERS}from'@jantstack/adonis-searchable'importtype{AllowedFilters}from'@jantstack/adonis-searchable'classInternalAuditServiceextendsSearchableService<typeofAuditRow>{protectedmodel=AuditRowprotectedallowedFilters: AllowedFilters=ALLOW_ALL_FILTERS// explicit opt-in}

The : AllowedFilters annotation is required — without it TypeScript widens the symbol and the assignment won't compile. Useful side effect: the opt-in is impossible to miss in code review.

Operator whitelist

The op of a condition is interpolated raw into SQL by Knex, so only these pass: =, !=, <>, >, >=, <, <=, like, ilike, not like, not ilike. Anything else (op = "IS NULL OR 1=1 --") drops the condition instead of injecting it.

Nesting with orGroup / andGroup

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],orGroup: [{where: [{field: 'priority',op: '>=',value: 8}]},{where: [{field: 'flagged',op: '=',value: true}]},],},})// WHERE status = 'active' AND (priority >= 8 OR flagged = true)

Inside an orGroup, where conditions are rewritten to orWhere automatically. Nesting is recursive, and the field whitelist applies at every level.

JSON columns

Use -> to reach into a JSON path; the whitelist checks the root field:

{where: [{field: 'metadata->plan',op: '=',value: 'pro'}]}// needs 'metadata' allowed{whereJsonLength: [{field: 'tags',op: '>',value: 3}]}

Complexity ceilings

Filters are recursive and, on POST/PUT, they arrive in a JSON body with no depth limit of its own. Three ceilings bound the damage: depth 5, 100 applied conditions per request, and 500 values in a single whereIn (a condition count alone doesn't help — one whereIn with 100 000 values is still one condition). What exceeds them is dropped silently, like everything else in the filter pipeline. Raise or lower them per service:

protectedfilterLimits={maxDepth: 8,maxConditions: 250,maxInValues: 1000}

An oversized whereIn is dropped whole rather than truncated: a truncated set answers a question the client didn't ask, and does it silently.


Preload whitelist

?with= is client input, so it gets the same treatment as filters. Two properties split the job:

  • indexWith / showWith say what the service loads. They always apply — whether the client asks or not.
  • allowedIncludes says what the client may ask for on top. Per method, and additive.
allowedIncludesClient may preloadUse it when
(not declared)exactly what that method preloadsthe common case — zero extra config
{ index: ['tags'], show: ['tags', 'audit'] }that, plus the method's own preloadsopening the listing without opening the detail, or the reverse
['tags']that, on both methodsthe same extra everywhere
ALLOW_ALL_INCLUDESany relation on the modelinternal tooling; this was the implicit behaviour before 2.0.0

The default is per method on purpose. A relation listed only in showWith doesn't become requestable on the listing: fetching one organization's invitations is proportionate; fetching them for the 25 rows of a page — every invited person's email — is a different thing. Nothing stops you from opening it, but opening it has to be something you wrote.

The explicit forms are additive: you never repeat in allowedIncludes what is already in indexWith or showWith. A relation preloaded by default travels in the response whether the client asks for it or not, so "denying" it would mean nothing.

A requested path also passes if it is a prefix of an allowed one (owner when owner.profile is allowed) — asking for less is always fine. The reverse is not: owner.profile when only owner is allowed is one level deeper than anyone authorized.

Without any of this, ?with=owner on a public listing pulls the entire related model into the response — including whatever that model serializes — and nested paths multiply the queries behind it.


Sorting

orderBy must name a column that exists and that the model serializes. The first half is the SQL-injection guard; the second closes an oracle: sorting by a hidden column and paging through the results lets an attacker compare that value across rows and reconstruct it by position. Same principle as filters — sortable ⊆ visible — and ALLOW_ALL_FILTERS lifts both restrictions together.

orderDirection only ever reaches Knex as asc or desc.


CRUD and lifecycle hooks

BaseService adds create, update and destroy on top of SearchableService, each with optional hooks and transaction support:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncbeforeCreate(data: Partial<Order>){data.reference??=generateReference()}protectedasyncafterCreate(record: Order,trx?: TransactionClientContract){awaitthis.notify(record,trx)}}

Available hooks: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDestroy, afterDestroy. All optional, all awaited, all receiving the transaction when one is passed.

findOne(uuid, includes?) returns null when the row doesn't exist; findOneOrFail, update and destroy throw RecordNotFoundError instead. It carries status = 404, so a standard AdonisJS exception handler maps it without extra wiring.


Escape hatch: applyCustomFilters

For conditions the declarative system can't express — rich jsonb, joins, subqueries, tenant scoping — override the hook. It runs inside the same query builder, so it also constrains count and pagination:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncapplyCustomFilters(query: ModelQueryBuilder,params: QueryParams){query.whereRaw("metadata @> ?",[JSON.stringify({region: this.region})])}}

This is your code, not client input, so the field whitelist doesn't apply here — that's the point of the hatch. Keep any client-supplied value parameterized.


API reference

// FunctionsfunctionparseQueryParams(request: HttpContext['request']): QueryParams// ClassesclassSearchableService<TModel>{index(params?: QueryParams): Promise<PaginatedResult<TModel>|{count: number}|{data: []}>findOne(uuid: string,includes?: string[]): Promise<InstanceType<TModel>|null>findOneOrFail(uuid: string,includes?: string[]): Promise<InstanceType<TModel>>protectedbuildQuery(params: QueryParams): Promise<{query: ModelQueryBuilder}>protectedapplyCustomFilters?(query,params): void|Promise<void>}classBaseService<TModel>extendsSearchableService<TModel>{create(data,trx?): Promise<InstanceType<TModel>>update(uuid,data,trx?): Promise<InstanceType<TModel>>destroy(uuid,trx?): Promise<void>}classRecordNotFoundErrorextendsError{status=404}// SentinelsconstALLOW_ALL_FILTERS: unique symbolconstFILTERABLE_FROM_MODEL: unique symbol// the default for allowedFiltersconstALLOW_ALL_INCLUDES: unique symbolconstINCLUDES_FROM_SERVICE: unique symbol// the default for allowedIncludes// ConstantsconstMAX_FILTER_DEPTH=5constMAX_FILTER_CONDITIONS=100constMAX_FILTER_IN_VALUES=500// TypestypeAllowedFilters,AllowedIncludes,AllowedIncludesByMethod,IncludesMethod,FilterLimits,QueryParams,PaginatedResult,PaginationMeta,PeriodFilter,FilterBlock,FilterCondition,FilterMethod

Design notes

No HttpContext. The service takes a plain object, so the same code serves an HTTP endpoint, a queue job, an ace command or another service. It's also what makes it testable without booting a server.

Silent skipping over errors. Unknown filter methods, non-whitelisted fields and unsafe operators are dropped, not rejected. Filters come from untrusted input; failing loudly turns every bad-faith query string into a 500. If your API needs explicit feedback, validate at the controller/validator layer.

Column introspection is cached per service instance: the orderBy guard reads the real table columns once; the default filter whitelist reads the model's own metadata and never touches the database.

Every client-controlled surface has a whitelist. Fields, operators, sort columns, preloads, scopes, search columns and every numeric bound. That symmetry is the point: a gap in one of them is worth more to an attacker than hardening the others further.

The package tests itself.npm test runs the full suite against in-memory SQLite — no Postgres, no migrations, no host application. CI runs it on Node 20, 22 and 24 before anything ships.


Compatibility

Node≥ 20.6
AdonisJS^7 (peer)
Lucid^22 (peer)
DatabasesPostgreSQL, MySQL, SQLite
Module formatESM only

Scope and maintenance

Extracted from the adonis7-base chassis, where it runs in production-shaped projects. It is maintained according to that chassis's needs: bug fixes and small additions are welcome, larger feature requests may not fit the roadmap.

License

MIT

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@jantstack/adonis-searchable

Generic service layer for AdonisJS 7 + Lucid ORM: paginated listing, multi-column fulltext search, declarative filters with a safe-by-default whitelist, date-range filters, sorting, preloads and Lucid scopes — all driven from the query string, none of it touching HttpContext.

Services stay testable in isolation and reusable from jobs, commands and other services. Controllers shrink to a few lines.

import{BaseService}from'@jantstack/adonis-searchable'importProductfrom'#models/product'exportdefaultclassProductsServiceextendsBaseService<typeofProduct>{protectedmodel=ProductprotectedsearchableColumns=['name','sku']protectedallowedFilters=['status','category_id','created_at']protectedindexWith=['category']}
import{parseQueryParams}from'@jantstack/adonis-searchable'exportdefaultclassProductsController{constructor(privateservice=newProductsService()){}asyncindex({ request }: HttpContext){returnthis.service.index(parseQueryParams(request))}}

That's a full listing endpoint with pagination, search, filters and preloads.


Table of contents


Install

npm i @jantstack/adonis-searchable

Peer dependencies: @adonisjs/core ^7 and @adonisjs/lucid ^22. No provider to register and no config file — you extend a class and you're done.

Engine-agnostic: the package emits no engine-specific SQL, so Postgres, MySQL and SQLite all work. (Fulltext search uses ILIKE on Postgres and LIKE elsewhere; column introspection uses each engine's standard catalog.)


Configuring a service

Every knob is a protected property on the subclass:

PropertyDefaultWhat it does
model(required)The Lucid model the service operates on.
searchableColumns[]Columns scanned by search. Empty = search does nothing.
allowedFiltersserialized columnsFields the client may filter by — see below.
periodColumns['created_at', 'updated_at']Columns allowed in date-range filters.
indexWith[]Relations preloaded by default in index().
showWith[]Relations preloaded by default in findOne().
allowedIncludesper method: what that method preloadsWhat the client may request on top — see below.
defaultPerPage25Page size when the client doesn't send one.
maxPerPage100Ceiling for perPage — protects against ?perPage=100000.
maxUnpaginatedLimit1000Row ceiling for paginate: false — the client's limit narrows it, never widens it.
filterLimitsdepth 5, 100 conditions, 500 whereIn valuesComplexity ceilings for client-supplied filters.

index() and the query string

index(params) accepts a QueryParams object. parseQueryParams(request) builds it from the HTTP request — it is the only piece of the package that knows about HttpContext, so services stay transport-agnostic (write another adapter for GraphQL or gRPC and nothing else changes). It reads the query string on GET and merges the body on POST/PUT, supporting these conventions:

?page=2&per_page=50
&search=acme&search_columns[]=name&search_columns[]=sku
&filters[where][0][field]=status&filters[where][0][op]==&filters[where][0][value]=active
&period_filters[0][column]=created_at&period_filters[0][start]=2026-01-01&period_filters[0][end]=2026-03-31
&order_by=created_at&order_direction=desc
&with=category,category.parent
&scopes[withStatus]=delivered
&count=true
&paginate=false&limit=500

Query-string key → QueryParams property: per_pageperPage, search_columns[]searchColumns, order_by/order_directionorderBy/orderDirection, with (CSV or array) → includes, period_filtersperiod. search_input is accepted as a legacy alias of search.

ParamTypeNotes
page / perPagenumberperPage is clamped to maxPerPage.
countbooleanReturns { count } only — no rows, no meta.
paginatebooleanfalse returns { data } unpaginated (bounded by limit).
limitnumberRow cap when paginate: false.
searchstringFulltext across searchableColumns.
searchColumnsstring[]Narrows the search to a subset (intersected with the whitelist).
filtersobject | object[]See Filters.
periodobject[]{ column, start?, end? }; column must be in periodColumns and the dates must be real YYYY-MM-DD.
orderBy / orderDirectionstringorderBy must be a real and visible column — see Sorting.
includesstring[]Relations to preload; dot notation for nested (profile.wallets). Filtered by allowedIncludes.
scopesobjectLucid scopes to apply: { withStatus: 'delivered' }.
paginationBaseUrl / paginationExtraQsstring / objectBuild absolute pagination links in meta.

Return shape:

// default{ data: Model[],meta: { total, perPage, currentPage, lastPage, firstPage, ...links}}// count: true{count: number}// paginate: false{ data: Model[]}

Filters

Filters are declarative and arrive from the client, so the package is deny-by-default in the two places that matter: which fields can be filtered, and which operators are allowed.

Filter methods

Each key of a filter block is a method, each value an array of conditions:

FamilyMethods
Comparisonwhere, orWhere
SetswhereIn, orWhereIn, whereNotIn, orWhereNotIn
RangeswhereBetween, orWhereBetween, whereNotBetween, orWhereNotBetween
NullabilitywhereNull, orWhereNull, whereNotNull, orWhereNotNull
JSONwhereJsonContains, orWhereJsonContains, whereJsonLength, orWhereJsonLength

A condition is { field, op?, value?, values? }. Anything unrecognized is skipped silently — by design, since the input is untrusted.

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],whereIn: [{field: 'category_id',values: [1,2,3]}],whereNotNull: [{field: 'published_at'}],},})

Field whitelist (safe by default)

The guiding principle is filterable ⊆ visible: if a column already travels in the API response, filtering by it reveals nothing new.

allowedFiltersFilterableUse it when
(not declared)columns the model serializesinternal CRUD, prototypes — safe with zero config
['name', 'status']only thosepublic APIs: the filter contract stops following the schema
[]nothingendpoints that must not accept filters at all
ALLOW_ALL_FILTERSevery column, hidden ones includedinternal tooling over non-sensitive models

The default excludes anything marked @column({ serializeAs: null }) — a password hash, for instance. This matters: a like filter over a hidden column is a blind exfiltration oracle. An attacker probes character by character ($scrypt$a%, $scrypt$b%…) and reads the answer from which rows come back. Excluding non-serialized columns closes that without any configuration on your part.

import{SearchableService,ALLOW_ALL_FILTERS}from'@jantstack/adonis-searchable'importtype{AllowedFilters}from'@jantstack/adonis-searchable'classInternalAuditServiceextendsSearchableService<typeofAuditRow>{protectedmodel=AuditRowprotectedallowedFilters: AllowedFilters=ALLOW_ALL_FILTERS// explicit opt-in}

The : AllowedFilters annotation is required — without it TypeScript widens the symbol and the assignment won't compile. Useful side effect: the opt-in is impossible to miss in code review.

Operator whitelist

The op of a condition is interpolated raw into SQL by Knex, so only these pass: =, !=, <>, >, >=, <, <=, like, ilike, not like, not ilike. Anything else (op = "IS NULL OR 1=1 --") drops the condition instead of injecting it.

Nesting with orGroup / andGroup

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],orGroup: [{where: [{field: 'priority',op: '>=',value: 8}]},{where: [{field: 'flagged',op: '=',value: true}]},],},})// WHERE status = 'active' AND (priority >= 8 OR flagged = true)

Inside an orGroup, where conditions are rewritten to orWhere automatically. Nesting is recursive, and the field whitelist applies at every level.

JSON columns

Use -> to reach into a JSON path; the whitelist checks the root field:

{where: [{field: 'metadata->plan',op: '=',value: 'pro'}]}// needs 'metadata' allowed{whereJsonLength: [{field: 'tags',op: '>',value: 3}]}

Complexity ceilings

Filters are recursive and, on POST/PUT, they arrive in a JSON body with no depth limit of its own. Three ceilings bound the damage: depth 5, 100 applied conditions per request, and 500 values in a single whereIn (a condition count alone doesn't help — one whereIn with 100 000 values is still one condition). What exceeds them is dropped silently, like everything else in the filter pipeline. Raise or lower them per service:

protectedfilterLimits={maxDepth: 8,maxConditions: 250,maxInValues: 1000}

An oversized whereIn is dropped whole rather than truncated: a truncated set answers a question the client didn't ask, and does it silently.


Preload whitelist

?with= is client input, so it gets the same treatment as filters. Two properties split the job:

  • indexWith / showWith say what the service loads. They always apply — whether the client asks or not.
  • allowedIncludes says what the client may ask for on top. Per method, and additive.
allowedIncludesClient may preloadUse it when
(not declared)exactly what that method preloadsthe common case — zero extra config
{ index: ['tags'], show: ['tags', 'audit'] }that, plus the method's own preloadsopening the listing without opening the detail, or the reverse
['tags']that, on both methodsthe same extra everywhere
ALLOW_ALL_INCLUDESany relation on the modelinternal tooling; this was the implicit behaviour before 2.0.0

The default is per method on purpose. A relation listed only in showWith doesn't become requestable on the listing: fetching one organization's invitations is proportionate; fetching them for the 25 rows of a page — every invited person's email — is a different thing. Nothing stops you from opening it, but opening it has to be something you wrote.

The explicit forms are additive: you never repeat in allowedIncludes what is already in indexWith or showWith. A relation preloaded by default travels in the response whether the client asks for it or not, so "denying" it would mean nothing.

A requested path also passes if it is a prefix of an allowed one (owner when owner.profile is allowed) — asking for less is always fine. The reverse is not: owner.profile when only owner is allowed is one level deeper than anyone authorized.

Without any of this, ?with=owner on a public listing pulls the entire related model into the response — including whatever that model serializes — and nested paths multiply the queries behind it.


Sorting

orderBy must name a column that exists and that the model serializes. The first half is the SQL-injection guard; the second closes an oracle: sorting by a hidden column and paging through the results lets an attacker compare that value across rows and reconstruct it by position. Same principle as filters — sortable ⊆ visible — and ALLOW_ALL_FILTERS lifts both restrictions together.

orderDirection only ever reaches Knex as asc or desc.


CRUD and lifecycle hooks

BaseService adds create, update and destroy on top of SearchableService, each with optional hooks and transaction support:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncbeforeCreate(data: Partial<Order>){data.reference??=generateReference()}protectedasyncafterCreate(record: Order,trx?: TransactionClientContract){awaitthis.notify(record,trx)}}

Available hooks: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDestroy, afterDestroy. All optional, all awaited, all receiving the transaction when one is passed.

findOne(uuid, includes?) returns null when the row doesn't exist; findOneOrFail, update and destroy throw RecordNotFoundError instead. It carries status = 404, so a standard AdonisJS exception handler maps it without extra wiring.


Escape hatch: applyCustomFilters

For conditions the declarative system can't express — rich jsonb, joins, subqueries, tenant scoping — override the hook. It runs inside the same query builder, so it also constrains count and pagination:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncapplyCustomFilters(query: ModelQueryBuilder,params: QueryParams){query.whereRaw("metadata @> ?",[JSON.stringify({region: this.region})])}}

This is your code, not client input, so the field whitelist doesn't apply here — that's the point of the hatch. Keep any client-supplied value parameterized.


API reference

// FunctionsfunctionparseQueryParams(request: HttpContext['request']): QueryParams// ClassesclassSearchableService<TModel>{index(params?: QueryParams): Promise<PaginatedResult<TModel>|{count: number}|{data: []}>findOne(uuid: string,includes?: string[]): Promise<InstanceType<TModel>|null>findOneOrFail(uuid: string,includes?: string[]): Promise<InstanceType<TModel>>protectedbuildQuery(params: QueryParams): Promise<{query: ModelQueryBuilder}>protectedapplyCustomFilters?(query,params): void|Promise<void>}classBaseService<TModel>extendsSearchableService<TModel>{create(data,trx?): Promise<InstanceType<TModel>>update(uuid,data,trx?): Promise<InstanceType<TModel>>destroy(uuid,trx?): Promise<void>}classRecordNotFoundErrorextendsError{status=404}// SentinelsconstALLOW_ALL_FILTERS: unique symbolconstFILTERABLE_FROM_MODEL: unique symbol// the default for allowedFiltersconstALLOW_ALL_INCLUDES: unique symbolconstINCLUDES_FROM_SERVICE: unique symbol// the default for allowedIncludes// ConstantsconstMAX_FILTER_DEPTH=5constMAX_FILTER_CONDITIONS=100constMAX_FILTER_IN_VALUES=500// TypestypeAllowedFilters,AllowedIncludes,AllowedIncludesByMethod,IncludesMethod,FilterLimits,QueryParams,PaginatedResult,PaginationMeta,PeriodFilter,FilterBlock,FilterCondition,FilterMethod

Design notes

No HttpContext. The service takes a plain object, so the same code serves an HTTP endpoint, a queue job, an ace command or another service. It's also what makes it testable without booting a server.

Silent skipping over errors. Unknown filter methods, non-whitelisted fields and unsafe operators are dropped, not rejected. Filters come from untrusted input; failing loudly turns every bad-faith query string into a 500. If your API needs explicit feedback, validate at the controller/validator layer.

Column introspection is cached per service instance: the orderBy guard reads the real table columns once; the default filter whitelist reads the model's own metadata and never touches the database.

Every client-controlled surface has a whitelist. Fields, operators, sort columns, preloads, scopes, search columns and every numeric bound. That symmetry is the point: a gap in one of them is worth more to an attacker than hardening the others further.

The package tests itself.npm test runs the full suite against in-memory SQLite — no Postgres, no migrations, no host application. CI runs it on Node 20, 22 and 24 before anything ships.


Compatibility

Node≥ 20.6
AdonisJS^7 (peer)
Lucid^22 (peer)
DatabasesPostgreSQL, MySQL, SQLite
Module formatESM only

Scope and maintenance

Extracted from the adonis7-base chassis, where it runs in production-shaped projects. It is maintained according to that chassis's needs: bug fixes and small additions are welcome, larger feature requests may not fit the roadmap.

License

MIT

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

@jantstack/adonis-searchable

Generic service layer for AdonisJS 7 + Lucid ORM: paginated listing, multi-column fulltext search, declarative filters with a safe-by-default whitelist, date-range filters, sorting, preloads and Lucid scopes — all driven from the query string, none of it touching HttpContext.

Services stay testable in isolation and reusable from jobs, commands and other services. Controllers shrink to a few lines.

import{BaseService}from'@jantstack/adonis-searchable'importProductfrom'#models/product'exportdefaultclassProductsServiceextendsBaseService<typeofProduct>{protectedmodel=ProductprotectedsearchableColumns=['name','sku']protectedallowedFilters=['status','category_id','created_at']protectedindexWith=['category']}
import{parseQueryParams}from'@jantstack/adonis-searchable'exportdefaultclassProductsController{constructor(privateservice=newProductsService()){}asyncindex({ request }: HttpContext){returnthis.service.index(parseQueryParams(request))}}

That's a full listing endpoint with pagination, search, filters and preloads.


Table of contents


Install

npm i @jantstack/adonis-searchable

Peer dependencies: @adonisjs/core ^7 and @adonisjs/lucid ^22. No provider to register and no config file — you extend a class and you're done.

Engine-agnostic: the package emits no engine-specific SQL, so Postgres, MySQL and SQLite all work. (Fulltext search uses ILIKE on Postgres and LIKE elsewhere; column introspection uses each engine's standard catalog.)


Configuring a service

Every knob is a protected property on the subclass:

PropertyDefaultWhat it does
model(required)The Lucid model the service operates on.
searchableColumns[]Columns scanned by search. Empty = search does nothing.
allowedFiltersserialized columnsFields the client may filter by — see below.
periodColumns['created_at', 'updated_at']Columns allowed in date-range filters.
indexWith[]Relations preloaded by default in index().
showWith[]Relations preloaded by default in findOne().
allowedIncludesper method: what that method preloadsWhat the client may request on top — see below.
defaultPerPage25Page size when the client doesn't send one.
maxPerPage100Ceiling for perPage — protects against ?perPage=100000.
maxUnpaginatedLimit1000Row ceiling for paginate: false — the client's limit narrows it, never widens it.
filterLimitsdepth 5, 100 conditions, 500 whereIn valuesComplexity ceilings for client-supplied filters.

index() and the query string

index(params) accepts a QueryParams object. parseQueryParams(request) builds it from the HTTP request — it is the only piece of the package that knows about HttpContext, so services stay transport-agnostic (write another adapter for GraphQL or gRPC and nothing else changes). It reads the query string on GET and merges the body on POST/PUT, supporting these conventions:

?page=2&per_page=50
&search=acme&search_columns[]=name&search_columns[]=sku
&filters[where][0][field]=status&filters[where][0][op]==&filters[where][0][value]=active
&period_filters[0][column]=created_at&period_filters[0][start]=2026-01-01&period_filters[0][end]=2026-03-31
&order_by=created_at&order_direction=desc
&with=category,category.parent
&scopes[withStatus]=delivered
&count=true
&paginate=false&limit=500

Query-string key → QueryParams property: per_pageperPage, search_columns[]searchColumns, order_by/order_directionorderBy/orderDirection, with (CSV or array) → includes, period_filtersperiod. search_input is accepted as a legacy alias of search.

ParamTypeNotes
page / perPagenumberperPage is clamped to maxPerPage.
countbooleanReturns { count } only — no rows, no meta.
paginatebooleanfalse returns { data } unpaginated (bounded by limit).
limitnumberRow cap when paginate: false.
searchstringFulltext across searchableColumns.
searchColumnsstring[]Narrows the search to a subset (intersected with the whitelist).
filtersobject | object[]See Filters.
periodobject[]{ column, start?, end? }; column must be in periodColumns and the dates must be real YYYY-MM-DD.
orderBy / orderDirectionstringorderBy must be a real and visible column — see Sorting.
includesstring[]Relations to preload; dot notation for nested (profile.wallets). Filtered by allowedIncludes.
scopesobjectLucid scopes to apply: { withStatus: 'delivered' }.
paginationBaseUrl / paginationExtraQsstring / objectBuild absolute pagination links in meta.

Return shape:

// default{ data: Model[],meta: { total, perPage, currentPage, lastPage, firstPage, ...links}}// count: true{count: number}// paginate: false{ data: Model[]}

Filters

Filters are declarative and arrive from the client, so the package is deny-by-default in the two places that matter: which fields can be filtered, and which operators are allowed.

Filter methods

Each key of a filter block is a method, each value an array of conditions:

FamilyMethods
Comparisonwhere, orWhere
SetswhereIn, orWhereIn, whereNotIn, orWhereNotIn
RangeswhereBetween, orWhereBetween, whereNotBetween, orWhereNotBetween
NullabilitywhereNull, orWhereNull, whereNotNull, orWhereNotNull
JSONwhereJsonContains, orWhereJsonContains, whereJsonLength, orWhereJsonLength

A condition is { field, op?, value?, values? }. Anything unrecognized is skipped silently — by design, since the input is untrusted.

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],whereIn: [{field: 'category_id',values: [1,2,3]}],whereNotNull: [{field: 'published_at'}],},})

Field whitelist (safe by default)

The guiding principle is filterable ⊆ visible: if a column already travels in the API response, filtering by it reveals nothing new.

allowedFiltersFilterableUse it when
(not declared)columns the model serializesinternal CRUD, prototypes — safe with zero config
['name', 'status']only thosepublic APIs: the filter contract stops following the schema
[]nothingendpoints that must not accept filters at all
ALLOW_ALL_FILTERSevery column, hidden ones includedinternal tooling over non-sensitive models

The default excludes anything marked @column({ serializeAs: null }) — a password hash, for instance. This matters: a like filter over a hidden column is a blind exfiltration oracle. An attacker probes character by character ($scrypt$a%, $scrypt$b%…) and reads the answer from which rows come back. Excluding non-serialized columns closes that without any configuration on your part.

import{SearchableService,ALLOW_ALL_FILTERS}from'@jantstack/adonis-searchable'importtype{AllowedFilters}from'@jantstack/adonis-searchable'classInternalAuditServiceextendsSearchableService<typeofAuditRow>{protectedmodel=AuditRowprotectedallowedFilters: AllowedFilters=ALLOW_ALL_FILTERS// explicit opt-in}

The : AllowedFilters annotation is required — without it TypeScript widens the symbol and the assignment won't compile. Useful side effect: the opt-in is impossible to miss in code review.

Operator whitelist

The op of a condition is interpolated raw into SQL by Knex, so only these pass: =, !=, <>, >, >=, <, <=, like, ilike, not like, not ilike. Anything else (op = "IS NULL OR 1=1 --") drops the condition instead of injecting it.

Nesting with orGroup / andGroup

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],orGroup: [{where: [{field: 'priority',op: '>=',value: 8}]},{where: [{field: 'flagged',op: '=',value: true}]},],},})// WHERE status = 'active' AND (priority >= 8 OR flagged = true)

Inside an orGroup, where conditions are rewritten to orWhere automatically. Nesting is recursive, and the field whitelist applies at every level.

JSON columns

Use -> to reach into a JSON path; the whitelist checks the root field:

{where: [{field: 'metadata->plan',op: '=',value: 'pro'}]}// needs 'metadata' allowed{whereJsonLength: [{field: 'tags',op: '>',value: 3}]}

Complexity ceilings

Filters are recursive and, on POST/PUT, they arrive in a JSON body with no depth limit of its own. Three ceilings bound the damage: depth 5, 100 applied conditions per request, and 500 values in a single whereIn (a condition count alone doesn't help — one whereIn with 100 000 values is still one condition). What exceeds them is dropped silently, like everything else in the filter pipeline. Raise or lower them per service:

protectedfilterLimits={maxDepth: 8,maxConditions: 250,maxInValues: 1000}

An oversized whereIn is dropped whole rather than truncated: a truncated set answers a question the client didn't ask, and does it silently.


Preload whitelist

?with= is client input, so it gets the same treatment as filters. Two properties split the job:

  • indexWith / showWith say what the service loads. They always apply — whether the client asks or not.
  • allowedIncludes says what the client may ask for on top. Per method, and additive.
allowedIncludesClient may preloadUse it when
(not declared)exactly what that method preloadsthe common case — zero extra config
{ index: ['tags'], show: ['tags', 'audit'] }that, plus the method's own preloadsopening the listing without opening the detail, or the reverse
['tags']that, on both methodsthe same extra everywhere
ALLOW_ALL_INCLUDESany relation on the modelinternal tooling; this was the implicit behaviour before 2.0.0

The default is per method on purpose. A relation listed only in showWith doesn't become requestable on the listing: fetching one organization's invitations is proportionate; fetching them for the 25 rows of a page — every invited person's email — is a different thing. Nothing stops you from opening it, but opening it has to be something you wrote.

The explicit forms are additive: you never repeat in allowedIncludes what is already in indexWith or showWith. A relation preloaded by default travels in the response whether the client asks for it or not, so "denying" it would mean nothing.

A requested path also passes if it is a prefix of an allowed one (owner when owner.profile is allowed) — asking for less is always fine. The reverse is not: owner.profile when only owner is allowed is one level deeper than anyone authorized.

Without any of this, ?with=owner on a public listing pulls the entire related model into the response — including whatever that model serializes — and nested paths multiply the queries behind it.


Sorting

orderBy must name a column that exists and that the model serializes. The first half is the SQL-injection guard; the second closes an oracle: sorting by a hidden column and paging through the results lets an attacker compare that value across rows and reconstruct it by position. Same principle as filters — sortable ⊆ visible — and ALLOW_ALL_FILTERS lifts both restrictions together.

orderDirection only ever reaches Knex as asc or desc.


CRUD and lifecycle hooks

BaseService adds create, update and destroy on top of SearchableService, each with optional hooks and transaction support:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncbeforeCreate(data: Partial<Order>){data.reference??=generateReference()}protectedasyncafterCreate(record: Order,trx?: TransactionClientContract){awaitthis.notify(record,trx)}}

Available hooks: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDestroy, afterDestroy. All optional, all awaited, all receiving the transaction when one is passed.

findOne(uuid, includes?) returns null when the row doesn't exist; findOneOrFail, update and destroy throw RecordNotFoundError instead. It carries status = 404, so a standard AdonisJS exception handler maps it without extra wiring.


Escape hatch: applyCustomFilters

For conditions the declarative system can't express — rich jsonb, joins, subqueries, tenant scoping — override the hook. It runs inside the same query builder, so it also constrains count and pagination:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncapplyCustomFilters(query: ModelQueryBuilder,params: QueryParams){query.whereRaw("metadata @> ?",[JSON.stringify({region: this.region})])}}

This is your code, not client input, so the field whitelist doesn't apply here — that's the point of the hatch. Keep any client-supplied value parameterized.


API reference

// FunctionsfunctionparseQueryParams(request: HttpContext['request']): QueryParams// ClassesclassSearchableService<TModel>{index(params?: QueryParams): Promise<PaginatedResult<TModel>|{count: number}|{data: []}>findOne(uuid: string,includes?: string[]): Promise<InstanceType<TModel>|null>findOneOrFail(uuid: string,includes?: string[]): Promise<InstanceType<TModel>>protectedbuildQuery(params: QueryParams): Promise<{query: ModelQueryBuilder}>protectedapplyCustomFilters?(query,params): void|Promise<void>}classBaseService<TModel>extendsSearchableService<TModel>{create(data,trx?): Promise<InstanceType<TModel>>update(uuid,data,trx?): Promise<InstanceType<TModel>>destroy(uuid,trx?): Promise<void>}classRecordNotFoundErrorextendsError{status=404}// SentinelsconstALLOW_ALL_FILTERS: unique symbolconstFILTERABLE_FROM_MODEL: unique symbol// the default for allowedFiltersconstALLOW_ALL_INCLUDES: unique symbolconstINCLUDES_FROM_SERVICE: unique symbol// the default for allowedIncludes// ConstantsconstMAX_FILTER_DEPTH=5constMAX_FILTER_CONDITIONS=100constMAX_FILTER_IN_VALUES=500// TypestypeAllowedFilters,AllowedIncludes,AllowedIncludesByMethod,IncludesMethod,FilterLimits,QueryParams,PaginatedResult,PaginationMeta,PeriodFilter,FilterBlock,FilterCondition,FilterMethod

Design notes

No HttpContext. The service takes a plain object, so the same code serves an HTTP endpoint, a queue job, an ace command or another service. It's also what makes it testable without booting a server.

Silent skipping over errors. Unknown filter methods, non-whitelisted fields and unsafe operators are dropped, not rejected. Filters come from untrusted input; failing loudly turns every bad-faith query string into a 500. If your API needs explicit feedback, validate at the controller/validator layer.

Column introspection is cached per service instance: the orderBy guard reads the real table columns once; the default filter whitelist reads the model's own metadata and never touches the database.

Every client-controlled surface has a whitelist. Fields, operators, sort columns, preloads, scopes, search columns and every numeric bound. That symmetry is the point: a gap in one of them is worth more to an attacker than hardening the others further.

The package tests itself.npm test runs the full suite against in-memory SQLite — no Postgres, no migrations, no host application. CI runs it on Node 20, 22 and 24 before anything ships.


Compatibility

Node≥ 20.6
AdonisJS^7 (peer)
Lucid^22 (peer)
DatabasesPostgreSQL, MySQL, SQLite
Module formatESM only

Scope and maintenance

Extracted from the adonis7-base chassis, where it runs in production-shaped projects. It is maintained according to that chassis's needs: bug fixes and small additions are welcome, larger feature requests may not fit the roadmap.

License

MIT

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

@jantstack/adonis-searchable

Generic service layer for AdonisJS 7 + Lucid ORM: paginated listing, multi-column fulltext search, declarative filters with a safe-by-default whitelist, date-range filters, sorting, preloads and Lucid scopes — all driven from the query string, none of it touching HttpContext.

Services stay testable in isolation and reusable from jobs, commands and other services. Controllers shrink to a few lines.

import{BaseService}from'@jantstack/adonis-searchable'importProductfrom'#models/product'exportdefaultclassProductsServiceextendsBaseService<typeofProduct>{protectedmodel=ProductprotectedsearchableColumns=['name','sku']protectedallowedFilters=['status','category_id','created_at']protectedindexWith=['category']}
import{parseQueryParams}from'@jantstack/adonis-searchable'exportdefaultclassProductsController{constructor(privateservice=newProductsService()){}asyncindex({ request }: HttpContext){returnthis.service.index(parseQueryParams(request))}}

That's a full listing endpoint with pagination, search, filters and preloads.


Table of contents


Install

npm i @jantstack/adonis-searchable

Peer dependencies: @adonisjs/core ^7 and @adonisjs/lucid ^22. No provider to register and no config file — you extend a class and you're done.

Engine-agnostic: the package emits no engine-specific SQL, so Postgres, MySQL and SQLite all work. (Fulltext search uses ILIKE on Postgres and LIKE elsewhere; column introspection uses each engine's standard catalog.)


Configuring a service

Every knob is a protected property on the subclass:

PropertyDefaultWhat it does
model(required)The Lucid model the service operates on.
searchableColumns[]Columns scanned by search. Empty = search does nothing.
allowedFiltersserialized columnsFields the client may filter by — see below.
periodColumns['created_at', 'updated_at']Columns allowed in date-range filters.
indexWith[]Relations preloaded by default in index().
showWith[]Relations preloaded by default in findOne().
allowedIncludesper method: what that method preloadsWhat the client may request on top — see below.
defaultPerPage25Page size when the client doesn't send one.
maxPerPage100Ceiling for perPage — protects against ?perPage=100000.
maxUnpaginatedLimit1000Row ceiling for paginate: false — the client's limit narrows it, never widens it.
filterLimitsdepth 5, 100 conditions, 500 whereIn valuesComplexity ceilings for client-supplied filters.

index() and the query string

index(params) accepts a QueryParams object. parseQueryParams(request) builds it from the HTTP request — it is the only piece of the package that knows about HttpContext, so services stay transport-agnostic (write another adapter for GraphQL or gRPC and nothing else changes). It reads the query string on GET and merges the body on POST/PUT, supporting these conventions:

?page=2&per_page=50
&search=acme&search_columns[]=name&search_columns[]=sku
&filters[where][0][field]=status&filters[where][0][op]==&filters[where][0][value]=active
&period_filters[0][column]=created_at&period_filters[0][start]=2026-01-01&period_filters[0][end]=2026-03-31
&order_by=created_at&order_direction=desc
&with=category,category.parent
&scopes[withStatus]=delivered
&count=true
&paginate=false&limit=500

Query-string key → QueryParams property: per_pageperPage, search_columns[]searchColumns, order_by/order_directionorderBy/orderDirection, with (CSV or array) → includes, period_filtersperiod. search_input is accepted as a legacy alias of search.

ParamTypeNotes
page / perPagenumberperPage is clamped to maxPerPage.
countbooleanReturns { count } only — no rows, no meta.
paginatebooleanfalse returns { data } unpaginated (bounded by limit).
limitnumberRow cap when paginate: false.
searchstringFulltext across searchableColumns.
searchColumnsstring[]Narrows the search to a subset (intersected with the whitelist).
filtersobject | object[]See Filters.
periodobject[]{ column, start?, end? }; column must be in periodColumns and the dates must be real YYYY-MM-DD.
orderBy / orderDirectionstringorderBy must be a real and visible column — see Sorting.
includesstring[]Relations to preload; dot notation for nested (profile.wallets). Filtered by allowedIncludes.
scopesobjectLucid scopes to apply: { withStatus: 'delivered' }.
paginationBaseUrl / paginationExtraQsstring / objectBuild absolute pagination links in meta.

Return shape:

// default{ data: Model[],meta: { total, perPage, currentPage, lastPage, firstPage, ...links}}// count: true{count: number}// paginate: false{ data: Model[]}

Filters

Filters are declarative and arrive from the client, so the package is deny-by-default in the two places that matter: which fields can be filtered, and which operators are allowed.

Filter methods

Each key of a filter block is a method, each value an array of conditions:

FamilyMethods
Comparisonwhere, orWhere
SetswhereIn, orWhereIn, whereNotIn, orWhereNotIn
RangeswhereBetween, orWhereBetween, whereNotBetween, orWhereNotBetween
NullabilitywhereNull, orWhereNull, whereNotNull, orWhereNotNull
JSONwhereJsonContains, orWhereJsonContains, whereJsonLength, orWhereJsonLength

A condition is { field, op?, value?, values? }. Anything unrecognized is skipped silently — by design, since the input is untrusted.

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],whereIn: [{field: 'category_id',values: [1,2,3]}],whereNotNull: [{field: 'published_at'}],},})

Field whitelist (safe by default)

The guiding principle is filterable ⊆ visible: if a column already travels in the API response, filtering by it reveals nothing new.

allowedFiltersFilterableUse it when
(not declared)columns the model serializesinternal CRUD, prototypes — safe with zero config
['name', 'status']only thosepublic APIs: the filter contract stops following the schema
[]nothingendpoints that must not accept filters at all
ALLOW_ALL_FILTERSevery column, hidden ones includedinternal tooling over non-sensitive models

The default excludes anything marked @column({ serializeAs: null }) — a password hash, for instance. This matters: a like filter over a hidden column is a blind exfiltration oracle. An attacker probes character by character ($scrypt$a%, $scrypt$b%…) and reads the answer from which rows come back. Excluding non-serialized columns closes that without any configuration on your part.

import{SearchableService,ALLOW_ALL_FILTERS}from'@jantstack/adonis-searchable'importtype{AllowedFilters}from'@jantstack/adonis-searchable'classInternalAuditServiceextendsSearchableService<typeofAuditRow>{protectedmodel=AuditRowprotectedallowedFilters: AllowedFilters=ALLOW_ALL_FILTERS// explicit opt-in}

The : AllowedFilters annotation is required — without it TypeScript widens the symbol and the assignment won't compile. Useful side effect: the opt-in is impossible to miss in code review.

Operator whitelist

The op of a condition is interpolated raw into SQL by Knex, so only these pass: =, !=, <>, >, >=, <, <=, like, ilike, not like, not ilike. Anything else (op = "IS NULL OR 1=1 --") drops the condition instead of injecting it.

Nesting with orGroup / andGroup

awaitservice.index({filters: {where: [{field: 'status',op: '=',value: 'active'}],orGroup: [{where: [{field: 'priority',op: '>=',value: 8}]},{where: [{field: 'flagged',op: '=',value: true}]},],},})// WHERE status = 'active' AND (priority >= 8 OR flagged = true)

Inside an orGroup, where conditions are rewritten to orWhere automatically. Nesting is recursive, and the field whitelist applies at every level.

JSON columns

Use -> to reach into a JSON path; the whitelist checks the root field:

{where: [{field: 'metadata->plan',op: '=',value: 'pro'}]}// needs 'metadata' allowed{whereJsonLength: [{field: 'tags',op: '>',value: 3}]}

Complexity ceilings

Filters are recursive and, on POST/PUT, they arrive in a JSON body with no depth limit of its own. Three ceilings bound the damage: depth 5, 100 applied conditions per request, and 500 values in a single whereIn (a condition count alone doesn't help — one whereIn with 100 000 values is still one condition). What exceeds them is dropped silently, like everything else in the filter pipeline. Raise or lower them per service:

protectedfilterLimits={maxDepth: 8,maxConditions: 250,maxInValues: 1000}

An oversized whereIn is dropped whole rather than truncated: a truncated set answers a question the client didn't ask, and does it silently.


Preload whitelist

?with= is client input, so it gets the same treatment as filters. Two properties split the job:

  • indexWith / showWith say what the service loads. They always apply — whether the client asks or not.
  • allowedIncludes says what the client may ask for on top. Per method, and additive.
allowedIncludesClient may preloadUse it when
(not declared)exactly what that method preloadsthe common case — zero extra config
{ index: ['tags'], show: ['tags', 'audit'] }that, plus the method's own preloadsopening the listing without opening the detail, or the reverse
['tags']that, on both methodsthe same extra everywhere
ALLOW_ALL_INCLUDESany relation on the modelinternal tooling; this was the implicit behaviour before 2.0.0

The default is per method on purpose. A relation listed only in showWith doesn't become requestable on the listing: fetching one organization's invitations is proportionate; fetching them for the 25 rows of a page — every invited person's email — is a different thing. Nothing stops you from opening it, but opening it has to be something you wrote.

The explicit forms are additive: you never repeat in allowedIncludes what is already in indexWith or showWith. A relation preloaded by default travels in the response whether the client asks for it or not, so "denying" it would mean nothing.

A requested path also passes if it is a prefix of an allowed one (owner when owner.profile is allowed) — asking for less is always fine. The reverse is not: owner.profile when only owner is allowed is one level deeper than anyone authorized.

Without any of this, ?with=owner on a public listing pulls the entire related model into the response — including whatever that model serializes — and nested paths multiply the queries behind it.


Sorting

orderBy must name a column that exists and that the model serializes. The first half is the SQL-injection guard; the second closes an oracle: sorting by a hidden column and paging through the results lets an attacker compare that value across rows and reconstruct it by position. Same principle as filters — sortable ⊆ visible — and ALLOW_ALL_FILTERS lifts both restrictions together.

orderDirection only ever reaches Knex as asc or desc.


CRUD and lifecycle hooks

BaseService adds create, update and destroy on top of SearchableService, each with optional hooks and transaction support:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncbeforeCreate(data: Partial<Order>){data.reference??=generateReference()}protectedasyncafterCreate(record: Order,trx?: TransactionClientContract){awaitthis.notify(record,trx)}}

Available hooks: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDestroy, afterDestroy. All optional, all awaited, all receiving the transaction when one is passed.

findOne(uuid, includes?) returns null when the row doesn't exist; findOneOrFail, update and destroy throw RecordNotFoundError instead. It carries status = 404, so a standard AdonisJS exception handler maps it without extra wiring.


Escape hatch: applyCustomFilters

For conditions the declarative system can't express — rich jsonb, joins, subqueries, tenant scoping — override the hook. It runs inside the same query builder, so it also constrains count and pagination:

exportdefaultclassOrdersServiceextendsBaseService<typeofOrder>{protectedmodel=OrderprotectedasyncapplyCustomFilters(query: ModelQueryBuilder,params: QueryParams){query.whereRaw("metadata @> ?",[JSON.stringify({region: this.region})])}}

This is your code, not client input, so the field whitelist doesn't apply here — that's the point of the hatch. Keep any client-supplied value parameterized.


API reference

// FunctionsfunctionparseQueryParams(request: HttpContext['request']): QueryParams// ClassesclassSearchableService<TModel>{index(params?: QueryParams): Promise<PaginatedResult<TModel>|{count: number}|{data: []}>findOne(uuid: string,includes?: string[]): Promise<InstanceType<TModel>|null>findOneOrFail(uuid: string,includes?: string[]): Promise<InstanceType<TModel>>protectedbuildQuery(params: QueryParams): Promise<{query: ModelQueryBuilder}>protectedapplyCustomFilters?(query,params): void|Promise<void>}classBaseService<TModel>extendsSearchableService<TModel>{create(data,trx?): Promise<InstanceType<TModel>>update(uuid,data,trx?): Promise<InstanceType<TModel>>destroy(uuid,trx?): Promise<void>}classRecordNotFoundErrorextendsError{status=404}// SentinelsconstALLOW_ALL_FILTERS: unique symbolconstFILTERABLE_FROM_MODEL: unique symbol// the default for allowedFiltersconstALLOW_ALL_INCLUDES: unique symbolconstINCLUDES_FROM_SERVICE: unique symbol// the default for allowedIncludes// ConstantsconstMAX_FILTER_DEPTH=5constMAX_FILTER_CONDITIONS=100constMAX_FILTER_IN_VALUES=500// TypestypeAllowedFilters,AllowedIncludes,AllowedIncludesByMethod,IncludesMethod,FilterLimits,QueryParams,PaginatedResult,PaginationMeta,PeriodFilter,FilterBlock,FilterCondition,FilterMethod

Design notes

No HttpContext. The service takes a plain object, so the same code serves an HTTP endpoint, a queue job, an ace command or another service. It's also what makes it testable without booting a server.

Silent skipping over errors. Unknown filter methods, non-whitelisted fields and unsafe operators are dropped, not rejected. Filters come from untrusted input; failing loudly turns every bad-faith query string into a 500. If your API needs explicit feedback, validate at the controller/validator layer.

Column introspection is cached per service instance: the orderBy guard reads the real table columns once; the default filter whitelist reads the model's own metadata and never touches the database.

Every client-controlled surface has a whitelist. Fields, operators, sort columns, preloads, scopes, search columns and every numeric bound. That symmetry is the point: a gap in one of them is worth more to an attacker than hardening the others further.

The package tests itself.npm test runs the full suite against in-memory SQLite — no Postgres, no migrations, no host application. CI runs it on Node 20, 22 and 24 before anything ships.


Compatibility

Node≥ 20.6
AdonisJS^7 (peer)
Lucid^22 (peer)
DatabasesPostgreSQL, MySQL, SQLite
Module formatESM only

Scope and maintenance

Extracted from the adonis7-base chassis, where it runs in production-shaped projects. It is maintained according to that chassis's needs: bug fixes and small additions are welcome, larger feature requests may not fit the roadmap.

License

MIT

Releases

Packages

Used by

Contributors

Languages