JCC Express

JCC Eloquent ORM

Defining Model

Introduction

In JCC-Eloquent, each model is a class that extends Model and represents a table record.

Basic rule:

  • class name -> table name (plural snake_case by default)
  • model properties control behavior (hidden fields, timestamps, soft deletes, mass assignment, etc.)

Generate a model

Use ArtisanNode instead of creating files manually:

Bash
bun artisanNode make:model User

Generated models should live in app/Models.


Basic model definition

TypeScript
import { Model } from "jcc-express-mvc/Eloquent";

export class User extends Model {}

By convention this maps to users table.


Customizing table and primary key

TypeScript
import { Model } from "jcc-express-mvc/Eloquent";

export class User extends Model {
  protected primaryKey = "id";
  // Optional static override when table naming is not conventional:
  // static table = "app_users";
}

Mass assignment control

Use fillable / guarded to control fill(...) and create/update assignments:

TypeScript
import { Model } from "jcc-express-mvc/Eloquent";

export class User extends Model {
  protected fillable = ["name", "email", "password"];
  protected guarded = ["id"];
}

Hiding attributes in JSON

TypeScript
import { Model } from "jcc-express-mvc/Eloquent";

export class User extends Model {
  protected hidden = ["password"];
}

toJSON() will omit hidden fields.


TypeScript property declarations (declare)

JCC Eloquent hydrates model instances at runtime — properties like name, email, and id are assigned dynamically from the database result after a query. TypeScript doesn't know about these properties by default, so accessing user.name would produce a type error.

The solution is the declare keyword. It tells TypeScript that a property will exist at runtime (injected by the ORM) without emitting any JavaScript or initializing the value:

TypeScript
import { Model } from "jcc-express-mvc/Eloquent";

export class User extends Model {
  protected hidden: string[] = ["password"];

  declare id?: number;
  declare name?: string;
  declare email?: string;
  declare password?: string;
  declare created_at?: Date;
  declare updated_at?: Date;
}

With these declarations in place, TypeScript fully understands the model's shape:

TypeScript
const user = await User.find(1);

console.log(user.name); // ✓ string | undefined
console.log(user.email); // ✓ string | undefined
console.log(user.id); // ✓ number | undefined

// Type errors are caught at compile time:
console.log(user.foo); // ✗ Property 'foo' does not exist on type 'User'

Why declare and not a normal property?

A regular property definition like name: string = "" would initialize the property to "" in the constructor and overwrite whatever the ORM hydrated. declare is purely a compile-time annotation — it emits no JavaScript, so the ORM's runtime assignment is never touched.

Why ? (optional)?

A freshly instantiated model (before any query) has none of these properties set, so marking them optional (name?: string) is accurate. It also matches the ORM's behavior when a column is nullable or when the model was created without all fields.

Recommended pattern

Declare every column your table has. This gives you full type safety across controllers, services, and views without any runtime cost:

TypeScript
export class Post extends Model {
  protected fillable = ["title", "body", "author_id"];
  protected hidden: string[] = [];

  declare id?: number;
  declare title?: string;
  declare body?: string;
  declare author_id?: number;
  declare created_at?: Date;
  declare updated_at?: Date;
}

Accessors and mutators

Define getter/setter style methods:

TypeScript
import { Model } from "jcc-express-mvc/Eloquent";

export class User extends Model {
  getNameAttribute() {
    return this._attributes["name"]?.toUpperCase();
  }

  setNameAttribute(value: string) {
    this._attributes["name"] = value.trim();
  }
}

Casts and timestamps behavior

Model/base supports static casts and timestamp handling:

TypeScript
import { Model } from "jcc-express-mvc/Eloquent";

export class User extends Model {
  protected static casts = {
    created_at: "datetime",
  };
}

Default model behavior:

  • timestamps enabled
  • primaryKey is id
  • softDeletes disabled unless enabled in model strategy

Defining relationships in model

TypeScript
export class User extends Model {
  posts() {
    return this.hasMany(Post, "author");
  }
}

Available relationship helpers include hasOne, hasMany, belongsTo, belongsToMany, and morph variants.


Model events and hooks

Register lifecycle callbacks on the model with static boot(). The framework calls boot() automatically the first time the model participates in an event.

