Skip to content

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.

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.

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

src/packages/blog.ts
// @skip-typecheck
import { 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 lookup
GET /posts/by-slug/:slug ← bySlug lookup
POST /posts/:id/publish ← publish transition

Test them:

Terminal window
# Get all posts by an author
curl http://localhost:3000/posts/by-author/user_abc123
# Look up a post by slug
curl http://localhost:3000/posts/by-slug/my-first-post
# Publish a draft
curl -X POST http://localhost:3000/posts/abc123/publish \
-H "Authorization: Bearer <token>"

Find records by field values. The most common operation.

// Find one record by a unique field
bySlug: op.lookup({
fields: { slug: 'param:slug' },
returns: 'one',
}),
// Find many records by a shared field
byAuthor: op.lookup({
fields: { authorId: 'param:authorId' },
returns: 'many',
}),
// Multi-field lookup
byAuthorAndStatus: op.lookup({
fields: { authorId: 'param:authorId', status: 'param:status' },
returns: 'many',
}),

Generated routes: GET /posts/by-slug/:slug, GET /posts/by-author/:authorId

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 }

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 states
restore: op.transition({
field: 'status',
from: ['archived', 'draft'],
to: 'published',
match: { id: 'param:id' },
}),
// Set additional fields during transition
publishWithTimestamp: 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.

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" }

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.

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

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

Create-or-update based on a unique match:

upsertByEmail: op.upsert({
match: ['email'],
set: ['name', 'avatarUrl'],
onCreate: { role: 'user' },
}),

Atomic counter operations:

incrementViews: op.increment({
field: 'viewCount',
match: { id: 'param:id' },
}),
decrementStock: op.increment({
field: 'stock',
by: -1,
match: { id: 'param:id' },
}),

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

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

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

Operation configs use binding strings to specify where values come from at runtime:

BindingSourceExample
param:fieldNameURL path parameter'param:id'
ctx:actor.idCurrent authenticated user'ctx:actor.id'
input:fieldNameRequest body'input:tag'
'now'Current timestampset: { publishedAt: 'now' }
literalA static value{ role: 'user' }