Skip to content

Operations Reference

Operations are plain JSON config objects with a kind field. The framework reads those objects and generates typed adapter methods (wired to five backends — memory, SQLite, Postgres, MongoDB, Redis) and HTTP routes. Standard CRUD comes from the entity definition itself; operations extend it with domain-specific behavior.

Each operation is a JSON object like:

Generated operation routes are framework-owned route shells. If the default behavior is close but not sufficient, prefer an entity-route executor override over replacing the route manually. Use:

  • overrides.operations.<name> to replace only a named operation executor
  • defineEntityRoute(...) for additional routes that live under the entity base path
  • a manual router in setupRoutes() only when the endpoint genuinely does not fit one entity shell

Extra routes and generated routes share one planner, so effective duplicates such as /:id and /:slug are rejected at startup even when the literal strings differ.

{ "kind": "lookup", "fields": { "roomId": "param:roomId" }, "returns": "many" }
{ "kind": "transition", "field": "status", "from": "sent", "to": "delivered", "match": { "id": "param:id" } }
{ "kind": "aggregate", "groupBy": "roomId", "compute": { "total": "count" } }

Today you author these using TypeScript builder functions that produce the same objects:

import { defineOperations, op } from '@lastshotlabs/slingshot';
declare const Message: Parameters<typeof defineOperations>[0];
const MessageOps = defineOperations(Message, {
byRoom: op.lookup({ fields: { roomId: 'param:roomId' }, returns: 'many' }),
markRead: op.transition({
field: 'status',
from: 'delivered',
to: 'read',
match: { id: 'param:id' },
}),
});

op.lookup({...}) stamps { kind: 'lookup', ...config } and freezes it. JSON and TypeScript forms are structurally identical.


Several operations accept filter expressions. The filter language works the same in JSON and TypeScript:

{ "status": "active" }
{ "createdAt": { "$gt": "param:since" } }
{ "role": { "$in": ["admin", "moderator"] } }
{ "status": { "$nin": ["deleted", "banned"] } }
{ "name": { "$contains": "param:query" } }
{ "$or": [{ "status": "active" }, { "status": "pending" }] }
{ "$and": [{ "verified": true }, { "age": { "$gte": 18 } }] }

Supported operators: $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains, $and, $or.

Value references:

  • "param:x" — resolved from call-time parameters at runtime
  • "now" — resolves to the current Date

Find one or many records by field match.

{
"kind": "lookup",
"fields": { "roomId": "param:roomId" },
"returns": "many",
"sort": "createdAt",
"direction": "desc",
"paginate": true,
"filter": { "status": { "$ne": "deleted" } }
}
OptionRequiredDescription
kindYes"lookup"
fieldsYesField-to-param/literal match conditions
returnsYes"one"Entity | null · "many"PaginatedResult<Entity>
sortNoField to sort results by
directionNoSort direction — default "asc"
limitNoMaximum records to return
paginateNoEnable cursor-based pagination (requires returns: "many")
filterNoAdditional filter conditions

Generated route: GET /{entity}/{op-kebab}?roomId=...

byRoom: op.lookup({
fields: { roomId: 'param:roomId' },
returns: 'many',
sort: 'createdAt',
direction: 'desc',
});
byEmail: op.lookup({ fields: { email: 'param:email' }, returns: 'one' });
activeSessions: op.lookup({
fields: { userId: 'param:userId' },
filter: { expiresAt: { $gt: 'now' } },
returns: 'many',
paginate: true,
});

Boolean existence check — more efficient than lookup when you only need yes/no.

{
"kind": "exists",
"fields": { "email": "param:email" },
"check": { "verified": true }
}
OptionRequiredDescription
kindYes"exists"
fieldsYesPrimary match conditions
checkNoAdditional field equality checks combined via AND — useful for asserting state alongside the primary match

Generated route: GET /{entity}/{op-kebab}?email=...{ "exists": true }

emailTaken: op.exists({ fields: { email: 'param:email' } });
tokenValid: op.exists({ fields: { token: 'param:token' }, check: { used: false } });

