ZodulaZodula
Basic

Extend Scripts

Learn how to extend Zodula applications using extend scripts. Use `.on()` to extend doctype events and `bxo` to extend the HTTP server with custom routes and middleware.

Overview

Extend scripts allow you to customize and enhance your Zodula application without modifying core code. You can:

  • Extend doctype events - Listen to and handle doctype lifecycle events (save, update, delete, etc.)
  • Extend HTTP server - Add custom routes, middleware, and API endpoints using bxo

Quick Start

Create an extend script in your app's scripts directory:

export default $extend((ctx) => {
  // Extend doctype events
  ctx.on("zodula__User", "after_save", async ({ doc, old }) => {
    console.log("User saved", doc);
  });

  // Extend HTTP server
  ctx.bxo
    .get("/new_path", async (ctx) => {
      return "new_path";
    });
});

File Location

Extend scripts are typically placed in the scripts directory of your app:

apps/your-app-name/scripts/core/zodula__User.extend.ts

The naming convention is: {doctype_name}.extend.ts

Extending Doctype Events

The .on() function allows you to listen to and handle doctype lifecycle events.

Syntax

ctx.on(doctypeName, eventName, handler)

Available Events

All event handlers receive the same parameter structure: { doc, old, input }

Event NameDescriptionNotes
before_insertTriggered before a new document is insertedold is undefined for new documents
after_insertTriggered after a new document is insertedold is undefined
before_saveTriggered before a document is saved (both insert and update)old is undefined for new documents
after_saveTriggered after a document is saved (both insert and update)old is undefined for new documents
before_changeTriggered before a document is updatedold contains previous state
after_changeTriggered after a document is updatedold contains previous state
before_deleteTriggered before a document is deletedold contains document being deleted
after_deleteTriggered after a document is deletedold contains deleted document
before_submitTriggered before a document is submittedFor submittable doctypes
after_submitTriggered after a document is submittedFor submittable doctypes
before_cancelTriggered before a submitted document is cancelledFor submittable doctypes
after_cancelTriggered after a submitted document is cancelledFor submittable doctypes
before_save_after_submitTriggered before saving a submitted documentFor submittable doctypes
after_save_after_submitTriggered after saving a submitted documentFor submittable doctypes

For a complete list of all available events and their usage, see DocType Events.

Example: User Event Extension

export default $extend((ctx) => {
  // Log when a user is created
  ctx.on("zodula__User", "after_save", async ({ doc, old }) => {
    if (!old) {
      console.log("New user created:", doc.name);
    }
  });

  // Validate email before saving
  ctx.on("zodula__User", "before_save", async ({ doc, input }) => {
    if (doc.email && !doc.email.includes("@")) {
      throw new Error("Invalid email address");
    }
  });

  // Hash password from input before saving
  ctx.on("zodula__User", "before_change", async ({ doc, old, input }) => {
    if (input?.password) {
      doc.password = await Bun.password.hash(input.password as string);
    }
  });

  // Cleanup after user deletion
  ctx.on("zodula__User", "after_delete", async ({ doc, old }) => {
    console.log("User deleted:", old.name);
    // Perform cleanup operations
  });
});

Event Handler Parameters

All event handlers receive the same context structure:

{
  doc: Document,      // Current state of the document (what will be saved)
  old: Document,      // Previous state of the document (undefined for new documents)
  input: Document     // Raw input data from the API request (may be undefined)
}

Parameter Details:

  • doc: The current document state that will be saved to the database. You can modify this object to change what gets saved.
  • old: The previous state of the document before changes. This is undefined for new documents (insert operations). Use this to compare changes or determine if a document is new.
  • input: The raw input data from the API request. This contains only the fields that were sent in the request. Use this to see what the user actually submitted, which may differ from doc if there's default value logic.

Extending HTTP Server

The bxo object provides methods to extend the HTTP server with custom routes and middleware. bxo is a lightweight HTTP framework - see the bxo npm package for complete documentation.

HTTP Methods

bxo supports all standard HTTP methods:

  • .get(path, handler) - Handle GET requests
  • .post(path, handler) - Handle POST requests
  • .put(path, handler) - Handle PUT requests
  • .patch(path, handler) - Handle PATCH requests
  • .delete(path, handler) - Handle DELETE requests

Example: Custom API Endpoint

