Skip to content

Latest commit

 

History

History
521 lines (436 loc) · 16.3 KB

File metadata and controls

521 lines (436 loc) · 16.3 KB
title Server-Side Error Handling
description 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](/docs/api#error-handling) and [Wire Format](/docs/api/wire-format#7-error-response-format) for both wire formats in use, and the [Error Catalog](/docs/api/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.

{/* os:check */}

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. Bulk (multi: true) writes fire these same events, not separate *Many 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:

{/* os:check */}

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.