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. Configured uniqueness is validated against the selected store at startup; consult the generated entity backend support matrix before relying on a backend switch:

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