Skip to content

Latest commit

 

History

History
471 lines (388 loc) · 12.5 KB

File metadata and controls

471 lines (388 loc) · 12.5 KB

Advanced Patterns: Extending Travels with Custom Logic

You can enhance Travels by wrapping its methods to add validation, permissions, logging, or other custom behavior.

Table of Contents

Intercepting and Modifying Operations

While subscribe() lets you observe state changes, it cannot prevent or modify operations. To add validation, permissions, or transform data before execution, wrap the Travels methods:

Adding validation:

const travels = createTravels({ count: 0 });

// Save the original method
const originalSetState = travels.setState.bind(travels);

// Wrap setState with validation
travels.setState = function (updater: any) {
  // Only validate direct values (not functions)
  if (typeof updater === 'object' && updater !== null) {
    // Validate
    if (updater.count > 10) {
      console.error('Count cannot exceed 10!');
      return; // Prevent execution
    }

    // Modify input - add metadata
    updater = {
      ...updater,
      count: Math.min(updater.count, 10),
      timestamp: Date.now(),
    };
  }

  // For mutation functions, wrap to validate after execution
  if (typeof updater === 'function') {
    const wrappedUpdater = (draft: any) => {
      // Execute the original mutation
      updater(draft);

      // Validate after mutation
      if (draft.count > 10) {
        draft.count = 10; // Fix invalid state
        console.warn('Count was capped at 10');
      }

      // Add metadata
      draft.timestamp = Date.now();
    };

    originalSetState(wrappedUpdater);
    return;
  }

  // Execute for direct values
  originalSetState(updater);
} as any;

travels.setState({ count: 5 }); // ✅ Works
travels.setState({ count: 100 }); // ❌ Blocked, capped at 10

// Also works with mutation functions
travels.setState((draft) => {
  draft.count = 100; // Will be capped at 10
});

Adding Permission Checks

Wrap methods to verify permissions before allowing execution:

const currentUser = { role: 'viewer' }; // Read-only user

// Prevent undo/redo for viewers
const originalBack = travels.back.bind(travels);
travels.back = function (amount?: number) {
  if (currentUser.role === 'viewer') {
    throw new Error('Permission denied: viewers cannot undo');
  }
  return originalBack(amount);
} as any;

// Same for other methods
const originalForward = travels.forward.bind(travels);
travels.forward = function (amount?: number) {
  if (currentUser.role === 'viewer') {
    throw new Error('Permission denied: viewers cannot redo');
  }
  return originalForward(amount);
} as any;

Automatically Adding Metadata to State Changes

Wrap setState to inject metadata like timestamps or user IDs:

const travels = createTravels<any>({ items: [] });
const currentUser = { id: 'user123' };

const originalSetState = travels.setState.bind(travels);

travels.setState = function (updater: any) {
  // Handle direct value
  if (typeof updater === 'object' && updater !== null) {
    if (updater.items) {
      updater = {
        ...updater,
        items: updater.items.map((item: any) => ({
          ...item,
          timestamp: Date.now(),
          userId: currentUser.id,
          version: (item.version || 0) + 1,
        })),
      };
    }
    return originalSetState(updater);
  }

  // Handle mutation function
  if (typeof updater === 'function') {
    const wrappedUpdater = (draft: any) => {
      updater(draft); // Execute original mutation

      // Add metadata after mutation
      if (draft.items) {
        draft.items.forEach((item: any) => {
          if (!item.timestamp) {
            item.timestamp = Date.now();
            item.userId = currentUser.id;
            item.version = (item.version || 0) + 1;
          }
        });
      }
    };
    return originalSetState(wrappedUpdater);
  }

  return originalSetState(updater);
} as any;

// Works with direct value
travels.setState({ items: [{ name: 'Task 1' }] });
// Result: { items: [{ name: 'Task 1', timestamp: ..., userId: ..., version: 1 }] }

// Also works with mutation
travels.setState((draft) => {
  draft.items.push({ name: 'Task 2' });
  // Metadata will be added automatically
});

Implementing Operation Logging and Auditing

Wrap methods to record all operations before and after execution:

const auditLog: any[] = [];

const originalSetState = travels.setState.bind(travels);

travels.setState = function (updater: any) {
  // Log before
  auditLog.push({
    type: 'setState',
    timestamp: Date.now(),
    user: currentUser.id,
    before: travels.getState(),
  });

  // Execute
  const result = originalSetState(updater);

  // Log after
  auditLog.push({
    type: 'setState',
    timestamp: Date.now(),
    user: currentUser.id,
    after: travels.getState(),
  });

  return result;
} as any;

Implementing Rate Limiting and Throttling

Wrap methods to control how frequently they can be called:

let lastCallTime = 0;
const throttleInterval = 100; // ms

const originalSetState = travels.setState.bind(travels);

travels.setState = function (updater: any) {
  const now = Date.now();
  if (now - lastCallTime < throttleInterval) {
    console.warn('Too many updates, throttled');
    return;
  }
  lastCallTime = now;
  return originalSetState(updater);
} as any;

Composing Multiple Wrappers

Create a reusable function that applies multiple enhancements:

const currentUser = { id: 'user123', role: 'admin' };

