Entities
Entities are the core of the config-driven model. An entity declaration gives Slingshot enough information to derive:
- storage adapters
- generated CRUD routes
- named operation routes
- validation schemas
- OpenAPI metadata
Define an entity
Section titled “Define an entity”import { defineEntity, field, index } from '@lastshotlabs/slingshot';
export const Note = defineEntity('Note', { namespace: 'community', fields: { id: field.string({ primary: true, default: 'uuid' }), title: field.string(), body: field.string(), authorId: field.string(), status: field.enum(['draft', 'published'] as const, { default: 'draft' }), createdAt: field.date({ default: 'now' }), }, indexes: [index(['authorId', 'createdAt'], { direction: 'desc' })],});An entity defines persistence structure first. Routes and adapters are derived from that structure later.
Attach operations
Section titled “Attach operations”Operations define the behavior that sits on top of the entity.
// @skip-typecheckimport { defineOperations, op } from '@lastshotlabs/slingshot';import { Note } from './note-entity';
export const NoteOps = defineOperations(Note, { byAuthor: op.lookup({ fields: { authorId: 'param:authorId' }, returns: 'many', }), publish: op.transition({ field: 'status', from: 'draft', to: 'published', match: { id: 'param:id' }, }),});See Operations Reference for the full operation catalog.
Mount an entity in a package
Section titled “Mount an entity in a package”// @skip-typecheckimport { definePackage } from '@lastshotlabs/slingshot';import { entity } from '@lastshotlabs/slingshot';import { Note } from './note-entity';import { NoteOps } from './note-operations';
export const NoteEntity = entity({ config: Note, operations: NoteOps,});
export const notesPackage = definePackage({ name: 'notes', mountPath: '/community', entities: [NoteEntity],});By default:
- the entity path is inferred from the entity name
- the framework resolves the adapter using standard wiring
- CRUD and named-operation routes mount through the managed entity shell
Export the entity module you pass to definePackage(...). That module is the canonical typed ref you use later from package domain routes:
const notes = ctx.entities.get(NoteEntity);const note = await notes.getById(noteId);That is the default path for same-package entity access. Do not fall back to string names and adapter casts unless you are deliberately using an escape hatch.
What entity routes map to
Section titled “What entity routes map to”The entity shell owns the standard route surface:
- CRUD routes like
create,list,get,update,delete - named operation routes defined by
defineOperations(...) - route auth, permission, policy, event, middleware, and rate-limit wiring
Use the entity shell when the route maps to one entity and its adapter behavior.
Path overrides and nesting
Section titled “Path overrides and nesting”entity({ config: Note, operations: NoteOps, path: '/knowledge-base', parentPath: '/orgs/:orgId',});Use path when the inferred path is not what you want. Use parentPath when entity routes must mount beneath a parent resource prefix.
Extra routes and overrides
Section titled “Extra routes and overrides”Entity modules still support:
extraRoutesfor custom routes inside the entity shelloverridesfor replacing generated route executors
Use them when the route still semantically belongs to the entity. If the route is package-level orchestration or spans multiple entities, prefer a package domain(...) route instead.
These are supported customization tools, not the first thing to reach for. The canonical order is:
- entity CRUD and named operations first
- package
domain(...)routes for cross-entity/reporting/orchestration extraRoutesoroverrideswhen the route still belongs to the entity shell
System field customization
Section titled “System field customization”By default, entities assume standard field names for audit and ownership concerns. Override them with systemFields:
const Order = defineEntity('Order', { fields: { orderId: field.string({ primary: true, default: 'uuid' }), workspaceId: field.string(), author: field.string(), lastEditor: field.string(), }, tenant: { field: 'workspaceId' }, systemFields: { createdBy: 'author', updatedBy: 'lastEditor', ownerField: 'author', tenantField: 'workspaceId', version: 'revision', },});Defaults when omitted:
| Field | Default |
|---|---|
createdBy | 'createdBy' |
updatedBy | 'updatedBy' |
ownerField | 'ownerId' |
tenantField | tenant.field or 'tenantId' |
version | 'version' |
Storage field mapping
Section titled “Storage field mapping”Backend adapters use configurable field names for Mongo primary keys and SQL TTL columns:
const Session = defineEntity('Session', { fields: { sid: field.string({ primary: true, default: 'uuid' }), }, ttl: { defaultSeconds: 3600 }, storageFields: { mongoPkField: '_sessionId', // default: '_id' ttlField: 'expiresAt', // default: '_expires_at' },});Adapters read the resolved mapping from config._storageFields instead of hardcoding conventions.
Storage conventions
Section titled “Storage conventions”Customize how records are keyed, how IDs are generated, and how fields update:
// @skip-typecheckimport { ulid } from 'ulid';
const Task = defineEntity('Task', { fields: { id: field.string({ primary: true, default: 'ulid' }), name: field.string(), updatedAt: field.date({ default: 'now', onUpdate: 'now' }), }, conventions: { // Custom Redis key format redisKey: ({ appName, storageName, pk }) => `${appName}/${storageName}/${pk}`, // Extend auto-default beyond uuid/cuid/now autoDefault: kind => (kind === 'ulid' ? ulid() : undefined), // Extend onUpdate beyond 'now' onUpdate: kind => (kind === 'increment' ? 1 : undefined), },});Built-in defaults when conventions are omitted:
| Convention | Default |
|---|---|
redisKey | `${storageName}:${appName}:${pk}` |
autoDefault | 'uuid' → crypto.randomUUID(), 'cuid' → lightweight cuid, 'now' → new Date() |
onUpdate | 'now' → new Date() |
Custom resolvers return undefined for unrecognized sentinels to signal a fall-through to the built-in handler.
Channel declarations
Section titled “Channel declarations”Entity modules can also own realtime declarations through channels.
That keeps HTTP, adapter, and realtime behavior attached to the same entity definition rather than spreading it across multiple plugin files.