Skip to main content

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:

  1. Before hook executes
  2. Field writers execute for each field
  3. Database operation
  4. 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.