SuperAPI AI Directory

crud

General & other

View repository

Updated 10-15 21:41

About this skill

CRUD

A CRUD feature is a stack, not a single file. The route declares one router.resource(...) scoped by auth middleware; the controller has 5–7 tiny methods that only orchestrate; each mutation has a VineJS validator; a BasePolicy gates every method; each write goes through a single-purpose action class; a transformer produces per-page shape variants; the read side lives in queries/; the UI is Inertia pages under <mod>/ui/pages/. The point is that no single layer holds more than its own responsibility, and adding the next resource is copy-shape, not think-shape.

Rules

Every HTTP method — including index — follows the same 3-step spine:

  1. Authorize via bouncer.with(XPolicy).authorize(...). A where('owner_id', user.id) clause in the query is not a substitute — the policy is the source of truth.
  2. Validate via request.validateUsing(...).
  3. Render / redirect — either inertia.render('...', props) for reads or response.redirect().back() / .toRoute(...) for writes. Writes call an action in between.

Authentication is the middleware's job — controllers never call authenticate(); use auth.getUserOrFail() when the handler needs the user. Anything past .authorize() that also does an owner check is redundant; anything before it that touches the DB other than loading the resource being authorized is a leak.

Layer conventions

  • Route — router.resource('/entities', EntitiesController).only([...]). Guard by wrapping in a router.group(() => {...}).middleware(middleware.auth()) block (see [[routes]]). Custom verbs go as separate router.post(...) calls, not extra controller methods.
  • Controller (thin) — one method per resource action. Body order: policy → validator → action call → render/redirect. No business logic, no queries longer than one line, no ORM calls beyond Model.findOrFail.
  • Validator — VineJS vine.create({...}) per mutation, exported from <mod>/validators/<entity>.ts (one file per entity — validators/users.ts, validators/tokens.ts). Use vine.withMetaData<{...}>().create({...}) when a rule depends on route params (e.g. unique-except-self on edit). vine.compile() is deprecated — use vine.create().
  • Policy — BasePolicy subclass at <mod>/policies/<entity>_policy.ts. One method per gated action. Return boolean | AuthorizerResponse. Gate before validating: await bouncer.with(XPolicy).authorize('action', resource?). See [[authorization]].
  • Action — one class per write in <mod>/actions/<verb_entity>.ts. Input is a plain interface; the class has one async handle(input) method. Never takes HttpContext. Side effects go through domain events — see [[actions-events]].
  • Transformer — BaseTransformer<Model> with toObject() as the base + variants (forList, forEdit, forSharedProps, forProfile). Call Transformer.transform(model).useVariant('forEdit') or Transformer.paginate(rows, meta).useVariant('forList'). Never send raw Lucid instances to Inertia — the shape leaks and the response type drifts.
  • Query — read-only, <mod>/queries/list_<entities>.ts. Full patterns (list queries + read models for aggregate screens) live in [[queries]].
  • Inertia pages — under <mod>/ui/pages/<entity>/. Index is a full page; create/edit are modals mounted over the index. See [[inertia]].

Repo refs

The users module is the canonical end-to-end CRUD:

  • Route → app/users/routes.ts. Thin controller → app/users/controllers/users_controller.ts.
  • Validator → app/users/validators/users.ts. Policy → app/users/policies/user_policy.ts.
  • Action → app/users/actions/create_user.ts. Transformer variants → app/users/transformers/user_transformer.ts.
  • List query → app/users/queries/list_users.ts. Pages → app/users/ui/pages/.

Doc refs

Workflow

Prerequisite: the module exists — see [[module-scaffolding]].

Build in a vertical slice: wire index end-to-end first (policy → query → transformer → page) until the list renders and its spec is green, then widen to create/edit/delete. A thin working path surfaces wiring breakage before you've written seven validators and policies against it. The steps below are the layers each slice passes through, not a breadth-first "all validators, then all policies" order.

1. Model + migration + factory

  • app/<mod>/models/<entity>.ts — Lucid BaseModel subclass with @column() declarations. Relationships and computed getters live here too.
  • app/<mod>/database/migrations/<ts>_create_<entities>_table.ts — see [[migrations]].
  • app/<mod>/database/factories/<entity>.ts — Factory.define(Model, ({ faker }) => ({...})).build().

2. Validators (one file per entity)

// app/<mod>/validators/entities.ts
import vine from '@vinejs/vine'

export const createEntityValidator = vine.create({
  name: vine.string().trim().minLength(3),
  // ...
})

export const editEntityValidator = vine.withMetaData<{ entityId: number }>().create({
  name: vine.string().trim().minLength(3),
  email: vine
    .string()
    .email()
    .unique(async (_, value, field) => {
      const row = await Entity.query()
        .where('email', value)
        .whereNot('id', field.meta.entityId)
        .first()
      return !row
    }),
})

export const listEntityValidator = vine.create({
  ...baseSearchValidator.getProperties(),
  // filters, sort, order
})