Atomically move a record from one state to another. Rejects if the current field value doesn’t match from.

{
"kind": "transition",
"field": "status",
"from": "draft",
"to": "published",
"match": { "id": "param:id" },
"set": { "publishedAt": "now" },
"returns": "entity"
}
OptionRequiredDescription
kindYes"transition"
fieldYesEntity field holding the state value
fromYesExpected current value — transition is rejected if it doesn’t match
toYesValue to write on success
matchYesField conditions to identify the record
setNoAdditional fields to update atomically. "now" → current timestamp · "param:x" → call-time param
returnsNo"entity" (default) returns updated record · "boolean" returns success flag

Generated route: POST /{entity}/:id/{op-kebab}

publish: op.transition({
field: 'status',
from: 'draft',
to: 'published',
match: { id: 'param:id' },
set: { publishedAt: 'now' },
});
archive: op.transition({
field: 'status',
from: ['draft', 'published'],
to: 'archived',
match: { id: 'param:id' },
});
activate: op.transition({
field: 'status',
from: 'pending',
to: 'active',
match: { token: 'param:token' },
returns: 'boolean',
});

Partially update specific fields on a matched record — without replacing the whole entity.

{
"kind": "fieldUpdate",
"match": { "id": "param:id" },
"set": ["title", "body"],
"partial": true
}
OptionRequiredDescription
kindYes"fieldUpdate"
matchYesRecord identification conditions
setYesField names the operation may write — caller provides values at call time
partialNoWhen true, all set fields are optional — omitted fields are left unchanged
nullableNoWhen true, null is accepted for any set field

Generated route: PATCH /{entity}/:id/{op-kebab}

editPost: op.fieldUpdate({ match: { id: 'param:id' }, set: ['title', 'body'] });
setLocked: op.fieldUpdate({ match: { id: 'param:id' }, set: ['locked'] });
updateProfile: op.fieldUpdate({
match: { id: 'param:id' },
set: ['displayName', 'bio', 'avatarUrl'],
partial: true,
nullable: true,
});

Append a value to an array field. Deduplicated by default.

The executor owns any transaction or locking required for correctness. Consumers should not need to manage transaction strategy for this operation.

{
"kind": "arrayPush",
"field": "watchers",
"value": "ctx:actor.id",
"dedupe": true
}
OptionRequiredDescription
kindYes"arrayPush"
fieldYesArray field to push into
valueYesBinding expression — "ctx:key" (Hono context) · "param:key" (URL param) · "input:key" (request body) · or a literal
dedupeNoWhen true (default), value is only pushed if not already present

Generated route: POST /{entity}/:id/{op-kebab}

watch: op.arrayPush({ field: 'watchers', value: 'ctx:actor.id' });
addTag: op.arrayPush({ field: 'tags', value: 'input:tag' });
addRole: op.arrayPush({ field: 'roles', value: 'input:role', dedupe: false });

Remove all occurrences of a value from an array field.

The executor owns any transaction or locking required for correctness. Consumers should not need to manage transaction strategy for this operation.

{
"kind": "arrayPull",
"field": "watchers",
"value": "ctx:actor.id"
}
OptionRequiredDescription
kindYes"arrayPull"
fieldYesArray field to remove from
valueYesSame binding syntax as arrayPush

Generated route: DELETE /{entity}/:id/{op-kebab}

unwatch: op.arrayPull({ field: 'watchers', value: 'ctx:actor.id' });
removeTag: op.arrayPull({ field: 'tags', value: 'input:tag' });

Replace the entire contents of an array field. Deduplicated before writing by default.

{
"kind": "arraySet",
"field": "labelIds",
"value": "input:labelIds",
"dedupe": true
}
OptionRequiredDescription
kindYes"arraySet"
fieldYesArray field to replace
valueYesBinding expression resolving to the replacement array — typically "input:key"
dedupeNoWhen true (default), the array is deduplicated before writing

Generated route: PUT /{entity}/:id/{op-kebab}

Use arraySet when the caller sends the full new list. Use arrayPush/arrayPull for incremental mutations.

