Skip to main content

Hooks

Hooks allow you to intercept and modify data before and after CRUD operations. They're perfect for implementing business logic, validation, audit logging, and data transformation.

Overview

Sentro provides two types of hooks:

  • Before Hooks - Execute before an operation, can modify incoming data
  • After Hooks - Execute after an operation, can modify outgoing data

Both hooks support all CRUD operations:

  • CREATE - Insert new records
  • READ - Fetch records
  • UPDATE - Update existing records
  • DELETE - Delete records

Before Hooks

Before hooks execute before the database operation. They receive the incoming payload and can modify it before it reaches the database.

Type Signature

type BeforeHook<T extends TableName, O extends Operation> = (
  payload: PayloadByOp<T, O>,
  ctx: BaseContext<T, O>
) => Promise<PayloadByOp<T, O> | void> | (PayloadByOp<T, O> | void)

type BaseContext<T extends TableName, O extends Operation> = {
  table: T;
  op: O;
}

Registration

c.onBefore<Operation>(
  operation,  // "CREATE" | "READ" | "UPDATE" | "DELETE"
  handler     // Hook function
)

Examples

Before CREATE - Set Timestamps

connector.customize(() => {
  const c = new ModelCustomizer("users");

  c.onBefore("CREATE", (payload, ctx) => {
    payload.createdAt = new Date().toISOString();
    payload.updatedAt = new Date().toISOString();
    return payload;
  });

  return c;
});

Before CREATE - Hash Password

import bcrypt from 'bcrypt';

connector.customize(() => {
  const c = new ModelCustomizer("users");

  c.onBefore("CREATE", async (payload, ctx) => {
    if (payload.password) {
      payload.password = await bcrypt.hash(payload.password, 10);
    }
    return payload;
  });

  return c;
});

Before UPDATE - Update Timestamp

connector.customize(() => {
  const c = new ModelCustomizer("posts");

  c.onBefore("UPDATE", (payload, ctx) => {
    payload.patch.updatedAt = new Date().toISOString();

    // Set published date when status changes to published
    if (payload.patch.status === 'published' && !payload.patch.publishedAt) {
      payload.patch.publishedAt = new Date().toISOString();
    }

    return payload;
  });

  return c;
});

Before READ - Add Default Filters

connector.customize(() => {
  const c = new ModelCustomizer("posts");

  c.onBefore("READ", (payload, ctx) => {
    // Only show published posts by default
    if (!payload.where) {
      payload.where = {};
    }
    if (!payload.where.status) {
      payload.where.status = 'published';
    }
    return payload;
  });

  return c;
});

Before DELETE - Soft Delete

connector.customize(() => {
  const c = new ModelCustomizer("users");

  c.onBefore("DELETE", async (payload, ctx) => {
    // Convert DELETE to UPDATE (soft delete)
    const updatePayload = {
      patch: { deletedAt: new Date().toISOString() },
      where: payload.where
    };

    // Perform the soft delete
    // Note: This changes the operation type
    return updatePayload as any;
  });

  return c;
});

After Hooks

After hooks execute after the database operation. They receive the result and can modify it before returning to the client.

Type Signature

type AfterHook<T extends TableName, O extends Operation> = (
  result: ResultArrayByOp<T, O>,
  ctx: BaseContext<T, O>
) => void | ResultArrayByOp<T, O> | Promise<void | ResultArrayByOp<T, O>>

Registration

c.onAfter<Operation>(
  operation,  // "CREATE" | "READ" | "UPDATE" | "DELETE"
  handler     // Hook function
)

Examples

After READ - Remove Sensitive Fields

connector.customize(() => {
  const c = new ModelCustomizer("users");

  c.onAfter("READ", (result, ctx) => {
    result.rows.forEach(row => {
      delete row.password;
      delete row.secretToken;
    });
    return result;
  });

  return c;
});

After READ - Add Computed Fields

