Использование хуков для вычисляемых полей

В Dexie.js вычисляемые поля реализуются не как отдельный слой ORM-логики, а через систему хуков таблиц, позволяющую вмешиваться в жизненный цикл операций над объектами. Такой подход обеспечивает прозрачную денормализацию, синхронизацию производных значений и контроль целостности данных без необходимости пересчитывать их вручную в бизнес-логике приложения.

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


Понятие вычисляемых полей в контексте IndexedDB

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

Типичные примеры:

  • fullName как конкатенация firstName и lastName
  • age как вычисление из birthDate
  • searchIndex как нормализованный текст для поиска
  • isActive как производное от статуса и даты последнего входа

В контексте IndexedDB и Dexie.js такие поля нельзя полагаться на вычисление “на лету” в UI, поскольку запросы могут выполняться на уровне индексов, где требуется физически присутствующее значение. Поэтому вычисляемые поля часто материализуются в записи.


Хуки Dexie.js как основа для вычислений

Dexie.js предоставляет несколько типов хуков на уровне таблиц:

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

Именно creating и updating используются для вычисляемых полей, поскольку они позволяют модифицировать данные до их записи в хранилище.


Базовая структура подключения хуков

Хуки привязываются к таблице:

db.users.hook('creating', (primKey, obj, transaction) => {
  // модификация объекта перед сохранением
});

Для обновления:

db.users.hook('updating', (modifications, primKey, obj, transaction) => {
  // модификация partial update
});

Вычисляемое поле fullName

Наиболее простой и распространённый пример — формирование полного имени.

db.users.hook('creating', (primKey, obj) => {
  obj.fullName = `${obj.firstName} ${obj.lastName}`;
});

db.users.hook('updating', (modifications) => {
  if (modifications.firstName || modifications.lastName) {
    const firstName = modifications.firstName;
    const lastName = modifications.lastName;

    // если обновляется только часть данных, полное имя пересчитывается частично
    modifications.fullName = `${firstName ?? ''} ${lastName ?? ''}`.trim();
  }
});

Особенность обновляющего хука заключается в том, что он работает с патчем, а не с полным объектом, поэтому требуется аккуратная логика обработки undefined.


Денормализация для поиска

Один из наиболее важных кейсов — подготовка данных для индексации поиска. IndexedDB эффективно работает с индексами, но не поддерживает сложные поисковые выражения. Поэтому создаются вспомогательные поля.

function normalizeText(text) {
  return text
    .toLowerCase()
    .replace(/\s+/g, ' ')
    .trim();
}

db.products.hook('creating', (primKey, obj) => {
  obj.searchIndex = normalizeText(`${obj.title} ${obj.description}`);
});

db.products.hook('updating', (modifications, primKey, obj) => {
  const title = modifications.title ?? obj.title;
  const description = modifications.description ?? obj.description;

  if (title || description) {
    modifications.searchIndex = normalizeText(`${title} ${description}`);
  }
});

Такой подход позволяет выполнять быстрые запросы через индекс searchIndex, не прибегая к full scan с фильтрацией на клиенте.


Использование транзакционного контекста

Все хуки Dexie получают доступ к текущей транзакции. Это позволяет выполнять дополнительные операции над связанными таблицами.

Пример: автоматическое обновление агрегированных данных.

db.orders.hook('creating', async (primKey, order, transaction) => {
  const user = await transaction.table('users').get(order.userId);

  order.userSnapshot = {
    name: user.fullName,
    email: user.email
  };
});

Здесь создаётся снимок пользователя на момент создания заказа, что позволяет сохранять историческую консистентность данных.


Вычисление зависимых полей при обновлении

Сложность возрастает, когда одно поле зависит от нескольких других, и обновления происходят частично.

db.profile.hook('updating', (modifications, primKey, obj) => {
  const newFirst = modifications.firstName ?? obj.firstName;
  const newLast = modifications.lastName ?? obj.lastName;
  const newNick = modifications.nickname ?? obj.nickname;

  modifications.displayName =
    newNick?.length > 0
      ? newNick
      : `${newFirst} ${newLast}`.trim();
});