export default $extend((ctx) => {
  // GET endpoint
  ctx.bxo.get("/api/custom/users", async (ctx) => {
    const users = await $zodula
      .doctype("zodula__User")
      .select()
      .where("active", "=", 1);
    
    return users;
  });

  // POST endpoint
  ctx.bxo.post("/api/custom/send-email", async (ctx) => {
    const { to, subject, body } = await ctx.req.json();
    
    // Send email logic
    console.log("Sending email to:", to);
    
    return { success: true, message: "Email sent" };
  });
});

Route Handlers

Route handlers receive a context object (ctx) with request information:

ctx.bxo.get("/api/data/:id", async (ctx) => {
  const id = ctx.params.id;           // URL parameters
  const query = ctx.req.query;         // Query string parameters
  const body = await ctx.req.json();   // Request body (for POST/PUT)
  
  return { id, query, body };
});

Middleware Support

bxo supports middleware for request preprocessing:

export default $extend((ctx) => {
  // Middleware example
  ctx.bxo.use(async (ctx, next) => {
    // Pre-processing
    console.log("Request:", ctx.req.method, ctx.req.url);
    
    // Call next middleware or handler
    await next();
    
    // Post-processing
    console.log("Response sent");
  });

  ctx.bxo.get("/api/protected", async (ctx) => {
    // This route goes through the middleware above
    return { message: "Protected route" };
  });
});

Complete Example

Here's a complete extend script combining both doctype events and HTTP server extensions:

export default $extend((ctx) => {
  // Doctype event: Log user activity
  ctx.on("zodula__User", "after_save", async ({ doc, old }) => {
    console.log("User saved:", doc.name);
    
    // Create an activity log entry
    await $zodula
      .doctype("ActivityLog")
      .insert({
        user: doc.name,
        action: old ? "User updated" : "User created",
        timestamp: new Date().toISOString()
      });
  });

  // HTTP endpoint: Get user statistics
  ctx.bxo.get("/api/stats/users", async (ctx) => {
    const totalUsers = await $zodula
      .doctype("zodula__User")
      .count();
    
    const activeUsers = await $zodula
      .doctype("zodula__User")
      .count()
      .where("active", "=", 1);
    
    return {
      total: totalUsers,
      active: activeUsers,
      inactive: totalUsers - activeUsers
    };
  });

  // HTTP endpoint: Custom user search
  ctx.bxo.post("/api/users/search", async (ctx) => {
    const { query, limit = 10 } = await ctx.req.json();
    
    const users = await $zodula
      .doctype("zodula__User")
      .select()
      .where("name", "like", `%${query}%`)
      .limit(limit);
    
    return users;
  });
});

Best Practices

  1. Organize Scripts: Group related extensions in the same file or create separate files for different concerns
  2. Error Handling: Always handle errors in event handlers and route handlers
  3. Async Operations: Use async/await for database operations and other async tasks
  4. Type Safety: Use TypeScript types from Zodula for doctypes and fields
  5. Performance: Be mindful of performance when adding event handlers, especially on frequently saved doctypes
  6. Logging: Log important operations for debugging and monitoring

Common Patterns

Email Notification on Document Save

ctx.on("zodula__User", "after_save", async ({ doc, old }) => {
  if (!old) {
    // Send welcome email to new users
    console.log(`Sending welcome email to ${doc.email}`);
    // Email sending logic here
  }
});

Audit Trail

ctx.on("zodula__User", "after_save", async ({ doc, old }) => {
  await $zodula.doctype("AuditLog").insert({
    doctype: "zodula__User",
    document_name: doc.name,
    action: old ? "Update" : "Insert",
    old_value: old,
    new_value: doc,
    timestamp: new Date().toISOString()
  });
});

Custom Validation

ctx.on("zodula__User", "before_save", async ({ doc, input }) => {
  // Validate business rules
  if (doc.role === "Admin" && !doc.has_admin_permission) {
    throw new Error("User cannot be admin without permission");
  }
});

Password Hashing from Input

ctx.on("zodula__User", "before_change", async ({ doc, old, input }) => {
  // Hash password only if provided in input
  if (input?.password) {
    doc.password = await Bun.password.hash(input.password as string);
  }
});

Detecting Changes

ctx.on("zodula__User", "after_change", async ({ doc, old }) => {
  // Only send notification if email changed
  if (old && old.email !== doc.email) {
    console.log(`User email changed from ${old.email} to ${doc.email}`);
    // Send notification logic
  }
});

Next Steps