setLabels: op.arraySet({ field: 'labelIds', value: 'input:labelIds' });
setPermissions: op.arraySet({ field: 'permissions', value: 'input:permissions', dedupe: false });

Compute summary statistics over a set of records — count, sum, avg, min, max — with optional grouping.

{
"kind": "aggregate",
"groupBy": "status",
"compute": {
"total": "count",
"revenue": { "sum": "amount" },
"byPlan": { "countBy": "plan" }
},
"filter": { "createdAt": { "$gte": "param:since" } }
}
OptionRequiredDescription
kindYes"aggregate"
computeYesOutput columns — field name → compute spec
groupByNoField name or { field, truncate? } object. When set, result is an array grouped by this field’s (optionally truncated) distinct values
filterNoFilter applied before aggregating

groupBy accepts a plain field name string or an object for date truncation:

{ "field": "createdAt", "truncate": "month" }

Truncation levels: "year" · "month" · "week" · "day" · "hour". Groups date field values into truncated string keys (e.g., "2026-04" for month).

Compute specs: "count" · { "sum": "fieldName" } · { "countBy": "fieldName" } · { "where": {...}, "count": true }

Generated route: GET /{entity}/{op-kebab}

messagesByRoom: op.aggregate({ groupBy: 'roomId', compute: { total: 'count' } });
revenueStats: op.aggregate({
compute: { total: 'count', revenue: { sum: 'amount' }, byPlan: { countBy: 'plan' } },
filter: { createdAt: { $gte: 'param:since' } },
});
// Group by truncated date — monthly revenue:
monthlyRevenue: op.aggregate({
groupBy: { field: 'createdAt', truncate: 'month' },
compute: { revenue: { sum: 'amount' } },
});

Aggregate records from a source entity and write the result back into a target entity. Used for denormalized summary fields like commentCount on a post.

{
"kind": "computedAggregate",
"source": "comments",
"target": "posts",
"sourceFilter": { "postId": "param:postId" },
"compute": { "commentCount": "count" },
"materializeTo": "commentCount",
"targetMatch": { "id": "param:postId" },
"atomic": true
}
OptionRequiredDescription
kindYes"computedAggregate"
sourceYesSource entity storage name
targetYesTarget entity storage name
sourceFilterYesFilter applied to source records before aggregating
computeYesAggregation spec (same as aggregate)
materializeToYesTarget entity field to write the result into
targetMatchYesConditions to find the target record to update
atomicNoRequest a real transaction for this multi-step flow where the backend supports it

Generated route: POST /{entity}/{op-kebab}

computedAggregate is one of the few operations that exposes an atomic flag because it is inherently a read-aggregate-write sequence. Ordinary single-statement operations should not require consumers to ask for a transaction.

syncCommentCount: op.computedAggregate({
source: 'comments',
target: 'posts',
sourceFilter: { postId: 'param:postId' },
compute: { commentCount: 'count' },
materializeTo: 'commentCount',
targetMatch: { id: 'param:postId' },
atomic: true,
});

Update or delete multiple records matching a filter in one call.

{
"kind": "batch",
"action": "update",
"filter": { "expiresAt": { "$lt": "now" } },
"set": { "status": "expired", "expiredAt": "now" },
"atomic": true,
"returns": "count"
}
OptionRequiredDescription
kindYes"batch"
actionYes"update" mutates matched records · "delete" removes them
filterYesFilter selecting which records to act on
setNoFields to write when action is "update". "now" → current timestamp
atomicNoRequest a stricter transaction boundary for the batch where the backend supports it
returnsNo"count" (default) returns rows affected · "void" returns nothing

Generated route: POST /{entity}/{op-kebab}

Most single-record operations do not expose a transaction knob. batch does because it can be a meaningful semantic boundary and some callers want the stricter all-or-nothing behavior.

purgeExpired: op.batch({
action: 'update',
filter: { expiresAt: { $lt: 'now' } },
set: { status: 'expired' },
});
clearRoom: op.batch({ action: 'delete', filter: { roomId: 'param:roomId' } });

