Defining Entities
defineEntity turns a data model into a full API. You define the shape once — the framework
generates routes, validation, OpenAPI docs, and storage wiring.
Start with three fields
Section titled “Start with three fields”Every entity needs at least an id and one data field:
import { defineEntity, field } from '@lastshotlabs/slingshot';
export const Task = defineEntity('Task', { namespace: 'app', fields: { id: field.string({ primary: true, default: 'uuid' }), title: field.string(), done: field.boolean({ default: false }), },});That gives you GET /tasks, POST /tasks, GET /tasks/:id, PATCH /tasks/:id,
DELETE /tasks/:id — all validated, all in OpenAPI docs.
Field types
Section titled “Field types”Every field type maps to a Zod schema under the hood. Here is what is available:
fields: { // Strings name: field.string(), email: field.string({ format: 'email' }), slug: field.string({ immutable: true }),
// Numbers price: field.number(), quantity: field.integer(),
// Booleans active: field.boolean({ default: true }),
// Dates createdAt: field.date({ default: 'now' }), updatedAt: field.date({ default: 'now', onUpdate: 'now' }),
// Enums — use `as const` for type safety status: field.enum(['draft', 'published', 'archived'] as const, { default: 'draft' }),
// JSON blobs metadata: field.json({ optional: true }),
// Arrays of strings tags: field.stringArray({ optional: true }),}Field options
Section titled “Field options”| Option | What it does | Example |
|---|---|---|
primary | Marks this field as the primary key | field.string({ primary: true }) |
default | Auto-generates on create: 'uuid', 'cuid', 'now', or a literal | default: 'uuid' |
onUpdate | Auto-sets on update | onUpdate: 'now' |
optional | Field is not required on create | optional: true |
immutable | Cannot be changed after creation | immutable: true |
format | String format hint for validation | format: 'email' |
Add timestamps
Section titled “Add timestamps”Most entities need createdAt and updatedAt:
const Post = defineEntity('Post', { namespace: 'blog', fields: { id: field.string({ primary: true, default: 'uuid' }), title: field.string(), body: field.string(), createdAt: field.date({ default: 'now' }), updatedAt: field.date({ default: 'now', onUpdate: 'now' }), },});default: 'now' sets the field on creation. onUpdate: 'now' refreshes it on every update.
Add an enum for status
Section titled “Add an enum for status”Enums are the foundation for transitions (publish, archive, complete):
const Post = defineEntity('Post', { namespace: 'blog', fields: { id: field.string({ primary: true, default: 'uuid' }), title: field.string(), body: field.string(), authorId: field.string(), status: field.enum(['draft', 'published', 'archived'] as const, { default: 'draft' }), createdAt: field.date({ default: 'now' }), updatedAt: field.date({ default: 'now', onUpdate: 'now' }), },});The as const gives you type-safe transition checks. When you add an op.transition later,
the framework validates from and to against these exact values.
Add indexes
Section titled “Add indexes”Indexes tell the storage backend to optimize queries on specific fields:
import { defineEntity, field, index } from '@lastshotlabs/slingshot';
const Post = defineEntity('Post', { namespace: 'blog', fields: { id: field.string({ primary: true, default: 'uuid' }), slug: field.string(), authorId: field.string(), title: field.string(), body: field.string(), status: field.enum(['draft', 'published', 'archived'] as const, { default: 'draft' }), createdAt: field.date({ default: 'now' }), }, indexes: [ index(['slug']), // look up posts by slug index(['authorId', 'createdAt']), // list posts by author, sorted by date index(['status']), // filter by publication status ],});Indexes also serve as documentation for your team — they show which query patterns the entity is designed to support.
Add unique constraints
Section titled “Add unique constraints”Use uniques when a field (or combination of fields) must be globally unique:
const User = defineEntity('User', { namespace: 'app', fields: { id: field.string({ primary: true, default: 'uuid' }), email: field.string(), username: field.string(), }, uniques: [{ fields: ['email'] }, { fields: ['username'] }],});Add upsert: true when you want create-or-update behavior:
uniques: [{ fields: ['email'], upsert: true }],Add relations
Section titled “Add relations”Relations declare foreign-key relationships for storage-level enforcement and query planning:
const Post = defineEntity('Post', { namespace: 'blog', fields: { id: field.string({ primary: true, default: 'uuid' }), authorId: field.string(), title: field.string(), body: field.string(), }, relations: { author: { field: 'authorId', references: { entity: 'User', field: 'id' } }, },});Protect routes with auth
Section titled “Protect routes with auth”Use routes to control auth, permissions, and behavior per generated route:
const Post = defineEntity('Post', { namespace: 'blog', fields: { id: field.string({ primary: true, default: 'uuid' }), authorId: field.string(), title: field.string(), body: field.string(), status: field.enum(['draft', 'published'] as const, { default: 'draft' }), createdAt: field.date({ default: 'now' }), }, routes: { defaults: { auth: 'userAuth' }, // all routes require login list: { auth: 'none' }, // except browsing get: { auth: 'none' }, // and reading update: { permission: { requires: 'blog:post.write', ownerField: 'authorId', // only the author can edit }, }, },});See Route Policy for the full set of per-route options.
System fields
Section titled “System fields”systemFields tells the framework which fields represent ownership and tenancy. This powers
automatic owner-based permission checks and multi-tenant data isolation:
const Post = defineEntity('Post', { namespace: 'blog', fields: { id: field.string({ primary: true, default: 'uuid' }), authorId: field.string(), orgId: field.string({ optional: true }), title: field.string(), body: field.string(), }, systemFields: { ownerField: 'authorId', // who owns this record tenantField: 'orgId', // which tenant this belongs to },});Multi-tenant entities
Section titled “Multi-tenant entities”For SaaS apps with per-tenant data isolation:
const Invoice = defineEntity('Invoice', { namespace: 'billing', fields: { id: field.string({ primary: true, default: 'uuid' }), orgId: field.string(), customerId: field.string(), amount: field.number(), status: field.enum(['draft', 'sent', 'paid'] as const, { default: 'draft' }), }, tenant: { field: 'orgId' }, systemFields: { tenantField: 'orgId' },});The tenant config tells the framework to automatically scope queries to the current
tenant — a GET /invoices from org A never returns org B’s data.
Default sorting and pagination
Section titled “Default sorting and pagination”Control how list endpoints return data:
const Post = defineEntity('Post', { namespace: 'blog', fields: { id: field.string({ primary: true, default: 'uuid' }), title: field.string(), createdAt: field.date({ default: 'now' }), }, defaultSort: { field: 'createdAt', direction: 'desc' }, pagination: { cursor: { fields: ['createdAt', 'id'] }, defaultLimit: 20, maxLimit: 100, },});Soft delete
Section titled “Soft delete”Keep records around instead of permanently deleting them:
const Post = defineEntity('Post', { namespace: 'blog', fields: { id: field.string({ primary: true, default: 'uuid' }), title: field.string(), deletedAt: field.date({ optional: true }), }, softDelete: { field: 'deletedAt' },});DELETE /posts/:id now sets deletedAt instead of removing the record. List and get
endpoints automatically filter out soft-deleted records.
TTL (auto-expiry)
Section titled “TTL (auto-expiry)”For records that should expire automatically:
const Session = defineEntity('Session', { namespace: 'auth', fields: { id: field.string({ primary: true, default: 'uuid' }), userId: field.string(), token: field.string(), createdAt: field.date({ default: 'now' }), }, ttl: { defaultSeconds: 86400 }, // 24 hours});Storage-specific config
Section titled “Storage-specific config”Override table names, key prefixes, and collection names when you need to match an existing schema:
const LegacyUser = defineEntity('LegacyUser', { namespace: 'app', fields: { id: field.string({ primary: true }), name: field.string(), }, storage: { postgres: { tableName: 'users_v1' }, sqlite: { tableName: 'users_v1' }, mongo: { collectionName: 'legacy_users' }, redis: { keyPrefix: 'usr' }, },});A complete real-world entity
Section titled “A complete real-world entity”Here is a blog post entity with everything wired up:
// @skip-typecheckimport { defineEntity, field, index } from '@lastshotlabs/slingshot';
export const Post = defineEntity('Post', { namespace: 'blog', fields: { id: field.string({ primary: true, default: 'uuid' }), slug: field.string({ immutable: true }), authorId: field.string(), title: field.string(), body: field.string(), excerpt: field.string({ optional: true }), status: field.enum(['draft', 'published', 'archived'] as const, { default: 'draft' }), tags: field.stringArray({ optional: true }), metadata: field.json({ optional: true }), createdAt: field.date({ default: 'now' }), updatedAt: field.date({ default: 'now', onUpdate: 'now' }), }, indexes: [index(['slug']), index(['authorId', 'createdAt']), index(['status'])], relations: { author: { field: 'authorId', references: { entity: 'User', field: 'id' } }, }, systemFields: { ownerField: 'authorId', }, defaultSort: { field: 'createdAt', direction: 'desc' }, pagination: { cursor: { fields: ['createdAt', 'id'] }, defaultLimit: 20, maxLimit: 100, }, routes: { defaults: { auth: 'userAuth' }, list: { auth: 'none' }, get: { auth: 'none' }, create: { permission: { requires: 'blog:post.write' }, event: { key: 'blog:post.created', payload: ['id', 'authorId', 'title'] }, }, update: { permission: { requires: 'blog:post.write', ownerField: 'authorId' }, }, delete: { permission: { requires: 'blog:post.delete', ownerField: 'authorId', or: 'blog:post.moderate', }, }, },});That single definition gives you:
- Five CRUD routes with auth and permissions
- Events on create
- Cursor-based pagination
- Slug and author indexes
- Owner-based access control
- OpenAPI docs with full request/response schemas
- Operations — add domain actions like publish, lookup, search
- Route Policy — full auth, permissions, rate limits, and event config
- Storage and Adapter Wiring — swap backends without changing entity code