Services
Read and write Content Engine records from your own Hono routes, cron jobs and queue tasks with the typed public, plain and editorial services, and reuse the generated Zod schemas.
The generated routes cover the usual reads and writes. When you need something they don't do, such as "the three newest articles in a category" or "publish a digest every Monday", call a service from your own code. It is a typed object you get from the model:
const article = await articleContent.service(c).findById(1);
article?.title; // string, and a typo is a compile errorarticleContent is the model from createContentModel, and c is the Hono context of your route, cron job or queue task.
Pick a service
| You want to | Use | It checks permissions | It emits events and writes history |
|---|---|---|---|
| Show content to visitors: published records, public fields | articleContent.publicService(c) | Only public data | Read only |
| Read or write anything from a job or a staff route | articleContent.service(c) | No | No |
| Save a change the way the AdminCP does | articleContent.editorialService(c, opts) | No | Revisions, and events through the effects helpers |
Read published records
publicService returns exactly what the public API would, so a custom public route can never leak a draft:
handler: async c => {
const { publicService } = articleContent;
if (!publicService) throw new Error("Articles have no public API.");
const { edges } = await publicService(c).findMany({
filters: { category: c.req.valid("param").categoryId },
orderBy: { column: "publishedAt", order: "desc" },
query: { first: "3" },
});
return c.json(edges.map(({ slug, title }) => ({ slug, title })), 200);
},filters takes only publicApi.filterableFields and orderBy only orderable fields. query takes the same strings as a list URL, which is why first is "3". For one record, use findBySlug(slug) or findById(id); both return null for anything unpublished.
Write from a job
The plain service sees every record and checks nothing, which suits a cron job:
export const weeklyDigestCron = buildCron({
name: "weekly-digest",
schedule: "0 8 * * 1",
handler: async c => {
const service = articleContent.service(c);
const article = await service.create({
title: "Weekly digest",
code: `digest-${Date.now()}`,
category: 1,
gallery: [21],
});
await service.publish(article.id);
},
});create validates against the same rules as the AdminCP form and throws a ZodError before writing anything. Pass { tx } as the last argument to run several writes in one transaction.
The plain service skips events, revisions and the search update. Use the editorial service when a listener or the site search should notice.
Save like the AdminCP
editorialService (with editorial) writes a revision and refuses to overwrite a newer save. Run the effects after it, so events, search and URLs follow:
import {
contentEditorialEffects,
resolveContentActor,
withHttpErrors,
} from "@vitnode/core/content/server";
const { editorialService } = articleContent;
if (!editorialService) throw new Error("Articles have no editorial.");
const outcome = await withHttpErrors(
"update",
async () =>
await editorialService(c, { pluginId: manifest.id }).update(
id,
{ featured: true },
{ actor: resolveContentActor(c), expectedVersion },
),
{ contentTypeId: articleContent.definition.id, itemId: id, structured: true },
);
if (outcome) {
await contentEditorialEffects(c, articleContent.definition, outcome, {
model: articleContent,
pluginId: manifest.id,
});
}expectedVersionis the version the page loaded. A newer save turns the call into a409 CONTENT_VERSION_CONFLICT, whichwithHttpErrorsanswers for you.resolveContentActor(c)names the signed-in user on the revision. A job passesCONTENT_SYSTEM_ACTOR.- Protect a staff route with
adminStaffPermission: { module: "article", permission: "can_edit" }.
Hiding and duplicating work the same way, with hide, unhide and duplicate and the contentVisibilityEffects and contentDuplicateEffects helpers. They exist only when visibility or duplication is on.
IDs from a URL
Parse an id with the content type's ID strategy before you call a service, so a malformed id is a 400, not a database error:
import { parseContentIdOf } from "@vitnode/core/content";
const id = parseContentIdOf(eventContentType, c.req.param("id"));
if (id === null) throw new HTTPException(400);Reference
Methods
publicService reads only: findMany, findById, findBySlug. editorialService writes only: create, update, delete, publish, unpublish, restore, relations, repeatable, plus hide, unhide and duplicate when their option is on. service has everything:
| Method | Returns |
|---|---|
findMany({ filters, orderBy, query, where }) | { edges, pageInfo }, drafts included. where takes a Drizzle condition |
findById(id), findDetail(id) | The row, or null. findDetail adds relations, repeatables and files |
create(values) | The new row |
update(id, values) | { row, changedFields }, or null |
delete(id) | The deleted row, or null |
publish(id), unpublish(id) | { changed, publishedAt, row }, or null. With publication |
hide(id), unhide(id) | { changed, hiddenAt, hiddenBy, row }, or null. With visibility |
duplicate(sourceId) | { row, sourceId, translations, skippedLocales }, or null. With duplication |
relations.<field>, repeatable.<field> | Add, remove, reorder and list to-many values |
A localized content type also has localizedService for translations.
Schemas
articleContent.schemas holds the Zod schemas the generated routes use. Reuse them so a custom route accepts and returns the same shapes: create, update, updateEnvelope, select, publicSelect, filters, publicFilters, order, params, publicParams and form.