npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

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

About

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

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

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

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

Open Software & Tools

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

© 2026 – Pkg Stats / Ryan Hefner

@aginix/adonis-vulcan

v0.1.2

Published

PostgREST-style filtering and resource scaffolding for AdonisJS Lucid ORM

Downloads

687

Readme

@aginix/adonis-vulcan

PostgREST-style filtering and Rapid CRUD controllers for AdonisJS Lucid ORM.

Two layers, each independently usable:

  1. Filter layer — a query parser that brings the PostgREST horizontal filtering grammar to AdonisJS: comparison operators, logical groups (and/or), pattern matching, regex, array/range/full-text operators, and embedded resource filters via dot notation.
  2. Rapid CRUD layerBaseResourceController so a controller extends BaseResourceController ships index/show/store/update/destroy with PostgREST filtering, page-based pagination, optional VineJS validators, and integrates with AdonisJS's official BaseTransformer — all behind a stable { data } / { data, metadata } response contract, with override-friendly primitives for Bouncer-style authorization.

Installation

Use node ace add to install and configure the package in one step:

node ace add @aginix/adonis-vulcan

This will:

  • Register the package's provider in your adonisrc.ts so Model.filter(...) becomes available on every Lucid query builder.
  • Register the package's commands so node ace make:filter and node ace make:resource-controller work.

If you prefer to install manually:

npm install @aginix/adonis-vulcan
node ace configure @aginix/adonis-vulcan

The package has no runtime dependency on adonis-lucid-filterBaseModelFilter, Filterable, and the filter() query macro are vendored directly to avoid the broken getDirname import in [email protected] (which fails under @poppinss/utils v6+).


Quick start (filter layer)

1. Scaffold a filter

node ace make:filter Course

That writes app/models/filters/course_filter.ts:

