<!-- Markdown mirror of https://justscale.sh/docs/models/overview -->

# Models

Define your domain models with type-safe field builders

Models in JustScale define your domain entities using a declarative field builder API. They provide compile-time type safety, automatic TypeScript type inference, and separation between your domain schema and storage implementation.

## Creating a Model

Use `defineModel` to create a model class. The model extends the returned class, and TypeScript automatically infers the types from your field definitions:

user.model.tsTypeScript

```typescript
import { defineModel, field } from '@justscale/core/models';

// Define a User model with field builders
class User extends defineModel({
  email: field.string().max(255).unique(),
  name: field.string().max(100),
  balance: field.decimal(10, 2).default('0.00'),
  isActive: field.boolean().default(true),
}) {}

// TypeScript infers the type automatically:
// {
//   email: string
//   name: string
//   balance: string      // Decimals are strings for precision
//   isActive: boolean
// }

export { User };
```

## Creating Instances

Models can be instantiated directly with `new`. The constructor accepts partial data for all fields. This creates a **transient** instance (not yet persisted):

create-user.tsTypeScript

```typescript
import { User } from './user.model';

// Create a new transient user (no id yet)
const user = new User({
  email: 'alice@example.com',
  name: 'Alice',
  // balance defaults to '0.00'
  // isActive defaults to true
});

console.log(user.email);    // 'alice@example.com'
console.log(user.balance);  // '0.00'
```

## Field Builders

Field builders provide a fluent API for defining field types and constraints. Each field type has specific modifiers and options:

product.model.tsTypeScript

```typescript
import { defineModel, field } from '@justscale/core/models';

class Product extends defineModel({
  // String fields
  sku: field.string().max(50).unique().index(),
  name: field.string().max(200),
  description: field.text(),  // For long content

  // Number fields
  quantity: field.int().default(0),
  price: field.decimal(10, 2),  // Precision for money
  weight: field.float().optional(),

  // Boolean
  inStock: field.boolean().default(true),

  // Date/Time
  manufacturedAt: field.timestamp().optional(),

  // JSON
  metadata: field.jsonb<{ tags: string[] }>().optional(),
}) {}

export { Product };
```

