Hooks

Lifecycle callbacks for business logic on create, update, and delete.

Hooks let you run code before or after CRUD operations. Define them on the entity:


          import { entity, text, slug } from "@cms/config";

const posts = entity("posts", {
  hooks: {
    beforeCreate: async (data, context) => {
      data.slug = slugify(data.title);
      return data;
    },
    afterCreate: async (record, context) => {
      await notify(record);
    },
    beforeUpdate: async (data, existing, context) => {
      if (data.status === "published" && !existing.publishedAt) {
        data.publishedAt = new Date().toISOString();
      }
      return data;
    },
    afterUpdate: async (record, context) => {},
    beforeDelete: async (id, context) => {},
    afterDelete: async (id, context) => {},
  },
  fields: [
    text("title", { required: true }),
    slug("slug", { required: true }),
  ],
});
        

Hook signatures

Before hooks

Before hooks receive the data being written and can modify it or throw to abort:


          // beforeCreate — receives the incoming data
beforeCreate: (data: Record<string, unknown>, context: HookContext) =>
  Promise<Record<string, unknown>> | Record<string, unknown>;

// beforeUpdate — receives the incoming data AND the existing record
beforeUpdate: (
  data: Record<string, unknown>,
  existing: Record<string, unknown>,
  context: HookContext
) => Promise<Record<string, unknown>> | Record<string, unknown>;

// beforeDelete — receives the record ID, throw to abort
beforeDelete: (id: string, context: HookContext) =>
  Promise<void> | void;
        

Return the (potentially modified) data from beforeCreate and beforeUpdate. The returned value is what gets written.

After hooks

After hooks run after the operation completes. They’re fire-and-forget — errors are logged but don’t affect the response:


          afterCreate: (record: Record<string, unknown>, context: HookContext) =>
  Promise<void> | void;

afterUpdate: (record: Record<string, unknown>, context: HookContext) =>
  Promise<void> | void;

afterDelete: (id: string, context: HookContext) =>
  Promise<void> | void;
        

Hook context

Every hook receives a context object:


          type HookContext = {
  user: { id: string; email: string; role: string } | null;
  role: string;
  db: PostgresJsDatabase;  // Drizzle instance for custom queries
  collection: string;       // entity name
};
        

The db instance gives you full access to Drizzle ORM for running custom queries inside hooks:


          beforeCreate: async (data, { db }) => {
  const [existing] = await db
    .select()
    .from(someTable)
    .where(eq(someTable.slug, data.slug));

  if (existing) {
    throw new Error("Slug already exists");
  }

  return data;
},
        

Built-in hooks

The _users entity has automatic password hashing via built-in hooks. The API accepts plaintext passwords — they’re hashed with bcrypt before storage. Don’t send pre-hashed values.

Throwing to abort

Any before* hook can throw to abort the operation. The error message is returned to the client:


          beforeDelete: async (id, context) => {
  const hasChildren = await checkChildren(id, context.db);
  if (hasChildren) {
    throw new Error("Cannot delete: entity has children");
  }
},
        

Previous

Permissions

Next

Internationalization