Использование хуков для аудита и логирования

Архитектурная роль хуков в модели данных

Система хуков в Dexie.js представляет собой механизм перехвата операций над таблицами до и после их выполнения. Каждый хук привязан к конкретному жизненному циклу записи: созданию, чтению, обновлению и удалению. На уровне архитектуры это позволяет внедрять слой наблюдаемости без изменения бизнес-логики запросов и без дублирования кода в каждом месте доступа к данным.

Хуки работают на уровне таблицы и активируются в контексте транзакции, что делает их частью атомарной операции. Это критически важно для аудита: любые изменения фиксируются синхронно с фактической модификацией данных, исключая расхождения между логом и состоянием базы.


Основные типы хуков и их поведение

Каждая таблица поддерживает несколько категорий хуков:

  • creating — вызывается перед добавлением записи
  • reading — вызывается при чтении данных
  • updating — вызывается перед изменением записи
  • deleting — вызывается перед удалением записи

Каждый из них может модифицировать входные данные или полностью отменить операцию, выбросив исключение.

Пример базовой регистрации:

db.users.hook('creating', function (primKey, obj, transaction) {
  obj.createdAt = new Date();
});

В этом сценарии данные обогащаются метаданными до фактической записи.


Принцип построения аудита через hooks

Аудит в контексте IndexedDB и Dexie.js строится на перехвате всех мутаций и сохранении их в отдельной журналируемой таблице. Такой подход обеспечивает неизменяемую историю событий.

Базовая модель аудита обычно включает:

  • тип операции (create, update, delete)
  • имя таблицы
  • первичный ключ
  • снимок данных до изменения
  • снимок данных после изменения
  • временную метку
  • контекст пользователя или сессии

Реализация универсального логирующего слоя

Логирование удобно централизовать через обертку над хуками таблиц. Пример структуры audit-таблицы:

db.version(1).stores({
  users: '++id, name, email',
  auditLogs: '++id, table, action, key, timestamp'
});

Далее подключается универсальный механизм:

function registerAudit(tableName) {
  const table = db.table(tableName);

  table.hook('creating', function (primKey, obj, transaction) {
    transaction._audit = transaction._audit || [];
    transaction._audit.push({
      table: tableName,
      action: 'create',
      key: primKey,
      before: null,
      after: obj,
      timestamp: Date.now()
    });
  });

  table.hook('updating', function (mods, primKey, obj, transaction) {
    transaction._audit = transaction._audit || [];
    transaction._audit.push({
      table: tableName,
      action: 'update',
      key: primKey,
      before: obj,
      after: { ...obj, ...mods },
      timestamp: Date.now()
    });
  });

  table.hook('deleting', function (primKey, obj, transaction) {
    transaction._audit = transaction._audit || [];
    transaction._audit.push({
      table: tableName,
      action: 'delete',
      key: primKey,
      before: obj,
      after: null,
      timestamp: Date.now()
    });
  });
}

Завершение транзакции и фиксация аудита

Ключевой момент заключается в том, что сами хуки не должны выполнять отдельные записи в IndexedDB вне текущей транзакции, иначе возникает риск несогласованности. Вместо этого используется финализация после завершения операции.

db.on('transaction', function (tx) {
  tx.on('complete', function () {
    if (!tx._audit) return;
    db.auditLogs.bulkAdd(tx._audit);
  });
});

Такой подход гарантирует, что лог сохраняется только в случае успешного завершения транзакции.


Логирование изменений на уровне diff

Полный снимок объекта часто избыточен, особенно при частых обновлениях. Более эффективная стратегия — хранение различий между состояниями.

function diff(oldObj, newObj) {
  const changes = {};
  for (const key in newObj) {
    if (oldObj[key] !== newObj[key]) {
      changes[key] = {
        from: oldObj[key],
        to: newObj[key]
      };
    }
  }
  return changes;
}

Использование diff внутри updating хука:

table.hook('updating', function (mods, primKey, obj) {
  const changes = diff(obj, { ...obj, ...mods });

  transaction._audit.push({
    table: table.name,
    action: 'update',
    key: primKey,
    changes
  });
});

Контроль целостности данных через хуки

Хуки могут использоваться не только для логирования, но и для валидации. В контексте аудита это важно, поскольку предотвращает запись неконсистентных данных до фиксации события.

db.users.hook('creating', function (primKey, obj) {
  if (!obj.email || !obj.email.includes('@')) {
    throw new Error('Invalid email format');
  }
});

Любая ошибка в хуке отменяет операцию, что автоматически предотвращает попадание некорректных данных в журнал изменений.


Проблема циклического логирования

При внедрении аудита возникает риск рекурсии: запись логов сама вызывает хуки. Это приводит к бесконечным циклам.

Решение заключается в разделении контекста:

table.hook('creating', function (primKey, obj, tx) {
  if (tx.name === 'readonly') return;
});

Или через явный флаг:

transaction._isAuditWrite = true;

И проверка:

if (transaction._isAuditWrite) return;

Асинхронные ограничения хуков

Хуки в Dexie.js выполняются синхронно в рамках транзакции. Это означает, что нельзя использовать await внутри обработчиков. Любая попытка асинхронного вызова нарушает модель выполнения.

Для обхода используется буферизация данных:

  • хук собирает события
  • события сохраняются в транзакционный контекст
  • запись в audit-таблицу происходит после commit

Расширенная модель аудита с контекстом пользователя

Для полноценного логирования часто требуется привязка к пользователю или сессии. Это реализуется через внешний контекст:

const auditContext = {
  userId: null,
  sessionId: null
};

Далее контекст прокидывается в транзакции:

db.users.hook('updating', function (mods, primKey, obj, tx) {
  tx._auditContext = auditContext;
});

И сохраняется вместе с логом:

{
  userId: tx._auditContext.userId,
  sessionId: tx._auditContext.sessionId
}

Наблюдение за чтением данных

Хотя чтение не изменяет состояние базы, оно может быть важным для аналитики и безопасности. Хук reading позволяет фиксировать доступ к данным.

db.users.hook('reading', function (obj) {
  return {
    ...obj,
    _accessedAt: Date.now()
  };
});

В аудиторных системах это используется для построения профиля активности, обнаружения аномалий доступа и контроля чувствительных данных.


Масштабирование аудита в больших приложениях

При росте объема данных основная проблема аудита — нагрузка на запись. Для оптимизации применяются следующие подходы:

  • агрегация логов в памяти перед записью
  • батчевый bulkAdd
  • разделение audit-таблиц по времени (шардирование)
  • ограничение глубины diff

Пример батча:

let buffer = [];

function flush() {
  if (!buffer.length) return;
  db.auditLogs.bulkAdd(buffer);
  buffer = [];
}

Комбинирование хуков с транзакциями

Хуки становятся наиболее мощными при совместном использовании с транзакциями. Они позволяют строить связные цепочки изменений, где каждое действие логируется как часть единого атомарного процесса.

db.transaction('rw', db.users, db.auditLogs, async () => {
  await db.users.update(1, { name: 'new' });
});

Все хуки внутри этой транзакции работают в едином контексте, обеспечивая консистентность аудита.


Поведенческая модель системы аудита

При зрелой архитектуре логирование перестает быть вспомогательной функцией и становится отдельным слоем инфраструктуры. Хуки выполняют роль триггеров событий, а audit-таблица превращается в источник правды о состоянии системы.

Такой подход позволяет:

  • восстанавливать историю изменений
  • анализировать поведение пользователей
  • отлаживать сложные цепочки мутаций
  • строить event-sourcing поверх IndexedDB

Структура системы становится реактивной: любое изменение данных автоматически порождает событие, фиксируемое без участия прикладного кода.