- name
- stacks-orm
- description
- Use when working with the Stacks ORM - defining models with defineModel(), model relationships (hasOne, hasMany, belongsTo, belongsToMany, morphOne, hasManyThrough), attributes, traits, factories, computed properties, query building, transactions, or the 105 built-in models. Covers @stacksjs/orm, storage/framework/orm/, 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 ORM
## Key Paths
- Core ORM package: `storage/framework/core/orm/src/`
- ORM implementation: `storage/framework/orm/`
- Model definitions: `storage/framework/defaults/app/Models/` (105 models)
- Application models: `app/Models/`
- Default model templates: `storage/framework/defaults/app/Models/`
- ORM type globals: `storage/framework/types/orm-globals.d.ts`
- Attribute types: `storage/framework/types/attributes.ts` (240+ attributes)
- Model events: `storage/framework/types/events.ts`
## Source Files
```
orm/src/
├── define-model.ts # defineModel() + buildEventHooks() + buildTraitMethods()
├── index.ts # Re-exports orm/src + db + subquery + transaction + types
├── db.ts # Database query builder bridge
├── subquery.ts # Subquery support
├── transaction.ts # transaction(), savepoint(), transactional()
├── model-types.ts # ModelRow<T>, NewModelData<T>, UpdateModelData<T>
├── types.ts # ORM type definitions
├── utils.ts # modelTableName, getRelations, getFillableAttributes, etc.
├── builder.ts # Query builder integration
├── generated/ # Auto-generated model types and table traits
│ ├── types.ts
│ ├── index.ts
│ └── table-traits.ts
└── traits/
├── index.ts # Re-exports all trait creators
├── taggable.ts # createTaggableMethods()
├── categorizable.ts # createCategorizableMethods()
├── commentable.ts # createCommentableMethods()
├── billable.ts # createBillableMethods()
├── likeable.ts # createLikeableMethods()
└── two-factor.ts # createTwoFactorMethods()
```
## defineModel() API (define-model.ts)
Wraps bun-query-builder's `createModel()` with Stacks-specific enhancements:
- Event dispatching via `traits.observe` (emits `{model}:created`, `{model}:updated`, `{model}:deleted` via `@stacksjs/events`)
- Trait methods (billable, taggable, categorizable, commentable, likeable, 2FA)
- Raw definition access for generators (`getDefinition()`, `_isStacksModel`)
```typescript
import { defineModel } from '@stacksjs/orm'
export default defineModel({
name: 'Product',
table: 'products',
primaryKey: 'id', // default: 'id'
autoIncrement: true, // default: true
traits: {
useUuid: true, // adds uuid column
useTimestamps: true, // adds created_at, updated_at
useAuth: { usePasskey: true }, // adds auth columns + passkey support
useSocials: ['github'], // social login providers
useSearch: { // search engine indexing
displayable: ['name', 'email'],
searchable: ['name', 'email'],
sortable: ['name', 'created_at'],
filterable: ['status']
},
useSeeder: { count: 10 }, // or just `true` (defaults to 10)
useApi: {
uri: 'products',
routes: ['index', 'store', 'show', 'update', 'destroy']
},
categorizable: true, // adds category relations via categorizable table
taggable: true, // adds tag relations via taggable table
commentables: true, // adds comment relations (NOTE: plural 's' in key)
billable: true, // adds Stripe integration methods
likeable: true, // or { table?: string, foreignKey?: string }
observe: true, // emit model events (true = all, or array: ['create','update','delete'])
},
// Relationships
hasOne: ['Subscriber'],
hasMany: ['Post', 'Order'],
belongsTo: ['User', 'Category'],
belongsToMany: ['Tag'],
hasOneThrough: ['Profile'],
morphOne: 'Image', // or { model, morphName?, type?, id? }
indexes: [
{ name: 'idx_email', columns: ['email'] },
{ name: 'idx_composite', columns: ['email', 'name'] }
],
attributes: {
name: {
order: 1,
fillable: true,
required: true,
unique: false,
validation: {
rule: schema.string().max(100),
message: { max: 'Name is too long' }
},
factory: (faker) => faker.lorem.word()
},
price: {
fillable: true,
required: true,
validation: { rule: schema.number().min(1) },
factory: (faker) => faker.datatype.number({ min: 100, max: 10000 })
},
status: {
fillable: true,
default: 'draft',
validation: { rule: schema.enum(['draft', 'published', 'archived']) }
},
password: {
hidden: true, // excluded from JSON serialization
guarded: true, // not mass-assignable
}
},
// Computed properties (accessors)
get: {
fullName: (attrs) => `${attrs.first_name} ${attrs.last_name}`,
formattedPrice: (attrs) => `$${(attrs.price / 100).toFixed(2)}`
},
// Mutators (setters)
set: {
email: (attrs) => attrs.email?.toLowerCase()
},
// Model hooks (lifecycle callbacks)
hooks: {
afterCreate: (model) => { /* ... */ },
afterUpdate: (model) => { /* ... */ },
afterDelete: (model) => { /* ... */ },
},
dashboard: { highlight: true } // highlight in admin dashboard
} as const)
```
### How defineModel() Works Internally
1. `buildEventHooks(definition)` -- if `traits.observe` is truthy, creates `afterCreate`/`afterUpdate`/`afterDelete` hooks that lazy-import `@stacksjs/events` and call `dispatch()`
2. Merges event hooks with any user-defined hooks
3. Calls `createModel(defWithHooks)` from bun-query-builder (provides typed query methods)
4. `buildTraitMethods(definition)` -- checks each trait flag and creates method objects
5. Returns `Object.assign(baseModel, traitMethods, definition)` + `getDefinition()` + `_isStacksModel`
## Transactions (transaction.ts)
```typescript
import { transaction, savepoint, transactional } from '@stacksjs/orm'
// Basic transaction -- auto-commit on success, auto-rollback on error
const result = await transaction(async (tx) => {
await tx.insertInto('users').values({ name: 'John' }).execute()
await tx.insertInto('profiles').values({ user_id: 1 }).execute()
return 'success'
})
// With options
await transaction(callback, {
retries: 3,
isolation: 'serializable', // 'read committed' | 'repeatable read' | 'serializable'
readOnly: false,
onRollback: (error) => console.error(error),
afterRollback: () => { /* cleanup */ }
})
// Savepoints (nested transactions)
await transaction(async (tx) => {
await tx.insertInto('users').values({ name: 'Bob' }).execute()
await savepoint(async (sp) => {
await sp.insertInto('logs').values({ action: 'created' }).execute()
// If this fails, only this savepoint rolls back
})
})
// Decorator-style -- wraps function to auto-run in transaction
const createUser = transactional(async (tx, name: string, email: string) => {
const user = await tx.insertInto('users').values({ name }).returningAll().executeTakeFirst()
await tx.insertInto('profiles').values({ user_id: user.id }).execute()
return user
})
await createUser('Alice', 'alice@example.com') // auto-wrapped
```
Both `transaction()` and `savepoint()` delegate to `db.transaction()` and `db.savepoint()` from `@stacksjs/database`.
### Transaction executor boundary
Every query that must commit or roll back together must use the callback handle (`tx` or `sp`), including validation reads, pivot writes, and the final readback. Do not mix `Model.find()`, `Model.create()`, instance `update()` / `delete()`, or instance relation calls into a raw query-builder transaction. The model executor is a separate execution surface and is not rebound to the callback handle. On SQLite it may use a separate connection, so it cannot observe an uncommitted row written through `tx`.
`runInTransactionScope()` buffers supported side effects until commit, but it does not rebind model queries. For a transaction-backed custom action, use the model definition as the schema and relationship source of truth, then execute the complete persistence workflow through `tx`. Read the created or updated row through `tx` before returning so a readback failure also rolls back the mutation.
## Trait Methods (traits/)
### Taggable (when `traits.taggable: true`)
Uses `taggable` table with polymorphic `taggable_type` + `taggable_id` columns.
- `Model._taggable.tags(id: number): Promise<any[]>`
- `Model._taggable.tagCount(id: number): Promise<number>` -- uses `count(*)`
- `Model._taggable.addTag(id, { name, description? }): Promise<any>` -- auto-generates slug
- `Model._taggable.activeTags(id): Promise<any[]>` -- filters `is_active = true`
- `Model._taggable.inactiveTags(id): Promise<any[]>` -- filters `is_active = false`
- `Model._taggable.removeTag(id, tagId): Promise<void>`
### Categorizable (when `traits.categorizable: true`)
Uses `categorizable` + `categorizable_models` pivot table.
- `Model._categorizable.categories(id): Promise<any[]>` -- joins through pivot
- `Model._categorizable.categoryCount(id): Promise<number>`
- `Model._categorizable.addCategory(id, { name, description? }): Promise<any>` -- creates category if not exists, then links
- `Model._categorizable.activeCategories(id): Promise<any[]>`
- `Model._categorizable.inactiveCategories(id): Promise<any[]>`
- `Model._categorizable.removeCategory(id, categoryId): Promise<void>` -- removes pivot link
### Commentable (when `traits.commentables: true`)
Uses `comments` table with `commentables_id` + `commentables_type` columns.
- `Model._commentable.comments(id): Promise<any[]>`
- `Model._commentable.commentCount(id): Promise<number>`
- `Model._commentable.addComment(id, { title, body }): Promise<any>` -- status defaults to `'pending'`
- `Model._commentable.approvedComments(id): Promise<any[]>` -- status = `'approved'`
- `Model._commentable.pendingComments(id): Promise<any[]>` -- status = `'pending'`
- `Model._commentable.rejectedComments(id): Promise<any[]>` -- status = `'rejected'`
### Likeable (when `traits.likeable: true` or `{ table?, foreignKey? }`)
Table defaults to `{tableName}_likes`, FK defaults to `{singular}_id`.
- `Model._likeable.likes(id): Promise<any[]>`
- `Model._likeable.likeCount(id): Promise<number>`
- `Model._likeable.like(id, userId): Promise<any>`
- `Model._likeable.unlike(id, userId): Promise<void>`
- `Model._likeable.isLiked(id, userId): Promise<boolean>`
### Billable (when `traits.billable: true`) -- the configured payment driver
All methods lazy-import `@stacksjs/payments`. Full reference: `stacks-payments`.
- Provider-neutral (`config.payment.driver`): `paymentCustomer(model)`, `charge(model, money, paymentMethodId, options)`, `createPayment(model, money, options)`, `checkout(model, { mode, lines, successUrl, cancelUrl })`, `paymentMethods(model)`, `removePaymentMethod(model, providerId)`, `newSubscription(model, type, price)` -> `SubscriptionSummary`, `cancelSubscription(model, providerId, { atPeriodEnd })` (ownership-checked), `activeSubscription(model)` -> `{ subscription, providerSubscription }`
- Stripe only (throw `PaymentUnsupportedError` under another driver): `createStripeUser`, `updateStripeUser`, `deleteStripeUser`, `createOrGetStripeUser`, `retrieveStripeUser`, `syncStripeCustomerDetails`, `setDefaultPaymentMethod`, `addPaymentMethod`, `updateSubscription`, `createSetupIntent`, `subscriptionHistory`, Connect methods
- Local: `defaultPaymentMethod(model)`, `storeTransaction(model, productId, options)`, `transactionHistory(model)`
### Two-Factor Auth (when `traits.useAuth.useTwoFactor: true`)
View on GitHub