Field Writers
Field writers allow you to replace the default field writing logic for specific fields during CREATE and UPDATE operations. They're perfect for handling file uploads, data transformations, and complex field processing.
Overview
When a field writer is registered for a field, it completely replaces the default behavior for that field. The writer receives the incoming value and can transform it or perform side effects before writing to the database.
Key Features
- Replace field writing logic for CREATE and UPDATE operations
- Transform field values before database insertion
- Handle file uploads to external storage (S3, CDN)
- Perform validation and data sanitization
- Return multiple fields to write to the database
Type Signature
type FieldWriter<T extends TableName, K extends string> = (
value: unknown,
ctx: BaseContext<T, "CREATE" | "UPDATE">
) => Promise<Record<string, unknown> | void> | (Record<string, unknown> | void)
type BaseContext<T extends TableName, O extends Operation> = {
table: T;
op: O;
}Registration
c.replaceFieldWriting<FieldName>(
fieldName, // The field to replace writing for
handler // Field writer function
)Return Value
Field writers can return:
- Object - Fields to write to database
- void/undefined - Skip writing this field
Important: The returned object can include multiple fields. This allows you to write to related fields based on the input value.
Basic Examples
Simple Transformation
connector.customize(() => {
const c = new ModelCustomizer("users");
c.replaceFieldWriting("email", (value, ctx) => {
// Normalize email to lowercase
if (typeof value === 'string') {
return { email: value.toLowerCase().trim() };
}
return { email: value };
});
return c;
});Data Validation
c.replaceFieldWriting("age", (value, ctx) => {
const age = Number(value);
if (isNaN(age)) {
throw new Error('Age must be a number');
}
if (age < 0 || age > 150) {
throw new Error('Age must be between 0 and 150');
}
return { age };
});Conditional Logic
c.replaceFieldWriting("status", (value, ctx) => {
if (ctx.op === "CREATE") {
// Always set to pending on create
return { status: "pending" };
} else {
// Allow updates
return { status: value };
}
});File Upload Examples
Upload to S3
connector.customize(() => {
const c = new ModelCustomizer("posts");
c.replaceFieldWriting("image", async (value, ctx) => {
if (!value || typeof value !== 'string') {
return { image: null };
}
// Check if it's a base64 data URL
if (value.startsWith('data:')) {
const s3 = connector.using<S3Integration>('s3');
// Extract base64 data and content type
const matches = value.match(/^data:(.+);base64,(.+)$/);
if (!matches) {
throw new Error('Invalid data URL');
}
const contentType = matches[1];
const base64Data = matches[2];
const buffer = Buffer.from(base64Data, 'base64');
// Upload to S3
const key = `posts/${Date.now()}-${Math.random()}.jpg`;
const result = await s3.prepareUpload({
key,
fileSize: buffer.length,
contentType
});
// Upload the file
await uploadToS3(result.uploadUrl, buffer);
// Return S3 URL to store in database
return {
image: result.url,
imageKey: key
};
}
// If it's already a URL, keep it
return { image: value };
});
return c;
});Multiple File Upload
c.replaceFieldWriting("images", async (value, ctx) => {
if (!Array.isArray(value)) {
return { images: [] };
}
const s3 = connector.using<S3Integration>('s3');
const uploadedUrls: string[] = [];
for (const imageData of value) {
if (typeof imageData === 'string' && imageData.startsWith('data:')) {
const result = await uploadImageToS3(s3, imageData);
uploadedUrls.push(result.url);
} else {
uploadedUrls.push(imageData); // Already a URL
}
}
return { images: JSON.stringify(uploadedUrls) };
});Image Processing
import sharp from 'sharp';
c.replaceFieldWriting("avatar", async (value, ctx) => {
if (!value || typeof value !== 'string') {
return { avatar: null };
}
if (value.startsWith('data:')) {
const base64Data = value.split(',')[1];
const buffer = Buffer.from(base64Data, 'base64');
// Resize and optimize image
const processed = await sharp(buffer)
.resize(200, 200, { fit: 'cover' })
.jpeg({ quality: 80 })
.toBuffer();
// Upload to S3
const s3 = connector.using<S3Integration>('s3');
const key = `avatars/${Date.now()}.jpg`;
const result = await s3.prepareUpload({
key,
fileSize: processed.length,
contentType: 'image/jpeg'
});
await uploadToS3(result.uploadUrl, processed);
return {
avatar: result.url,
avatarKey: key
};
}
return { avatar: value };
});Advanced Examples
Generate Slug from Title
c.replaceFieldWriting("title", async (value, ctx) => {
const title = String(value);
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
// Write both title and slug
return {
title,
slug
};
});Geocoding Address
c.replaceFieldWriting("address", async (value, ctx) => {
const address = String(value);
// Geocode the address
const coords = await geocodeAddress(address);
// Write address and coordinates
return {
address,
latitude: coords.lat,
longitude: coords.lng
};
});JSON Schema Validation
import Ajv from 'ajv';
const ajv = new Ajv();
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' }
},
required: ['name']
};
c.replaceFieldWriting("metadata", (value, ctx) => {
const validate = ajv.compile(schema);
if (!validate(value)) {
throw new Error(`Invalid metadata: ${JSON.stringify(validate.errors)}`);
}
return { metadata: JSON.stringify(value) };
});Encryption
import crypto from 'crypto';
const algorithm = 'aes-256-gcm';
const key = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');
c.replaceFieldWriting("ssn", (value, ctx) => {
if (!value) return { ssn: null };
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(String(value), 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Store IV, auth tag, and encrypted data
const combined = `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
return {
ssn: combined,
ssnEncrypted: true
};
});External API Integration
c.replaceFieldWriting("companyName", async (value, ctx) => {
const companyName = String(value);
// Fetch company data from external API
const response = await fetch(
`https://api.company-data.com/search?name=${encodeURIComponent(companyName)}`
);
const data = await response.json();
if (data.results.length > 0) {
const company = data.results[0];
return {
companyName,
companyDomain: company.domain,
companyIndustry: company.industry,
companySize: company.size
};
}
return { companyName };
});Handling Different Operations
c.replaceFieldWriting("document", async (value, ctx) => {
if (ctx.op === "CREATE") {
// On create, upload new file
return await uploadDocument(value);
} else {
// On update, check if file changed
if (value && typeof value === 'string' && value.startsWith('data:')) {
// New file uploaded, replace old one
return await uploadDocument(value);
} else {
// Keep existing file
return { document: value };
}
}
});Skipping Field Write
Return void or undefined to skip writing the field:
c.replaceFieldWriting("computed", (value, ctx) => {
// Don't allow writing to computed field
return undefined;
});
c.replaceFieldWriting("readonly", (value, ctx) => {
// Only allow on create
if (ctx.op === "CREATE") {
return { readonly: value };
}
return undefined; // Skip on update
});Error Handling
c.replaceFieldWriting("image", async (value, ctx) => {
try {
if (value && typeof value === 'string' && value.startsWith('data:')) {
const result = await uploadToS3(value);
return { image: result.url };
}
return { image: value };
} catch (error) {
console.error('Failed to upload image:', error);
throw new Error(`Image upload failed: ${error.message}`);
}
});Combining with Hooks
Field writers work alongside hooks. The execution order is:
- Before hook executes
- Field writers execute for each field
- Database operation
- After hook executes
connector.customize(() => {
const c = new ModelCustomizer("posts");
// 1. Before hook - runs first
c.onBefore("CREATE", (payload) => {
payload.createdAt = new Date().toISOString();
return payload;
});
// 2. Field writer - runs for specific field
c.replaceFieldWriting("image", async (value) => {
const url = await uploadToS3(value);
return { image: url };
});
// 3. After hook - runs last
c.onAfter("CREATE", (result) => {
sendNotification(`New post created: ${result.rows[0].title}`);
return result;
});
return c;
});Best Practices
- Always validate input values before processing
- Handle errors gracefully and provide clear error messages
- Return consistent object shapes
- Use field writers for field-specific logic, hooks for cross-field logic
- Consider performance - field writers run for every create/update
- Don't perform heavy operations in field writers (use background jobs)
- Clean up resources (delete old files when replacing)
- Log important operations for debugging
Performance: Field writers execute for every CREATE and UPDATE operation. Avoid expensive operations that can slow down your API.
Next Steps: Check out the API Reference for a complete list of all available methods and types.