← All docs
Reference

Lifecycle hooks (transforms)

Custom JS that runs at well-defined points in a content node's lifecycle. Use these for: validation, computed fields, slug generation, content rewrites, audit logging, fan-out to external services.

Lifecycle hooks (transforms)

Custom JS that runs at well-defined points in a content node's lifecycle. Use these for: validation, computed fields, slug generation, content rewrites, audit logging, fan-out to external services.

The three hooks

Hook When Use case
onSave Before a content write commits Validation, slug generation, computed fields, content normalization
beforeRender Before a content node is rendered to HTML/PNG/etc. Resolve relationships, expand references, inject computed data
onPublish After a Release is deployed Fan-out to external services (Buffer, Mailchimp), log events, send notifications

Each hook runs in a sandboxed VM with a constrained API surface. Hooks are versioned (per-content-type and per-template), can be enabled/disabled per-environment, and execute under a 5-second timeout (default; configurable).


Hook code shape

// onSave example: generate a slug from the title
async function onSave({ type, id, fields, parentId }, ctx) {
  if (!fields.slug && fields.title) {
    fields.slug = fields.title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
  }
  return { fields };
}

The hook receives:

Returning { fields } merges those fields into the write. Returning nothing leaves the input untouched.

onSave shape

{
  type: string,            // content type name, e.g. "article"
  id: string,              // full id, e.g. "article:my-post"
  fields: Record<string, unknown>,
  parentId: string | null, // for tree-structured content
}

Return: { fields } to apply changes, or nothing.

Errors thrown abort the write with a 400 response carrying the error message.

beforeRender shape

{
  type: string,
  id: string,
  fields: Record<string, unknown>,
  // Pre-resolved 1-hop relationships:
  // fields[relName] = [...related content nodes]
  template: string,         // template name being used
}

Return: { fields } to inject additional context for the render.

onPublish shape

{
  releaseId: string,
  deploymentId: string,
  envId: string,
  siteId: string,
  artifacts: Array<{ path, hash, size, sourceContentId }>,
  validFrom: string,
}

Return value ignored (this hook is for side effects).


The sandbox

Hooks run in isolated-vm — a fresh V8 isolate per execution with no shared heap with the host. The implementation is in packages/core/src/crud/transform-runner.ts.

Available globals (curated)

NOT available — verified by isolation tests

Resource limits


Security posture

Hostile JS in a transform CANNOT reach the host. The isolate has no shared heap, no Node intrinsics, and no path back to process / require / fs. Verified by 23 unit tests including the classic prototype-chain escape probe.

What this still relies on:

What this doesn't protect against:

Bottom line: the sandbox does its one job. Threats it doesn't address need different defenses (RBAC on transform creation, agent-scoped tokens, network egress filtering).


Authoring + management

Hooks are first-class graph entities (Transform nodes). Manage via:

Per-type binding

Each transform binds to a content type and a hook lifecycle stage:

{
  id: 'transform:slug-generator',
  name: 'slug-generator',
  description: 'Auto-generate slugs from titles',
  boundType: 'article',           // or '*' for all types
  stage: 'onSave',                // | 'beforeRender' | 'onPublish'
  enabled: true,
  code: '...',                     // the JS source
  templateOverride: null,          // for beforeRender — bind to specific template
  timeoutMs: 5000,
  envFilter: ['dev', 'staging'],   // empty = all envs
}

Versioning

Like any other Versionable, transforms are bundled into Releases. A transform change rolls forward via the same Release/Deployment flow as content. Replay shows you exactly what hook code ran when.


Common patterns

Slug + canonical URL generation (onSave)

async function onSave({ type, fields }, ctx) {
  if (!fields.slug && fields.title) {
    fields.slug = fields.title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '')
      .slice(0, 80);
  }
  if (!fields.canonicalUrl && fields.slug) {
    const site = ctx.config.get('publicUrl');
    fields.canonicalUrl = `${site}/${type}/${fields.slug}/`;
  }
  return { fields };
}

Resolve author bio for render (beforeRender)

async function beforeRender({ type, fields }, ctx) {
  if (fields.author && fields.author.length > 0) {
    const author = fields.author[0];
    // Fetch additional data not in the relationship resolver
    const r = await ctx.graph.query(
      `MATCH (a:Author {id: $id}) RETURN a.bio AS bio, a.avatar AS avatar`,
      { id: author.id },
    );
    fields.authorBio = r.data[0]?.bio ?? '';
    fields.authorAvatar = r.data[0]?.avatar ?? null;
  }
  return { fields };
}

Fan-out to Buffer + Slack on publish (onPublish)

async function onPublish({ releaseId, artifacts, envId }, ctx) {
  if (envId !== 'prod') return;   // only on production deploys
  const newPosts = artifacts.filter(a => a.path.startsWith('/blog/'));
  if (newPosts.length === 0) return;

  // Notify Slack
  await ctx.fetch('https://hooks.slack.com/...', {
    method: 'POST',
    body: JSON.stringify({
      text: `📝 ${newPosts.length} new post${newPosts.length === 1 ? '' : 's'} live: ` +
             newPosts.map(a => a.path).join(', '),
    }),
  });

  // Schedule a Buffer post
  for (const post of newPosts) {
    await ctx.fetch('https://api.bufferapp.com/1/updates/create.json', {
      method: 'POST',
      body: JSON.stringify({
        access_token: ctx.config.get('bufferToken'),
        text: `New post: https://example.com${post.path}`,
        profile_ids: ['...'],
      }),
    });
  }
}

Validate content + abort on bad data (onSave)

async function onSave({ type, fields }) {
  if (type === 'product' && fields.price != null && fields.price < 0) {
    throw new Error('product.price cannot be negative');
  }
  if (fields.publishedAt && new Date(fields.publishedAt) < new Date()) {
    throw new Error('publishedAt must be in the future when set');
  }
}

Errors + observability

Errors

Logs

ctx.log.info / .warn writes go to the same structured log stream as the rest of the server. Hooks emit component: 'transform' so you can filter:

# Tail transform errors
journalctl -u graphiquity-cms -o json | jq 'select(.component == "transform")'

Observability via Release facts

Every onSave and beforeRender execution stamps the Release with a transformExecutions count. onPublish failures attach as Finding entries on the Deployment.


See also