connector.customize(() => {
  const c = new ModelCustomizer("orders");

  c.onAfter("READ", (result, ctx) => {
    result.rows.forEach(row => {
      // Calculate total with tax
      row.totalWithTax = row.total * 1.1;

      // Add formatted currency
      row.formattedTotal = `$${row.total.toFixed(2)}`;
    });
    return result;
  });

  return c;
});

After CREATE - Send Welcome Email

connector.customize(() => {
  const c = new ModelCustomizer("users");

  c.onAfter("CREATE", async (result, ctx) => {
    const user = result.rows[0];

    // Send welcome email (don't await to not block response)
    sendWelcomeEmail(user.email, user.name).catch(err => {
      console.error('Failed to send welcome email:', err);
    });

    return result;
  });

  return c;
});

After UPDATE - Audit Logging

connector.customize(() => {
  const c = new ModelCustomizer("users");

  c.onAfter("UPDATE", async (result, ctx) => {
    // Log the update to audit table
    for (const row of result.rows) {
      await db.insert({
        table: 'audit_log',
        data: {
          table: 'users',
          recordId: row.id,
          action: 'UPDATE',
          timestamp: new Date().toISOString()
        }
      });
    }

    return result;
  });

  return c;
});

Multiple Hooks

You can register multiple hooks for the same operation:

connector.customize(() => {
  const c = new ModelCustomizer("users");

  // First hook - validation
  c.onBefore("CREATE", (payload) => {
    if (!payload.email || !payload.email.includes('@')) {
      throw new Error('Invalid email address');
    }
    return payload;
  });

  // Second hook - set defaults
  c.onBefore("CREATE", (payload) => {
    payload.role = payload.role || 'user';
    payload.isActive = payload.isActive ?? true;
    return payload;
  });

  // Third hook - timestamps
  c.onBefore("CREATE", (payload) => {
    payload.createdAt = new Date().toISOString();
    return payload;
  });

  return c;
});
Execution Order: Hooks are executed in the order they are registered. Each hook receives the output of the previous hook.

Context Object

The context object provides metadata about the operation:

c.onBefore("CREATE", (payload, ctx) => {
  console.log(ctx.table);  // "users"
  console.log(ctx.op);     // "CREATE"

  // Use context for conditional logic
  if (ctx.table === 'users') {
    // User-specific logic
  }

  return payload;
});

Async Hooks

Hooks can be async functions:

c.onBefore("CREATE", async (payload, ctx) => {
  // Fetch data from external API
  const userData = await fetch(`https://api.example.com/users/${payload.externalId}`);
  const data = await userData.json();

  // Merge external data
  payload.metadata = data;

  return payload;
});

c.onAfter("READ", async (result, ctx) => {
  // Enrich with data from another service
  for (const row of result.rows) {
    const profile = await getUserProfile(row.id);
    row.profile = profile;
  }

  return result;
});

Error Handling

Throw errors in hooks to abort the operation:

c.onBefore("CREATE", (payload) => {
  if (!payload.email) {
    throw new Error('Email is required');
  }

  if (payload.age < 18) {
    throw new Error('Must be 18 or older');
  }

  return payload;
});

c.onBefore("DELETE", async (payload, ctx) => {
  // Prevent deletion of admin users
  const user = await db.getSingle({
    table: 'users',
    where: payload.where
  });

  if (user.role === 'admin') {
    throw new Error('Cannot delete admin users');
  }

  return payload;
});

Best Practices

  • Keep hooks focused on a single responsibility
  • Use before hooks for validation and data transformation
  • Use after hooks for side effects (logging, notifications)
  • Don't perform heavy computations in hooks - consider using background jobs
  • Always return the modified payload/result or void
  • Handle errors gracefully and provide meaningful error messages
  • Avoid modifying data that shouldn't be changed (like IDs)
Next Steps: Learn about Actions to create custom operations on your tables.