ZodulaZodula
Core

DocType Events

Learn how to use DocType lifecycle events to add custom logic when documents are created, updated, deleted, or submitted. Hook into document operations with before and after event callbacks.

Overview

DocType events allow you to execute custom logic at specific points in a document's lifecycle. You can hook into events that occur when documents are created, updated, deleted, or go through submission workflows.

Event Syntax

Events are registered using the .on() method chained to your DocType definition:

export default $doctype<"zodula__User">({
    // Field definitions...
}, {
    // Configuration...
})
.on("event_name", async (context) => {
    // Your custom logic
})

Available Events

Zodula supports the following DocType events:

EventTriggeredUse Case
before_insertBefore a new document is inserted into the databaseValidation, data transformation before creation
after_insertAfter a new document is inserted into the databaseLogging, notifications, related record creation
before_saveBefore a document is saved (both insert and update)Validation, data transformation
after_saveAfter a document is saved (both insert and update)Logging, notifications, cache updates
before_changeBefore a document is updatedData transformation, conditional updates
after_changeAfter a document is updatedLogging changes, updating related records
before_deleteBefore a document is deletedValidation, cleanup checks
after_deleteAfter a document is deletedCleanup operations, logging
before_submitBefore a document is submitted for approvalValidation, workflow checks
after_submitAfter a document is submitted for approvalNotifications, workflow processing
before_cancelBefore a submitted document is cancelledValidation, cancellation checks
after_cancelAfter a submitted document is cancelledCleanup, notifications
before_save_after_submitBefore saving changes to a submitted documentValidation, workflow checks
after_save_after_submitAfter saving changes to a submitted documentNotifications, workflow updates

Event Context

Each event handler receives a context object with the following properties:

{
    old: Document,      // Previous state of the document (undefined for insert)
    doc: Document,     // Current state of the document
    input: Document    // Input data from the API request (may be undefined)
}

Event Context Properties

PropertyDescriptionAvailable In
oldThe previous state of the document before changesbefore_change, after_change, before_delete, after_delete
docThe current document state (what will be saved)All events
inputThe raw input data from the API requestAll events (may be undefined)

Basic Examples

Password Hashing on Update

Automatically hash passwords when they're changed:

export default $doctype<"zodula__User">({
    email: {
        type: "Email",
        required: 1,
        unique: 1
    },
    password: {
        type: "Password",
        required: 1
    }
}, {
    label: "User"
})
.on("before_change", async ({ doc, old, input }) => {
    if (input?.password) {
        doc.password = await Bun.password.hash(input.password as string);
    }
})

Validation Before Insert

Validate data before inserting a new document:

export default $doctype<"zodula__Audit Trail">({
    doctype: {
        type: "Reference",
        reference: "zodula__Doctype",
        required: 1
    },
    action: {
        type: "Select",
        options: "Insert\nUpdate\nDelete\nComment",
        required: 1
    }
}, {
    label: "Audit Trail"
})
.on("before_insert", async ({ doc }) => {
    const doctype = await $zodula.doctype("zodula__Doctype").get(doc.doctype);
    if (doctype.comments_enabled && doc.action === "Comment") {
        throw new Error("Comments are not enabled for this doctype");
    }
})

Logging After Save

Log document changes after saving:

export default $doctype<"zodula__User">({
    name: {
        type: "Text",
        required: 1
    },
    email: {
        type: "Email",
        required: 1
    }
}, {
    label: "User"
})
.on("after_save", async ({ doc, old }) => {
    console.log(`User ${doc.name} was ${old ? 'updated' : 'created'}`);
    
    // Create an audit log entry
    await $zodula.doctype("zodula__Audit Trail").insert({
        doctype: "zodula__User",
        doctype_id: doc.id,
        action: old ? "Update" : "Insert",
        new_value: doc
    });
})

Advanced Patterns

Multiple Event Handlers

You can chain multiple event handlers for the same event or different events:

export default $doctype<"zodula__User">({
    name: {
        type: "Text",
        required: 1
    },
    email: {
        type: "Email",
        required: 1,
        unique: 1
    },
    password: {
        type: "Password",
        required: 1
    }
}, {
    label: "User"
})
.on("before_save", async ({ doc }) => {
    // Validate email format
    if (!doc.email.includes("@")) {
        throw new Error("Invalid email address");
    }
})
.on("before_change", async ({ doc, input }) => {
    // Hash password if provided
    if (input?.password) {
        doc.password = await Bun.password.hash(input.password as string);
    }
})
.on("after_save", async ({ doc, old }) => {
    // Send welcome email for new users
    if (!old) {
        console.log(`Sending welcome email to ${doc.email}`);
        // Email sending logic here
    }
})

Conditional Logic Based on Old Values

Use the old parameter to implement conditional logic:

export default $doctype<"zodula__Order">({
    status: {
        type: "Select",
        options: "Draft\nPending\nShipped\nCancelled",
        default: "Draft"
    },
    customer: {
        type: "Reference",
        reference: "zodula__Customer",
        required: 1
    }
}, {
    label: "Order",
    is_submittable: 1
})
.on("after_change", async ({ doc, old }) => {
    // Only send notification if status changed
    if (old && old.status !== doc.status) {
        console.log(`Order status changed from ${old.status} to ${doc.status}`);
        
        // Send notification to customer
        if (doc.status === "Shipped") {
            console.log(`Sending shipping notification for order ${doc.id}`);
        }
    }
})

Workflow Events

Handle submission workflow events:

export default $doctype<"zodula__Purchase Order">({
    items: {
        type: "Reference Table",
        reference: "zodula__Purchase Order Item"
    },
    status: {
        type: "Select",
        options: "Draft\nSubmitted\nApproved\nCancelled",
        default: "Draft"
    }
}, {
    label: "Purchase Order",
    is_submittable: 1
})
.on("before_submit", async ({ doc }) => {
    // Validate that order has items before submission
    const items = await $zodula
        .doctype("zodula__Purchase Order Item")
        .select()
        .where("parent", "=", doc.id);
    
    if (items.length === 0) {
        throw new Error("Cannot submit order without items");
    }
})
.on("after_submit", async ({ doc }) => {
    console.log(`Purchase Order ${doc.id} submitted for approval`);
    // Send notification to approver
})
.on("before_cancel", async ({ doc }) => {
    // Check if order can be cancelled
    if (doc.status === "Approved") {
        throw new Error("Cannot cancel an approved order");
    }
})
.on("after_cancel", async ({ doc }) => {
    console.log(`Purchase Order ${doc.id} cancelled`);
    // Send cancellation notification
})

Cleanup on Delete

Perform cleanup operations when documents are deleted:

export default $doctype<"zodula__Customer">({
    name: {
        type: "Text",
        required: 1
    },
    email: {
        type: "Email",
        required: 1
    }
}, {
    label: "Customer"
})
.on("before_delete", async ({ doc }) => {
    // Check if customer has active orders
    const activeOrders = await $zodula
        .doctype("zodula__Order")
        .select()
        .where("customer", "=", doc.id)
        .where("status", "!=", "Cancelled");
    
    if (activeOrders.length > 0) {
        throw new Error("Cannot delete customer with active orders");
    }
})
.on("after_delete", async ({ doc }) => {
    // Clean up related records
    console.log(`Cleaning up data for deleted customer ${doc.id}`);
    
    // Delete related records if needed
    await $zodula
        .doctype("zodula__Customer Address")
        .delete()
        .where("customer", "=", doc.id);
})

Event Ordering

Events fire in a specific order during document operations:

Insert Operation

  1. before_insert
  2. before_save
  3. after_insert
  4. after_save

Update Operation

  1. before_change
  2. before_save
  3. after_change
  4. after_save

Delete Operation

  1. before_delete
  2. after_delete

Submit Workflow (for submittable DocTypes)

  1. before_submit
  2. after_submit
  3. before_save_after_submit (when updating submitted document)
  4. after_save_after_submit

Cancel Operation (for submittable DocTypes)

  1. before_cancel
  2. after_cancel

Best Practices

1. Use Async/Await

All event handlers support async operations. Always use async/await for database operations or other asynchronous tasks:

.on("after_save", async ({ doc }) => {
    await $zodula.doctype("zodula__Log").insert({
        message: `User ${doc.id} saved`
    });
})

2. Error Handling

Throw errors in before_* events to prevent operations:

.on("before_save", async ({ doc }) => {
    if (!doc.email) {
        throw new Error("Email is required");
    }
})

3. Avoid Circular Dependencies

Be careful not to create infinite loops by modifying the same document in event handlers:

// ❌ Bad: This could create an infinite loop
.on("after_save", async ({ doc }) => {
    await $zodula.doctype("zodula__User").update(doc.id, { last_updated: new Date() });
})

// ✅ Good: Use a flag to prevent recursion
.on("after_save", async ({ doc }) => {
    if (!doc._skip_update) {
        // Perform update with flag
    }
})

4. Keep Event Handlers Lightweight

Avoid heavy computations in event handlers. Consider using background jobs for time-consuming operations:

.on("after_save", async ({ doc }) => {
    // Queue a background job instead of processing immediately
    await queueJob("send_welcome_email", { user_id: doc.id });
})

5. Use Type Safety

Leverage TypeScript types for type-safe event handlers:

export default $doctype<"zodula__User">({
    // ...
})
.on("before_change", async ({ doc, old, input }: {
    doc: Zodula.SelectDoctype<"zodula__User">,
    old: Zodula.SelectDoctype<"zodula__User">,
    input?: Zodula.UpdateDoctype<"zodula__User">
}) => {
    // Type-safe access to document properties
    if (input?.email) {
        doc.email = input.email;
    }
})

Common Use Cases

Auto-generating Fields

.on("before_insert", async ({ doc }) => {
    if (!doc.code) {
        doc.code = `USER-${Date.now()}`;
    }
})
.on("after_save", async ({ doc, old }) => {
    if (old && old.status !== doc.status) {
        // Update related records
        await $zodula
            .doctype("zodula__Order")
            .update()
            .where("customer", "=", doc.id)
            .set({ customer_status: doc.status });
    }
})

Audit Logging

.on("after_save", async ({ doc, old }) => {
    await $zodula.doctype("zodula__Audit Trail").insert({
        doctype: "zodula__User",
        doctype_id: doc.id,
        action: old ? "Update" : "Insert",
        old_value: old,
        new_value: doc,
        by_name: ctx.user?.name || "system"
    });
})

Validation

.on("before_submit", async ({ doc }) => {
    const items = await $zodula
        .doctype("zodula__Order Item")
        .select()
        .where("parent", "=", doc.id);
    
    if (items.length === 0) {
        throw new Error("Cannot submit order without items");
    }
    
    const total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
    if (total <= 0) {
        throw new Error("Order total must be greater than zero");
    }
})

Next Steps

  • Learn about DocTypes - Core DocType documentation
  • See how to Extend Scripts - Use extend scripts for additional customization
  • Check Field Types - Available field types for your DocTypes