Create-or-update by a natural key. Idempotent — if a matching record exists, it’s updated; if not, it’s created.

{
"kind": "upsert",
"match": ["provider", "providerId"],
"set": ["accessToken", "refreshToken", "expiresAt"],
"onCreate": { "id": "uuid" },
"returns": { "entity": true, "created": true }
}
OptionRequiredDescription
kindYes"upsert"
matchYesField names that form the natural key
setYesFields to write on both create and update
onCreateNoFields set only on create. Sentinels: "uuid", "cuid", "now", or literals
returnsNo"entity" (default) · { "entity": true, "created": true } also returns a created boolean

Generated route: POST /{entity}/{op-kebab}

ensureProfile: op.upsert({
match: ['userId'],
set: ['displayName', 'bio'],
onCreate: { id: 'uuid', createdAt: 'now' },
});
syncOAuth: op.upsert({
match: ['provider', 'providerId'],
set: ['accessToken', 'refreshToken', 'expiresAt'],
onCreate: { id: 'uuid' },
returns: { entity: true, created: true },
});

Full-text or keyword search across entity fields. Delegates to an external search provider when configured, otherwise falls back to DB-native text search.

{
"kind": "search",
"fields": ["title", "body"],
"filter": { "status": "published" },
"paginate": true,
"useSearchProvider": true
}
OptionRequiredDescription
kindYes"search"
fieldsYesEntity fields to search across
filterNoAdditional filter applied alongside the text query
paginateNoEnable cursor pagination on results
useSearchProviderNoDelegate to external provider (Meilisearch, Typesense) when available. Default: true if entity has a search config

Generated route: GET /{entity}/{op-kebab}?q=...

searchPosts: op.search({
fields: ['title', 'body'],
filter: { status: 'published' },
paginate: true,
});
quickSearch: op.search({ fields: ['name'], useSearchProvider: false });

Manage an embedded ordered list of sub-documents within a parent entity.

{
"kind": "collection",
"parentKey": "members",
"itemFields": {
"id": { "type": "string", "primary": true, "default": "uuid" },
"userId": { "type": "string" },
"role": { "type": "enum", "values": ["owner", "admin", "member"], "default": "member" }
},
"operations": ["list", "add", "remove", "update"],
"identifyBy": "id",
"maxItems": 50
}
OptionRequiredDescription
kindYes"collection"
parentKeyYesField on the parent entity holding the embedded array
itemFieldsYesField definitions for each collection item
operationsYesSub-operations to generate: "list", "add", "remove", "update", "set"
identifyByNoField used to identify items for remove and update. Default: "id"
maxItemsNoMaximum number of items

A collection op named members generates adapter methods membersList, membersAdd, membersRemove, membersUpdate, membersSet. Routes follow the same pattern.

members: op.collection({
parentKey: 'members',
itemFields: {
id: field.string({ primary: true, default: 'uuid' }),
userId: field.string(),
role: field.enum(['owner', 'admin', 'member'] as const, { default: 'member' }),
},
operations: ['list', 'add', 'remove', 'update'],
});

Read and delete a record atomically in a single call. Used for one-time-use tokens, OTPs, and magic links.

{
"kind": "consume",
"filter": { "token": "param:token", "type": "email_verification" },
"expiry": { "field": "expiresAt" },
"returns": "entity"
}
OptionRequiredDescription
kindYes"consume"
filterYesConditions to find the record to consume
returnsYes"boolean" returns true/false · "entity" returns the consumed record or null
expiryNoWhen set, records whose field value is in the past are rejected without consuming

Generated route: POST /{entity}/{op-kebab}

verifyEmail: op.consume({
filter: { token: 'param:token', type: 'email_verification' },
expiry: { field: 'expiresAt' },
returns: 'entity',
});
redeemOtp: op.consume({
filter: { code: 'param:code', userId: 'param:userId' },
expiry: { field: 'expiresAt' },
returns: 'boolean',
});

Compose results from multiple entity sources using a merge strategy. Used for feed or inbox queries that pull from more than one entity type.

