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:
| Event | Triggered | Use Case |
|---|---|---|
before_insert | Before a new document is inserted into the database | Validation, data transformation before creation |
after_insert | After a new document is inserted into the database | Logging, notifications, related record creation |
before_save | Before a document is saved (both insert and update) | Validation, data transformation |
after_save | After a document is saved (both insert and update) | Logging, notifications, cache updates |
before_change | Before a document is updated | Data transformation, conditional updates |
after_change | After a document is updated | Logging changes, updating related records |
before_delete | Before a document is deleted | Validation, cleanup checks |
after_delete | After a document is deleted | Cleanup operations, logging |
before_submit | Before a document is submitted for approval | Validation, workflow checks |
after_submit | After a document is submitted for approval | Notifications, workflow processing |
before_cancel | Before a submitted document is cancelled | Validation, cancellation checks |
after_cancel | After a submitted document is cancelled | Cleanup, notifications |
before_save_after_submit | Before saving changes to a submitted document | Validation, workflow checks |
after_save_after_submit | After saving changes to a submitted document | Notifications, 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
| Property | Description | Available In |
|---|---|---|
old | The previous state of the document before changes | before_change, after_change, before_delete, after_delete |
doc | The current document state (what will be saved) | All events |
input | The raw input data from the API request | All 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
before_insertbefore_saveafter_insertafter_save
Update Operation
before_changebefore_saveafter_changeafter_save
Delete Operation
before_deleteafter_delete
Submit Workflow (for submittable DocTypes)
before_submitafter_submitbefore_save_after_submit(when updating submitted document)after_save_after_submit
Cancel Operation (for submittable DocTypes)
before_cancelafter_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()}`;
}
})Updating Related Records
.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
Zodula