ObjectStackObjectStack

Server-Side Error Handling

Best practices for handling and throwing errors in ObjectStack plugins — custom errors, hook propagation, validation, transactions, and safeParsePretty

Server-Side Error Handling

This guide covers best practices for handling and throwing errors within ObjectStack plugins and server-side code — from creating custom errors with proper codes to transaction rollback and integration with safeParsePretty().

Error Contract: ObjectStack defines a standardized error envelope, ErrorResponseSchema (exported from @objectstack/spec/api). It wraps an EnhancedApiError with a machine-readable code, httpStatus, optional category, and fieldErrors. This is the spec's target contract for plugin/hook authors to throw against — but the kernel REST server (@objectstack/rest) that serves /api/v1/data/* does not auto-translate an arbitrary thrown error into this envelope today. It emits a flatter { error, code } shape and only recognizes a small, fixed set of SCREAMING_SNAKE_CASE codes (VALIDATION_FAILED, PERMISSION_DENIED, RECORD_NOT_FOUND, CONCURRENT_UPDATE, DELETE_RESTRICTED, …). See the API Overview and Wire Format for both wire formats in use, and the Error Catalog for the full StandardErrorCode list.


1. Creating Custom Errors with Proper Error Codes

ObjectStack does not ship a base error class — there is no ObjectStackError export. Instead, define a small server-side helper that extends the native Error and carries the fields from the EnhancedApiError schema (code, httpStatus, fieldErrors; category is optional). The valid code values are the lowercase snake_case members of StandardErrorCode (see @objectstack/spec/api), e.g. validation_error, resource_conflict, permission_denied.

import type { ErrorResponse, StandardErrorCode } from '@objectstack/spec/api';

type FieldError = NonNullable<ErrorResponse['error']['fieldErrors']>[number];

// Domain helper: a throwable error that maps cleanly onto EnhancedApiError.
class AppError extends Error {
  constructor(
    public code: StandardErrorCode,
    message: string,
    public httpStatus: number,
    public fieldErrors?: FieldError[],
  ) {
    super(message);
    this.name = 'AppError';
  }
}

class TaskAlreadyCompletedError extends AppError {
  constructor(taskId: string) {
    super(
      'resource_conflict',
      `Task ${taskId} is already completed and cannot be modified`,
      409,
      [{ field: 'status', code: 'lock_conflict', message: 'Task is in terminal state' }],
    );
  }
}

class InvalidStatusTransitionError extends AppError {
  constructor(from: string, to: string) {
    super(
      'invalid_field',
      `Cannot transition from '${from}' to '${to}'`,
      400,
      [{ field: 'status', code: 'invalid_field', message: `Invalid transition: ${from} → ${to}` }],
    );
  }
}

Error Codes: Per-field code values must be members of the StandardErrorCode enum (lowercase snake_case, e.g. invalid_field, value_out_of_range, duplicate_value). Use the category and httpStatus that match — ErrorHttpStatusMap in @objectstack/spec/api maps each category to its HTTP status.


2. Error Propagation in Hooks

Hooks are a primary extension point for business logic. A hook is a Hook object (imported from @objectstack/spec/data) with object, events (an array of lifecycle events such as beforeInsert / afterUpdate), and a handler: async (ctx) => { ... }. Errors thrown from a hook handler abort the operation and propagate to the API layer. The handler receives a HookContext: the pending record's fields are exposed directly on ctx.input, the pre-change snapshot on ctx.previous, and the persisted result (after hooks) on ctx.result.

Before Hooks (Validation / Guard)

import { Hook } from '@objectstack/spec/data';

export const ValidateStatusTransition: Hook = {
  name: 'validate_status_transition',
  object: 'task',
  events: ['beforeUpdate'],
  handler: async (ctx) => {
    const changes = ctx.input as Record<string, any>;
    const previous = ctx.previous ?? {};

    // Guard: prevent modifying completed tasks
    if (previous.status === 'done' && changes.status !== undefined) {
      throw new TaskAlreadyCompletedError(previous.id);
    }

    // Validate status transitions
    if (changes.status) {
      const validTransitions: Record<string, string[]> = {
        open: ['in_progress', 'cancelled'],
        in_progress: ['open', 'done', 'blocked'],
        blocked: ['in_progress', 'cancelled'],
        done: [],         // terminal state
        cancelled: [],    // terminal state
      };

      const allowed = validTransitions[previous.status as string] ?? [];
      if (!allowed.includes(changes.status)) {
        throw new InvalidStatusTransitionError(previous.status, changes.status);
      }
    }
  },
};

After Hooks (Side Effects)

Throwing from an after* hook surfaces an error, but the main write has already been persisted. Swallow and log failures from non-critical side effects so the response still succeeds:

export const NotifyOnCompletion: Hook = {
  name: 'notify_on_completion',
  object: 'task',
  events: ['afterUpdate'],
  handler: async (ctx) => {
    const record = ctx.result as Record<string, any>;
    if (record.status === 'done') {
      try {
        // notificationService is provided by your plugin
        await notificationService.send({
          to: record.assigned_to,
          template: 'task_completed',
          data: { taskTitle: record.title },
        });
      } catch (error) {
        // Log but don't fail the response
        console.error('Failed to send completion notification', {
          taskId: record.id,
          error: error instanceof Error ? error.message : String(error),
        });
      }
    }
  },
};

3. Validation Error Formatting

Format validation errors so the client can map them to specific form fields:

import { z } from 'zod';
import { Hook } from '@objectstack/spec/data';

// Map a ZodError onto an AppError carrying field-level errors.
function createValidationError(zodError: z.ZodError): AppError {
  const fieldErrors = zodError.issues.map((issue) => ({
    field: issue.path.join('.'),
    message: issue.message,
    code: 'invalid_field' as const,
  }));

  return new AppError(
    'validation_error',
    `Validation failed: ${fieldErrors.length} error(s)`,
    400,
    fieldErrors,
  );
}

// Usage in a hook
export const ValidateTaskData: Hook = {
  name: 'validate_task_data',
  object: 'task',
  events: ['beforeInsert'],
  handler: async (ctx) => {
    const data = ctx.input as Record<string, any>;
    const schema = z.object({
      title: z.string().min(1, 'Title is required').max(200),
      priority: z.enum(['low', 'medium', 'high', 'critical']),
      estimated_hours: z.number().min(0).max(1000).optional(),
    });

    const result = schema.safeParse(data);
    if (!result.success) {
      throw createValidationError(result.error);
    }
  },
};

4. Error Logging and Monitoring

There is no global onError lifecycle event — the hook HookEvent enum only covers before* / after* of find, insert, update, delete (and their bulk variants). Each Hook does expose an onError strategy field ('abort' — the default — or 'log') that decides whether a handler failure aborts the operation or is merely logged. For structured error logging, wrap the risky work in a try/catch inside the handler and log there:

import { Hook } from '@objectstack/spec/data';

export const TaskWriteWithLogging: Hook = {
  name: 'task_write_with_logging',
  object: 'task',
  events: ['beforeInsert', 'beforeUpdate'],
  onError: 'abort',   // failure aborts the write (default); use 'log' to continue
  handler: async (ctx) => {
    try {
      await runBusinessRules(ctx.input);
    } catch (error) {
      const httpStatus = (error as AppError).httpStatus ?? 500;
      const logEntry = {
        code: (error as AppError).code,
        message: error instanceof Error ? error.message : String(error),
        httpStatus,
        object: ctx.object,
        event: ctx.event,
        userId: ctx.session?.userId,
        timestamp: new Date().toISOString(),
        stack: httpStatus >= 500 && error instanceof Error ? error.stack : undefined,
      };

      if (httpStatus >= 500) {
        console.error('Server error', logEntry);
        await alertService.notify('server-error', logEntry);
      } else if (httpStatus >= 400) {
        console.warn('Client error', logEntry);
      }

      // Re-throw to propagate to the API layer
      throw error;
    }
  },
};

5. Error Recovery Patterns

Graceful Degradation

When a non-critical service fails, continue with a fallback:

import { Hook } from '@objectstack/spec/data';

export const EnrichTaskWithAI: Hook = {
  name: 'enrich_task_with_ai',
  object: 'task',
  events: ['afterInsert'],
  onError: 'log',   // AI enrichment is non-critical — never abort the write
  handler: async (ctx) => {
    const record = ctx.result as Record<string, any>;
    try {
      const suggestions = await aiService.suggestTags(record.description);
      if (suggestions.length > 0) {
        // Scoped cross-object write — stays inside the current execution context
        await ctx.api.object('task').updateById(record.id, {
          ai_suggested_tags: suggestions,
        });
      }
    } catch (error) {
      // AI enrichment is non-critical — log and continue
      console.warn('AI tag suggestion failed', {
        taskId: record.id,
        error: error instanceof Error ? error.message : String(error),
      });
      // Don't re-throw — the task was already created successfully
    }
  },
};

Circuit Breaker

Prevent cascading failures when an external service is down:

class CircuitBreaker {
  private failures = 0;
  private lastFailure = 0;
  private readonly threshold: number;
  private readonly resetTimeout: number;

  constructor(threshold = 5, resetTimeout = 60000) {
    this.threshold = threshold;
    this.resetTimeout = resetTimeout;
  }

  get isOpen(): boolean {
    if (this.failures >= this.threshold) {
      if (Date.now() - this.lastFailure > this.resetTimeout) {
        this.failures = 0; // Reset after timeout
        return false;
      }
      return true;
    }
    return false;
  }

  recordFailure() {
    this.failures++;
    this.lastFailure = Date.now();
  }

  recordSuccess() {
    this.failures = 0;
  }

  async execute<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
    if (this.isOpen) return fallback;
    try {
      const result = await fn();
      this.recordSuccess();
      return result;
    } catch (error) {
      this.recordFailure();
      return fallback;
    }
  }
}

