Перейти к содержанию

10 intent event

Аудит-лог переходов статуса интента: одна строка — один переход status_from → status_to с опциональным reason/payload. Источник правды для эндпоинта GET /intents/:id/events.

Имя таблицы

pm.intent_event

Назначение

Иммутабельный журнал жизненного цикла интента. Каждый переход IntentStatus (а также промежуточные PSP-state'ы) пишется отдельной строкой через writeIntentEvent(). Используется для:

  • Аудита — кто/когда/почему перевёл интент; история не может быть «затёрта» (нет UPDATE) и переживает любые ретраи и саги.
  • Внешнего API — эндпоинт GET /intents/:id/events (src/intent/handler.ts строка 492) отдаёт сервису-владельцу всю историю переходов (фильтр по serviceId владельца интента).
  • Real-time уведомлений — параллельно с INSERT публикуется сообщение в Redis-канал intent.<id> (через publishIntentStatus()), на который подписаны Auth Center streams и live-клиенты.
  • Post-mortem диагностики — по payload/reason восстанавливается контекст PSP-ошибки, эскалации в MANUAL_REVIEW, причины CANCELED/FAILED.

Запись в таблицу — единственная точка, через которую PM фиксирует факт перехода. Сам pm.intent.status хранит только текущее значение; история — здесь.

  • Пишут: только Payment Manager — writeIntentEvent() (общая обёртка) либо напрямую tx.insert(intentEvent) в psp-worker.ts (когда нужна явная транзакционная семантика).
  • Читают: только PM сам (для эндпоинта GET /intents/:id/events). Admin Panel в production через эту таблицу пока не ходит — там используется Redis live-stream.

DDL

DDL из миграции 0000_init.sql — таблица создаётся сразу в финальном виде, последующие миграции её не трогают.

CREATE TABLE "pm"."intent_event" (
    "id" serial PRIMARY KEY NOT NULL,
    "intent_id" uuid NOT NULL,
    "status_from" varchar(20),
    "status_to" varchar(20) NOT NULL,
    "reason" text,
    "payload" jsonb,
    "created_at" timestamp with time zone DEFAULT now() NOT NULL
);

CREATE INDEX "intent_event_intent_id_idx" ON "pm"."intent_event" USING btree ("intent_id");

status_from/status_to хранятся как VARCHAR(20), а не PG enum, — потому что значения объединяют сразу два набора: IntentStatus и PspState (см. src/shared/schema.ts строка 183, тип EventStatus = IntentStatus | PspState). Использовать enum значило бы либо синхронизировать два enum-а, либо плодить миграции на каждое новое значение состояния PSP.

Поля

Поле Тип NULL Default Описание
id serial no автогенерация PK; монотонно растёт — фактический порядок INSERT. Полезен как tie-breaker, если у двух событий совпал created_at (с timestamptz это редко, но возможно).
intent_id uuid no Логическая ссылка на pm.intent.id. FK не объявлен (по соглашению PM — целостность гарантируется кодом, не БД; см. 01-schema-overview.md).
status_from varchar(20) yes Предыдущий статус (EventStatus). NULL — для самого первого события null → CREATED, которое пишется внутри POST /intents (handler.ts строка 297). Для остальных переходов всегда заполнено.
status_to varchar(20) no Новый статус (EventStatus). Это «куда перешли». Применяется как payload Redis-сообщения в канале intent.<id>.
reason text yes Свободный текст-причина. Заполняется для CANCELED (текст от инициатора), FAILED (фраза от PSP / outbox / TB), MANUAL_REVIEW (escalation code из psp-worker.ts). Для «happy path» (CREATED → VALIDATED → AUTHORIZED → SETTLED) — NULL.
payload jsonb yes Доп. контекст. Используется PSP-воркером — { psp: { escalation, ... } } при эскалациях; конверты от PSP confirm пишут сюда audit-блок (см. src/psp/types.ts audit-поле). Расширяемое поле — не требует миграций.
created_at timestamptz no now() Момент INSERT. Это и есть «когда произошёл переход» — для аудита и сортировки в GET /intents/:id/events.

Возможные значения status_from / status_to

EventStatus = IntentStatus | PspState:

  • IntentStatus (см. pm.intent.status): CREATED, VALIDATED, AUTHORIZED, SETTLING, SETTLED, FAILED, MANUAL_REVIEW, CANCELED, EXPIRED.
  • PspState (см. pm.psp_tx_map.state) — промежуточные состояния IPPS-воркера, например PSP_INQUIRY, PSP_PENDING, PSP_COMPLETED и др. Они появляются в status_from при переходе <PspState> → MANUAL_REVIEW либо <PspState> → SETTLED|FAILED (psp-worker.ts строки 185, 248, 283, 332).

Индексы

Индекс Тип Колонки Назначение
intent_event_pkey btree, PK id Внутренние ссылки/UPDATE недопустимы (таблица append-only), но PK всё равно нужен — Drizzle и PostgreSQL предполагают его наличие.
intent_event_intent_id_idx btree (intent_id) Основной запрос: SELECT ... FROM intent_event WHERE intent_id = $1 ORDER BY created_at — обслуживает GET /intents/:id/events и любую диагностику.

Отдельного индекса на created_at нет: выборки всегда привязаны к одному intent_id, и в рамках одного интента событий мало (десятки максимум) — сортировка делается in-memory.

Связи

Логические (без FK)

Связь Куда Колонка
N : 1 pm.intent — родительский интент intent_idintent.id
(косвенно) N : 1 pm.psp_tx_map — для переходов с PSP-state'ами в status_from через intent_id

FK на intent.id не объявлен — по тому же соглашению, что tx_history и outbox_event.

Сторонние эффекты при INSERT

  • Redis: writeIntentEvent() принимает опциональный аргумент publishFn. Когда вызывающий передаёт publishIntentStatus, после INSERT публикуется сообщение { intentId, status, updatedAt } в Redis-канал intent.<id> (формат — JSON). Подписчики: Auth Center streamStatus() и live-клиенты. Если publishFn не передан — пишется только строка в БД, без Redis (используется в outbox-worker для тех переходов, что уже опубликованы ранее, чтобы не дублировать).
  • Уведомления / push: в эту таблицу не пишут — push-notify живёт отдельно (через outbox-worker и Notifications service).

Связанный код

Модуль / функция Роль
src/intent/intent-events.ts writeIntentEvent(db, intentId, statusFrom, statusTo, reason?, payload?, publishFn?) Каноническая обёртка: один INSERT + опциональная публикация в Redis. Ошибка publish логируется как warning и не ломает транзакцию (void + .catch()).
src/intent/intent-events.ts publishIntentStatus(intentId, status) Тонкая обёртка над redis.publish('intent.<id>', JSON.stringify(...)). Передаётся в writeIntentEvent как publishFn из API-хендлеров.
src/intent/handler.ts POST /intents Пишет цепочку null → CREATED → VALIDATED → AUTHORIZED → SETTLED (для INTERNAL) либо null → CREATED → VALIDATED → AUTHORIZED (для IPPS — оставляет на outbox).
src/intent/handler.ts GET /intents/:id/events (строка 492) Чтение: возвращает все события одного интента, отсортированные по created_at. Authz — serviceId владельца совпадает с X-Service-Id HMAC.
src/intent/confirm-handler.ts Переходы при двух-шаговой схеме: CREATED → VALIDATED → AUTHORIZED → SETTLED|FAILED.
src/intent/cancel-handler.ts Переход CREATED → CANCELED с reason от инициатора.
src/intent/outbox-worker.ts Переходы AUTHORIZED → SETTLED (post_pending) и AUTHORIZED → FAILED (void_pending); publishFn не передаётся — публикация выполнена ранее.
src/workers/psp-worker.ts Переходы между PspState'ами и <PspState> → MANUAL_REVIEW|SETTLED|FAILED. Пишет напрямую через tx.insert(intentEvent) для контроля транзакции.

Примеры запросов

История событий одного интента (тот же запрос, что в GET /intents/:id/events)

SELECT id, status_from, status_to, reason, payload, created_at
FROM   pm.intent_event
WHERE  intent_id = $1
ORDER  BY created_at, id;

(обслуживается индексом intent_event_intent_id_idx; id как tie-breaker)

Все интенты, которые когда-либо побывали в MANUAL_REVIEW

SELECT DISTINCT intent_id
FROM   pm.intent_event
WHERE  status_to = 'MANUAL_REVIEW';

(полный скан таблицы по status_to — допустимо для диагностики; на production-объёмах см. отдельный индекс при необходимости)

Сводка переходов за сутки

SELECT status_from, status_to, count(*)
FROM   pm.intent_event
WHERE  created_at > now() - interval '24 hours'
GROUP  BY status_from, status_to
ORDER  BY count(*) DESC;

(распределение состояний потока — индикатор «всё ли идёт по happy-path»)

Причины FAILED за последний час

SELECT intent_id, reason, payload, created_at
FROM   pm.intent_event
WHERE  status_to = 'FAILED'
  AND  created_at > now() - interval '1 hour'
ORDER  BY created_at DESC;

(быстрый разбор инцидентов — reason обычно содержит сообщение от PSP/TB)