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_id → intent.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 CenterstreamStatus()и 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¶
(полный скан таблицы по 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)