Documentation

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 error

articleContent is the model from createContentModel, and c is the Hono context of your route, cron job or queue task.

Pick a service

You want toUseIt checks permissionsIt emits events and writes history
Show content to visitors: published records, public fieldsarticleContent.publicService(c)Only public dataRead only
Read or write anything from a job or a staff routearticleContent.service(c)NoNo
Save a change the way the AdminCP doesarticleContent.editorialService(c, opts)NoRevisions, 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:

plugins/example/src/api/modules/articles/by-category.route.ts
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:

plugins/example/src/api/cron/weekly-digest.cron.ts
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:

plugins/example/src/api/modules/admin/routes/feature.route.ts
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,
  });
}
  • expectedVersion is the version the page loaded. A newer save turns the call into a 409 CONTENT_VERSION_CONFLICT, which withHttpErrors answers for you.
  • resolveContentActor(c) names the signed-in user on the revision. A job passes CONTENT_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:

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