TypeScript
import { Model } from "jcc-express-mvc/Eloquent";
import { Post } from "./Post";

export class User extends Model {
  protected hidden: string[] = ["password"];
  protected hasToken: boolean = true;
  declare id?: number;
  declare name?: string;
  declare email?: string;
  declare password?: string;
  declare created_at?: Date;
  declare updated_at?: Date;

  posts() {
    return this.hasMany(Post, "author");
  }

  static boot(): void {
    this.creating(async (user: User) => {
      // before insert
    });

    this.created(async (user: User) => {
      // after insert
    });

    this.updating(async (user: User) => {
      // before update
    });

    this.updated(async (user: User) => {
      // after update
    });

    this.deleting(async (user: User) => {
      // before delete
    });

    this.deleted(async (user: User) => {
      // after delete
    });

    this.saving(async (user: User) => {
      // before insert or update
    });

    this.saved(async (user: User) => {
      // after insert or update
    });

    this.restoring(async (user: User) => {
      // before soft-delete restore
    });

    this.restored(async (user: User) => {
      // after soft-delete restore
    });
  }
}

Use this.<event>(callback) inside boot() for each hook you need — omit events you do not use.

To skip events for a single operation, use saveQuietly(), executeWithoutEvents(), and related helpers. For observer classes and full event reference, see Observer.


API tokens (createToken)

Models can issue JWT access tokens for API authentication. This is opt-in: set protected hasToken = true on the model class. Without it, calling createToken() throws ModelTokenError.

TypeScript
import { Model } from "jcc-express-mvc/Eloquent";

export class User extends Model {
  protected hidden: string[] = ["password"];
  protected hasToken: boolean = true;

  declare id?: number;
  declare email?: string;
}

Issuing a token

Call createToken() on a loaded model instance:

TypeScript
const user = await User.where("email", "admin@example.com").first();

// default payload: { id: user.getKey() }, expires in 22 hours
const token = user.createToken();

// custom payload — fields are used later by apiAuth to reload the user
const token = user.createToken({ email: user.email });
ArgumentTypeDefaultDescription
payloadRecord<string, any>{ id: this.getKey() }Claims embedded in the JWT.

Returns a signed JWT string (via jwtSign from jcc-express-mvc).

Using the token with apiAuth

Protect API routes with the apiAuth middleware. Clients send the token as:

http
Authorization: Bearer <token>

On each request, apiAuth:

  1. Verifies the JWT signature and access-token rules.
  2. Strips exp, iat, and typ from the payload.
  3. Looks up the user with the remaining claims as a where filter (JCC Eloquent: User.where({ ...data }).first(); Sequelize/Mongoose use their findOne equivalents).
  4. Attaches the resolved model to req.user.

Example route pair:

TypeScript
import { User } from "@/Models/User";
import { Route } from "jcc-express-mvc/Core";

Route.post("/login/token", async () => {
  const user = await User.where("email", "admin@example.com").first();
  if (!user) return { message: "User not found" };

  return {
    token: user.createToken({ email: user.email }),
  };
});

Route.middleware(["apiAuth"]).get("/me", async (req) => {
  return { user: req.user };
});

Payload design tips

  • Include fields that uniquely identify the user in your database (id, email, etc.) — apiAuth uses them to reload the record.
  • Include typ: "access" when you enable JWT_REQUIRE_TYPED_TOKENS=true in production (see JWT).
  • Do not put secrets (passwords, refresh tokens) in the JWT payload.
  • Token lifetime is fixed at 22 hours in the current createToken implementation.

Errors

If hasToken is not enabled on the model:

text
ModelTokenError: User Model Missing required token. Please set 'protected hasToken = true' .

The global error handler returns 500 with the error message for ModelTokenError.

For cookie-based login (Auth.attempt), refresh tokens, and web auth middleware, see Authentication.


Summary

  • Define models by extending Model.
  • Prefer generated model files (make:model) in app/Models.
  • Configure table/key/mass-assignment/hidden/accessors directly in the class.
  • Use declare col?: Type for every table column — gives full TypeScript type safety with zero runtime cost.
  • Add relationship methods to express domain links cleanly.
  • Register lifecycle hooks in static boot() with this.creating(...), this.saved(...), etc.