import { PostgRESTModelFilter } from '@aginix/adonis-vulcan'
import type { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'
import Course from '#models/course'

export default class CourseFilter extends PostgRESTModelFilter {
  declare $query: ModelQueryBuilderContract<typeof Course>

  // Add custom filter methods here. Each public method becomes a query
  // parameter — defining `q(value)` enables `?q=search` in URLs.
}

2. Wire the filter to a model

Add the Filterable mixin and a static $filter getter to your model:

import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { Filterable } from '@aginix/adonis-vulcan'
import CourseFilter from '#models/filters/course_filter'

export default class Course extends compose(BaseModel, Filterable) {
  static $filter = () => CourseFilter

  @column({ isPrimary: true })
  declare id: string
}

3. Use it in a controller (manual)

import Course from '#models/course'
import type { HttpContext } from '@adonisjs/core/http'

export default class CoursesController {
  async index({ request }: HttpContext) {
    return Course.filter(request.qs()).exec()
  }
}

4. Query with PostgREST-style URL parameters

GET /courses?status=eq.active&faculty.nameTh=like.*วิศว*

Rapid CRUD with BaseResourceController

For a typical resource endpoint you can skip the manual controller. Subclass BaseResourceController, declare a model, and you get index/show/store/update/destroy with PostgREST filtering, pagination, and the standard response envelope.

Minimal controller

node ace make:resource-controller Course
import Course from '#models/course'
import { BaseResourceController } from '@aginix/adonis-vulcan'

export default class CoursesController extends BaseResourceController<typeof Course> {
  protected model = Course
}

Wire it to a resource route:

router.resource('courses', '#controllers/courses_controller').apiOnly()

You now have:

| Route | Response | Status | |-------|----------|--------| | GET /courses | { data: Course[], metadata: {...} } — PostgREST filters + ?page=&perPage= | 200 | | GET /courses/:id | { data: Course } | 200 | | POST /courses | { data: Course } | 201 | | PUT /courses/:id | { data: Course } | 200 | | DELETE /courses/:id | (empty) | 204 |

metadata comes straight from Lucid's paginator .getMeta().

Full controller with all optional slots

import Course from '#models/course'
import CourseFilter from '#models/filters/course_filter'
import CourseTransformer from '#transformers/course_transformer'
import { createCourseValidator, updateCourseValidator } from '#validators/course'
import { BaseResourceController } from '@aginix/adonis-vulcan'
import type { HttpContext } from '@adonisjs/core/http'
import type { ModelQueryBuilderContract } from '@adonisjs/lucid/types/model'

export default class CoursesController extends BaseResourceController<typeof Course> {
  protected model = Course
  protected filter = CourseFilter              // optional — falls back to Model.filter()
  protected transformer = CourseTransformer    // pass the CLASS, not new ...
  protected validators = {
    store: createCourseValidator,
    update: updateCourseValidator,
  }
  protected pagination = { defaultPerPage: 20, maxPerPage: 100 }

  // Apply on every list request — tenant scope, default ordering, .preload(), etc.
  protected async modifyQuery(
    ctx: HttpContext,
    query: ModelQueryBuilderContract<typeof Course>
  ) {
    query.where('tenantId', ctx.auth.user!.tenantId).preload('faculty')
  }

  // Or override an action and compose primitives — `super.<action>()` also works
  async index(ctx: HttpContext) {
    return this.respondPaginated(ctx, (q) => q.where('published', true))
  }
}

Transformers

Use AdonisJS's official BaseTransformer — this package does NOT ship its own transformer class:

node ace make:transformer Course
// app/transformers/course_transformer.ts
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Course from '#models/course'

export default class CourseTransformer extends BaseTransformer<Course> {
  toObject() {
    return this.pick(this.resource, ['id', 'nameTh', 'facultyId'])
  }

  // Variants — call via .useVariant('forDetailedView') in overrides
  async forDetailedView() {
    return {
      ...this.toObject(),
      description: this.resource.description,
    }
  }
}

Pass the class (not an instance) on the controller's transformer slot. The controller wires:

  • indexTransformer.paginate(rows, paginator.getMeta()) → serializer → { data, metadata }
  • show / store / updateTransformer.transform(record) → serializer → { data }

When transformer is omitted, the controller hand-shapes the same envelope using model.serialize() so the response shape is identical either way.

Serializer ({ data, metadata } envelope)

The default ResourceSerializer extends BaseSerializer from @adonisjs/core/transformers with wrap: 'data' and identity metadata, matching the response shape documented for AdonisJS transformers:

  • Item{ data: {...} }
  • Collection{ data: [...] }
  • Paginator{ data: [...], metadata: {...} }

Resolution order in pickSerializer(ctx) — first match wins:

  1. this.serializer — per-controller override (subclass sets it)
  2. ctx.serialize — app-wide, when augmented onto HttpContext via HttpContext.instanceProperty('serialize', ...) (Adonis pattern)
  3. sharedResourceSerializer — package default

So if your app already wires ctx.serialize (a custom BaseSerializer subclass on every request), BaseResourceController picks it up automatically — no per-controller config:

// start/serialize.ts (your app, wired once)
HttpContext.instanceProperty('serialize', /* serialize fn from ApiSerializer */)

// any controller — uses ctx.serialize transparently
export default class CoursesController extends BaseResourceController<typeof Course> {
  protected model = Course
  protected transformer = CourseTransformer
}

To override per-controller (different wrap key, different metadata shape, etc.):

import { BaseSerializer } from '@adonisjs/core/transformers'

class MinimalSerializer extends BaseSerializer<{
  Wrap: 'result'
  PaginationMetaData: { page: number; total: number }
}> {
  wrap = 'result' as const
  definePaginationMetaData(meta: any) {
    return { page: meta.currentPage, total: meta.total }
  }
}

export default class CoursesController extends BaseResourceController<typeof Course> {
  protected model = Course
  protected serializer = new MinimalSerializer()
}

Validators

VineJS validators on the validators slot are run via ctx.request.validateUsing(...):

protected validators = {
  store: createCourseValidator,
  update: updateCourseValidator,
}

Omit either action to accept the raw ctx.request.body() for that action.

Hooks

Override any of these in a subclass — no need to touch the action method:

modifyQuery(ctx, query)              // every list — global query scope
beforeIndex(ctx, query)              // before pagination (mutate in place)
afterIndex(ctx, response)  → response?
beforeStore(ctx, payload)  → payload?
afterStore(ctx, record)    → record?
beforeUpdate(ctx, record, payload) → { payload }?
afterUpdate(ctx, record)   → record?
beforeDestroy(ctx, record)
afterDestroy(ctx, record)

Authorization (Bouncer)

Two override patterns are supported. The base controller has no built-in Bouncer integration — choose the one that matches your authorization needs:

Action-level (no record needed) — call super.<action>(ctx):

async store(ctx: HttpContext) {
  await ctx.bouncer.with(CoursePolicy).authorize('create')
  return super.store(ctx)
}

Record-bound — compose findOrFail + respondItem:

async show(ctx: HttpContext) {
  const course = await this.findOrFail(ctx)
  await ctx.bouncer.with(CoursePolicy).authorize('view', course)
  return this.respondItem(course, ctx)
}

findOrFail, respondItem, respondPaginated, buildListQuery, validateInput, resolvePagination, pickSerializer are all protected — compose them freely.

Preloading relationships

| Action | Where to call .preload() | |--------|---------------------------| | index (list) | modifyQuery(ctx, query) hook, or pass an extra mutator to respondPaginated(ctx, q => q.preload('...')) in an overridden action | | show / update / destroy | Override findOrFail — the default uses Model.findByOrFail() which doesn't support preload |

// Index preload
protected async modifyQuery(_ctx: HttpContext, query: any) {
  query.preload('faculty').preload('department')
}

// Single-resource preload
protected async findOrFail(ctx: HttpContext) {
  return this.model.query()
    .preload('faculty')
    .preload('department')
    .where(this.findBy, ctx.request.param(this.paramKey))
    .firstOrFail()
}

Customizing pagination

protected pagination = {
  defaultPerPage: 50,         // default 20
  maxPerPage: 200,            // default 100 — clamps the requested page size
  pageInput: 'p',             // default 'page'
  perPageInput: 'limit',      // default 'perPage'
}

Empty/garbage values fall back to defaultPerPage. perPage > maxPerPage is silently clamped.

Lookups by something other than id

protected findBy = 'slug'    // Lucid column to match
protected paramKey = 'slug'  // route param name

Operators

Comparison

| Operator | Description | Example | | -------- | --------------------- | --------------------- | | eq | Equal | ?status=eq.active | | neq | Not equal | ?status=neq.deleted | | gt | Greater than | ?price=gt.100 | | gte | Greater than or equal | ?price=gte.100 | | lt | Less than | ?price=lt.500 | | lte | Less than or equal | ?price=lte.500 |

Lists, null/boolean, patterns

?id=in.(1,2,3,4,5)
?id=in.()               # empty set — returns 0 rows
?deletedAt=is.null
?isActive=is.true
?status=is.unknown
?name=like.*test*       # contains "test" (case-sensitive)
?name=ilike.*TEST*      # contains "TEST" (case-insensitive)
?name=like(any).{*foo*,*bar*}  # OR across patterns
?name=like(all).{*urgent*,*important*}  # AND across patterns

The (any) / (all) modifiers also apply to ilike, match, and imatch.

Empty and malformed in values: ?id=in.() parses as an empty list and emits WHERE 1 = 0, returning no rows (matches PostgREST semantics). A malformed in — missing the list entirely (?id=in., ?id=in) or missing the parentheses (?id=in.1,2) — throws FilterValidationError and returns HTTP 400, even in lenient mode. This prevents the previous footgun where a malformed in would be silently dropped and the request would fall through with no constraint.

Regex (PostgreSQL)

?code=match.^CPE[0-9]+$    # case-sensitive
?code=imatch.^cpe[0-9]+$   # case-insensitive

Array (PostgreSQL)

| Operator | SQL | Description | | -------- | ---- | ------------ | | cs | @> | Contains | | cd | <@ | Contained in | | ov | && | Overlaps |

?tags=cs.{admin,user}
?roles=cd.{admin,user,guest}
?categories=ov.{tech,science}

Range (PostgreSQL)

| Operator | SQL | Description | | -------- | ------------------ | ------------------------ | | sl | << | Strictly left of | | sr | >> | Strictly right of | | nxr | &< | Does not extend right of | | nxl | &> | Does not extend left of | | adj | -|- | Adjacent to |

Bounds use PostgreSQL range syntax: [a,b] inclusive, (a,b) exclusive.

?period=sl.[2024-01-01,2024-12-31]   # period is entirely before this range
?period=adj.[2024-01-01,2024-06-30]  # period is directly adjacent

Full-text search (PostgreSQL)

| Operator | Variant | | -------- | ------------------------------- | | fts | to_tsquery | | plfts | plainto_tsquery (plain query) | | phfts | phraseto_tsquery (phrase) | | wfts | websearch_to_tsquery |

?description=fts.search+terms
?description=fts.thai.ค้นหา      # with language config
?description=plfts.simple+phrase

Negation

Add not. before any operator:

?status=not.eq.deleted
?id=not.in.(1,2,3)
?deletedAt=not.is.null
?name=not.like.*test*

Logical operators

and / or groups

?or=(status.eq.active,status.eq.pending)
?and=(price.gte.100,price.lte.500)
?not.or=(status.eq.deleted,status.eq.archived)

Nested groups

?and=(or(status.eq.active,status.eq.pending),or(type.eq.course,type.eq.program))
?or=(status.eq.draft,and(status.eq.approved,publishedAt.not.is.null))

Flat filters are implicitly AND-ed with logical groups:

?facultyId=eq.1&or=(status.eq.active,status.eq.pending)

Inside a logical group the syntax is <column>.<operator>.<value> (dot-separated, no =). Use ?or=(name.like.*test*,...), NOT ?or=(name=like.*test*,...).


Embedded filters

Filter on related resources using dot notation. Works automatically — no configuration required.

?faculty.nameTh=like.*วิศว*
?faculty.department.code=eq.CPE
?course.instructor.faculty.id=eq.1
?faculty.order=nameTh.asc
?faculty.order=score.desc.nullslast
?faculty.limit=10
?courseSpecifications.exists=true

Preloading and relationship mapping

By default embedded filters only apply whereHas. To preload the related data or remap names:

export default class CourseFilter extends PostgRESTModelFilter {
  declare $query: ModelQueryBuilderContract<typeof Course>

  protected $embeddedOptions = {
    // true | false | string[]
    preloadEmbedded: ['faculty', 'department'],
    relationshipMap: {
      dept: 'department',
      specs: 'courseSpecifications',
    },
  }
}

Custom filter methods

Any public method on the filter class is treated as a custom filter. The query parameter name matches the method name; the value is forwarded as the first argument.

Custom methods take precedence over PostgREST parsing. If a query key matches a public method name, the method is invoked and the key is consumed before the parser runs — so defining q(value) shadows any incoming ?q=eq.foo filter.

export default class CourseFilter extends PostgRESTModelFilter {
  declare $query: ModelQueryBuilderContract<typeof Course>

  // ?q=search+term
  q(value: string): void {
    this.$query
      .orWhereILike('nameTh', `%${value}%`)
      .orWhereILike('nameEn', `%${value}%`)
      .orWhereILike('code', `%${value}%`)
  }

  // ?hasApprover=true
  hasApprover(value: string): void {
    this.$query.whereRaw(
      value === 'true' ? 'cardinality("approverEmails") > 0' : 'cardinality("approverEmails") = 0'
    )
  }

  // ?year=2024
  year(value: string): void {
    this.$query.whereRaw('EXTRACT(YEAR FROM "createdAt") = ?', [value])
  }
}

Notes & gotchas

  • Top-level order is applied by BaseResourceController. ?order=name.asc, ?order=name.asc,createdAt.desc, and ?order=name.asc.nullslast work out of the box on any controller subclassing BaseResourceController. Unknown columns are dropped through $columnsDefinitions (override resolveOrderColumn to widen, or set orderInput = null to disable when a PostgRESTModelFilter subclass owns the order param via a custom order(value) method). The filter-layer (PostgRESTModelFilter / Model.filter()) does not apply ordering on its own — call from a controller, or apply manually via Model.query().orderBy(...).
  • Top-level select, limit, offset, on_conflict, columns, not are reserved — recognized as non-filters and skipped, but not yet implemented at the top level. Use the embedded form (<rel>.order=..., <rel>.limit=...) for related-resource ordering, and the controller's pagination slot (or ?page=&perPage=) for top-level pagination.
  • Unknown columns are silently dropped. LucidFilterBuilder only applies conditions for columns declared in the model's $columnsDefinitions — typos and missing @column() decorations result in no-op filters with no error.
  • Parsing is lenient by default. Invalid operator strings are skipped without erroring. Pass { strict: true } to FilterParser (or parseFilters(params, true)) when you want a ParseError instead.
  • Malformed in is the one exception to lenient parsing. A missing list (?id=in., ?id=in) or missing parentheses (?id=in.1,2) throws FilterValidationError (status 400) regardless of strict mode, because silently dropping it would let the request return every row. Empty lists (?id=in.()) are valid and return 0 rows.
  • Hooks that take a query are void-only. modifyQuery / beforeIndex mutate in place — they don't return the query. ModelQueryBuilderContract is thenable in Lucid (await query returns the rows), so awaiting Promise<Query | void> would silently unwrap a returned query into InstanceType<Model>[]. To swap the query entirely, override buildListQuery.

Programmatic API

import { FilterParser, parseFilters } from '@aginix/adonis-vulcan'

const parser = new FilterParser()

// Flat conditions
parser.parse({ status: 'eq.active', price: 'gte.100' })

// + logical groups
parser.parseWithLogical({
  status: 'eq.active',
  or: '(type.eq.a,type.eq.b)',
})

// + embedded filters
parser.parseWithEmbedded({
  'status': 'eq.active',
  'faculty.name': 'like.*test*',
  'faculty.order': 'name.asc',
})

// Strict mode — throws ParseError on invalid filters
new FilterParser({ strict: true }).parse({ status: 'bogus.value' })

// Convenience helper
parseFilters({ status: 'eq.active' })

The package also exports LucidFilterBuilder, LucidEmbeddedBuilder, BaseResourceController, ResourceSerializer, and the underlying types from @aginix/adonis-vulcan/types.


Worked example

GET /courses
  ?status=eq.active
  &or=(type.eq.required,type.eq.elective)
  &faculty.nameTh=like.*วิศวกรรม*
  &faculty.department.code=eq.CPE
  &courseSpecifications.exists=true
  &credits=gte.3
  &not.deletedAt=is.null
  &page=2&perPage=20

Conceptually equivalent to:

SELECT * FROM courses
WHERE status = 'active'
  AND (type = 'required' OR type = 'elective')
  AND EXISTS (
    SELECT 1 FROM faculties
    WHERE faculties.id = courses.faculty_id
      AND faculties.name_th LIKE '%วิศวกรรม%'
      AND EXISTS (
        SELECT 1 FROM departments
        WHERE departments.faculty_id = faculties.id
          AND departments.code = 'CPE'
      )
  )
  AND EXISTS (
    SELECT 1 FROM course_specifications
    WHERE course_specifications.course_id = courses.id
  )
  AND credits >= 3
  AND deleted_at IS NOT NULL
LIMIT 20 OFFSET 20;

Wrapped in a { data, metadata } envelope by the rapid CRUD controller.


Development

yarn install
yarn quick:test    # run unit tests without lint
yarn test          # lint + tests + coverage
yarn typecheck
yarn build         # compiles to ./build

License

Proprietary — Copyright (c) Aginix. All rights reserved. See LICENSE.md.