If the module has multiple entities (e.g. users also has tokens), split them into separate files: validators/users.ts, validators/tokens.ts.

3. Policy

// app/<mod>/policies/<entity>_policy.ts
export default class EntityPolicy extends BasePolicy {
  async viewList(user: User) { return user.hasPermission(PERMISSIONS.entityViewList) }
  async view(user: User, entity: Entity) { /* self OR permission */ }
  async create(user: User) { return user.hasPermission(PERMISSIONS.entityCreate) }
  async update(user: User, entity: Entity) { /* self OR permission */ }
  async delete(user: User, entity: Entity) { /* deny self-delete + permission */ }
}

Register matching PERMISSIONS.entity* in the RBAC catalog — see [[authorization]].

4. Actions (one per write)

app/<mod>/actions/create_entity.ts, update_entity.ts, delete_entity.ts. Each exposes an Input interface and an async handle(input). See [[actions-events]].

5. Transformer

// app/<mod>/transformers/entity_transformer.ts
export default class EntityTransformer extends BaseTransformer<Entity> {
  toObject() {
    return { id: this.resource.id, name: this.resource.name }
  }
  forList() {
    return { ...this.toObject(), createdAt: this.resource.createdAt.toISO()! }
  }
  forEdit() {
    return { ...this.toObject(), /* extras only edit needs */ }
  }
}

Consumers:

  • Single: EntityTransformer.transform(entity).useVariant('forEdit')
  • Paginated: EntityTransformer.paginate(rows, paginator.getMeta()).useVariant('forList')

6. List query (when index has filters)

app/<mod>/queries/list_entities.ts — takes { q, filters, sort, order } + { page, perPage }, returns paginated result. See [[queries]].

7. Controller (thin)

public async index({ bouncer, inertia, request }: HttpContext) {
  await bouncer.with(EntityPolicy).authorize('viewList')                       // 1
  const payload = await request.validateUsing(listEntityValidator)             // 2
  const rows = await new ListEntities().handle({ /* filters */ }, payload)     // 3
  return inertia.render('entities/index', {                                    // 4
    entities: EntityTransformer.paginate(rows.all(), rows.getMeta()).useVariant('forList'),
  })
}

Nested resource (parents.children) — load the owner first, gate on it:

public async store({ auth, bouncer, params, request, response }: HttpContext) {
  const user = auth.getUserOrFail()
  const parent = await Parent.findOrFail(params.parent_id)
  await bouncer.with(ChildPolicy).authorize('create', parent)
  const payload = await request.validateUsing(createChildValidator)
  await new CreateChild().handle({ userId: user.id, parentId: parent.id, ...payload })
  return response.redirect().back()
}

8. Route

// app/<mod>/routes.ts
const EntitiesController = () => import('#<mod>/controllers/entities_controller')

router
  .group(() => {
    router
      .resource('/entities', EntitiesController)
      .only(['index', 'create', 'store', 'edit', 'update', 'destroy'])
      .where('id', router.matchers.number())
      .as('entities')
  })
  .middleware(middleware.auth())

See [[routes]] for verb vs. resource, numeric matchers, nested resources.

9. Inertia pages

  • <mod>/ui/pages/<entities>/index.tsx — list page.
  • <mod>/ui/pages/<entities>/create.tsx + edit.tsx — modals launched by controller create/edit methods.

See [[inertia]] for <Form>, useForm, usePageProps, urlFor, and modal wiring.

10. i18n

Every UI string used in the pages needs an entry in every supported locale JSON — see [[i18n]].

11. Tests

  • Functional: app/<mod>/tests/functional/<entities>_endpoint.spec.ts — cover unauth (302 → login), forbidden (302 → home), validation (422), happy path per method.
  • Unit: one per action.
  • See [[testing]].

12. Verify

Not done until it runs green. Run pnpm typecheck && pnpm lint, then the module's functional spec (pnpm ace test --files="<entities>"). Writing the specs in step 11 is not the finish line — an unrun or red spec means the feature isn't done.

Anti-patterns

  • ❌ Business logic in the controller — move it to an action.
  • ❌ Skipping bouncer.authorize(...) on any mutation — every write must be gated. A where clause is not authorization.
  • ❌ Sending a Lucid model to Inertia without a transformer — the shape leaks and page prop types drift.
  • ❌ A transformer with one variant returning everything — over-fetch, hurts the response payload. Trim variants per page.
  • ❌ Validator declared inline in the controller — always in <mod>/validators/<entity>.ts.
  • ❌ Custom verbs (activate, publish) added as extra methods on the resource controller — put them as separate router.post(...) calls with their own controller (or method), keeping the resource .only([...]) list clean.
  • ❌ Controllers calling auth.authenticate() — the auth() middleware ran first; use auth.getUserOrFail().

Related skills

[[module-scaffolding]] · [[routes]] · [[actions-events]] · [[queries]] · [[authorization]] · [[inertia]] · [[testing]] · [[i18n]] · [[migrations]]