Versioning через valid_from: единая модель для цен, скидок, шаблонов и правил

Веб Штурм
  • PostgreSQL
  • versioning
  • Supabase
  • архитектура
  • audit

В Круизном флоте и Прогулочный флот один и тот же паттерн versioning через valid_from обслуживает discounts, prices, booking_rules, promotions и document templates. Insert-only, один резолвер, никаких race conditions при обновлениях, бесплатный audit trail. После двух лет в продакшене — лучшее решение для бизнес-данных, у которых есть «срок действия».

Контекст

Любая система бронирования рано или поздно ловит классическую боль: «Цена на тур поменялась 1 марта. У клиента бронь от 28 февраля. По какой цене считать?». Стандартные подходы — valid_to + is_active поля — ломаются на race conditions: два менеджера одновременно «закрывают» старую версию и «открывают» новую, и в БД остаётся либо две активных, либо ноль.

Versioning через единое поле valid_from решает это структурно. Старые записи никогда не правятся (insert-only), новая версия добавляется новым INSERT, резолвер выбирает max(valid_from) ≤ effective_date.

Базовая схема

CREATE TABLE prices (
 id bigserial PRIMARY KEY,
 tour_id bigint NOT NULL REFERENCES tours(id),
 cabin_class text NOT NULL,
 price_rub numeric NOT NULL,
 valid_from date NOT NULL,
 -- НЕТ valid_to. НЕТ is_active. НЕТ updated_at.
 created_at timestamptz NOT NULL DEFAULT now(),
 created_by bigint REFERENCES users(id)
);

CREATE INDEX idx_prices_lookup ON prices (tour_id, cabin_class, valid_from DESC);

Никаких UPDATE-ов на prices. Изменилась цена — новый INSERT с valid_from = '2026-03-01'. Старая запись с valid_from = '2025-12-01' остаётся жить вечно.

Резолвер на дату эффективности

Бронь от 28 февраля 2026 года должна посчитаться по цене, действующей на 28 февраля. SQL-резолвер:

-- Активная цена на конкретную дату для конкретного тура+класса
SELECT price_rub
FROM prices
WHERE tour_id = $1
 AND cabin_class = $2
 AND valid_from <= $3 -- effective_date (дата заявки или дата старта тура)
ORDER BY valid_from DESC
LIMIT 1;

Главное правило: effective_date — это дата бизнес-события, не now(). Для цены тура — дата тура. Для скидки — дата заявки. Для правила бронирования — дата заявки. Иначе теряется смысл historical-резолвинга.

LATERAL JOIN для list-fetching

Когда нужно показать список туров с актуальной ценой на конкретную дату — приходит LATERAL JOIN:

SELECT
 t.id,
 t.title,
 t.start_date,
 p.price_rub
FROM tours t
LEFT JOIN LATERAL (
 SELECT price_rub
 FROM prices
 WHERE tour_id = t.id
 AND cabin_class = 'standard'
 AND valid_from <= t.start_date
 ORDER BY valid_from DESC
 LIMIT 1
) p ON true
WHERE t.start_date BETWEEN $1 AND $2
ORDER BY t.start_date;

Один SQL-запрос — корректные цены на даты каждого тура. Без N+1 проблем, без фильтрации в коде, без valid_to-логики.

Seed для исторических сущностей

Когда в БД заливаются исторические данные (миграция legacy), у некоторых сущностей нет «даты начала действия» — они существовали всегда. Решение — seed с valid_from = '2000-01-01':

-- Миграция тарифов 2018-2025 годов из MariaDB
INSERT INTO prices (tour_id, cabin_class, price_rub, valid_from)
SELECT
 new_tour_id_map[old.tour_id],
 old.cabin_class,
 old.price,
 COALESCE(old.effective_from, DATE '2000-01-01')
FROM legacy.aa_prices old;

Любая будущая «эталонная» цена с valid_from > '2000-01-01' корректно перекроет seed.

Где применили

В river-sb эта схема используется для:

Из 211 SQL миграций в production около 40 — это insert-only версионные обновления тарифов и правил, никогда не приводившие к race condition или потере истории.

Преимущества (которые получаешь бесплатно)

  1. Audit trail — кто и когда сменил цену, видно из created_at + created_by. Без отдельной audit-таблицы.
  2. Time-travel queries — «покажи цены, действовавшие 1 января 2025» — один WHERE-clause.
  3. Откат без потери данных — отменить ошибочное обновление = INSERT новой версии с актуальной ценой и valid_from = now(). Старое (неправильное) обновление видно в истории, но не активно.
  4. Параллельные обновления безопасны — два менеджера могут одновременно создать две новые версии. Резолвер выберет ту, у которой valid_from позже. Никаких блокировок.

Что мы из этого вынесли

Ссылки