- name
- stacks-models
- description
- Use when working with data models in Stacks - the defineModel() API, model attributes with validation and factories, relationships (hasOne/hasMany/belongsTo/belongsToMany), traits (useAuth, useUuid, useTimestamps, useSearch, useApi, billable, taggable, categorizable, commentable, likeable, observe), computed properties (get/set), model generation, and the 105 built-in framework models. Covers model definitions and storage/framework/defaults/app/Models/.
- license
- MIT
- compatibility
- Bun >= 1.3.0, TypeScript, SQLite >= 3.47.2
- allowed-tools
- Read Edit Write Bash Grep Glob
# Stacks Models
## Key Paths
- Your models: `app/Models/` (create it; it does not exist in a fresh project)
- Built-in models: `storage/framework/defaults/app/Models/` (62 files, grouped
into `commerce/`, `Content/`, `realtime/` and a flat top level)
- `ModelOptions` / `Attribute` types: `storage/framework/core/types/src/model.ts`
- Attribute presets: `storage/framework/types/attributes.ts`
To customize a built-in model, create the same filename under `app/Models/` -
`app/Models/User.ts` wins over the default. `buddy publish:model User` copies the
default across as a starting point.
## Writing a model
Everything - schema, validation, factory, relationships, behavior - is declared
in one `defineModel()` call. Migrations are derived from this; you do not write
the SQL.
```ts
// app/Models/Product.ts
import { defineModel } from '@stacksjs/orm'
import { schema } from '@stacksjs/validation'
export default defineModel({
name: 'Product', // defaults to the file name
table: 'products', // defaults to lowercase plural of `name`
primaryKey: 'id', // default
autoIncrement: true, // default
traits: {
useUuid: true,
useTimestamps: true,
useApi: { uri: 'products', routes: ['index', 'store', 'show', 'update', 'destroy'] },
useSearch: { searchable: ['name'], filterable: ['status'] },
observe: true,
},
belongsTo: ['Category'],
hasMany: ['Review'],
attributes: {
name: {
required: true,
fillable: true,
order: 1,
validation: {
rule: schema.string().min(3).max(100),
message: { max: 'Name must have a maximum of 100 characters' },
},
factory: faker => faker.commerce.productName(),
},
status: {
required: true,
fillable: true,
default: 'draft',
validation: { rule: schema.enum(['draft', 'published', 'archived']) },
factory: faker => faker.helpers.arrayElement(['draft', 'published', 'archived']),
},
},
} as const)
```
`as const` is what the built-in models use - it narrows literal types so the
generated model types stay precise.
### Attribute fields
`validation.rule` is the only required key on an attribute.
| Field | Effect |
|---|---|
| `required` | Value required; emits a `NOT NULL` column |
| `nullable` | Explicit nullability override |
| `default` | Column default (`string \| number \| boolean \| Date`) |
| `unique` | Unique constraint |
| `type` | Force the column type instead of inferring from the rule |
| `order` | Column order in the table and in dashboard forms |
| `fillable` | Allow mass assignment |
| `guarded` | Block mass assignment |
| `hidden` | Exclude from JSON serialization (passwords, tokens) |
| `foreignKey` | Disable, infer, or configure the FK constraint |
| `personal` | Personal data: exported by `gdpr:export`, anonymized by erasure and retention. `true`, or `{ anonymize?, export? }`. See "Personal data (GDPR)" below |
| `factory` | `(faker, attributes) => value`, used by seeders and tests. `attributes` holds what this record's earlier-declared factories produced, so one value can depend on another: `(faker, { discountType }) => ...` |
| `validation` | `{ rule, message? }` - `rule` from `schema`, `message` keyed by rule name |
### Traits
| Trait | What it adds |
|---|---|
| `useUuid` | UUID column alongside the primary key |
| `useTimestamps` (alias `timestampable`) | `created_at` / `updated_at`. On by default |
| `useSoftDeletes` (alias `softDeletable`) | `deleted_at` plus soft-delete query scopes |
| `useAuth` (alias `authenticatable`) | Auth columns; `{ usePasskey: true }` adds passkeys. Also confers `morphMany: { tokenable: 'PersonalAccessToken' }`, so any authenticatable model can hold API tokens |
| `useApi` | Generates REST actions and routes: `{ uri, routes, middleware? }` |
| `useSearch` (alias `searchable`) | Search-engine indexing: `{ displayable, searchable, sortable, filterable }` |
| `useSocials` | OAuth identities, e.g. `['github']` |
| `useActivityLog` | Writes an `activities` feed row per change: `{ logOnly }` / `{ include }` / `{ exclude }` pick the attributes |
| `useAudit` | Writes a `model_audits` row per change with an old/new diff |
| `observe` | Emits `{model}:created` / `:updated` / `:deleted` events |
| `billable` | Stripe methods (`checkout()`, `activeSubscription()`, ...) |
| `taggable` / `categorizable` / `commentable` / `likeable` | Pivot tables and their relation methods |
| `gdpr` | Whose data the rows are (`subject`), what erasure does (`erasure`), how long rows live (`retention`), and why (`basis`, `purpose`). See "Personal data (GDPR)" below |
Also at the top level: `indexes: [{ name, columns, unique?, where? }]` for
composite and partial-unique indexes, and `dashboard: { highlight: true }` to
feature the model in the admin UI.
`useApi` is an API capability, not a dashboard-view generator. Its generated
routes are registered from the merged model registry. Framework defaults are
loaded first, then recursive `app/Models/` definitions override matching model
names. Protect non-public resources at the model:
```ts
useApi: {
uri: 'mail-preferences',
routes: ['index', 'store', 'show', 'update', 'destroy'],
middleware: ['auth'],
}
```
Dashboard-specific endpoints may still use scoped Actions when their transport
shape, authorization boundary, or aggregation differs from generic CRUD. Do
not expose a sensitive model through unguarded generated routes just because a
separate dashboard endpoint is protected.
Generated store and update routes accept both spellings of every fillable
attribute and each foreign key implied by `belongsTo`. Declaring Product as a
belongs-to relation therefore accepts `productId` or `product_id` without
duplicating that relationship column as an attribute.
### Relationships
`hasOne`, `hasMany`, `belongsTo`, `belongsToMany`, `hasOneThrough`,
`hasManyThrough`, `morphOne`, `morphMany`, `morphTo`, `morphToMany`,
`morphedByMany`. Each takes an array of model names, or an object form when you
need to name the foreign key.
The object form is also where a `belongsTo` says what happens to its row when
the row it points at is deleted:
```ts
belongsTo: [
{ model: 'Repository', onDelete: 'cascade' },
{ model: 'User', foreignKey: 'author_id', onDelete: 'set null' },
],
```
`'cascade' | 'set null' | 'restrict' | 'no action'`, enforced by the database
on the foreign key. Left off, the default applies: the delete is refused while
a child still points at the row.
Worth declaring rather than deleting children in application code. The order
has to be right in every place that deletes, forever, and the place that misses
one leaves rows nothing can reach - while the database applies the rule to
deletes the application never made: a manual `DELETE`, a restore, another
service sharing the schema. Not for a polymorphic pair (`commentable_id`
beside `commentable_type`): those carry no foreign key at all, because a
constraint would name one table and reject every row pointing at another.
Use the named object form for a many-to-many relation that owns its pivot
schema. It keeps the relation accessor, migration, pivot defaults, timestamps,
and uniqueness in the model definition:
```ts
belongsToMany: {
tags: {
model: 'Tag',
table: 'taggable_models',
foreignKey: 'taggable_id',
relatedKey: 'tag_id',
pivot: {
columns: {
taggable_type: { default: 'posts' },
},
timestamps: true,
uniques: [['tag_id', 'taggable_id', 'taggable_type']],
},
},
},
```
An instance then exposes the named relation directly:
```ts
const post = await Post.find(id)
await post.tags().sync(tagIds)
await post.tags().detach()
```
The legacy array form remains supported. Prefer the named form when the pivot
has custom keys, columns, defaults, timestamps, or uniqueness. Run
`buddy generate:migrations` after changing pivot metadata.
### Computed properties and scopes
```ts
get: {
fullName: (model) => `${model.firstName} ${model.lastName}`,
},
set: {
password: (value) => makeHash(value),
},
scopes: {
published: (query) => query.where('status', 'published'),
},
```
## Personal data (GDPR)
Declared on the model, read by access exports, erasure, retention and the
processing register (stacksjs/stacks#365). Full guide: `docs/guide/gdpr.md`.
```ts
belongsTo: ['Customer'],
traits: {
gdpr: {
subject: { via: 'Customer' }, // or 'user_id', ['a_id', 'b_id'], { column, where }, { email: 'col' }
erasure: 'anonymize', // default; or 'delete' | 'keep'
retention: { days: 3650, action: 'anonymize' }, // optional; column defaults to created_at
basis: 'legal_obligation',
purpose: 'Order fulfilment, kept for tax and accounting',
},
},
attributes: {
deliveryAddress: { personal: true, validation: { rule: schema.string() } },
},
```
- `subject` is derived when left out: `id` on `User`, the `belongsTo: ['User']`
foreign key elsewhere. A `via` parent must itself declare a subject.
- Anonymization writes NULL to nullable columns and a typed placeholder to
`NOT NULL` ones (`erased-<id>` when unique). A `NOT NULL` enum or timestamp
needs `personal: { anonymize: <value> }`, or every request refuses to run.
- A model that `belongsTo: ['User']` and declares nothing is **unclassified**:
erasure does not reach it and `buddy gdpr:register` lists it. Classify it, even
as `erasure: 'keep'`. `core/orm/tests/gdpr.test.ts` fails if a built-in one is.
- Engine: `exportSubjectData`, `eraseSubject({ dryRun })`, `pruneRetainedData`,
`resolveGdprPlan`, `renderProcessingRegister`, all from `@stacksjs/orm`.
Commands: `buddy gdpr:export|erase|prune|register|register:check`. Every
request writes a `gdpr_requests` row (the `GdprRequest` model).
## Workflow
```sh
buddy make:model Product # scaffold app/Models/Product.ts
buddy generate:migrations # diff models against the schema, emit SQL
# review the generated file in database/migrations/
buddy migrate # apply it
buddy migrate:fresh --seed # dev only: drop, re-migrate, seed
```
Models resolve at runtime through `createModel()` from `bun-query-builder` -
there is no build step between editing a model and querying it. Only migrations
need generating.
## Seeding
Seed data is declared on the model, through the `useSeeder` trait plus the
per-attribute `factory` functions:
```ts
traits: {
useSeeder: {
count: 20,
// Optional: pin specific rows over the generated ones. Keys use the
// model's camelCase attribute names.
fixtures: [
{ name: 'Flagship Widget', status: 'published' },
],
},
},
```
`buddy seed` walks every model carrying the trait and fills its table from the
attribute factories. Model fixture data needs no separate seeder or registration.
Idempotent application bootstrap work can live in `database/seeders` as a
default-exported class extending `Seeder` from `@stacksjs/database`. Buddy runs
those application seeders after the model factories.
```bash
buddy seed # every model with a useSeeder trait
buddy seed --fresh # truncate each table first
buddy seed --only Product,Review # just these models
buddy seed --except User # everything but these
buddy seed --include-defaults # framework built-ins too
```
A model with no `useSeeder` trait is never seeded. Auth and OAuth models are
skipped on a non-fresh database so re-seeding cannot invalidate live sessions -
pass `--allow-protected` to override. A `belongsTo` to `User`, `Team` or `Customer` is
filled only from accounts the same run seeded (never a pre-existing, possibly real one);
`--attach-accounts` lifts that on a scratch database. Composite `unique: true` indexes are
honoured: fewer rows are seeded rather than a duplicate combination.
## Built-in models by category
The ones below are worth knowing by name. They are a selection, not the set:
`storage/framework/defaults/app/Models/` holds 105, and that directory is the
authority. This section said "All 62 built-in models by category" while
listing fewer than that against 102 on disk, so an agent reading to the end
had no way to tell it was short.
No count of what this section itself lists, deliberately - that number is
maintained by hand, drifts the moment anyone adds a bullet, and is the same
habit that produced the "All 62". The total above is pinned by
`buddy docs:agent-counts`.
Run `find storage/framework/defaults/app/Models -name '*.ts'` for the full
list, or `buddy list` for what a given project resolves.
### Users & Auth
- **User** — name, email, password | traits: useAuth(passkey), useUuid, useTimestamps, useSocials(github) | hasOne: Subscriber, Driver, Author | hasMany: PersonalAccessToken, Customer
- **Author** — name, email | belongsTo: User | hasMany: Post
- **Customer** — name, email, phone, totalSpent, lastOrder, status, avatar | belongsTo: User | hasMany: Order, GiftCard, Review, Payment
- **Driver** — name, phone, vehicleNumber, license, status | belongsTo: User | hasMany: DeliveryRoute
- **Subscriber** — email, status, source | belongsTo: User | hasMany: SubscriberEmail
### Content
- **Post** — title, content, poster, excerpt, views, publishedAt, status, isFeatured | belongsTo: Author | traits: categorizable, taggable, commentable | seeder: 20
- **Page** — similar to Post with taggable, categorizable
- **Comment** — author info, approval, content fields
- **Tag** — name(unique), slug(unique), description, postCount, color | seeder: 15
- **Category** — name, description, slug, imageUrl, isActive, parentCategoryId, displayOrder | hasMany: Product | seeder: 10
### Commerce (20+ models)
View on GitHub