{
"kind": "derive",
"sources": [
{ "from": "directMessages", "where": { "recipientId": "param:userId" } },
{ "from": "notifications", "where": { "userId": "param:userId" } },
{
"from": "follows",
"where": { "followerId": "param:userId" },
"traverse": { "to": "posts", "on": "followedUserId", "select": "id" }
}
],
"merge": "union"
}
OptionRequiredDescription
kindYes"derive"
sourcesYesOrdered list of entity sources to query
mergeYes"union" (dedup by ID) · "concat" (preserve order+dups) · "intersect" (only shared IDs) · "first" (first non-empty source) · "priority" (first, with numeric source weights)
flattenNoFlatten result arrays one level
postFilterNoFilter applied to the merged result

Each source accepts from (entity name), where (match conditions), select (field to extract), and optionally traverse (one-hop relation: reads on field from from records, looks up to entity, returns select from traversed records).

activityFeed: op.derive({
sources: [
{ from: 'directMessages', where: { recipientId: 'param:userId' } },
{ from: 'notifications', where: { userId: 'param:userId' } },
{
from: 'follows',
where: { followerId: 'param:userId' },
traverse: { to: 'posts', on: 'followedUserId', select: 'id' },
},
],
merge: 'union',
});

Atomically add to (or subtract from) a numeric field on a specific record. All backends perform this atomically where supported — Postgres uses SET field = field + $n, Mongo uses $inc, memory/Redis use read-modify-write.

{
"kind": "increment",
"field": "viewCount",
"by": 1
}
OptionRequiredDescription
kindYes"increment"
fieldYesNumeric field to increment
byNoAmount to add. Use a negative number to decrement. Default: 1

Generated route: POST /{entity}/:id/{op-kebab}

incrementViews: op.increment({ field: 'viewCount' });
addScore: op.increment({ field: 'score', by: 5 });
decreaseBalance: op.increment({ field: 'balance', by: -50 });

Escape hatch for backend-specific logic not covered by built-in operation kinds.

{
"kind": "custom",
"http": { "method": "post" }
}
OptionRequiredDescription
kindYes"custom"
httpNoWhen set, mounts a route at POST /{entity}/{op-kebab} (or the specified method/path). Omit for adapter-only ops.

The custom op is extended in TypeScript with per-backend factories (not expressible in JSON):

rawPostCount: op.custom({
http: { method: 'get' },
postgres: pool => async (userId: string) => {
const { rows } = await pool.query('SELECT count(*) FROM posts WHERE author_id = $1', [userId]);
return parseInt(rows[0].count, 10);
},
memory: store => (userId: string) =>
[...store.values()].filter(r => r.authorId === userId && !r.deletedAt).length,
});
// Route-only marker for a method mixed in from a composite adapter
hybridSearch: op.custom({ http: { method: 'post' } });

Cross-entity access: A custom handler’s factory only receives its own entity’s backend driver (e.g. pool for Postgres, store for memory). To access other entities’ adapters — for example, creating a Transaction record from within an Account operation — capture adapters at startup with an afterAdapters hook:

app.config.ts
import type { BareEntityAdapter } from '@lastshotlabs/slingshot-entity';
let transactionAdapter: BareEntityAdapter | null = null;
export const hooks = {
captureAdapters(ctx: unknown) {
const { adapters } = ctx as { adapters: Readonly<Record<string, BareEntityAdapter>> };
transactionAdapter = adapters.Transaction ?? null;
},
};

The captured adapter is then available inside any custom operation handler.


NeedKind
Find records by field valueslookup
Boolean check without fetchingexists
Move through a state machinetransition
Update specific fieldsfieldUpdate
Add a value to a listarrayPush
Remove a value from a listarrayPull
Replace a whole listarraySet
Count, sum, average, group-by (with optional date truncation)aggregate
Write aggregated result back to entitycomputedAggregate
Multi-record update or deletebatch
Insert or update by natural keyupsert
Text search across fieldssearch
Embedded sub-document listcollection
One-time-use token / OTPconsume
Feed from multiple entity typesderive
Atomically increment / decrement a numberincrement
Backend-specific or non-declarative logiccustom