From 2a787cdcf19b5000d44e2c4724536be43e26158d Mon Sep 17 00:00:00 2001 From: Colin Ozanne Date: Fri, 29 May 2026 13:29:31 +0200 Subject: [PATCH] feat: save external services incidents to db with status (#2202) * feat: save external services incidents to db with status * refactor: bulk upsert, tighter cron concurrency, prune index * ci: apply automated fixes * fix: address review feedback on external-incident upsert * fix: validate prune TTL and handle incidents DB errors * test: cover startedAt backfill + note api_config.endpoint scope * chore: add adr + regenerate migration * chore: remove type in col --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../app/(landing)/status/[id]/incidents.tsx | 2 +- .../src/cron/external-incidents-prune.ts | 52 + apps/workflows/src/cron/external-status.ts | 265 +- apps/workflows/src/cron/index.ts | 5 + ...006-persist-external-incidents-in-turso.md | 155 + docs/adr/README.md | 1 + packages/api/src/router/externalService.ts | 88 +- packages/db/drizzle/0072_curvy_vulcan.sql | 22 + packages/db/drizzle/meta/0072_snapshot.json | 4284 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../external_service_incident.ts | 50 + .../db/src/schema/external_services/index.ts | 1 + .../schema/external_services/validation.ts | 16 + packages/services/package.json | 4 + .../external-service-incident.test.ts | 336 ++ .../src/external-service-incident/index.ts | 17 + .../src/external-service-incident/list.ts | 110 + .../src/external-service-incident/prune.ts | 48 + .../src/external-service-incident/upsert.ts | 79 + .../__tests__/fetchers/atlassian.test.ts | 102 + .../__tests__/fetchers/incidentio.test.ts | 83 + packages/status-fetcher/__tests__/helpers.ts | 34 + packages/status-fetcher/src/fetch.ts | 20 + .../status-fetcher/src/fetchers/atlassian.ts | 14 +- .../status-fetcher/src/fetchers/incidentio.ts | 14 +- packages/status-fetcher/src/incidents.ts | 70 + packages/status-fetcher/src/index.ts | 6 + packages/status-fetcher/src/types.ts | 23 + 28 files changed, 5798 insertions(+), 110 deletions(-) create mode 100644 apps/workflows/src/cron/external-incidents-prune.ts create mode 100644 docs/adr/0006-persist-external-incidents-in-turso.md create mode 100644 packages/db/drizzle/0072_curvy_vulcan.sql create mode 100644 packages/db/drizzle/meta/0072_snapshot.json create mode 100644 packages/db/src/schema/external_services/external_service_incident.ts create mode 100644 packages/services/src/external-service-incident/__tests__/external-service-incident.test.ts create mode 100644 packages/services/src/external-service-incident/index.ts create mode 100644 packages/services/src/external-service-incident/list.ts create mode 100644 packages/services/src/external-service-incident/prune.ts create mode 100644 packages/services/src/external-service-incident/upsert.ts create mode 100644 packages/status-fetcher/src/incidents.ts diff --git a/apps/web/src/app/(landing)/status/[id]/incidents.tsx b/apps/web/src/app/(landing)/status/[id]/incidents.tsx index c0075a5ff..f447ddc72 100644 --- a/apps/web/src/app/(landing)/status/[id]/incidents.tsx +++ b/apps/web/src/app/(landing)/status/[id]/incidents.tsx @@ -69,7 +69,7 @@ export function Incidents({ }: IncidentsProps) { const [data] = api.externalService.incidents.useSuspenseQuery({ slug }); - if (!data.supported || data.unavailable) { + if (!data.supported) { return ( { + return pruneStaleRawPayloads({ ctx: { db } }); +} + +export async function handleExternalIncidentsPruneCron(c: Context) { + const { cronCompleted, cronFailed } = runSentryCron( + "external-incidents-prune", + ); + + void Effect.runPromise( + Effect.tryPromise({ + try: () => runExternalIncidentsPruneTick(), + catch: (e) => + new Error( + `external-incidents-prune tick failed: ${e instanceof Error ? e.message : String(e)}`, + ), + }).pipe( + Effect.tap((res) => + Effect.sync(() => { + logger.info( + "external-incidents-prune tick complete: purged={purged}", + { purged: res.purged }, + ); + void cronCompleted(); + }), + ), + Effect.catchAll((e) => + Effect.sync(() => { + logger.error("external-incidents-prune tick errored: {message}", { + message: e.message, + }); + void reportBackgroundError(e.message); + void cronFailed(); + }), + ), + ), + ); + + return c.json({ success: true }, 200); +} diff --git a/apps/workflows/src/cron/external-status.ts b/apps/workflows/src/cron/external-status.ts index 158242fc5..642e7b110 100644 --- a/apps/workflows/src/cron/external-status.ts +++ b/apps/workflows/src/cron/external-status.ts @@ -1,10 +1,19 @@ import { getLogger } from "@logtape/logtape"; import { listExternalServices } from "@openstatus/services/external-service"; import type { ExternalServiceRow } from "@openstatus/services/external-service"; +import { + type UpsertExternalIncidentInput, + upsertExternalIncidentsForService, +} from "@openstatus/services/external-service-incident"; import { FetchError, fetchers } from "@openstatus/status-fetcher"; -import type { StatusPageEntry, StatusResult } from "@openstatus/status-fetcher"; +import type { + NormalizedIncident, + StatusFetcher, + StatusPageEntry, + StatusResult, +} from "@openstatus/status-fetcher"; import { OSTinybird } from "@openstatus/tinybird"; -import { Effect, Either } from "effect"; +import { Effect } from "effect"; import type { Context } from "hono"; import { env } from "../env"; @@ -15,6 +24,10 @@ const logger = getLogger(["workflow", "external-status"]); const tb = new OSTinybird(env().TINY_BIRD_API_KEY); +// 10 per phase × 2 phases = peak 20 concurrent HTTP requests upstream; keeps +// Atlassian/Incident.io CDNs comfortable while still parallelising heavily. +const PHASE_CONCURRENCY = 10; + function toStatusPageEntry(row: ExternalServiceRow): StatusPageEntry { return { id: row.slug, @@ -55,70 +68,229 @@ function buildSnapshot(args: { }; } -export async function runExternalStatusTick(): Promise<{ +function toUpsertInput( + incident: NormalizedIncident, +): UpsertExternalIncidentInput { + return { + providerIncidentId: incident.providerIncidentId, + name: incident.name, + status: incident.status, + impact: incident.impact, + shortlink: incident.shortlink, + startedAt: incident.startedAt, + createdAt: incident.createdAt, + resolvedAt: incident.resolvedAt, + raw: incident.raw, + }; +} + +type PhaseCounts = { successCount: number; failureCount: number; + skippedCount: number; total: number; -}> { - const services = await listExternalServices({ ctx: { db } }); +}; + +type StatusPhaseOutcome = + | { kind: "ok"; snapshot: Snapshot } + | { kind: "no-fetcher"; slug: string } + | { kind: "fail"; slug: string; reason: string }; + +type IncidentPhaseOutcome = + | { kind: "ok"; slug: string; count: number } + | { kind: "skip"; slug: string } + | { kind: "fail"; slug: string; reason: string }; + +type Triplet = { + row: ExternalServiceRow; + entry: StatusPageEntry; + fetcher: StatusFetcher | null; +}; - const entries = services.map(toStatusPageEntry); - const fetchedAt = Date.now(); - - const results = await Effect.runPromise( - Effect.forEach( - entries, - (entry) => { - const fetcher = fetchers.find((f) => f.canHandle(entry)); - if (!fetcher) { - return Effect.either( - Effect.fail( +function runStatusPhase( + triplets: Triplet[], + fetchedAt: number, +): Effect.Effect { + return Effect.forEach( + triplets, + ({ entry, fetcher }) => { + if (!fetcher) { + return Effect.succeed({ + kind: "no-fetcher", + slug: entry.id, + }); + } + return fetcher.fetch(entry).pipe( + Effect.map( + (result): StatusPhaseOutcome => ({ + kind: "ok", + snapshot: buildSnapshot({ entry, result, fetchedAt }), + }), + ), + Effect.catchAll((err: FetchError) => + Effect.succeed({ + kind: "fail", + slug: entry.id, + reason: err.message, + }), + ), + ); + }, + { concurrency: PHASE_CONCURRENCY }, + ); +} + +function runIncidentPhase( + triplets: Triplet[], + tickStartedAt: Date, +): Effect.Effect { + return Effect.forEach( + triplets, + ({ row, entry, fetcher }) => { + if (!fetcher || !fetcher.fetchIncidents) { + return Effect.succeed({ + kind: "skip", + slug: entry.id, + }); + } + return fetcher.fetchIncidents(entry).pipe( + Effect.flatMap((incidents) => + Effect.tryPromise({ + try: () => + upsertExternalIncidentsForService({ + ctx: { db }, + externalServiceId: row.id, + incidents: incidents.map(toUpsertInput), + now: tickStartedAt, + }), + catch: (e) => new FetchError({ url: entry.status_page_url, + fetcherName: fetcher.name, entryId: entry.id, - cause: new Error(`no fetcher matches entry slug=${entry.id}`), + cause: e instanceof Error ? e : new Error(String(e)), + }), + }).pipe( + Effect.map( + (result): IncidentPhaseOutcome => ({ + kind: "ok", + slug: entry.id, + count: result.upserted, }), ), - ); - } - return fetcher.fetch(entry).pipe( - Effect.map((result) => buildSnapshot({ entry, result, fetchedAt })), - Effect.either, - ); - }, - { concurrency: 25 }, - ), + ), + ), + Effect.catchAll((err: FetchError) => + Effect.succeed({ + kind: "fail", + slug: entry.id, + reason: err.message, + }), + ), + ); + }, + { concurrency: PHASE_CONCURRENCY }, ); +} +function summarizeStatus(outcomes: StatusPhaseOutcome[]): { + counts: PhaseCounts; + snapshots: Snapshot[]; +} { const snapshots: Snapshot[] = []; + let successCount = 0; let failureCount = 0; - for (const [i, r] of results.entries()) { - if (Either.isRight(r)) { - snapshots.push(r.right); + let skippedCount = 0; + for (const o of outcomes) { + if (o.kind === "ok") { + snapshots.push(o.snapshot); + successCount++; + } else if (o.kind === "no-fetcher") { + skippedCount++; + logger.warn("external-status status: no fetcher matches slug={slug}", { + slug: o.slug, + }); } else { failureCount++; - const slug = entries[i]?.id ?? ""; logger.warn( - "external-status tick: fetcher failed for slug={slug}: {reason}", - { - slug, - reason: r.left.message, - }, + "external-status status: fetch failed for slug={slug}: {reason}", + { slug: o.slug, reason: o.reason }, ); } } + return { + counts: { + successCount, + failureCount, + skippedCount, + total: outcomes.length, + }, + snapshots, + }; +} - if (snapshots.length > 0) { - await tb.publishExternalStatus(snapshots); +function summarizeIncidents(outcomes: IncidentPhaseOutcome[]): PhaseCounts { + let successCount = 0; + let failureCount = 0; + let skippedCount = 0; + for (const o of outcomes) { + if (o.kind === "ok") { + successCount++; + } else if (o.kind === "skip") { + skippedCount++; + } else { + failureCount++; + logger.warn( + "external-status incidents: failed for slug={slug}: {reason}", + { slug: o.slug, reason: o.reason }, + ); + } } - return { - successCount: snapshots.length, + successCount, failureCount, - total: entries.length, + skippedCount, + total: outcomes.length, }; } +function buildTriplets(services: ExternalServiceRow[]): Triplet[] { + return services.map((row) => { + const entry = toStatusPageEntry(row); + const fetcher = fetchers.find((f) => f.canHandle(entry)) ?? null; + return { row, entry, fetcher }; + }); +} + +export async function runExternalStatusTick(): Promise<{ + status: PhaseCounts; + incidents: PhaseCounts; +}> { + const services = await listExternalServices({ ctx: { db } }); + + const triplets = buildTriplets(services); + const tickStartedAt = new Date(); + + const [statusOutcomes, incidentOutcomes] = await Effect.runPromise( + Effect.all( + [ + runStatusPhase(triplets, tickStartedAt.getTime()), + runIncidentPhase(triplets, tickStartedAt), + ], + { concurrency: "unbounded" }, + ), + ); + + const status = summarizeStatus(statusOutcomes); + const incidents = summarizeIncidents(incidentOutcomes); + + if (status.snapshots.length > 0) { + await tb.publishExternalStatus(status.snapshots); + } + + return { status: status.counts, incidents }; +} + export async function handleExternalStatusCron(c: Context) { const { cronCompleted, cronFailed } = runSentryCron("external-status"); @@ -137,11 +309,16 @@ export async function handleExternalStatusCron(c: Context) { Effect.tap((res) => Effect.sync(() => { logger.info( - "external-status tick complete: {success}/{total} ({failures} failures)", + "external-status tick complete: status={statusOk}/{statusTotal} ({statusFail} failures, {statusSkip} skipped), incidents={incOk}/{incTotal} ({incFail} failures, {incSkip} skipped)", { - success: res.successCount, - total: res.total, - failures: res.failureCount, + statusOk: res.status.successCount, + statusTotal: res.status.total, + statusFail: res.status.failureCount, + statusSkip: res.status.skippedCount, + incOk: res.incidents.successCount, + incTotal: res.incidents.total, + incFail: res.incidents.failureCount, + incSkip: res.incidents.skippedCount, }, ); void cronCompleted(); diff --git a/apps/workflows/src/cron/index.ts b/apps/workflows/src/cron/index.ts index f48843c4e..19e70dd0c 100644 --- a/apps/workflows/src/cron/index.ts +++ b/apps/workflows/src/cron/index.ts @@ -6,6 +6,7 @@ import { env } from "../env"; import { reportBackgroundError, runSentryCron } from "../lib/sentry"; import { sendCheckerTasks } from "./checker"; import { sendFollowUpEmails } from "./emails"; +import { handleExternalIncidentsPruneCron } from "./external-incidents-prune"; import { handleExternalStatusCron } from "./external-status"; import { LaunchMonitorWorkflow, @@ -75,6 +76,10 @@ app.get("/external-status", async (c) => { return handleExternalStatusCron(c); }); +app.get("/external-incidents-prune", async (c) => { + return handleExternalIncidentsPruneCron(c); +}); + app.get("/emails/follow-up", async (c) => { try { await sendFollowUpEmails(); diff --git a/docs/adr/0006-persist-external-incidents-in-turso.md b/docs/adr/0006-persist-external-incidents-in-turso.md new file mode 100644 index 000000000..70ae516b7 --- /dev/null +++ b/docs/adr/0006-persist-external-incidents-in-turso.md @@ -0,0 +1,155 @@ +--- +status: "accepted" +date: 2026-05-28 +decision-makers: openstatus maintainers +--- + +# Persist external-service incidents in Turso, not Tinybird + +## Context and Problem Statement + +openstatus runs a public directory of third-party services (Atlassian, +Incident.io, …) and polls each one's status page on a cron. Until now a tick +produced one thing: a **status snapshot** (current indicator / message), pushed +to Tinybird as a time-series. The public page fetched each provider's +**incidents** live, on every request, straight from `/api/v2/incidents.json` +with a 5 s timeout. + +We want to persist incidents so the page no longer depends on a per-request +upstream fetch and so we retain an incident history. Where should incidents +live — Tinybird, alongside the status snapshots they arrive with, or Turso? + +ADR-0005 already split these stores and set a default: *a new high-volume, +append-only event stream goes to Tinybird, not a new Turso table.* An incident +stream looks append-y, so this needs a decision rather than the default. + +## Decision Drivers + +- An incident is a **mutable entity with a stable provider id**: its status + moves (`investigating` → `resolved`) and `resolvedAt` is filled in later. + Each tick re-fetches the *full* current incident list, so the same incident + is seen many times. +- Incidents are **low-volume** — a handful per service — unlike the + check-result firehose that motivated Tinybird in ADR-0005. +- The public page reads **"the latest N incidents for this service"**, joined + to the service row — a row-store query, not a time-window aggregation. +- The page must not 500 or hang when a third party is slow or down. +- We want to keep the **raw upstream payload** to debug normalisation and to + recover fields we currently drop, without letting JSON blobs grow unbounded. + +## Considered Options + +- Keep fetching incidents live from upstream on each page request (status quo) +- Persist incidents to Tinybird, append-only, alongside the status snapshots +- Persist incidents to Turso in a relational table, upserted on + `(externalServiceId, providerIncidentId)` + +## Decision Outcome + +Chosen option: "persist incidents to Turso, upserted on `(externalServiceId, +providerIncidentId)`". + +A new `external_service_incident` table (FK to `external_service`) stores the +normalised incident plus its raw payload. The `external-status` cron now runs +two phases per tick: the **status phase** still publishes a snapshot to +Tinybird, and a new **incident phase** fetches each provider's incident list +and **bulk-upserts** it into Turso. The unique index `(external_service_id, +provider_incident_id)` is the idempotency key — re-seeing an incident updates +the current row instead of appending a new version, so N re-fetches collapse to +one row; `firstSeenAt` / `lastSeenAt` track its observation window. Only +providers that implement `fetchIncidents` (Atlassian, Incident.io) participate; +others are skipped. + +This is a deliberate exception to ADR-0005's "append-only stream → Tinybird" +default. The force behind that default is *check-result volume*, which is +absent here; and because incidents are mutable, Tinybird's append-only model +would accumulate duplicate versions needing a dedup / last-write pipe on read. +A mutable, low-volume, relationally-read entity is exactly a Turso entity. + +The public `incidents` tRPC read now serves from Turso and **fails soft**: a DB +error degrades to `supported: false` (the upstream-link UI) rather than a 500, +matching the `grid` / `detail` procedures. + +### Raw payload retention + +Each row keeps the raw upstream JSON in `rawPayload` for two reasons: to debug +when normalisation looks wrong, and to recover fields we currently drop in +normalisation should we later want them. To bound growth, the +`external-incidents-prune` cron nulls `rawPayload` and stamps +`rawPayloadPurgedAt` once an incident has been **resolved for more than 90 days** +(a sane default, configurable via `olderThanDays`; guarded against a ≤ 0 TTL +that would purge live rows). Only resolved incidents are purged — an open one +may still change. `rawPayloadPurgedAt` distinguishes "purged" from "never had +raw", and the upsert resets it to `null` if a purged incident is re-seen, so it +regains its raw. + +### Consequences + +- Good, because the public page reads a local, indexed table — no per-request + upstream fetch, no 5 s timeout, no coupling to provider availability. +- Good, because the upsert keeps exactly one current row per incident; status + and resolution updates land in place while history accrues. +- Good, because raw payloads let us diagnose normalisation and backfill future + columns, while the prune caps storage. +- Bad, because incidents and their status snapshots now live in two different + stores written in the same tick — linked only by service id / slug, with no + cross-store transaction. +- Bad, because the upsert trusts each provider's incident id to be stable; a + provider that recycles ids would cause rows to collide. +- Bad, because purged raw payloads are unrecoverable — a normalisation gap + found more than 90 days after resolution cannot be backfilled for old rows. + +### Confirmation + +Incident write / read / prune logic lives in +`packages/services/src/external-service-incident/` (per ADR-0001), with tests +covering upsert idempotency, the resolved-only / TTL prune guard, and the +`startedAt` ordering. These verbs use the directory's light `{ db }` context, +not the workspace `ServiceContext` — external services are a global, public, +cron-driven catalogue, so there is no workspace scope, audit row, or +`requireScope` here. A new mutable, low-volume entity defaults to a Turso table +with an upsert key; a new high-volume, append-only stream still defaults to +Tinybird (ADR-0005). + +## Pros and Cons of the Options + +### Fetch incidents live on each request (status quo) + +- Good, because there is nothing to store, migrate, or prune. +- Bad, because every page view pays upstream latency and a 5 s timeout, and the + page breaks when the provider is slow or down. +- Bad, because there is no incident history and the payload is re-fetched and + re-parsed on every request. + +### Persist to Tinybird, append-only + +- Good, because incidents arrive in the same tick as the status snapshots that + already go to Tinybird — one store for both. +- Bad, because incidents are mutable: append-only keeps every version, so + "current state" needs a dedup / last-write-wins pipe on read. +- Bad, because the read is "latest N per service" — a row-store shape, not the + time-window aggregation Tinybird is for — and the volume that justifies + Tinybird is not present. + +### Persist to Turso, upsert on (service, provider incident id) + +- Good, because the natural key gives idempotent upserts and one current row + per incident. +- Good, because the relational "latest N per service" read and the FK to + `external_service` are exactly Turso's strengths. +- Bad, because it splits incidents (Turso) from their status snapshots + (Tinybird) across two stores linked only by id. + +## More Information + +- Schema: `packages/db/src/schema/external_services/external_service_incident.ts` + (migrations `0072`, `0073`). +- Services: `packages/services/src/external-service-incident/` — `upsert`, + `list`, `prune`. +- Normalisation: `packages/status-fetcher` — `fetchJsonWithRaw` keeps parsed + + raw; `fetchIncidents` on the Atlassian / Incident.io fetchers. +- Crons: `apps/workflows/src/cron/external-status.ts` (status + incident + phases) and `external-incidents-prune.ts`. +- Read: the `incidents` procedure in `packages/api/src/router/externalService.ts`. +- Related: ADR-0005 (the store-split rule this refines), ADR-0001 + (services-layer logic). diff --git a/docs/adr/README.md b/docs/adr/README.md index 158fee119..f8a0eb13e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,6 +17,7 @@ today; ADRs explain how those conventions came to be. | [0003](0003-shared-ui-comes-from-openstatus-ui.md) | All shared UI comes from `@openstatus/ui` | accepted | | [0004](0004-go-for-the-probing-tier.md) | Go for the probing tier | accepted | | [0005](0005-turso-for-app-data-tinybird-for-time-series.md) | Turso for application data, Tinybird for time-series | accepted | +| [0006](0006-persist-external-incidents-in-turso.md) | Persist external-service incidents in Turso, not Tinybird | accepted | ## When to write an ADR diff --git a/packages/api/src/router/externalService.ts b/packages/api/src/router/externalService.ts index febc40a00..dbcb60864 100644 --- a/packages/api/src/router/externalService.ts +++ b/packages/api/src/router/externalService.ts @@ -5,6 +5,7 @@ import { getExternalServiceBySlug, listExternalServices, } from "@openstatus/services/external-service"; +import { listExternalIncidentsBySlug } from "@openstatus/services/external-service-incident"; import { OSTinybird } from "@openstatus/tinybird"; import { env } from "../env"; @@ -13,8 +14,7 @@ import { createTRPCRouter, publicProcedure } from "../trpc"; const tb = new OSTinybird(env.TINY_BIRD_API_KEY); const DEFAULT_HISTORY_DAYS = 45; -const INCIDENTS_TIMEOUT_MS = 5000; -const MAX_INCIDENTS = 5; +const INCIDENTS_LIMIT = 5; async function safeData( promise: Promise<{ data: T[] }>, @@ -74,21 +74,6 @@ const incidentSchema = z.object({ resolvedAt: z.string().nullable().optional(), }); -const atlassianIncidentSchema = z.object({ - id: z.string(), - name: z.string(), - status: z.string(), - impact: z.string().optional(), - shortlink: z.string().optional(), - started_at: z.string().optional(), - created_at: z.string(), - resolved_at: z.string().nullable().optional(), -}); - -const atlassianIncidentsResponseSchema = z.object({ - incidents: z.array(atlassianIncidentSchema), -}); - export const externalServiceRouter = createTRPCRouter({ grid: publicProcedure.output(z.array(gridItemSchema)).query(async () => { const [services, latestRows] = await Promise.all([ @@ -189,63 +174,40 @@ export const externalServiceRouter = createTRPCRouter({ .output( z.object({ supported: z.boolean(), - unavailable: z.boolean().optional(), incidents: z.array(incidentSchema), }), ) .query(async ({ input }) => { - const service = await getExternalServiceBySlug({ slug: input.slug }); - if (!service || service.apiConfig?.type !== "atlassian") { - return { supported: false, incidents: [] }; - } - - const url = `${service.statusPageUrl}/api/v2/incidents.json`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), INCIDENTS_TIMEOUT_MS); try { - // packages/api carries no Next types; widen the init so the Next data - // cache hint survives without an `as` cast. - const init: RequestInit & { next?: { revalidate?: number } } = { - signal: controller.signal, - headers: { Accept: "application/json" }, - next: { revalidate: 60 }, - }; - const res = await fetch(url, init); - if (!res.ok) { - console.warn( - `[external-service incidents] non-200 from ${url}: ${res.status}`, - ); - return { supported: true, unavailable: true, incidents: [] }; - } - const json = await res.json(); - const parsed = atlassianIncidentsResponseSchema.safeParse(json); - if (!parsed.success) { - console.warn( - `[external-service incidents] invalid payload from ${url}`, - parsed.error.issues, - ); - return { supported: true, unavailable: true, incidents: [] }; + const { supported, incidents } = await listExternalIncidentsBySlug({ + slug: input.slug, + limit: INCIDENTS_LIMIT, + }); + if (!supported) { + return { supported: false, incidents: [] }; } - const incidents = parsed.data.incidents - .slice(0, MAX_INCIDENTS) - .map((i) => ({ - id: i.id, + return { + supported: true, + incidents: incidents.map((i) => ({ + id: i.providerIncidentId, name: i.name, status: i.status, - impact: i.impact, - shortlink: i.shortlink, - startedAt: i.started_at, - createdAt: i.created_at, - resolvedAt: i.resolved_at ?? null, - })); - return { supported: true, incidents }; + impact: i.impact ?? undefined, + shortlink: i.shortlink ?? undefined, + startedAt: i.startedAt?.toISOString(), + createdAt: i.createdAt.toISOString(), + resolvedAt: i.resolvedAt?.toISOString() ?? null, + })), + }; } catch (err) { + // matches the graceful-degrade pattern used by `grid` / `detail`: + // a DB hiccup on a public page shouldn't 500, it should fall back to + // the upstream-link UI via supported=false. console.warn( - `[external-service incidents] fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`, + `[external-service incidents] DB read failed for slug=${input.slug}:`, + err, ); - return { supported: true, unavailable: true, incidents: [] }; - } finally { - clearTimeout(timer); + return { supported: false, incidents: [] }; } }), }); diff --git a/packages/db/drizzle/0072_curvy_vulcan.sql b/packages/db/drizzle/0072_curvy_vulcan.sql new file mode 100644 index 000000000..f294a128d --- /dev/null +++ b/packages/db/drizzle/0072_curvy_vulcan.sql @@ -0,0 +1,22 @@ +CREATE TABLE `external_service_incident` ( + `id` integer PRIMARY KEY NOT NULL, + `external_service_id` integer NOT NULL, + `provider_incident_id` text NOT NULL, + `name` text NOT NULL, + `status` text NOT NULL, + `impact` text, + `shortlink` text, + `started_at` integer, + `created_at` integer NOT NULL, + `resolved_at` integer, + `raw_payload` text, + `raw_payload_purged_at` integer, + `first_seen_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL, + `last_seen_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL, + `updated_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL, + FOREIGN KEY (`external_service_id`) REFERENCES `external_service`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `external_service_incident_unique_idx` ON `external_service_incident` (`external_service_id`,`provider_incident_id`);--> statement-breakpoint +CREATE INDEX `external_service_incident_started_at_idx` ON `external_service_incident` (`external_service_id`,`started_at`);--> statement-breakpoint +CREATE INDEX `external_service_incident_resolved_at_idx` ON `external_service_incident` (`resolved_at`); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0072_snapshot.json b/packages/db/drizzle/meta/0072_snapshot.json new file mode 100644 index 000000000..7bf9363e8 --- /dev/null +++ b/packages/db/drizzle/meta/0072_snapshot.json @@ -0,0 +1,4284 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "92505161-27ae-4e83-a520-f156d29ba5e1", + "prevId": "e17802f1-b891-43fc-92b2-e105312bc608", + "tables": { + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_until": { + "name": "paid_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "limits": { + "name": "limits", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "workspace_stripe_id_unique": { + "name": "workspace_stripe_id_unique", + "columns": [ + "stripe_id" + ], + "isUnique": true + }, + "workspace_id_dsn_unique": { + "name": "workspace_id_dsn_unique", + "columns": [ + "id", + "dsn" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "columns": [ + "provider", + "provider_account_id" + ], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_tenant_id_unique": { + "name": "user_tenant_id_unique", + "columns": [ + "tenant_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_to_workspaces": { + "name": "users_to_workspaces", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "users_to_workspaces_workspace_id_idx": { + "name": "users_to_workspaces_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_to_workspaces_user_id_user_id_fk": { + "name": "users_to_workspaces_user_id_user_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_to_workspaces_workspace_id_workspace_id_fk": { + "name": "users_to_workspaces_workspace_id_workspace_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_workspaces_user_id_workspace_id_pk": { + "columns": [ + "user_id", + "workspace_id" + ], + "name": "users_to_workspaces_user_id_workspace_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_token": { + "name": "verification_token", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verification_token_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report": { + "name": "status_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_workspace_created_idx": { + "name": "status_report_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "status_report_page_id_idx": { + "name": "status_report_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_workspace_id_workspace_id_fk": { + "name": "status_report_workspace_id_workspace_id_fk", + "tableFrom": "status_report", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_report_page_id_page_id_fk": { + "name": "status_report_page_id_page_id_fk", + "tableFrom": "status_report", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_update": { + "name": "status_report_update", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_status_report_id_idx": { + "name": "status_report_update_status_report_id_idx", + "columns": [ + "status_report_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_status_report_id_status_report_id_fk": { + "name": "status_report_update_status_report_id_status_report_id_fk", + "tableFrom": "status_report_update", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential": { + "name": "credential", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_workspace_id_idx": { + "name": "integration_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_workspace_id_workspace_id_fk": { + "name": "integration_workspace_id_workspace_id_fk", + "tableFrom": "integration", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page": { + "name": "page", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "slug": { + "name": "slug", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "force_theme": { + "name": "force_theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "password": { + "name": "password", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_protected": { + "name": "password_protected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'public'" + }, + "auth_email_domains": { + "name": "auth_email_domains", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_ip_ranges": { + "name": "allowed_ip_ranges", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homepage_url": { + "name": "homepage_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_url": { + "name": "contact_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "locales": { + "name": "locales", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_page": { + "name": "legacy_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_index": { + "name": "allow_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_monitor_values": { + "name": "show_monitor_values", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_slug_unique": { + "name": "page_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "page_lower_slug_idx": { + "name": "page_lower_slug_idx", + "columns": [ + "LOWER(\"slug\")" + ], + "isUnique": false + }, + "page_lower_custom_domain_idx": { + "name": "page_lower_custom_domain_idx", + "columns": [ + "LOWER(\"custom_domain\")" + ], + "isUnique": false + }, + "page_workspace_id_idx": { + "name": "page_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_workspace_id_workspace_id_fk": { + "name": "page_workspace_id_workspace_id_fk", + "tableFrom": "page", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor": { + "name": "monitor", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'http'" + }, + "periodicity": { + "name": "periodicity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "external_name": { + "name": "external_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 45000 + }, + "degraded_after": { + "name": "degraded_after", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assertions": { + "name": "assertions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_endpoint": { + "name": "otel_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_headers": { + "name": "otel_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "retry": { + "name": "retry", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3 + }, + "follow_redirects": { + "name": "follow_redirects", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "monitor_workspace_id_active_idx": { + "name": "monitor_workspace_id_active_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false, + "where": "\"monitor\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "monitor_workspace_id_workspace_id_fk": { + "name": "monitor_workspace_id_workspace_id_fk", + "tableFrom": "monitor", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_subscriber": { + "name": "page_subscriber", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_type": { + "name": "channel_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_config": { + "name": "channel_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'self_signup'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "idx_page_subscriber_email_page_active": { + "name": "idx_page_subscriber_email_page_active", + "columns": [ + "LOWER(\"email\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'email'" + }, + "idx_page_subscriber_webhook_page_active": { + "name": "idx_page_subscriber_webhook_page_active", + "columns": [ + "LOWER(\"webhook_url\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'webhook'" + } + }, + "foreignKeys": { + "page_subscriber_page_id_page_id_fk": { + "name": "page_subscriber_page_id_page_id_fk", + "tableFrom": "page_subscriber", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_subscriber_channel_check": { + "name": "page_subscriber_channel_check", + "value": "(\"page_subscriber\".\"channel_type\" = 'email' AND \"page_subscriber\".\"email\" IS NOT NULL AND \"page_subscriber\".\"webhook_url\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'webhook' AND \"page_subscriber\".\"webhook_url\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL)" + } + } + }, + "page_subscriber_to_page_component": { + "name": "page_subscriber_to_page_component", + "columns": { + "page_subscriber_id": { + "name": "page_subscriber_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk": { + "name": "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_subscriber", + "columnsFrom": [ + "page_subscriber_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_subscriber_to_page_component_page_component_id_page_component_id_fk": { + "name": "page_subscriber_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk": { + "columns": [ + "page_subscriber_id", + "page_component_id" + ], + "name": "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification": { + "name": "notification", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notification_workspace_id_idx": { + "name": "notification_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_workspace_id_workspace_id_fk": { + "name": "notification_workspace_id_workspace_id_fk", + "tableFrom": "notification", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_trigger": { + "name": "notification_trigger", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_id_monitor_id_crontimestampe": { + "name": "notification_id_monitor_id_crontimestampe", + "columns": [ + "notification_id", + "monitor_id", + "cron_timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notification_trigger_monitor_id_monitor_id_fk": { + "name": "notification_trigger_monitor_id_monitor_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_trigger_notification_id_notification_id_fk": { + "name": "notification_trigger_notification_id_notification_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications_to_monitors": { + "name": "notifications_to_monitors", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notifications_to_monitors_notification_id_idx": { + "name": "notifications_to_monitors_notification_id_idx", + "columns": [ + "notification_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_to_monitors_monitor_id_monitor_id_fk": { + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_to_monitors_notification_id_notification_id_fk": { + "name": "notifications_to_monitors_notification_id_notification_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notifications_to_monitors_monitor_id_notification_id_pk": { + "columns": [ + "monitor_id", + "notification_id" + ], + "name": "notifications_to_monitors_monitor_id_notification_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_status": { + "name": "monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_status_idx": { + "name": "monitor_status_idx", + "columns": [ + "monitor_id", + "region" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_status_monitor_id_monitor_id_fk": { + "name": "monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_status_monitor_id_region_pk": { + "columns": [ + "monitor_id", + "region" + ], + "name": "monitor_status_monitor_id_region_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "invitation_workspace_id_idx": { + "name": "invitation_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incident": { + "name": "incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'triage'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "incident_screenshot_url": { + "name": "incident_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recovery_screenshot_url": { + "name": "recovery_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_resolved": { + "name": "auto_resolved", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "incident_workspace_id_idx": { + "name": "incident_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "incident_monitor_id_started_at_unique": { + "name": "incident_monitor_id_started_at_unique", + "columns": [ + "monitor_id", + "started_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "incident_monitor_id_monitor_id_fk": { + "name": "incident_monitor_id_monitor_id_fk", + "tableFrom": "incident", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set default", + "onUpdate": "no action" + }, + "incident_workspace_id_workspace_id_fk": { + "name": "incident_workspace_id_workspace_id_fk", + "tableFrom": "incident", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_acknowledged_by_user_id_fk": { + "name": "incident_acknowledged_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "acknowledged_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_resolved_by_user_id_fk": { + "name": "incident_resolved_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag": { + "name": "monitor_tag", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_workspace_id_idx": { + "name": "monitor_tag_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_workspace_id_workspace_id_fk": { + "name": "monitor_tag_workspace_id_workspace_id_fk", + "tableFrom": "monitor_tag", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag_to_monitor": { + "name": "monitor_tag_to_monitor", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_tag_id": { + "name": "monitor_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_to_monitor_monitor_tag_id_idx": { + "name": "monitor_tag_to_monitor_monitor_tag_id_idx", + "columns": [ + "monitor_tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor_tag", + "columnsFrom": [ + "monitor_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { + "columns": [ + "monitor_id", + "monitor_tag_id" + ], + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "application": { + "name": "application", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "application_dsn_unique": { + "name": "application_dsn_unique", + "columns": [ + "dsn" + ], + "isUnique": true + }, + "application_workspace_id_idx": { + "name": "application_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "application_workspace_id_workspace_id_fk": { + "name": "application_workspace_id_workspace_id_fk", + "tableFrom": "application", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance": { + "name": "maintenance", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_page_id_idx": { + "name": "maintenance_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "maintenance_workspace_id_idx": { + "name": "maintenance_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_workspace_id_workspace_id_fk": { + "name": "maintenance_workspace_id_workspace_id_fk", + "tableFrom": "maintenance", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "maintenance_page_id_page_id_fk": { + "name": "maintenance_page_id_page_id_fk", + "tableFrom": "maintenance", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "check": { + "name": "check", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "count_requests": { + "name": "count_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "check_workspace_id_idx": { + "name": "check_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "check_workspace_id_workspace_id_fk": { + "name": "check_workspace_id_workspace_id_fk", + "tableFrom": "check", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_run": { + "name": "monitor_run", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runned_at": { + "name": "runned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_run_workspace_id_idx": { + "name": "monitor_run_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "monitor_run_monitor_id_idx": { + "name": "monitor_run_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_run_workspace_id_workspace_id_fk": { + "name": "monitor_run_workspace_id_workspace_id_fk", + "tableFrom": "monitor_run", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "monitor_run_monitor_id_monitor_id_fk": { + "name": "monitor_run_monitor_id_monitor_id_fk", + "tableFrom": "monitor_run", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location": { + "name": "private_location", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_workspace_id_idx": { + "name": "private_location_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_workspace_id_workspace_id_fk": { + "name": "private_location_workspace_id_workspace_id_fk", + "tableFrom": "private_location", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_to_monitor": { + "name": "private_location_to_monitor", + "columns": { + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "private_location_to_monitor_private_location_id_idx": { + "name": "private_location_to_monitor_private_location_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + }, + "private_location_to_monitor_monitor_id_idx": { + "name": "private_location_to_monitor_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_to_monitor_private_location_id_private_location_id_fk": { + "name": "private_location_to_monitor_private_location_id_private_location_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_to_monitor_monitor_id_monitor_id_fk": { + "name": "private_location_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_group": { + "name": "monitor_group", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_group_workspace_id_idx": { + "name": "monitor_group_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "monitor_group_page_id_idx": { + "name": "monitor_group_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_group_workspace_id_workspace_id_fk": { + "name": "monitor_group_workspace_id_workspace_id_fk", + "tableFrom": "monitor_group", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_group_page_id_page_id_fk": { + "name": "monitor_group_page_id_page_id_fk", + "tableFrom": "monitor_group", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer": { + "name": "viewer", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "viewer_email_unique": { + "name": "viewer_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_accounts": { + "name": "viewer_accounts", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_accounts_user_id_viewer_id_fk": { + "name": "viewer_accounts_user_id_viewer_id_fk", + "tableFrom": "viewer_accounts", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "viewer_accounts_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "viewer_accounts_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_session": { + "name": "viewer_session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_session_user_id_viewer_id_fk": { + "name": "viewer_session_user_id_viewer_id_fk", + "tableFrom": "viewer_session", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_key": { + "name": "api_key", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hashed_token": { + "name": "hashed_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"write\"]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_key_prefix_unique": { + "name": "api_key_prefix_unique", + "columns": [ + "prefix" + ], + "isUnique": true + }, + "api_key_hashed_token_unique": { + "name": "api_key_hashed_token_unique", + "columns": [ + "hashed_token" + ], + "isUnique": true + }, + "api_key_prefix_idx": { + "name": "api_key_prefix_idx", + "columns": [ + "prefix" + ], + "isUnique": false + }, + "api_key_workspace_id_idx": { + "name": "api_key_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_id_user_id_fk": { + "name": "api_key_created_by_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_to_page_component": { + "name": "maintenance_to_page_component", + "columns": { + "maintenance_id": { + "name": "maintenance_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_to_page_component_page_component_id_idx": { + "name": "maintenance_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_to_page_component_maintenance_id_maintenance_id_fk": { + "name": "maintenance_to_page_component_maintenance_id_maintenance_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "maintenance", + "columnsFrom": [ + "maintenance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "maintenance_to_page_component_page_component_id_page_component_id_fk": { + "name": "maintenance_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "maintenance_to_page_component_maintenance_id_page_component_id_pk": { + "columns": [ + "maintenance_id", + "page_component_id" + ], + "name": "maintenance_to_page_component_maintenance_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component": { + "name": "page_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monitor'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_order": { + "name": "group_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_workspace_id_idx": { + "name": "page_component_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "page_component_page_id_monitor_id_unique": { + "name": "page_component_page_id_monitor_id_unique", + "columns": [ + "page_id", + "monitor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "page_component_workspace_id_workspace_id_fk": { + "name": "page_component_workspace_id_workspace_id_fk", + "tableFrom": "page_component", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_page_id_page_id_fk": { + "name": "page_component_page_id_page_id_fk", + "tableFrom": "page_component", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_monitor_id_monitor_id_fk": { + "name": "page_component_monitor_id_monitor_id_fk", + "tableFrom": "page_component", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_group_id_page_component_groups_id_fk": { + "name": "page_component_group_id_page_component_groups_id_fk", + "tableFrom": "page_component", + "tableTo": "page_component_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_component_type_check": { + "name": "page_component_type_check", + "value": "\"page_component\".\"type\" = 'monitor' AND \"page_component\".\"monitor_id\" IS NOT NULL OR \"page_component\".\"type\" = 'static' AND \"page_component\".\"monitor_id\" IS NULL" + } + } + }, + "status_report_to_page_component": { + "name": "status_report_to_page_component", + "columns": { + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_to_page_component_page_component_id_idx": { + "name": "status_report_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_to_page_component_status_report_id_status_report_id_fk": { + "name": "status_report_to_page_component_status_report_id_status_report_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_to_page_component_status_report_id_page_component_id_pk": { + "columns": [ + "status_report_id", + "page_component_id" + ], + "name": "status_report_to_page_component_status_report_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component_groups": { + "name": "page_component_groups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_open": { + "name": "default_open", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_groups_page_id_idx": { + "name": "page_component_groups_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "page_component_groups_workspace_id_idx": { + "name": "page_component_groups_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_component_groups_workspace_id_workspace_id_fk": { + "name": "page_component_groups_workspace_id_workspace_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_groups_page_id_page_id_fk": { + "name": "page_component_groups_page_id_page_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback": { + "name": "feedback", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocker": { + "name": "blocker", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "feedback_workspace_id_idx": { + "name": "feedback_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "feedback_workspace_id_workspace_id_fk": { + "name": "feedback_workspace_id_workspace_id_fk", + "tableFrom": "feedback", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_user_id_user_id_fk": { + "name": "feedback_user_id_user_id_fk", + "tableFrom": "feedback", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_fields": { + "name": "changed_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + "workspace_id", + "entity_type", + "entity_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service": { + "name": "external_service", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_page_url": { + "name": "status_page_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_config": { + "name": "api_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_slug_unique": { + "name": "external_service_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "external_service_deleted_at_idx": { + "name": "external_service_deleted_at_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_incident": { + "name": "external_service_incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_incident_id": { + "name": "provider_incident_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shortlink": { + "name": "shortlink", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload_purged_at": { + "name": "raw_payload_purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_incident_unique_idx": { + "name": "external_service_incident_unique_idx", + "columns": [ + "external_service_id", + "provider_incident_id" + ], + "isUnique": true + }, + "external_service_incident_started_at_idx": { + "name": "external_service_incident_started_at_idx", + "columns": [ + "external_service_id", + "started_at" + ], + "isUnique": false + }, + "external_service_incident_resolved_at_idx": { + "name": "external_service_incident_resolved_at_idx", + "columns": [ + "resolved_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_incident_external_service_id_external_service_id_fk": { + "name": "external_service_incident_external_service_id_external_service_id_fk", + "tableFrom": "external_service_incident", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_session": { + "name": "chat_session", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_session_workspace_user_updated_idx": { + "name": "chat_session_workspace_user_updated_idx", + "columns": [ + "workspace_id", + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_session_workspace_id_workspace_id_fk": { + "name": "chat_session_workspace_id_workspace_id_fk", + "tableFrom": "chat_session", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_session_user_id_user_id_fk": { + "name": "chat_session_user_id_user_id_fk", + "tableFrom": "chat_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "page_lower_slug_idx": { + "columns": { + "LOWER(\"slug\")": { + "isExpression": true + } + } + }, + "page_lower_custom_domain_idx": { + "columns": { + "LOWER(\"custom_domain\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_email_page_active": { + "columns": { + "LOWER(\"email\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_webhook_page_active": { + "columns": { + "LOWER(\"webhook_url\")": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 9f8147ad0..263f16c56 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -505,6 +505,13 @@ "when": 1779706543817, "tag": "0071_breezy_trish_tilby", "breakpoints": true + }, + { + "idx": 72, + "version": "6", + "when": 1779967971625, + "tag": "0072_curvy_vulcan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/external_services/external_service_incident.ts b/packages/db/src/schema/external_services/external_service_incident.ts new file mode 100644 index 000000000..ef0a53e46 --- /dev/null +++ b/packages/db/src/schema/external_services/external_service_incident.ts @@ -0,0 +1,50 @@ +import { sql } from "drizzle-orm"; +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; + +import { externalService } from "./external_service"; + +export const externalServiceIncident = sqliteTable( + "external_service_incident", + { + id: integer("id").primaryKey(), + externalServiceId: integer("external_service_id") + .notNull() + .references(() => externalService.id), + providerIncidentId: text("provider_incident_id").notNull(), + name: text("name").notNull(), + status: text("status").notNull(), + impact: text("impact"), + shortlink: text("shortlink"), + startedAt: integer("started_at", { mode: "timestamp" }), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), + resolvedAt: integer("resolved_at", { mode: "timestamp" }), + rawPayload: text("raw_payload", { mode: "json" }), + rawPayloadPurgedAt: integer("raw_payload_purged_at", { mode: "timestamp" }), + firstSeenAt: integer("first_seen_at", { mode: "timestamp" }) + .notNull() + .default(sql`(strftime('%s', 'now'))`), + lastSeenAt: integer("last_seen_at", { mode: "timestamp" }) + .notNull() + .default(sql`(strftime('%s', 'now'))`), + updatedAt: integer("updated_at", { mode: "timestamp" }) + .notNull() + .default(sql`(strftime('%s', 'now'))`), + }, + (t) => [ + uniqueIndex("external_service_incident_unique_idx").on( + t.externalServiceId, + t.providerIncidentId, + ), + index("external_service_incident_started_at_idx").on( + t.externalServiceId, + t.startedAt, + ), + index("external_service_incident_resolved_at_idx").on(t.resolvedAt), + ], +); diff --git a/packages/db/src/schema/external_services/index.ts b/packages/db/src/schema/external_services/index.ts index 24ea804c0..1d1fd50ea 100644 --- a/packages/db/src/schema/external_services/index.ts +++ b/packages/db/src/schema/external_services/index.ts @@ -1,3 +1,4 @@ export * from "./constants"; export * from "./external_service"; +export * from "./external_service_incident"; export * from "./validation"; diff --git a/packages/db/src/schema/external_services/validation.ts b/packages/db/src/schema/external_services/validation.ts index 37ef114c7..ce660132d 100644 --- a/packages/db/src/schema/external_services/validation.ts +++ b/packages/db/src/schema/external_services/validation.ts @@ -2,6 +2,7 @@ import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { z } from "zod"; import { externalService } from "./external_service"; +import { externalServiceIncident } from "./external_service_incident"; export const externalServiceSlugRegex = /^[a-z0-9](-?[a-z0-9])*$/; export const externalServiceSlugSchema = z @@ -19,3 +20,18 @@ export const insertExternalServiceSchema = createInsertSchema(externalService, { export type ExternalService = z.infer; export type InsertExternalService = z.infer; + +export const selectExternalServiceIncidentSchema = createSelectSchema( + externalServiceIncident, +); + +export const insertExternalServiceIncidentSchema = createInsertSchema( + externalServiceIncident, +); + +export type ExternalServiceIncident = z.infer< + typeof selectExternalServiceIncidentSchema +>; +export type InsertExternalServiceIncident = z.infer< + typeof insertExternalServiceIncidentSchema +>; diff --git a/packages/services/package.json b/packages/services/package.json index 16ec5ee3c..5764d9744 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -97,6 +97,10 @@ "import": "./src/external-service/index.ts", "types": "./src/external-service/index.ts" }, + "./external-service-incident": { + "import": "./src/external-service-incident/index.ts", + "types": "./src/external-service-incident/index.ts" + }, "./test/helpers": { "import": "./test/helpers.ts", "types": "./test/helpers.ts" diff --git a/packages/services/src/external-service-incident/__tests__/external-service-incident.test.ts b/packages/services/src/external-service-incident/__tests__/external-service-incident.test.ts new file mode 100644 index 000000000..a2ad30832 --- /dev/null +++ b/packages/services/src/external-service-incident/__tests__/external-service-incident.test.ts @@ -0,0 +1,336 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { and, db, eq, like } from "@openstatus/db"; +import { + externalService, + externalServiceIncident, +} from "@openstatus/db/src/schema"; + +import { + listExternalIncidentsBySlug, + pruneStaleRawPayloads, + upsertExternalIncidentsForService, +} from "../index"; + +const TEST_PREFIX = "svc-extincident-test"; + +afterEach(async () => { + const ids = await db + .select({ id: externalService.id }) + .from(externalService) + .where(like(externalService.slug, `${TEST_PREFIX}-%`)) + .all(); + for (const { id } of ids) { + await db + .delete(externalServiceIncident) + .where(eq(externalServiceIncident.externalServiceId, id)) + .run(); + } + await db + .delete(externalService) + .where(like(externalService.slug, `${TEST_PREFIX}-%`)) + .run(); +}); + +async function seedService(opts: { + slug: string; + aliases?: string[]; +}): Promise { + const { slug, aliases = [] } = opts; + const rows = await db + .insert(externalService) + .values({ + slug, + aliases, + name: `Test ${slug}`, + url: "https://example.com", + statusPageUrl: "https://status.example.com", + provider: "atlassian-statuspage", + industry: ["saas"], + apiConfig: { type: "atlassian" }, + }) + .returning({ id: externalService.id }) + .all(); + const id = rows[0]?.id; + if (!id) throw new Error("failed to seed external_service"); + return id; +} + +describe("upsertExternalIncidentsForService", () => { + test("inserts incidents and is idempotent on second call (update path)", async () => { + const serviceId = await seedService({ slug: `${TEST_PREFIX}-upsert` }); + const baseDate = new Date("2024-06-01T12:00:00.000Z"); + + const first = await upsertExternalIncidentsForService({ + externalServiceId: serviceId, + now: baseDate, + incidents: [ + { + providerIncidentId: "abc", + name: "Initial outage", + status: "investigating", + impact: "major", + shortlink: "https://stspg.io/abc", + startedAt: new Date("2024-06-01T11:55:00.000Z"), + createdAt: new Date("2024-06-01T11:56:00.000Z"), + resolvedAt: null, + raw: { id: "abc", body: "initial" }, + }, + ], + }); + expect(first.upserted).toBe(1); + + const inserted = await db + .select() + .from(externalServiceIncident) + .where( + and( + eq(externalServiceIncident.externalServiceId, serviceId), + eq(externalServiceIncident.providerIncidentId, "abc"), + ), + ) + .all(); + expect(inserted).toHaveLength(1); + expect(inserted[0]?.status).toBe("investigating"); + expect(inserted[0]?.resolvedAt).toBeNull(); + expect(inserted[0]?.firstSeenAt.getTime()).toBe(baseDate.getTime()); + + const laterDate = new Date("2024-06-01T13:00:00.000Z"); + const second = await upsertExternalIncidentsForService({ + externalServiceId: serviceId, + now: laterDate, + incidents: [ + { + providerIncidentId: "abc", + name: "Initial outage", + status: "resolved", + impact: "major", + shortlink: "https://stspg.io/abc", + startedAt: new Date("2024-06-01T11:55:00.000Z"), + createdAt: new Date("2024-06-01T11:56:00.000Z"), + resolvedAt: new Date("2024-06-01T12:45:00.000Z"), + raw: { id: "abc", body: "resolved" }, + }, + ], + }); + expect(second.upserted).toBe(1); + + const updated = await db + .select() + .from(externalServiceIncident) + .where( + and( + eq(externalServiceIncident.externalServiceId, serviceId), + eq(externalServiceIncident.providerIncidentId, "abc"), + ), + ) + .all(); + expect(updated).toHaveLength(1); + expect(updated[0]?.status).toBe("resolved"); + expect(updated[0]?.resolvedAt).toBeInstanceOf(Date); + expect(updated[0]?.firstSeenAt.getTime()).toBe(baseDate.getTime()); + expect(updated[0]?.lastSeenAt.getTime()).toBe(laterDate.getTime()); + }); + + test("backfills startedAt when upstream populates it after first sight", async () => { + const serviceId = await seedService({ slug: `${TEST_PREFIX}-backfill` }); + + await upsertExternalIncidentsForService({ + externalServiceId: serviceId, + incidents: [ + { + providerIncidentId: "late-start", + name: "triaging", + status: "investigating", + createdAt: new Date("2024-07-01T00:00:00.000Z"), + startedAt: undefined, + resolvedAt: null, + raw: {}, + }, + ], + }); + + const populatedStartedAt = new Date("2024-07-01T00:05:00.000Z"); + await upsertExternalIncidentsForService({ + externalServiceId: serviceId, + incidents: [ + { + providerIncidentId: "late-start", + name: "triaging", + status: "identified", + createdAt: new Date("2024-07-01T00:00:00.000Z"), + startedAt: populatedStartedAt, + resolvedAt: null, + raw: {}, + }, + ], + }); + + const rows = await db + .select() + .from(externalServiceIncident) + .where( + and( + eq(externalServiceIncident.externalServiceId, serviceId), + eq(externalServiceIncident.providerIncidentId, "late-start"), + ), + ) + .all(); + expect(rows).toHaveLength(1); + expect(rows[0]?.startedAt?.getTime()).toBe(populatedStartedAt.getTime()); + }); + + test("no-op when given an empty list", async () => { + const serviceId = await seedService({ slug: `${TEST_PREFIX}-empty` }); + const res = await upsertExternalIncidentsForService({ + externalServiceId: serviceId, + incidents: [], + }); + expect(res.upserted).toBe(0); + }); +}); + +describe("listExternalIncidentsBySlug", () => { + test("orders by started_at desc and respects limit", async () => { + const serviceId = await seedService({ slug: `${TEST_PREFIX}-list` }); + + await upsertExternalIncidentsForService({ + externalServiceId: serviceId, + incidents: [ + { + providerIncidentId: "old", + name: "Old", + status: "resolved", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + startedAt: new Date("2024-01-01T00:00:00.000Z"), + resolvedAt: new Date("2024-01-01T01:00:00.000Z"), + raw: {}, + }, + { + providerIncidentId: "new", + name: "New", + status: "investigating", + createdAt: new Date("2024-06-01T00:00:00.000Z"), + startedAt: new Date("2024-06-01T00:00:00.000Z"), + resolvedAt: null, + raw: {}, + }, + { + providerIncidentId: "mid", + name: "Mid", + status: "monitoring", + createdAt: new Date("2024-03-01T00:00:00.000Z"), + startedAt: new Date("2024-03-01T00:00:00.000Z"), + resolvedAt: null, + raw: {}, + }, + ], + }); + + const { service, incidents } = await listExternalIncidentsBySlug({ + slug: `${TEST_PREFIX}-list`, + limit: 2, + }); + + expect(service).not.toBeNull(); + expect(incidents).toHaveLength(2); + expect(incidents[0]?.providerIncidentId).toBe("new"); + expect(incidents[1]?.providerIncidentId).toBe("mid"); + }); + + test("resolves alias slug to canonical service", async () => { + const serviceId = await seedService({ + slug: `${TEST_PREFIX}-canonical`, + aliases: [`${TEST_PREFIX}-aliased`], + }); + + await upsertExternalIncidentsForService({ + externalServiceId: serviceId, + incidents: [ + { + providerIncidentId: "x", + name: "Hit via alias", + status: "resolved", + createdAt: new Date(), + startedAt: new Date(), + resolvedAt: new Date(), + raw: {}, + }, + ], + }); + + const viaAlias = await listExternalIncidentsBySlug({ + slug: `${TEST_PREFIX}-aliased`, + }); + expect(viaAlias.service?.id).toBe(serviceId); + expect(viaAlias.incidents).toHaveLength(1); + }); + + test("returns empty for unknown slug", async () => { + const { service, incidents } = await listExternalIncidentsBySlug({ + slug: `${TEST_PREFIX}-missing`, + }); + expect(service).toBeNull(); + expect(incidents).toEqual([]); + }); +}); + +describe("pruneStaleRawPayloads", () => { + test("nulls raw_payload for resolved incidents past the TTL only", async () => { + const serviceId = await seedService({ slug: `${TEST_PREFIX}-prune` }); + const now = new Date("2025-01-01T00:00:00.000Z"); + + await upsertExternalIncidentsForService({ + externalServiceId: serviceId, + now: new Date("2024-01-01T00:00:00.000Z"), + incidents: [ + { + providerIncidentId: "stale-resolved", + name: "stale", + status: "resolved", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + startedAt: new Date("2024-01-01T00:00:00.000Z"), + resolvedAt: new Date("2024-01-02T00:00:00.000Z"), + raw: { keep: "no" }, + }, + { + providerIncidentId: "recent-resolved", + name: "recent", + status: "resolved", + createdAt: new Date("2024-12-25T00:00:00.000Z"), + startedAt: new Date("2024-12-25T00:00:00.000Z"), + resolvedAt: new Date("2024-12-25T01:00:00.000Z"), + raw: { keep: "yes" }, + }, + { + providerIncidentId: "ongoing-old", + name: "ongoing", + status: "investigating", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + startedAt: new Date("2024-01-01T00:00:00.000Z"), + resolvedAt: null, + raw: { keep: "yes-ongoing" }, + }, + ], + }); + + const res = await pruneStaleRawPayloads({ + now, + olderThanDays: 90, + }); + expect(res.purged).toBe(1); + + const rows = await db + .select() + .from(externalServiceIncident) + .where(eq(externalServiceIncident.externalServiceId, serviceId)) + .all(); + + const byId = new Map(rows.map((r) => [r.providerIncidentId, r])); + expect(byId.get("stale-resolved")?.rawPayload).toBeNull(); + expect(byId.get("stale-resolved")?.rawPayloadPurgedAt).toBeInstanceOf(Date); + expect(byId.get("recent-resolved")?.rawPayload).not.toBeNull(); + expect(byId.get("recent-resolved")?.rawPayloadPurgedAt).toBeNull(); + expect(byId.get("ongoing-old")?.rawPayload).not.toBeNull(); + expect(byId.get("ongoing-old")?.rawPayloadPurgedAt).toBeNull(); + }); +}); diff --git a/packages/services/src/external-service-incident/index.ts b/packages/services/src/external-service-incident/index.ts new file mode 100644 index 000000000..c72852679 --- /dev/null +++ b/packages/services/src/external-service-incident/index.ts @@ -0,0 +1,17 @@ +export { + type UpsertExternalIncidentInput, + type UpsertExternalIncidentsResult, + upsertExternalIncidentsForService, +} from "./upsert"; +export { + type ExternalIncidentListItem, + type ListBySlugResult, + INCIDENT_SUPPORTED_API_CONFIG_TYPES, + listExternalIncidentsByServiceId, + listExternalIncidentsBySlug, + supportsIncidents, +} from "./list"; +export { + type PruneRawPayloadsResult, + pruneStaleRawPayloads, +} from "./prune"; diff --git a/packages/services/src/external-service-incident/list.ts b/packages/services/src/external-service-incident/list.ts new file mode 100644 index 000000000..24a855958 --- /dev/null +++ b/packages/services/src/external-service-incident/list.ts @@ -0,0 +1,110 @@ +import { db as defaultDb, desc, eq, sql } from "@openstatus/db"; +import type { ApiConfigType } from "@openstatus/db/src/schema"; +import { externalServiceIncident } from "@openstatus/db/src/schema"; + +import { getExternalServiceBySlug } from "../external-service"; +import type { GlobalReadContext } from "../external-service/internal"; + +export type ExternalIncidentListItem = { + id: number; + externalServiceId: number; + providerIncidentId: string; + name: string; + status: string; + impact: string | null; + shortlink: string | null; + startedAt: Date | null; + createdAt: Date; + resolvedAt: Date | null; + firstSeenAt: Date; + lastSeenAt: Date; +}; + +const DEFAULT_LIMIT = 5; + +// must stay in sync with fetchers that implement `fetchIncidents` in @openstatus/status-fetcher +export const INCIDENT_SUPPORTED_API_CONFIG_TYPES = new Set([ + "atlassian", + "incidentio", +]); + +export function supportsIncidents(type: ApiConfigType | undefined): boolean { + return type !== undefined && INCIDENT_SUPPORTED_API_CONFIG_TYPES.has(type); +} + +export async function listExternalIncidentsByServiceId(args: { + ctx?: GlobalReadContext; + externalServiceId: number; + limit?: number; +}): Promise { + const { ctx, externalServiceId } = args; + const db = ctx?.db ?? defaultDb; + const limit = args.limit ?? DEFAULT_LIMIT; + + const rows = await db + .select({ + id: externalServiceIncident.id, + externalServiceId: externalServiceIncident.externalServiceId, + providerIncidentId: externalServiceIncident.providerIncidentId, + name: externalServiceIncident.name, + status: externalServiceIncident.status, + impact: externalServiceIncident.impact, + shortlink: externalServiceIncident.shortlink, + startedAt: externalServiceIncident.startedAt, + createdAt: externalServiceIncident.createdAt, + resolvedAt: externalServiceIncident.resolvedAt, + firstSeenAt: externalServiceIncident.firstSeenAt, + lastSeenAt: externalServiceIncident.lastSeenAt, + }) + .from(externalServiceIncident) + .where(eq(externalServiceIncident.externalServiceId, externalServiceId)) + .orderBy( + desc( + sql`COALESCE(${externalServiceIncident.startedAt}, ${externalServiceIncident.createdAt})`, + ), + desc(externalServiceIncident.createdAt), + ) + .limit(limit) + .all(); + + return rows; +} + +export type ListBySlugResult = { + service: { id: number; apiConfigType?: ApiConfigType } | null; + supported: boolean; + incidents: ExternalIncidentListItem[]; +}; + +export async function listExternalIncidentsBySlug(args: { + ctx?: GlobalReadContext; + slug: string; + limit?: number; +}): Promise { + const { ctx, slug } = args; + const service = await getExternalServiceBySlug({ ctx, slug }); + if (!service) { + return { service: null, supported: false, incidents: [] }; + } + + const apiConfigType = service.apiConfig?.type; + if (!supportsIncidents(apiConfigType)) { + return { + service: { id: service.id, apiConfigType }, + supported: false, + incidents: [], + }; + } + + const incidents = await listExternalIncidentsByServiceId({ + ctx, + externalServiceId: service.id, + limit: args.limit, + }); + + return { + service: { id: service.id, apiConfigType }, + supported: true, + incidents, + }; +} diff --git a/packages/services/src/external-service-incident/prune.ts b/packages/services/src/external-service-incident/prune.ts new file mode 100644 index 000000000..6b7b3be7f --- /dev/null +++ b/packages/services/src/external-service-incident/prune.ts @@ -0,0 +1,48 @@ +import { and, db as defaultDb, isNotNull, lt } from "@openstatus/db"; +import { externalServiceIncident } from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; +import { withBusyRetry } from "../retry"; + +const DEFAULT_RAW_PAYLOAD_TTL_DAYS = 90; + +export type PruneRawPayloadsResult = { + purged: number; +}; + +export async function pruneStaleRawPayloads(args: { + ctx?: { db?: DB }; + olderThanDays?: number; + now?: Date; +}): Promise { + const { ctx } = args; + const olderThanDays = args.olderThanDays ?? DEFAULT_RAW_PAYLOAD_TTL_DAYS; + // guard: a negative/zero cutoff would purge recent or not-yet-resolved rows + if (!Number.isFinite(olderThanDays) || olderThanDays < 1) { + throw new Error( + `pruneStaleRawPayloads: olderThanDays must be >= 1, got ${olderThanDays}`, + ); + } + const now = args.now ?? new Date(); + const cutoff = new Date(now.getTime() - olderThanDays * 24 * 60 * 60 * 1000); + const db = ctx?.db ?? defaultDb; + + return withBusyRetry(async () => { + const updated = await db + .update(externalServiceIncident) + .set({ + rawPayload: null, + rawPayloadPurgedAt: now, + }) + .where( + and( + isNotNull(externalServiceIncident.rawPayload), + isNotNull(externalServiceIncident.resolvedAt), + lt(externalServiceIncident.resolvedAt, cutoff), + ), + ) + .returning({ id: externalServiceIncident.id }) + .all(); + return { purged: updated.length }; + }); +} diff --git a/packages/services/src/external-service-incident/upsert.ts b/packages/services/src/external-service-incident/upsert.ts new file mode 100644 index 000000000..e906a6506 --- /dev/null +++ b/packages/services/src/external-service-incident/upsert.ts @@ -0,0 +1,79 @@ +import { db as defaultDb, sql } from "@openstatus/db"; +import { externalServiceIncident } from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; +import { withBusyRetry } from "../retry"; + +export type UpsertExternalIncidentInput = { + providerIncidentId: string; + name: string; + status: string; + impact?: string; + shortlink?: string; + startedAt?: Date; + createdAt: Date; + resolvedAt: Date | null; + raw: unknown; +}; + +export type UpsertExternalIncidentsResult = { + upserted: number; +}; + +export async function upsertExternalIncidentsForService(args: { + ctx?: { db?: DB }; + externalServiceId: number; + incidents: UpsertExternalIncidentInput[]; + now?: Date; +}): Promise { + const { ctx, externalServiceId, incidents } = args; + if (incidents.length === 0) return { upserted: 0 }; + + const now = args.now ?? new Date(); + const db = ctx?.db ?? defaultDb; + + const values = incidents.map((incident) => ({ + externalServiceId, + providerIncidentId: incident.providerIncidentId, + name: incident.name, + status: incident.status, + impact: incident.impact, + shortlink: incident.shortlink, + startedAt: incident.startedAt, + createdAt: incident.createdAt, + resolvedAt: incident.resolvedAt, + rawPayload: incident.raw, + rawPayloadPurgedAt: null, + firstSeenAt: now, + lastSeenAt: now, + updatedAt: now, + })); + + return withBusyRetry(() => + db.transaction(async (tx) => { + await tx + .insert(externalServiceIncident) + .values(values) + .onConflictDoUpdate({ + target: [ + externalServiceIncident.externalServiceId, + externalServiceIncident.providerIncidentId, + ], + set: { + name: sql`excluded.name`, + status: sql`excluded.status`, + impact: sql`excluded.impact`, + shortlink: sql`excluded.shortlink`, + startedAt: sql`excluded.started_at`, + resolvedAt: sql`excluded.resolved_at`, + rawPayload: sql`excluded.raw_payload`, + rawPayloadPurgedAt: null, + lastSeenAt: now, + updatedAt: now, + }, + }) + .run(); + return { upserted: values.length }; + }), + ); +} diff --git a/packages/status-fetcher/__tests__/fetchers/atlassian.test.ts b/packages/status-fetcher/__tests__/fetchers/atlassian.test.ts index 65e49528e..da0facc64 100644 --- a/packages/status-fetcher/__tests__/fetchers/atlassian.test.ts +++ b/packages/status-fetcher/__tests__/fetchers/atlassian.test.ts @@ -3,9 +3,12 @@ import { AtlassianFetcher } from "../../src/fetchers/atlassian"; import type { StatusPageEntry } from "../../src/types"; import { expectFetchError, + expectIncidentsFetchError, installMockFetch, runFetcher, runFetcherExit, + runIncidents, + runIncidentsExit, } from "../helpers"; describe("AtlassianFetcher", () => { @@ -283,4 +286,103 @@ describe("AtlassianFetcher", () => { expect(err.cause).toBeInstanceOf(Error); }); }); + + describe("fetchIncidents", () => { + const entry: StatusPageEntry = { + id: "github", + name: "GitHub", + url: "https://github.com", + status_page_url: "https://www.githubstatus.com", + provider: "atlassian-statuspage", + industry: ["development-tools"], + api_config: { type: "atlassian" }, + }; + + it("normalizes incidents and preserves the raw payload", async () => { + const mockResponse = { + incidents: [ + { + id: "abc", + name: "API errors", + status: "investigating", + impact: "major", + shortlink: "https://stspg.io/abc", + started_at: "2024-02-16T11:00:00.000Z", + created_at: "2024-02-16T12:00:00.000Z", + resolved_at: null, + extra_provider_field: { foo: 1, bar: ["x"] }, + }, + { + id: "def", + name: "Resolved blip", + status: "resolved", + created_at: "2024-02-15T08:00:00.000Z", + resolved_at: "2024-02-15T08:30:00.000Z", + }, + ], + }; + + const fetchMock = installMockFetch(() => + Promise.resolve({ + ok: true, + json: async () => mockResponse, + } as Response), + ); + + const incidents = await runIncidents(fetcher, entry); + + expect(fetchMock).toHaveBeenCalledWith( + "https://www.githubstatus.com/api/v2/incidents.json", + expect.any(Object), + ); + expect(incidents).toHaveLength(2); + expect(incidents[0]).toMatchObject({ + providerIncidentId: "abc", + name: "API errors", + status: "investigating", + impact: "major", + shortlink: "https://stspg.io/abc", + resolvedAt: null, + }); + expect(incidents[0]?.startedAt).toBeInstanceOf(Date); + expect(incidents[0]?.createdAt).toBeInstanceOf(Date); + expect(incidents[0]?.raw).toMatchObject({ + id: "abc", + extra_provider_field: { foo: 1, bar: ["x"] }, + }); + expect(incidents[1]).toMatchObject({ + providerIncidentId: "def", + status: "resolved", + }); + expect(incidents[1]?.resolvedAt).toBeInstanceOf(Date); + }); + + it("fails with FetchError on non-200", async () => { + installMockFetch(() => + Promise.resolve({ + ok: false, + status: 503, + statusText: "Service Unavailable", + } as Response), + ); + + const exit = await runIncidentsExit(fetcher, entry); + const err = expectIncidentsFetchError(exit); + expect(err.httpStatus).toBe(503); + expect(err.fetcherName).toBe("atlassian"); + }); + + it("fails with FetchError on malformed payload", async () => { + installMockFetch(() => + Promise.resolve({ + ok: true, + json: async () => ({ incidents: [{ no_id: true }] }), + } as Response), + ); + + const exit = await runIncidentsExit(fetcher, entry); + const err = expectIncidentsFetchError(exit); + expect(err.cause).toBeInstanceOf(Error); + }); + }); }); diff --git a/packages/status-fetcher/__tests__/fetchers/incidentio.test.ts b/packages/status-fetcher/__tests__/fetchers/incidentio.test.ts index dd01544ac..95196d755 100644 --- a/packages/status-fetcher/__tests__/fetchers/incidentio.test.ts +++ b/packages/status-fetcher/__tests__/fetchers/incidentio.test.ts @@ -3,9 +3,12 @@ import { IncidentioFetcher } from "../../src/fetchers/incidentio"; import type { StatusPageEntry } from "../../src/types"; import { expectFetchError, + expectIncidentsFetchError, installMockFetch, runFetcher, runFetcherExit, + runIncidents, + runIncidentsExit, } from "../helpers"; describe("IncidentioFetcher", () => { @@ -282,4 +285,84 @@ describe("IncidentioFetcher", () => { expect(err.cause).toBeInstanceOf(Error); }); }); + + describe("fetchIncidents", () => { + const entry: StatusPageEntry = { + id: "linear", + name: "Linear", + url: "https://linear.app", + status_page_url: "https://status.linear.app", + provider: "incidentio", + industry: ["development-tools"], + api_config: { type: "incidentio" }, + }; + + it("normalizes incident.io payloads", async () => { + const mockResponse = { + incidents: [ + { + id: "INC-1", + name: "Login degraded", + status: "monitoring", + impact: "minor", + created_at: "2024-05-01T10:00:00.000Z", + started_at: "2024-05-01T09:55:00.000Z", + resolved_at: null, + severity: "low", + }, + ], + }; + + const fetchMock = installMockFetch(() => + Promise.resolve({ + ok: true, + json: async () => mockResponse, + } as Response), + ); + + const incidents = await runIncidents(fetcher, entry); + + expect(fetchMock).toHaveBeenCalledWith( + "https://status.linear.app/api/v2/incidents.json", + expect.any(Object), + ); + expect(incidents).toHaveLength(1); + expect(incidents[0]).toMatchObject({ + providerIncidentId: "INC-1", + name: "Login degraded", + status: "monitoring", + impact: "minor", + resolvedAt: null, + }); + expect(incidents[0]?.raw).toMatchObject({ + id: "INC-1", + severity: "low", + }); + }); + + it("returns an empty list when upstream sends no incidents", async () => { + installMockFetch(() => + Promise.resolve({ + ok: true, + json: async () => ({ incidents: [] }), + } as Response), + ); + const incidents = await runIncidents(fetcher, entry); + expect(incidents).toEqual([]); + }); + + it("fails with FetchError on non-200", async () => { + installMockFetch(() => + Promise.resolve({ + ok: false, + status: 500, + statusText: "Server Error", + } as Response), + ); + const exit = await runIncidentsExit(fetcher, entry); + const err = expectIncidentsFetchError(exit); + expect(err.httpStatus).toBe(500); + expect(err.fetcherName).toBe("incidentio"); + }); + }); }); diff --git a/packages/status-fetcher/__tests__/helpers.ts b/packages/status-fetcher/__tests__/helpers.ts index 8b067767e..0cf67b22f 100644 --- a/packages/status-fetcher/__tests__/helpers.ts +++ b/packages/status-fetcher/__tests__/helpers.ts @@ -2,6 +2,7 @@ import { mock } from "bun:test"; import { Cause, Effect, Exit, Option } from "effect"; import type { FetchError } from "../src/fetch"; import type { + NormalizedIncident, StatusFetcher, StatusPageEntry, StatusResult, @@ -37,3 +38,36 @@ export const expectFetchError = ( } return failure.value; }; + +export const runIncidents = ( + fetcher: StatusFetcher, + entry: StatusPageEntry, +): Promise => { + if (!fetcher.fetchIncidents) { + throw new Error(`fetcher ${fetcher.name} has no fetchIncidents`); + } + return Effect.runPromise(fetcher.fetchIncidents(entry)); +}; + +export const runIncidentsExit = ( + fetcher: StatusFetcher, + entry: StatusPageEntry, +) => { + if (!fetcher.fetchIncidents) { + throw new Error(`fetcher ${fetcher.name} has no fetchIncidents`); + } + return Effect.runPromiseExit(fetcher.fetchIncidents(entry)); +}; + +export const expectIncidentsFetchError = ( + exit: Exit.Exit, +): FetchError => { + if (!Exit.isFailure(exit)) { + throw new Error("expected Exit.Failure, got Success"); + } + const failure = Cause.failureOption(exit.cause); + if (Option.isNone(failure)) { + throw new Error("expected Cause.Fail, got defect"); + } + return failure.value; +}; diff --git a/packages/status-fetcher/src/fetch.ts b/packages/status-fetcher/src/fetch.ts index c2f599dcc..b5e9fc0ec 100644 --- a/packages/status-fetcher/src/fetch.ts +++ b/packages/status-fetcher/src/fetch.ts @@ -1,5 +1,6 @@ import { type Duration, Effect, Schedule } from "effect"; import type { z } from "zod"; +import type { JsonValue } from "./types"; type FetchErrorInit = { url: string; @@ -139,6 +140,25 @@ export const fetchJson = ( ), ); +// `response.json()` is typed `Promise` by the platform lib; the cast pins +// the boundary to a concrete JsonValue so callers never touch `any`/`unknown`. +export const fetchJsonWithRaw = ( + opts: FetchBaseOptions & { schema: z.ZodType }, +): Effect.Effect<{ parsed: T; raw: JsonValue }, FetchError> => + fetchBody(opts, JSON_HEADERS, (response) => + Effect.tryPromise({ + try: () => response.json() as Promise, + catch: failWith(opts), + }).pipe( + Effect.flatMap((raw) => + Effect.try({ + try: () => ({ parsed: opts.schema.parse(raw), raw }), + catch: failWith(opts), + }), + ), + ), + ); + export const fetchText = ( opts: FetchBaseOptions, ): Effect.Effect => diff --git a/packages/status-fetcher/src/fetchers/atlassian.ts b/packages/status-fetcher/src/fetchers/atlassian.ts index b01879df5..062808666 100644 --- a/packages/status-fetcher/src/fetchers/atlassian.ts +++ b/packages/status-fetcher/src/fetchers/atlassian.ts @@ -1,7 +1,13 @@ import { Effect } from "effect"; import { z } from "zod"; import { type FetchError, fetchJson } from "../fetch"; -import type { StatusFetcher, StatusPageEntry, StatusResult } from "../types"; +import { fetchAtlassianCompatibleIncidents } from "../incidents"; +import type { + NormalizedIncident, + StatusFetcher, + StatusPageEntry, + StatusResult, +} from "../types"; import { SEVERITY_LEVELS } from "../types"; import { inferStatus, urlHostnameEndsWith } from "../utils"; @@ -54,4 +60,10 @@ export class AtlassianFetcher implements StatusFetcher { }), ); } + + fetchIncidents( + entry: StatusPageEntry, + ): Effect.Effect { + return fetchAtlassianCompatibleIncidents({ entry, fetcherName: this.name }); + } } diff --git a/packages/status-fetcher/src/fetchers/incidentio.ts b/packages/status-fetcher/src/fetchers/incidentio.ts index acee70250..21200ca2c 100644 --- a/packages/status-fetcher/src/fetchers/incidentio.ts +++ b/packages/status-fetcher/src/fetchers/incidentio.ts @@ -1,7 +1,13 @@ import { Effect } from "effect"; import { z } from "zod"; import { type FetchError, fetchJson } from "../fetch"; -import type { StatusFetcher, StatusPageEntry, StatusResult } from "../types"; +import { fetchAtlassianCompatibleIncidents } from "../incidents"; +import type { + NormalizedIncident, + StatusFetcher, + StatusPageEntry, + StatusResult, +} from "../types"; import { SEVERITY_LEVELS } from "../types"; import { inferStatus, urlHostnameEndsWith } from "../utils"; @@ -57,4 +63,10 @@ export class IncidentioFetcher implements StatusFetcher { }), ); } + + fetchIncidents( + entry: StatusPageEntry, + ): Effect.Effect { + return fetchAtlassianCompatibleIncidents({ entry, fetcherName: this.name }); + } } diff --git a/packages/status-fetcher/src/incidents.ts b/packages/status-fetcher/src/incidents.ts new file mode 100644 index 000000000..6354f0aaf --- /dev/null +++ b/packages/status-fetcher/src/incidents.ts @@ -0,0 +1,70 @@ +import { Effect } from "effect"; +import { z } from "zod"; +import { type FetchError, fetchJsonWithRaw } from "./fetch"; +import type { JsonValue, NormalizedIncident, StatusPageEntry } from "./types"; + +export const atlassianIncidentSchema = z.object({ + id: z.string(), + name: z.string(), + status: z.string(), + impact: z.string().optional(), + shortlink: z.string().optional(), + started_at: z.string().optional(), + created_at: z.string(), + resolved_at: z.string().nullable().optional(), +}); + +export const atlassianIncidentsResponseSchema = z.object({ + incidents: z.array(atlassianIncidentSchema), +}); + +export type AtlassianIncident = z.infer; + +const isJsonObject = ( + value: JsonValue, +): value is { [key: string]: JsonValue } => + value !== null && typeof value === "object" && !Array.isArray(value); + +const rawIncidentsArray = (raw: JsonValue): JsonValue[] => { + if (!isJsonObject(raw)) return []; + const incidents = raw.incidents; + return Array.isArray(incidents) ? incidents : []; +}; + +const normalizeAtlassianIncident = ( + parsed: AtlassianIncident, + raw: JsonValue, +): NormalizedIncident => ({ + providerIncidentId: parsed.id, + name: parsed.name, + status: parsed.status, + impact: parsed.impact, + shortlink: parsed.shortlink, + startedAt: parsed.started_at ? new Date(parsed.started_at) : undefined, + createdAt: new Date(parsed.created_at), + resolvedAt: parsed.resolved_at ? new Date(parsed.resolved_at) : null, + raw, +}); + +export const fetchAtlassianCompatibleIncidents = (args: { + entry: StatusPageEntry; + fetcherName: string; +}): Effect.Effect => { + const { entry, fetcherName } = args; + // `api_config.endpoint` overrides the summary URL only; incidents always + // come from the standard `/api/v2/incidents.json` path under status_page_url. + const url = `${entry.status_page_url}/api/v2/incidents.json`; + return fetchJsonWithRaw({ + url, + schema: atlassianIncidentsResponseSchema, + fetcherName, + entryId: entry.id, + }).pipe( + Effect.map(({ parsed, raw }) => { + const rawList = rawIncidentsArray(raw); + return parsed.incidents.map((incident, index) => + normalizeAtlassianIncident(incident, rawList[index] ?? {}), + ); + }), + ); +}; diff --git a/packages/status-fetcher/src/index.ts b/packages/status-fetcher/src/index.ts index e21e68199..2e493a70d 100644 --- a/packages/status-fetcher/src/index.ts +++ b/packages/status-fetcher/src/index.ts @@ -2,4 +2,10 @@ export * from "./types"; export * from "./utils"; export { FetchError } from "./fetch"; export { fetchers } from "./fetchers"; +export { + atlassianIncidentSchema, + atlassianIncidentsResponseSchema, + fetchAtlassianCompatibleIncidents, +} from "./incidents"; +export type { AtlassianIncident } from "./incidents"; export type { DirectoryEntry } from "./data/index"; diff --git a/packages/status-fetcher/src/types.ts b/packages/status-fetcher/src/types.ts index afdf0f85a..02630fcdb 100644 --- a/packages/status-fetcher/src/types.ts +++ b/packages/status-fetcher/src/types.ts @@ -107,8 +107,31 @@ export interface StatusResult { timezone?: string; } +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +export interface NormalizedIncident { + providerIncidentId: string; + name: string; + status: string; + impact?: string; + shortlink?: string; + startedAt?: Date; + createdAt: Date; + resolvedAt: Date | null; + raw: JsonValue; +} + export interface StatusFetcher { name: string; canHandle(entry: StatusPageEntry): boolean; fetch(entry: StatusPageEntry): Effect.Effect; + fetchIncidents?( + entry: StatusPageEntry, + ): Effect.Effect; } -- 2.51.2