Component Engine
Event lifecycle hooks: onBeforeInsert, onAfterUpdate, dan handler lainnya
Component Engine adalah fitur event lifecycle yang memungkinkan eksekusi custom logic (handler) sebelum dan sesudah operasi CRUD. Handler dikonfigurasi melalui property components di payload JSON dan dieksekusi secara otomatis di dalam transaction scope, sehingga konsistensi (consistency) data antara operasi utama dan custom logic terjamin.
Referensi Cepat (Quick Reference)
| Properti | Nilai |
|---|---|
| Konfigurasi | Property components di payload JSON |
| Event single table | onBeforeInsert, onAfterInsert, onBeforeUpdate, onAfterUpdate, onBeforeDelete, onAfterDelete |
| Event composite | onBeforeCompositeInsert, onAfterCompositeInsert, onBeforeCompositeUpdate, onAfterCompositeUpdate |
| Handler signature | async function(table_name, request_data, old_data, new_data, services) |
| Services injection | { db, logger, redis, kafka, cache } otomatis sebagai parameter terakhir |
| Return value | { success: true/false, message: '...' } |
| Sifat event | Semua blocking (seperti database trigger) |
| Rollback | Otomatis jika handler return {success: false} atau throw exception |
| Lokasi file handler | src/components/handlers/ |
Cara Kerja (How It Works)
Setiap operasi CRUD memiliki dua event hook yang dieksekusi di dalam transaction scope:
| Operasi | Event Sebelum | Event Sesudah |
|---|---|---|
| INSERT | onBeforeInsert | onAfterInsert |
| UPDATE | onBeforeUpdate | onAfterUpdate |
| DELETE | onBeforeDelete | onAfterDelete |
Seluruh event bersifat blocking dan berperilaku seperti database trigger. Jika handler mengembalikan {success: false} atau throw exception, transaction di-rollback secara keseluruhan.
Alur Eksekusi (Execution Flow)
Request masuk (POST /create, /update, /delete)
│
▼
┌─────────────────────────────┐
│ BEGIN Transaction │
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ Fetch oldData (UPDATE/DEL) │
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ onBefore handler │ ← Jika {success: false} → ROLLBACK
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ Operasi SQL utama │ ← INSERT / UPDATE / DELETE
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ Fetch newData │ ← SELECT setelah operasi
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ onAfter handler │ ← Jika {success: false} → ROLLBACK
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ COMMIT Transaction │
└─────────────────────────────┘Perbedaan onBefore dan onAfter
| Aspek | onBefore | onAfter |
|---|---|---|
| Eksekusi | Sebelum operasi SQL | Setelah operasi SQL |
newData | Selalu null | Data hasil operasi (null untuk DELETE) |
oldData | Data existing untuk UPDATE/DELETE, null untuk INSERT | Sama dengan onBefore |
| Kasus penggunaan | Validasi, pengecekan dependensi | Sinkronisasi tabel terkait, computed fields |
| Jika gagal | Transaction di-rollback | Transaction di-rollback |
Konfigurasi Payload (Payload Configuration)
Handler didaftarkan melalui property components di file payload JSON:
{
"tableName": "stock_inbound",
"primaryKey": "stock_inbound_id",
"fieldName": [
"stock_inbound_id",
"inbound_number",
"warehouse_id",
"inbound_date",
"status",
"total_qty"
],
"action": {
"create": true,
"update": true,
"delete": true
},
"components": [
{
"properties": {
"filename": "components/handlers/stock-inbound-handler.js",
"methods": [
{
"name": "validateBeforeInsert",
"events": "onBeforeInsert",
"params": [
{"value": "{tableName}"},
{"value": "{requestData}"},
{"value": "{oldData}"},
{"value": "{newData}"}
]
},
{
"name": "syncAfterUpdate",
"events": "onAfterUpdate",
"params": [
{"value": "{tableName}"},
{"value": "{requestData}"},
{"value": "{oldData}"},
{"value": "{newData}"}
]
}
]
}
}
]
}Properti Method
| Properti | Tipe | Keterangan |
|---|---|---|
name | string | Nama function yang di-export dari file handler |
events | string | Nama event (onBeforeInsert, onAfterInsert, dll.) |
params | array | Array template variable yang di-pass ke function |
Template Variable
| Variable | Tipe | Keterangan |
|---|---|---|
{tableName} | string | Nama tabel yang sedang dioperasikan |
{requestData} | object | Data asli dari request body |
{oldData} | object/null | Data existing sebelum operasi (null untuk INSERT) |
{newData} | object/null | Data setelah operasi (null untuk onBefore dan DELETE) |
{operation} | string | Tipe operasi: INSERT, UPDATE, DELETE |
{user_id} | string | User ID dari request header |
{timestamp} | string | ISO timestamp saat event dieksekusi |
{record_id} | string/null | ID record yang dioperasikan |
Menulis Handler (Writing Handlers)
Signature Function
Setiap handler menerima 5 parameter. Parameter kelima (services) di-inject otomatis oleh component engine:
async function namaHandler(table_name, request_data, old_data, new_data, services) {
const { db, logger } = services;
// Custom logic
return {
success: true,
message: 'Handler executed successfully'
};
}
module.exports = { namaHandler };Parameter
| Urutan | Nama | Tipe | Keterangan |
|---|---|---|---|
| 1 | table_name | string | Nama tabel (contoh: "stock_inbound") |
| 2 | request_data | object | Data dari request body |
| 3 | old_data | object/null | Data sebelum operasi (null untuk INSERT) |
| 4 | new_data | object/null | Data setelah operasi (null untuk onBefore dan DELETE) |
| 5 | services | object | Injected services: { db, logger, redis, kafka, cache } |
Return Value
Handler wajib mengembalikan object dengan property success:
// Sukses — operasi dilanjutkan
return { success: true, message: 'Validation passed' };
// Gagal — transaction di-rollback
return { success: false, message: 'Inbound number sudah ada di sistem' };Aturan Penulisan (Handler Rules)
- Function harus bersifat
async(atau return Promise) - Function harus di-export melalui
module.exports - Return value harus memiliki property
success(boolean) - Return
{success: false}men-trigger rollback untuk semua event (onBefore maupun onAfter) - Exception/throw di dalam handler juga men-trigger rollback
Data Flow per Operasi (Data Flow per Operation)
INSERT
| Parameter | onBeforeInsert | onAfterInsert |
|---|---|---|
table_name | "stock_inbound" | "stock_inbound" |
request_data | {inbound_number: "INB-001", ...} | {inbound_number: "INB-001", ...} |
old_data | null | null |
new_data | null | {stock_inbound_id: "uuid...", inbound_number: "INB-001", ...} |
UPDATE
| Parameter | onBeforeUpdate | onAfterUpdate |
|---|---|---|
table_name | "stock_inbound" | "stock_inbound" |
request_data | {stock_inbound_id: "uuid...", status: "confirmed"} | {stock_inbound_id: "uuid...", status: "confirmed"} |
old_data | {stock_inbound_id: "uuid...", status: "draft", ...} | {stock_inbound_id: "uuid...", status: "draft", ...} |
new_data | null | {stock_inbound_id: "uuid...", status: "confirmed", ...} |
DELETE
| Parameter | onBeforeDelete | onAfterDelete |
|---|---|---|
table_name | "stock_inbound" | "stock_inbound" |
request_data | {where: [{key: "stock_inbound_id", value: "uuid..."}]} | {where: [{key: "stock_inbound_id", value: "uuid..."}]} |
old_data | {stock_inbound_id: "uuid...", inbound_number: "INB-001", ...} | {stock_inbound_id: "uuid...", inbound_number: "INB-001", ...} |
new_data | null | null |
Contoh Penggunaan (Usage Examples)
Validasi sebelum INSERT
Handler berikut memvalidasi bahwa inbound_number tidak kosong dan belum ada di database sebelum INSERT dilakukan:
async function validateBeforeInsert(table_name, request_data, old_data, new_data, services) {
const { db, logger } = services;
logger.info({ table: table_name }, '[VALIDATE] Checking inbound number');
if (!request_data.inbound_number || request_data.inbound_number.trim() === '') {
return {
success: false,
message: 'Inbound number wajib diisi'
};
}
const existing = await db.executeQuery(
'SELECT stock_inbound_id FROM stock_inbound WHERE inbound_number = $1',
[request_data.inbound_number]
);
if (existing.rows.length > 0) {
return {
success: false,
message: `Inbound number ${request_data.inbound_number} sudah ada di sistem`
};
}
return { success: true, message: 'Validation passed' };
}
module.exports = { validateBeforeInsert };Konfigurasi payload untuk handler di atas:
{
"components": [
{
"properties": {
"filename": "components/handlers/stock-inbound-handler.js",
"methods": [
{
"name": "validateBeforeInsert",
"events": "onBeforeInsert",
"params": [
{"value": "{tableName}"},
{"value": "{requestData}"},
{"value": "{oldData}"},
{"value": "{newData}"}
]
}
]
}
}
]
}Sinkronisasi Data setelah UPDATE
Handler berikut menghitung ulang total_qty di tabel stock_inbound berdasarkan data di stock_inbound_item setelah operasi UPDATE:
async function syncTotalAfterUpdate(table_name, request_data, old_data, new_data, services) {
const { db, logger } = services;
if (!new_data || !new_data.stock_inbound_id) {
return { success: true, message: 'No sync needed' };
}
try {
const result = await db.executeQuery(
'SELECT COALESCE(SUM(qty), 0) as total_qty FROM stock_inbound_item WHERE stock_inbound_id = $1',
[new_data.stock_inbound_id]
);
await db.executeQuery(
'UPDATE stock_inbound SET total_qty = $1, updated_at = NOW() WHERE stock_inbound_id = $2',
[result.rows[0].total_qty, new_data.stock_inbound_id]
);
logger.info({ stockInboundId: new_data.stock_inbound_id }, '[SYNC] Total qty updated');
return { success: true, message: 'Total quantity synced' };
} catch (error) {
logger.error({ error: error.message }, '[SYNC] Failed to sync total');
return { success: false, message: `Sync failed: ${error.message}` };
}
}
module.exports = { syncTotalAfterUpdate };Validasi Dependensi sebelum DELETE
Handler berikut mencegah penghapusan stock_inbound yang masih memiliki item detail:
async function checkItemsBeforeDelete(table_name, request_data, old_data, new_data, services) {
const { db, logger } = services;
if (!old_data || !old_data.stock_inbound_id) {
return { success: true };
}
const result = await db.executeQuery(
'SELECT COUNT(*) as total FROM stock_inbound_item WHERE stock_inbound_id = $1',
[old_data.stock_inbound_id]
);
const itemCount = parseInt(result.rows[0].total, 10);
if (itemCount > 0) {
return {
success: false,
message: `Stock inbound tidak dapat dihapus karena masih memiliki ${itemCount} item`
};
}
logger.info({ table: table_name }, '[DEP-CHECK] No items found, delete allowed');
return { success: true, message: 'Dependency check passed' };
}
module.exports = { checkItemsBeforeDelete };Lokasi File Handler (Handler File Location)
File handler ditempatkan di folder src/components/handlers/ pada project deployment:
deployment/mini-inventory/
├── src/
│ ├── components/
│ │ └── handlers/
│ │ ├── stock-inbound-handler.js
│ │ ├── stock-sync-handler.js
│ │ └── audit-logger.js
│ └── modules/
│ └── ...
├── payload/
│ └── stock_inbound.json
└── config/
└── *.envServices yang Tersedia (Available Services)
Object services di-inject otomatis sebagai parameter terakhir handler:
| Service | Key | Keterangan |
|---|---|---|
| Database | db | { executeQuery, executeTransaction, getPool }, auto-route berdasarkan DB_TYPE |
| Logger | logger | pino logger instance (fallback ke console) |
| Redis | redis | Redis client (null jika tidak dikonfigurasi) |
| Kafka | kafka | Kafka service (null jika KAFKA_ENABLED bukan 'true') |
| Cache | cache | Cache manager (null jika cache tidak dikonfigurasi) |
async function myHandler(table_name, request_data, old_data, new_data, services) {
const { db, logger, redis } = services;
// Database query
const result = await db.executeQuery(
'SELECT * FROM product WHERE product_id = $1',
[new_data.product_id]
);
// Structured logging
logger.info({ table: table_name, event: 'handler_executed' }, 'Handler completed');
// Redis (null-check diperlukan karena bersifat opsional)
if (redis) {
await redis.del(`stock:${new_data.stock_inbound_id}`);
}
return { success: true };
}Handler yang tidak mendeklarasikan parameter services tetap berfungsi normal. JavaScript mengabaikan argument tambahan yang tidak ada dalam function signature.
Event Composite (Composite Events)
Component engine juga mendukung event hook untuk operasi composite (/create-composite, /update-composite):
| Operasi | Event Sebelum | Event Sesudah |
|---|---|---|
| Composite INSERT | onBeforeCompositeInsert | onAfterCompositeInsert |
| Composite UPDATE | onBeforeCompositeUpdate | onAfterCompositeUpdate |
Alur Eksekusi Composite (Composite Execution Flow)
create-composite:
BEGIN
onBeforeCompositeInsert(headerData, detailItems) ← Blocking
INSERT header
INSERT detail[0], detail[1], ...
onAfterCompositeInsert(headerData, insertedHeader, insertedItems) ← Blocking
COMMITupdate-composite:
BEGIN
Prefetch oldData
onBeforeCompositeUpdate(headerData, oldData, detailOperations) ← Blocking
UPDATE header
DELETE/UPDATE/INSERT detail items
onAfterCompositeUpdate(headerData, oldData, updatedHeader, detailResults) ← Blocking
COMMITContext Object
Event composite menerima context yang berbeda dari event single table. Berikut context untuk onAfterCompositeInsert sebagai contoh:
| Field | Tipe | Keterangan |
|---|---|---|
requestData | object | Header data asli dari request |
oldData | null | INSERT tidak memiliki data lama |
newData | object | Header yang sudah di-insert |
insertedItems | array | Detail items yang sudah di-insert |
detailTable | string | Nama tabel detail (contoh: "stock_inbound_item") |
foreignKey | string | Foreign key header-detail |
tableName | string | Nama tabel header (contoh: "stock_inbound") |
operation | string | 'COMPOSITE_INSERT' |
Untuk context onBeforeCompositeUpdate, field detailOperations berisi object {insert: [], update: [], delete: []} yang merepresentasikan perubahan detail yang akan dilakukan.
Event composite hanya berlaku di level header. Tidak ada hook per-item detail seperti onBeforeDetailInsert. Composite DELETE juga tidak tersedia karena tidak ada endpoint /delete-composite.
Contoh: Validasi Status sebelum Composite Update
{
"components": [
{
"properties": {
"filename": "components/handlers/stock-inbound-handler.js",
"methods": [
{
"name": "validateBeforeCompositeUpdate",
"events": "onBeforeCompositeUpdate",
"params": [
{"value": "{tableName}"},
{"value": "{oldData}"},
{"value": "{detailOperations}"}
]
}
]
}
}
]
}async function validateBeforeCompositeUpdate(table_name, old_data, detail_operations) {
if (old_data && old_data.status !== 'draft') {
return {
success: false,
message: `Stock inbound tidak dapat diubah karena status sudah '${old_data.status}'`
};
}
return { success: true };
}
module.exports = { validateBeforeCompositeUpdate };Catatan Penting (Important Notes)
Kompatibilitas Database (Database Compatibility)
Component engine berjalan identik pada PostgreSQL, MySQL, dan Oracle. Normalisasi key (Oracle mengembalikan UPPERCASE keys) ditangani secara internal, sehingga handler selalu menerima lowercase keys di semua platform.
Multiple Handler
Satu event dapat memiliki lebih dari satu handler. Handler dieksekusi secara sequential sesuai urutan definisi di payload. Jika satu handler mengembalikan {success: false}, handler berikutnya tidak dieksekusi dan transaction di-rollback.
Backward Compatibility
Jika payload tidak memiliki property components, seluruh operasi CRUD berjalan normal tanpa event lifecycle. Tidak ada perubahan perilaku untuk module yang sudah ada.
Import/Export
Component engine tidak diterapkan pada operasi import dan export. Operasi tersebut menggunakan mekanisme terpisah (importConfig dan exportQuery di payload).
Langkah Selanjutnya (Next Steps)
- Processor untuk membuat custom endpoint dengan business logic manual
- CRUD Dasar untuk operasi create, update, dan delete standar
- CRUD Composite untuk operasi header-detail dalam satu transaksi