const emailCircuit = new CircuitBreaker(5, 60000);

6. Transaction Rollback on Error

Ensure data consistency when multi-step operations fail:

The scoped context (ctx.api) exposes a transaction() method. Its callback receives a transaction-bound ScopedContext. A ScopedContext has no top-level insert/update methods — you reach an object's repository with tx.object(name) and then call insert(data) / update(data) / updateById(id, data) on it (the record id travels inside data for update). If the callback throws, the driver rolls the transaction back.

import { Hook } from '@objectstack/spec/data';

export const CreateProjectWithTasks: Hook = {
  name: 'create_project_with_tasks',
  object: 'project',
  events: ['afterInsert'],
  handler: async (ctx) => {
    const project = ctx.result as Record<string, any>;

    // Wrap multi-step operations in a transaction
    await ctx.api.transaction(async (tx) => {
      // Create default tasks for the project
      const defaultTasks = [
        { title: 'Project kickoff', status: 'open', project: project.id },
        { title: 'Requirements gathering', status: 'open', project: project.id },
        { title: 'Design review', status: 'open', project: project.id },
      ];

      for (const task of defaultTasks) {
        await tx.object('task').insert(task);
      }

      // Update project with task count (id inside the data payload)
      await tx.object('project').update({
        id: project.id,
        task_count: defaultTasks.length,
      });

      // If any operation fails, all changes are rolled back
    });
  },
};

7. Bulk Operation Error Handling

Handle partial failures in batch operations gracefully:

interface BulkOperationResult<T> {
  succeeded: T[];
  failed: Array<{ index: number; data: unknown; error: string }>;
}

// `engine` is a scoped context (e.g. ctx.api inside a hook).
async function bulkCreateTasks(
  engine: { object(name: string): { insert(data: unknown): Promise<Record<string, unknown>> } },
  tasks: Record<string, unknown>[]
): Promise<BulkOperationResult<Record<string, unknown>>> {
  const result: BulkOperationResult<Record<string, unknown>> = {
    succeeded: [],
    failed: [],
  };

  for (let i = 0; i < tasks.length; i++) {
    try {
      const created = await engine.object('task').insert(tasks[i]);
      result.succeeded.push(created);
    } catch (error) {
      result.failed.push({
        index: i,
        data: tasks[i],
        error: error instanceof Error ? error.message : String(error),
      });
    }
  }

  // If everything failed, throw a combined error
  if (result.succeeded.length === 0 && result.failed.length > 0) {
    throw new AppError(
      'batch_complete_failure',
      `All ${result.failed.length} operations failed`,
      400,
      result.failed.map((f) => ({
        field: `operations[${f.index}]`,
        message: f.error,
        code: 'invalid_field' as const,
      })),
    );
  }

  return result;
}

8. Integration with safeParsePretty()

ObjectStack provides safeParsePretty() for Zod schemas that returns human-readable error messages:

import { safeParsePretty } from '@objectstack/spec';
import { ObjectSchema } from '@objectstack/spec/data';

function validateObjectDefinition(input: unknown) {
  const result = safeParsePretty(ObjectSchema, input, 'object definition');

  if (!result.success) {
    // result.formatted is the multi-line, human-readable block;
    // result.error is the underlying ZodError.
    console.error(result.formatted);
    // Output:
    //   ✗ fields.0.name: Invalid identifier 'firstName'. Must be lowercase snake_case...
    //   ✗ fields.1.type: Invalid field type 'dropdown'. Did you mean 'select'?
    //   ✗ name: Required property 'name' is missing.

    // Convert to the standardized error format
    throw new AppError(
      'validation_error',
      'Invalid object definition',
      400,
      result.error.issues.map((issue) => ({
        field: issue.path.join('.'),
        message: issue.message,
        code: 'invalid_field' as const,
      })),
    );
  }

  return result.data;
}

Using safeParsePretty in Hooks

import { safeParsePretty } from '@objectstack/spec';
import { Hook } from '@objectstack/spec/data';
import { z } from 'zod';

const TaskBusinessRules = z.object({
  estimated_hours: z.number().max(1000, 'Estimated hours cannot exceed 1000'),
  due_date: z.string().refine(
    (d) => new Date(d) > new Date(),
    'Due date must be in the future'
  ),
});

export const ValidateBusinessRules: Hook = {
  name: 'validate_business_rules',
  object: 'task',
  events: ['beforeInsert'],
  handler: async (ctx) => {
    const result = safeParsePretty(TaskBusinessRules, ctx.input);
    if (!result.success) {
      throw new AppError(
        'validation_error',
        result.formatted,
        400,
        result.error.issues.map((i) => ({
          field: i.path.join('.'),
          message: i.message,
          code: 'invalid_field' as const,
        })),
      );
    }
  },
};

safeParsePretty vs safeParse: Always prefer safeParsePretty() in server-side code. It provides formatted error messages that are suitable for both logging and API responses, with "Did you mean?" suggestions for common typos.

On this page