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 эта схема используется для:
- prices — цены кают по теплоходу/дате
- discount_types — справочник скидок (пенсионер, агентский, ранний бронь)
- booking_rules — правила оформления (минимальное число пассажиров, депозит)
- promotion_rules — правила акций (по периоду продаж и периоду тура)
- document_templates — шаблоны ваучеров и расписаний (
templates_v2)
Из 211 SQL миграций в production около 40 — это insert-only версионные обновления тарифов и правил, никогда не приводившие к race condition или потере истории.
Преимущества (которые получаешь бесплатно)
- Audit trail — кто и когда сменил цену, видно из
created_at+created_by. Без отдельной audit-таблицы. - Time-travel queries — «покажи цены, действовавшие 1 января 2025» — один WHERE-clause.
- Откат без потери данных — отменить ошибочное обновление = INSERT новой версии с актуальной ценой и
valid_from = now(). Старое (неправильное) обновление видно в истории, но не активно. - Параллельные обновления безопасны — два менеджера могут одновременно создать две новые версии. Резолвер выберет ту, у которой
valid_fromпозже. Никаких блокировок.
Что мы из этого вынесли
- Не пишите
valid_to,is_active,updated_atна бизнес-данных. Эти три поля — источник 80% багов с историей. Insert-only решает структурно. - Effective_date — это бизнес-параметр, не
now(). На запросе цены тура передавайте дату тура, не текущий момент. - LATERAL JOIN — естественный паттерн для list-fetching по версионированной таблице. Без него уйдёшь в N+1 или сложную виндовую функцию.
Ссылки
- PostgreSQL LATERAL JOINs — официальная документация — синтаксис и примеры
- Slowly Changing Dimensions Type 2 — Kimball — теоретическая база паттерна
- Temporal tables в PostgreSQL — обзор подходов — альтернативы для тех, кому нужно SQL:2011 совместимое решение