// Helper function to wrap travels with multiple enhancers
function enhanceTravels<S>(
  travels: Travels<S>,
  config: {
    validation?: (state: any, draft?: any) => boolean | string;
    permissions?: (action: string) => boolean;
    logging?: boolean;
    metadata?: boolean;
  }
) {
  // Wrap setState
  if (config.validation || config.metadata || config.logging) {
    const original = travels.setState.bind(travels);
    travels.setState = function (updater: any) {
      // Logging - before
      if (config.logging) {
        console.log('[setState] before:', travels.getState());
      }

      // Handle direct value
      if (typeof updater === 'object' && updater !== null) {
        // Validation for direct values
        if (config.validation) {
          const result = config.validation(updater);
          if (result !== true) {
            throw new Error(
              typeof result === 'string' ? result : 'Validation failed'
            );
          }
        }

        // Add metadata for direct values
        if (config.metadata) {
          updater = {
            ...updater,
            _meta: { timestamp: Date.now(), user: currentUser.id },
          };
        }

        const res = original(updater);

        // Logging - after
        if (config.logging) {
          console.log('[setState] after:', travels.getState());
        }

        return res;
      }

      // Handle mutation function
      if (typeof updater === 'function') {
        const wrappedUpdater = (draft: any) => {
          updater(draft);

          // Validation for mutations
          if (config.validation) {
            const result = config.validation(travels.getState(), draft);
            if (result !== true) {
              throw new Error(
                typeof result === 'string' ? result : 'Validation failed'
              );
            }
          }

          // Add metadata for mutations
          if (config.metadata) {
            draft._meta = { timestamp: Date.now(), user: currentUser.id };
          }
        };

        const res = original(wrappedUpdater);

        // Logging - after
        if (config.logging) {
          console.log('[setState] after:', travels.getState());
        }

        return res;
      }

      return original(updater);
    } as any;
  }

  // Wrap navigation methods with permissions
  if (config.permissions) {
    ['back', 'forward', 'reset', 'archive', 'rebase'].forEach((method) => {
      const original = (travels as any)[method]?.bind(travels);
      if (original) {
        (travels as any)[method] = function (...args: any[]) {
          if (!config.permissions!(method)) {
            throw new Error(`Permission denied: ${method}`);
          }
          return original(...args);
        };
      }
    });
  }

  return travels;
}

// Usage
const travels = createTravels({ count: 0 });
const enhanced = enhanceTravels(travels, {
  validation: (state, draft) => {
    const target = draft || state;
    if (target.count < 0) return 'Count cannot be negative';
    if (target.count > 100) return 'Count cannot exceed 100';
    return true;
  },
  permissions: (action) => {
    return currentUser.role !== 'viewer' || action === 'setState';
  },
  logging: true,
  metadata: true,
});

// Now works with both styles
enhanced.setState({ count: 50 }); // ✅ Direct value
enhanced.setState((draft) => {
  draft.count = 75;
}); // ✅ Mutation

Detecting History Overflow

Use subscribe() to detect when history reaches the maximum limit:

const travels = createTravels({ count: 0 }, { maxHistory: 5 });
const archive: any[] = [];

let lastPosition = 0;

travels.subscribe(({ position }) => {
  // Detect overflow: position stops growing
  if (position === lastPosition && position >= 5) {
    // Archive to external storage
    archive.push({
      state: travels.getState(),
      patches: travels.getPatches(),
      timestamp: Date.now(),
    });

    // You can save to localStorage, IndexedDB, or API
    localStorage.setItem('archive', JSON.stringify(archive));
  }

  lastPosition = position;
});

Common Patterns

Here are some reusable wrapper patterns:

// Pattern 1: Validation wrapper
function withValidation<S>(
  travels: Travels<S>,
  validator: (state: any, draft?: any) => boolean | string
) {
  const original = travels.setState.bind(travels);
  travels.setState = function (updater: any) {
    // Handle direct value
    if (typeof updater === 'object' && updater !== null) {
      const result = validator(updater);
      if (result !== true) {
        throw new Error(
          typeof result === 'string' ? result : 'Validation failed'
        );
      }
      return original(updater);
    }

    // Handle mutation function
    if (typeof updater === 'function') {
      const wrapped = (draft: any) => {
        updater(draft);
        const result = validator(travels.getState(), draft);
        if (result !== true) {
          throw new Error(
            typeof result === 'string' ? result : 'Validation failed'
          );
        }
      };
      return original(wrapped);
    }

    return original(updater);
  } as any;
  return travels;
}

// Pattern 2: Logging wrapper
function withLogging<S>(travels: Travels<S>) {
  const methods = ['setState', 'back', 'forward', 'reset', 'archive', 'rebase'];
  methods.forEach((method) => {
    const original = (travels as any)[method]?.bind(travels);
    if (original) {
      (travels as any)[method] = function (...args: any[]) {
        console.log(`[${method}] called with:`, args);
        const result = original(...args);
        console.log(`[${method}] result:`, travels.getState());
        return result;
      };
    }
  });
  return travels;
}

// Pattern 3: Permissions wrapper
function withPermissions<S>(
  travels: Travels<S>,
  checkPermission: (action: string) => boolean
) {
  const methods = ['setState', 'back', 'forward', 'reset', 'archive', 'rebase'];
  methods.forEach((method) => {
    const original = (travels as any)[method]?.bind(travels);
    if (original) {
      (travels as any)[method] = function (...args: any[]) {
        if (!checkPermission(method)) {
          throw new Error(`Permission denied: ${method}`);
        }
        return original(...args);
      };
    }
  });
  return travels;
}

// Compose all wrappers
const travels = createTravels({ count: 0 });

withValidation(
  travels,
  (state) => state.count >= 0 || 'Count must be non-negative'
);
withLogging(travels);
withPermissions(travels, (action) => currentUser.role === 'admin');

Back to Main Documentation

See the main README for basic usage, API reference, and getting started guide.