Operations
CRUD routes are a starting point. Operations are how your entity becomes a real domain model.
You write operations in TypeScript using defineOperations() and the typed op.*()
builders. They are real functions — not config strings, not JSON. You get autocomplete on
every option, type inference into the generated routes, and the same compile-time
guarantees as the rest of your code.
Your blog needs more than CRUD
Section titled “Your blog needs more than CRUD”You have a Post entity with five generated routes. Now you need:
- “Get all posts by this author”
- “Look up a post by its slug”
- “Publish this draft”
- “Search posts by keyword”
Each of these is an operation — a named, typed behavior that generates its own route and adapter method.
Define operations in code
Section titled “Define operations in code”// @skip-typecheckimport { defineOperations, op } from '@lastshotlabs/slingshot';import { Post } from './post';
export const PostOps = defineOperations(Post, { byAuthor: op.lookup({ fields: { authorId: 'param:authorId' }, returns: 'many', }), bySlug: op.lookup({ fields: { slug: 'param:slug' }, returns: 'one', }), publish: op.transition({ field: 'status', from: 'draft', to: 'published', match: { id: 'param:id' }, }),});Wire them into your entity module:
// @skip-typecheckimport { definePackage, entity } from '@lastshotlabs/slingshot';import { Post } from '../entities/post';import { PostOps } from '../entities/post-operations';
export const blogPackage = definePackage({ name: 'blog', entities: [entity({ config: Post, operations: PostOps })],});This generates three new routes alongside the existing CRUD:
GET /posts/by-author/:authorId ← byAuthor lookupGET /posts/by-slug/:slug ← bySlug lookupPOST /posts/:id/publish ← publish transitionTest them:
# Get all posts by an authorcurl http://localhost:3000/posts/by-author/user_abc123
# Look up a post by slugcurl http://localhost:3000/posts/by-slug/my-first-post
# Publish a draftcurl -X POST http://localhost:3000/posts/abc123/publish \ -H "Authorization: Bearer <token>"Operation reference
Section titled “Operation reference”Lookups
Section titled “Lookups”Find records by field values. The most common operation.
// Find one record by a unique fieldbySlug: op.lookup({ fields: { slug: 'param:slug' }, returns: 'one',}),
// Find many records by a shared fieldbyAuthor: op.lookup({ fields: { authorId: 'param:authorId' }, returns: 'many',}),
// Multi-field lookupbyAuthorAndStatus: op.lookup({ fields: { authorId: 'param:authorId', status: 'param:status' }, returns: 'many',}),Generated routes: GET /posts/by-slug/:slug, GET /posts/by-author/:authorId
Existence checks
Section titled “Existence checks”Check if a record exists without returning it — useful for validation:
slugTaken: op.exists({ fields: { slug: 'param:slug' },}),Generated route: GET /posts/exists-by-slug/:slug → { "exists": true }
Transitions
Section titled “Transitions”Move a record through a state machine. The framework validates that the current state
matches from before allowing the change:
publish: op.transition({ field: 'status', from: 'draft', to: 'published', match: { id: 'param:id' },}),
archive: op.transition({ field: 'status', from: 'published', to: 'archived', match: { id: 'param:id' },}),
// Transition from multiple statesrestore: op.transition({ field: 'status', from: ['archived', 'draft'], to: 'published', match: { id: 'param:id' },}),
// Set additional fields during transitionpublishWithTimestamp: op.transition({ field: 'status', from: 'draft', to: 'published', match: { id: 'param:id' }, set: { publishedAt: 'now' },}),Generated route: POST /posts/:id/publish. Returns 400 if the record is not in the
expected from state.
Field updates
Section titled “Field updates”Update specific fields by name. Use this when you want a focused endpoint instead of
the generic PATCH:
retitle: op.fieldUpdate({ match: { id: 'param:id' }, set: ['title'],}),
updateProfile: op.fieldUpdate({ match: { id: 'param:id' }, set: ['displayName', 'bio', 'avatarUrl'], partial: true, // not all fields are required nullable: true, // fields can be set to null}),Generated route: PATCH /posts/:id/retitle with body { "title": "New Title" }
Search
Section titled “Search”Full-text search across string fields:
searchPublished: op.search({ fields: ['title', 'body'], filter: { status: 'published' },}),
searchAll: op.search({ fields: ['title', 'body', 'tags'], paginate: true,}),Generated route: GET /posts/search-published?q=slingshot
For production search (Meilisearch, Elasticsearch, Typesense), add useSearchProvider: true
and configure the search adapter. See Adding Search.
Aggregates
Section titled “Aggregates”Compute summaries across records:
postStats: op.aggregate({ groupBy: 'authorId', compute: { total: { fn: 'count' }, published: { fn: 'count', filter: { status: 'published' } }, },}),
revenueByMonth: op.aggregate({ groupBy: { field: 'createdAt', truncate: 'month' }, compute: { total: { fn: 'sum', field: 'amount' }, average: { fn: 'avg', field: 'amount' }, count: { fn: 'count' }, },}),Batch operations
Section titled “Batch operations”Update or delete multiple records at once:
archiveOld: op.batch({ action: 'update', filter: { status: 'draft', createdAt: { $lt: 'param:before' } }, set: { status: 'archived' }, returns: 'count',}),
purgeExpired: op.batch({ action: 'delete', filter: { expiresAt: { $lt: 'now' } }, returns: 'count',}),Upserts
Section titled “Upserts”Create-or-update based on a unique match:
upsertByEmail: op.upsert({ match: ['email'], set: ['name', 'avatarUrl'], onCreate: { role: 'user' },}),Increment
Section titled “Increment”Atomic counter operations:
incrementViews: op.increment({ field: 'viewCount', match: { id: 'param:id' },}),
decrementStock: op.increment({ field: 'stock', by: -1, match: { id: 'param:id' },}),Array operations
Section titled “Array operations”Push, pull, and set items in array fields:
addTag: op.arrayPush({ field: 'tags', value: 'param:tag', dedupe: true,}),
removeTag: op.arrayPull({ field: 'tags', value: 'param:tag',}),Collections
Section titled “Collections”Manage nested sub-items within a record (e.g., line items in an order):
lineItems: op.collection({ parentKey: 'orderId', itemFields: { productId: field.string(), quantity: field.integer(), price: field.number(), }, operations: ['list', 'add', 'remove', 'update'], identifyBy: 'productId', maxItems: 50,}),Custom operations
Section titled “Custom operations”When none of the built-in kinds fit, write backend-specific logic:
nearbyStores: op.custom({ http: { method: 'get', path: '/nearby' }, postgres: (pool) => async ({ lat, lng, radius }) => { const result = await pool.query( `SELECT * FROM stores WHERE ST_DWithin(location, ST_MakePoint($1, $2), $3)`, [lng, lat, radius], ); return result.rows; }, memory: (store) => async ({ lat, lng, radius }) => { // In-memory fallback for tests return [...store.values()]; },}),Real-world example: a task management entity
Section titled “Real-world example: a task management entity”// @skip-typecheckimport { defineEntity, defineOperations, field, index, op } from '@lastshotlabs/slingshot';
export const Task = defineEntity('Task', { namespace: 'app', fields: { id: field.string({ primary: true, default: 'uuid' }), title: field.string(), description: field.string({ optional: true }), assigneeId: field.string({ optional: true }), projectId: field.string(), status: field.enum(['todo', 'in_progress', 'done', 'cancelled'] as const, { default: 'todo' }), priority: field.enum(['low', 'medium', 'high', 'urgent'] as const, { default: 'medium' }), tags: field.stringArray({ optional: true }), createdAt: field.date({ default: 'now' }), updatedAt: field.date({ default: 'now', onUpdate: 'now' }), }, indexes: [index(['projectId', 'status']), index(['assigneeId', 'status'])],});
export const TaskOps = defineOperations(Task, { // Lookups byProject: op.lookup({ fields: { projectId: 'param:projectId' }, returns: 'many' }), byAssignee: op.lookup({ fields: { assigneeId: 'param:assigneeId' }, returns: 'many' }),
// Transitions start: op.transition({ field: 'status', from: 'todo', to: 'in_progress', match: { id: 'param:id' }, }), complete: op.transition({ field: 'status', from: 'in_progress', to: 'done', match: { id: 'param:id' }, }), cancel: op.transition({ field: 'status', from: ['todo', 'in_progress'], to: 'cancelled', match: { id: 'param:id' }, }), reopen: op.transition({ field: 'status', from: ['done', 'cancelled'], to: 'todo', match: { id: 'param:id' }, }),
// Field updates assign: op.fieldUpdate({ match: { id: 'param:id' }, set: ['assigneeId'], }), reprioritize: op.fieldUpdate({ match: { id: 'param:id' }, set: ['priority'], }),
// Search search: op.search({ fields: ['title', 'description'] }),
// Stats projectStats: op.aggregate({ groupBy: 'projectId', compute: { total: { fn: 'count' }, done: { fn: 'count', filter: { status: 'done' } }, inProgress: { fn: 'count', filter: { status: 'in_progress' } }, }, }),});That generates 16 routes from two declarations — five CRUD routes plus eleven operation routes.
Bindings: where values come from
Section titled “Bindings: where values come from”Operation configs use binding strings to specify where values come from at runtime:
| Binding | Source | Example |
|---|---|---|
param:fieldName | URL path parameter | 'param:id' |
ctx:actor.id | Current authenticated user | 'ctx:actor.id' |
input:fieldName | Request body | 'input:tag' |
'now' | Current timestamp | set: { publishedAt: 'now' } |
| literal | A static value | { role: 'user' } |
- Route Policy — auth, permissions, rate limits per route
- Storage and Adapter Wiring — swap backends
- Generated Routes, Overrides, and Extra Routes — customize the generated routes