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 recordsREAD- Fetch recordsUPDATE- Update existing recordsDELETE- 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.