feat(integrations): add parked state + per-kind failure counters schema

Two additive columns on integration_statuses driving B4 + B7:

- consecutive_failures_by_kind (jsonb) — bucketed counter
  { transient, reauth_required, persistent } so a transient hiccup
  followed by a persistent failure no longer resets the persistent
  streak counter. The single-column consecutive_failures stays for
  one release as a fallback; the alert ladder reads Math.max across
  buckets for backward compatibility.

- persistent_failure_started_at (timestamp) — start of the current
  persistent failure streak. Once >24h elapse with the persistent
  bucket still non-zero, the status writer flips state to 'parked'.

The state column stays free-form; 'parked' is recognised at the
application layer. Existing rows have NULL in both new columns and
are back-filled on the next write.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 22:19:29 +02:00
1 parent 937daaad04
commit bd538b02c2
2 files changed
+59 -11

No files matched your search

@@ -0,0 +1,46 @@
-- v1.4.43 W14 — IntegrationStatus parking + per-kind failure counters.
--
-- Two additive columns on `integration_statuses`:
--
-- 1. `consecutive_failures_by_kind` (jsonb)
-- A bucketed counter that increments only the bucket matching the
-- failure kind. The single-column `consecutive_failures` integer
-- stays in place for one release as a fallback (callers reading
-- the alert-ladder still use `Math.max(...buckets)`); it will be
-- removed in v1.4.44 once every reader is migrated.
--
-- Default shape: { "transient": 0, "reauth_required": 0,
-- "persistent": 0 }. A `null` value means the
-- row predates this migration — the status writer back-fills it
-- on the next write by bucketing the old `consecutive_failures`
-- value into the current `state`.
--
-- 2. `persistent_failure_started_at` (timestamp)
-- Wall-clock timestamp of the first persistent failure in the
-- current streak. Cleared whenever the persistent bucket resets
-- to zero. Once the gap between `now()` and this timestamp
-- exceeds 24h with the persistent bucket still non-zero, the
-- status writer flips `state` to `"parked"` — the integration
-- stops retrying and the UI surfaces a "Paused — reconnect
-- manually" pill that hits `/api/integrations/withings/resume`
-- to clear the park.
--
-- Idempotent guards (`IF NOT EXISTS`) match the 0067 / 0070 / 0071
-- pattern so reruns are safe.
--
-- The `state` column is a free-form string today (deliberate per the
-- 0029 migration comment — "adding new sentinels in v1.5 doesn't
-- require a migration"). Adding `"parked"` as a recognised value is
-- therefore a no-op at the schema level; the application layer owns
-- the enum widening.
--
-- Reversibility: down migration is `ALTER TABLE ... DROP COLUMN IF
-- EXISTS` on both new columns + a single UPDATE that clears any
-- `state = 'parked'` rows back to `'error_transient'`. Both columns
-- are nullable so existing rows keep working.
ALTER TABLE "integration_statuses"
ADD COLUMN IF NOT EXISTS "consecutive_failures_by_kind" JSONB;
ALTER TABLE "integration_statuses"
ADD COLUMN IF NOT EXISTS "persistent_failure_started_at" TIMESTAMP(3);
+13 -11
View File
@@ -1342,17 +1342,19 @@ model MedicationComplianceRollup {
// `integration` values are bare strings ("withings", "moodlog") rather
// than an enum so future integrations don't require a migration.
model IntegrationStatus {
id String @id @default(cuid())
userId String @map("user_id")
integration String // "withings" | "moodlog"
state String @default("connected") // "connected" | "error_reauth" | "error_transient" | "disconnected"
lastSuccessAt DateTime? @map("last_success_at")
lastAttemptAt DateTime? @map("last_attempt_at")
lastError String? @map("last_error") // AES-256-GCM encrypted (may contain token-fragment metadata)
consecutiveFailures Int @default(0) @map("consecutive_failures")
alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(cuid())
userId String @map("user_id")
integration String // "withings" | "moodlog"
state String @default("connected") // "connected" | "error_reauth" | "error_transient" | "disconnected" | "parked"
lastSuccessAt DateTime? @map("last_success_at")
lastAttemptAt DateTime? @map("last_attempt_at")
lastError String? @map("last_error") // AES-256-GCM encrypted (may contain token-fragment metadata)
consecutiveFailures Int @default(0) @map("consecutive_failures") // v1.4.44 will drop; per-kind counter below is the source of truth
consecutiveFailuresByKind Json? @map("consecutive_failures_by_kind") // v1.4.43 W14 — { transient, reauth_required, persistent }
persistentFailureStartedAt DateTime? @map("persistent_failure_started_at") // v1.4.43 W14 — start of the current persistent streak (drives >24h park)
alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)