Skip to content

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.

Every entity needs at least an id and one data field:

src/entities/task.ts
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.

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 }),
}
OptionWhat it doesExample
primaryMarks this field as the primary keyfield.string({ primary: true })
defaultAuto-generates on create: 'uuid', 'cuid', 'now', or a literaldefault: 'uuid'
onUpdateAuto-sets on updateonUpdate: 'now'
optionalField is not required on createoptional: true
immutableCannot be changed after creationimmutable: true
formatString format hint for validationformat: 'email'

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.

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.

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.

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 }],

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' } },
},
});

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.

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
},
});

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.

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,
},
});

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.

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
});

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' },
},
});

Here is a blog post entity with everything wired up:

src/entities/post.ts
// @skip-typecheck
import { 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