See [Field Builders Reference](https://justscale.sh/docs/models/field-builders) for a complete list of available field types and modifiers.

## Persistence and Type States

When you persist a model via a repository, you get back a `Persistent` — a readonly version of your domain data. The adapter tracks identity, timestamps, and versioning internally via non-enumerable symbols. Your domain code never sees `.id` or system fields:

user-service.tsTypeScript

```typescript
import { defineService } from '@justscale/core';
import { ModelRepository, type Persistent } from '@justscale/core/models';
import { User } from './user.model';

export class UserService extends defineService({
  inject: { users: ModelRepository.of(User) },
  factory: ({ users }) => ({
    create: async (email: string, name: string): Promise<Persistent<User>> => {
      const saved = await users.insert({ email, name });

      saved.email;    // string (readonly)
      saved.name;     // string (readonly)
      // saved.id     — does not exist! ID is an adapter concern

      return saved;
    },

    findByEmail: async (email: string) => {
      return users.findOne(User.fields.email.eq(email));
    },
  }),
}) {}
```

💡Tip

`Persistent` has the same fields as your model, but all `readonly`. To mutate a persistent entity, acquire a lock first — see [Type States](https://justscale.sh/docs/concepts/type-states).

If you need a raw identifier at a system boundary (URLs, external APIs), use the deliberately verbose escape hatch:

TypeScript

```typescript
const rawId = User.ref(savedUser).identifier;
```

For referencing entities in domain code, use typed references instead — see [References](https://justscale.sh/docs/concepts/references).

## Permissions

Models can declare **who is allowed to do what** directly in their definition. The `permissions` block receives the model's field expressions and returns a record of named checks built with `permit()`. Each check is stamped onto the model as `Model.can.*name*` and is also available as a route guard via the `@justscale/permission` middleware.

ticket.model.tsTypeScript

```typescript
import { defineModel, field } from '@justscale/core/models';
import { permit, Everyone } from '@justscale/permission';
import { Customer } from './customer.model';
import { Agent } from './agent.model';

export class Ticket extends defineModel({
  fields: {
    subject: field.string().max(255),
    body: field.text(),
    status: field.enum('TicketStatus',
      ['open', 'in_progress', 'resolved', 'closed']).default('open'),
    customer: field.ref(Customer),
    assignedAgent: field.ref(Agent).optional(),
    escalated: field.boolean().default(false),
    internalNotes: field.text().optional(),
  },

  // 'customer' is the field expression — permit(Customer).when(customer)
  // means: the principal is a Customer whose ref equals this ticket.customer.
  permissions: ({ customer }) => ({
    // Array = any of these passes
    view:    [permit(Customer).when(customer), permit(Agent).always()],
    comment: [permit(Customer).when(customer), permit(Agent).always()],

    // Single rule
    assign:  permit(Agent).always(),
    resolve: permit(Agent).always(),
    close:   permit(Customer).when(customer),

    // Everyone is a principal that matches any caller — use for public rules
    viewPublicSummary: permit(Everyone).always(),
  }),
}) {}
```

### Using permission names

The keys of the permissions record are stamped on `Model.can`:

use-permissions.tsTypeScript

```typescript
// As an HTTP route guard (@justscale/permission)
Get('/tickets/:ticket')
  .types({ Ticket })
  .guard(Ticket.can.view)
  .handle(({ params, res }) => {
    // Middleware proved the principal passes one of the 'view' rules.
  });

// Direct check with a resolved principal
if (await Ticket.can.assign({ principal, subject: ticket })) {
  await ticketService.assign(ticket, agent);
}
```

### Queryable rules (ORM-style filters)

Rules built with `.when(field)` are **queryable**: the same permission serves as both a row guard AND a WHERE-clause generator via `.toCondition(principal)`. This is how you filter list endpoints without writing the predicate twice.

queryable-permissions.tsTypeScript

```typescript
// Declared once on the model:
//   close: permit(Customer).when(customer)
//
// At runtime, .toCondition(principal) evaluates to an EqCondition on the
// same field — something the repository translates into SQL directly:

const principal = await principals.current();
const condition = Ticket.can.view.toCondition(principal);
//   → EqCondition { field: 'customer', value: principal.ref }
//   → SQL: WHERE tickets.customer_id = :principal

// Pass it straight to the repository — no manual predicate needed.
const visible = await tickets.find({ where: condition });

// Combine with other filters via q.and / q.or:
const openForMe = await tickets.find({
  where: q.and(condition, Ticket.fields.status.eq('open')),
});
```

### Traversing refs (JOIN-style)

When the owning relation isn't direct, `.when(field.has(Other.fields.X))`walks the ref chain. It produces a nested `HasCondition` that the adapter compiles to a JOIN:

traversal-permissions.tsTypeScript

```typescript
// Attachment doesn't reference Customer directly — it references a Ticket,
// which references a Customer. The traversal expresses the JOIN path.
export class Attachment extends defineModel({
  fields: {
    ticket: field.ref(Ticket),
    filename: field.string(),
  },
  permissions: ({ ticket }) => ({
    // permit(Customer).when(ticket.has(Ticket.fields.customer))
    //   guard:  resolve attachment.ticket → check ticket.customer === principal
    //   query:  HasCondition {
    //             field: 'ticket',
    //             condition: EqCondition { field: 'customer', value: principal.ref }
    //           }
    //   SQL:    JOIN tickets ON tickets.id = attachments.ticket_id
    //           WHERE tickets.customer_id = :principal
    view:   [permit(Customer).when(ticket.has(Ticket.fields.customer)),
             permit(Agent).always()],
    upload: [permit(Customer).when(ticket.has(Ticket.fields.customer)),
             permit(Agent).always()],
  }),
}) {}
```

💡Tip

Prefer `.when(field)` whenever the rule can be expressed as a field comparison — you get ORM integration for free. Reach for `.check(fn)` only for logic that cannot be projected to a queryable condition (e.g. time-of-day checks, external policy lookups). Non-queryable rules work as guards but cannot filter list endpoints.

### Field-level access

The optional `access` block attaches permissions to individual fields. Fields default to "visible and mutable by anyone allowed to see/edit the record," but `access` tightens specific fields to specific permissions:

field-access.tsTypeScript

```typescript
export class Ticket extends defineModel({
  fields: {
    subject: field.string(),
    body: field.text(),
    internalNotes: field.text().optional(),
    assignedAgent: field.ref(Agent).optional(),
    escalated: field.boolean().default(false),
  },

  permissions: ({ customer }) => ({
    view:   [permit(Customer).when(customer), permit(Agent).always()],
    assign: permit(Agent).always(),
  }),

  // access receives the permissions map — reuse named rules
  access: ({ assign }) => ({
    // Only callers who pass 'assign' can see or set these
    internalNotes: assign,
    assignedAgent: assign,
    escalated:     assign,
  }),
}) {}

// A Customer fetching a ticket gets back only subject/body — the
// assign-gated fields are filtered out at serialization time.
```

Fields with no `access` entry follow the model-wide default (typically `view`); fields listed are scoped to the referenced permission. The same rule governs visibility (read) and mutability (write).

💡Tip

`permit(Everyone)` is for rules that should pass for any caller (including anonymous). Declare `Everyone`-based rules*last* when you want owner-specific rules (e.g. `viewAsOwner`) to be checked first — earlier rules in an array win.

## Type Inference

JustScale models use TypeScript's advanced type inference to extract types directly from your field definitions. You rarely need to write explicit types:

type-inference.tsTypeScript

```typescript
import { defineModel, field, type ModelData } from '@justscale/core/models';

class User extends defineModel({
  email: field.string(),
  age: field.int().optional(),
  balance: field.decimal(10, 2),
  active: field.boolean().default(true),
}) {}

// Extract clean type reference
type UserData = ModelData<typeof User>;
// {
//   email: string
//   age: number | undefined
//   balance: string
//   active: boolean
// }

// TypeScript infers parameter types
function greetUser(user: UserData) {
  console.log(`Hello, ${user.email}`);
}

const user = new User({ email: 'test@example.com', balance: '100.00' });
greetUser(user);  // Type-safe!
```

## Special Field Types

### Timestamps

Use semantic timestamp fields for common patterns like created/updated tracking:

post.model.tsTypeScript

```typescript
import { defineModel, field } from '@justscale/core/models';

class Post extends defineModel({
  title: field.string(),
  content: field.text(),
  publishedAt: field.timestamp().optional(),
  createdAt: field.createdAt(),   // Auto-set on insert
  updatedAt: field.updatedAt(),   // Auto-updated on save
  deletedAt: field.deletedAt(),   // Soft delete support (optional by default)
}) {}

export { Post };
```

### Enums

Define PostgreSQL enums with type-safe values:

order.model.tsTypeScript

```typescript
import { defineModel, field } from '@justscale/core/models';

const ORDER_STATUSES = ['pending', 'processing', 'completed', 'cancelled'] as const;

class Order extends defineModel({
  orderNumber: field.string().max(50).unique(),
  status: field.enum('order_status', ORDER_STATUSES).default('pending'),
  total: field.decimal(10, 2),
}) {}

// TypeScript knows status must be one of the enum values
const order = new Order({
  orderNumber: 'ORD-001',
  status: 'pending',  // Type-safe
  // status: 'invalid',  // TypeScript error
  total: '99.99',
});

export { Order };
```

### Arrays

Store arrays of primitive types using PostgreSQL array columns:

tag.model.tsTypeScript

```typescript
import { defineModel, field } from '@justscale/core/models';

class Article extends defineModel({
  title: field.string(),
  tags: field.array(field.string()),
  ratings: field.array(field.int()).optional(),
}) {}

const article = new Article({
  title: 'TypeScript Tips',
  tags: ['typescript', 'programming', 'web'],
  ratings: [4, 5, 5, 3],
});

export { Article };
```

## Best Practices

- Use semantic field types - Prefer field.createdAt() over field.timestamp() for timestamps
- Set appropriate constraints - Use .max(), .unique(), .index() to express domain rules
- Use decimal for money - Never use float or double for currency; use field.decimal(10, 2)
- Provide defaults - Set sensible defaults with .default() to reduce required constructor parameters
- Let TypeScript infer - Avoid manual type annotations; let the field builders do the work
- Export the class, not the instance - Export User class, not new User()

## Next Steps

- Field Builders
- Repositories Overview
- References