Здесь вычисляемое поле имеет приоритетную логику: nickname перекрывает составное имя.


Инварианты и защита целостности данных

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

db.accounts.hook('updating', (modifications, primKey, obj) => {
  if (modifications.balance != null) {
    const newBalance = obj.balance + modifications.balance;

    if (newBalance < 0) {
      throw new Error('Баланс не может быть отрицательным');
    }

    modifications.balance = newBalance;
  }
});

Хотя это не вычисляемое поле в строгом смысле, механизм идентичен: значение выводится из текущего состояния и входящих изменений.


Проблема консистентности вычисляемых полей

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

Типовые проблемы:

  • частичное обновление без пересчёта зависимых полей
  • различие логики между creating и updating
  • изменение формулы вычисления без миграции данных

Для решения применяется единая функция расчёта:

function computeUserFields(obj) {
  return {
    fullName: `${obj.firstName} ${obj.lastName}`.trim(),
    searchIndex: normalizeText(`${obj.firstName} ${obj.lastName} ${obj.email}`)
  };
}

И использование её в хуках:

db.users.hook('creating', (primKey, obj) => {
  Object.assign(obj, computeUserFields(obj));
});

db.users.hook('updating', (modifications, primKey, obj) => {
  const merged = { ...obj, ...modifications };
  Object.assign(modifications, computeUserFields(merged));
});

Такой подход снижает вероятность расхождений логики.


Хук reading и виртуальные вычисляемые поля

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

db.users.hook('reading', (obj) => {
  obj.isAdult = obj.age >= 18;
  return obj;
});

Этот подход полезен, когда:

  • значение не используется в индексах
  • вычисление дешёвое
  • нет необходимости хранить дубликат

Однако reading не подходит для полей, участвующих в запросах через индексы.


Комбинация persisted и virtual computed fields

На практике вычисляемые поля делятся на два класса:

Материализованные (persisted):

  • участвуют в индексах
  • используются в запросах
  • сохраняются через creating/updating

Виртуальные (runtime):

  • вычисляются в reading
  • не хранятся
  • используются только в UI

Пример комбинирования:

db.products.hook('creating', (primKey, obj) => {
  obj.searchIndex = normalizeText(obj.title);
});

db.products.hook('reading', (obj) => {
  obj.isPopular = obj.views > 1000;
  return obj;
});

Массовые обновления и влияние на хуки

При использовании bulkPut, bulkAdd и bulkUpdate хуки вызываются для каждого объекта отдельно. Это важно учитывать при вычислениях.

Проблема возникает при дорогих вычислениях:

db.items.hook('creating', (primKey, obj) => {
  obj.hash = expensiveHash(obj.data);
});

При массовой вставке это может привести к значительным затратам.

Оптимизация:

  • кэширование промежуточных результатов
  • упрощение вычислений для bulk-операций
  • перенос части логики в предварительную обработку

Порядок выполнения хуков и влияние на вычисления

Dexie выполняет хуки в строгом порядке:

  1. creating
  2. updating
  3. запись в хранилище
  4. reading (при извлечении)

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


Типичные архитектурные паттерны

1. Денормализованный индекс

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

  • searchIndex
  • tagsString
  • categoryPath

2. Snapshot pattern

Фиксация состояния связанного объекта:

  • userSnapshot
  • productSnapshot

3. Derived state pattern

Вычисление агрегатов:

  • totalPrice
  • itemCount
  • ratingAverage

Ошибки проектирования вычисляемых полей

Распространённые проблемы:

  • дублирование логики в нескольких хуках
  • использование reading для индексируемых значений
  • отсутствие пересчёта при частичных обновлениях
  • хранение слишком сложных структур в вычисляемых полях

Особенно критична ситуация, когда логика расчёта распределена по нескольким хукам без централизованной функции.


Централизация вычислений как базовый принцип

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

  • чистые функции без побочных эффектов
  • единая точка расчёта
  • использование в creating, updating, batch-операциях

Такой подход делает систему предсказуемой и упрощает миграции схемы данных при изменении логики вычислений.