Skip to content

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
note-entity.ts
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.

Operations define the behavior that sits on top of the entity.

note-operations.ts
// @skip-typecheck
import { 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.

// @skip-typecheck
import { 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.

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.

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.

Entity modules still support:

  • extraRoutes for custom routes inside the entity shell
  • overrides for 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
  • extraRoutes or overrides when the route still belongs to the entity shell

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:

FieldDefault
createdBy'createdBy'
updatedBy'updatedBy'
ownerField'ownerId'
tenantFieldtenant.field or 'tenantId'
version'version'

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.

Customize how records are keyed, how IDs are generated, and how fields update:

// @skip-typecheck
import { 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:

ConventionDefault
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.

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.