From 2f80f91c72964688e9b574bc1474c7ba0dbdcdac Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 30 Jun 2026 21:26:52 +0200 Subject: [PATCH 1/5] feat(billing): stop ingestion once an org is 3 days overdue and never paid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dunning enforcement path that hard-stops OTLP + Cloudflare Logpush ingestion for orgs that signed up, never paid an invoice, and are now 3+ days past due. Autumn's `check()` does not block on `past_due` by default and can't express the narrower "3 days AND never paid" policy, so this is implemented as Autumn-idiomatic state replication: a Svix-verified `billing.updated` webhook maintains the overdue clock, a daily reconcile cron promotes overdue-≥3d + never-paid orgs to suspended (and clears them on payment), and the ingest gateway reads the resulting flag and 402s. - db: new `org_billing_suspensions` table (+ incremental migration 0003); `suspended_at IS NOT NULL` is the gateway enforcement flag. - api: `billing-webhook.http.ts` (Web Crypto Svix verification, no new dep), `BillingSuspensionService` + pure `BillingSuspensionPolicy`, daily cron wired via `event.cron` dispatch in worker.ts + alchemy/wrangler crons, new `AUTUMN_WEBHOOK_SECRET` env. Extracted the shared Autumn-call helper into `lib/AutumnClient.ts`; added `invoices` to the domain `BillingCustomer`. - ingest: `ingest_suspended` threaded through `OrgRouting` (1s TTL), a `LEFT JOIN org_billing_suspensions` in the key/connector/routing queries, and a `402 billing_suspended` before the entitlement gate on both paths. Tests: policy boundaries, PGlite reconcile (promote/clear), Svix signature accept/tamper/wrong-secret, gateway suspension propagation. Full api suite (654) + ingest cargo tests (39) + typecheck (24 pkgs) green. Co-Authored-By: Claude Opus 4.8 --- apps/api/alchemy.run.ts | 7 +- apps/api/src/app.ts | 4 + apps/api/src/billing-suspension-runtime.ts | 62 + apps/api/src/lib/AutumnClient.ts | 63 + apps/api/src/lib/Env.ts | 2 + apps/api/src/routes/billing-webhook.http.ts | 223 + apps/api/src/routes/billing-webhook.test.ts | 73 + apps/api/src/routes/billing.http.ts | 57 +- .../services/BillingSuspensionPolicy.test.ts | 105 + .../src/services/BillingSuspensionPolicy.ts | 57 + .../services/BillingSuspensionService.test.ts | 162 + .../src/services/BillingSuspensionService.ts | 207 + apps/api/src/worker.ts | 46 +- apps/api/wrangler.jsonc | 5 +- apps/ingest/src/main.rs | 138 +- .../db/drizzle/0003_lowly_fantastic_four.sql | 10 + packages/db/drizzle/meta/0003_snapshot.json | 5677 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/index.ts | 1 + .../db/src/schema/org-billing-suspensions.ts | 29 + packages/domain/src/http/billing.ts | 12 + 21 files changed, 6877 insertions(+), 70 deletions(-) create mode 100644 apps/api/src/billing-suspension-runtime.ts create mode 100644 apps/api/src/lib/AutumnClient.ts create mode 100644 apps/api/src/routes/billing-webhook.http.ts create mode 100644 apps/api/src/routes/billing-webhook.test.ts create mode 100644 apps/api/src/services/BillingSuspensionPolicy.test.ts create mode 100644 apps/api/src/services/BillingSuspensionPolicy.ts create mode 100644 apps/api/src/services/BillingSuspensionService.test.ts create mode 100644 apps/api/src/services/BillingSuspensionService.ts create mode 100644 packages/db/drizzle/0003_lowly_fantastic_four.sql create mode 100644 packages/db/drizzle/meta/0003_snapshot.json create mode 100644 packages/db/src/schema/org-billing-suspensions.ts diff --git a/apps/api/alchemy.run.ts b/apps/api/alchemy.run.ts index 59439d47..9960205f 100644 --- a/apps/api/alchemy.run.ts +++ b/apps/api/alchemy.run.ts @@ -143,8 +143,10 @@ export const createMapleApi = async ({ stage, domains }: CreateMapleApiOptions) url: true, adopt: true, routes: domains.api ? [{ pattern: `${domains.api}/*`, adopt: true }] : undefined, - // Periodic VCS sync backstop (every 12h) — enqueues a refresh per installation; see worker.ts `scheduled`. - crons: ["0 */12 * * *"], + // Cron schedules — dispatched by `event.cron` in worker.ts `scheduled`: + // - "0 *\/12 * * *": VCS sync backstop (enqueues a refresh per installation). + // - "0 6 * * *": daily billing-suspension reconcile (overdue ≥3d + never-paid → stop ingest). + crons: ["0 */12 * * *", "0 6 * * *"], eventSources: [ { queue: vcsSyncQueue, @@ -195,6 +197,7 @@ export const createMapleApi = async ({ stage, domains }: CreateMapleApiOptions) ...optionalPlain("CLERK_PUBLISHABLE_KEY"), ...optionalSecret("CLERK_JWT_KEY"), ...optionalSecret("AUTUMN_SECRET_KEY"), + ...optionalSecret("AUTUMN_WEBHOOK_SECRET"), ...optionalSecret("SD_INTERNAL_TOKEN"), ...optionalSecret("INTERNAL_SERVICE_TOKEN"), ...optionalPlain("HAZEL_API_BASE_URL"), diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 76f8750b..1136b4d1 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,6 +4,7 @@ import { HttpMiddleware, HttpRouter, HttpServerResponse } from "effect/unstable/ import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi" import { McpLive } from "./mcp/app" import { HttpBillingLive, HttpBillingPublicLive } from "./routes/billing.http" +import { BillingWebhookRouter } from "./routes/billing-webhook.http" import { HttpAiTriageLive } from "./routes/ai-triage.http" import { HttpAlertsLive } from "./routes/alerts.http" import { HttpAnomaliesLive } from "./routes/anomalies.http" @@ -36,6 +37,7 @@ import { HttpWarehouseLive } from "./routes/warehouse.http" import { AiTriageService } from "./services/AiTriageService" import { AlertRuntime, AlertsService } from "./services/AlertsService" import { AnomalyDetectionService } from "./services/AnomalyDetectionService" +import { BillingSuspensionService } from "./services/BillingSuspensionService" import { BucketCacheService, EdgeCacheService } from "@maple/query-engine/caching" import { CacheBackendLive } from "./lib/CacheBackendLive" import { ErrorsService } from "./services/ErrorsService" @@ -100,6 +102,7 @@ const CoreServicesLive = Layer.mergeAll( OrgIngestKeysService.layer, OrgClickHouseSettingsService.layer, OrganizationService.layer, + BillingSuspensionService.layer, // Shared with ScrapeTargetsService via layer memoization so the proxy and // the internal target list resolve sub-targets from one discovery cache. PlanetScaleDiscoveryService.layer, @@ -237,6 +240,7 @@ export const AllRoutes = Layer.mergeAll( PrometheusScrapeProxyRouter, ScraperInternalRouter, VcsWebhookRouter, + BillingWebhookRouter, McpLive, HealthRouter, McpGetFallback, diff --git a/apps/api/src/billing-suspension-runtime.ts b/apps/api/src/billing-suspension-runtime.ts new file mode 100644 index 00000000..3e458109 --- /dev/null +++ b/apps/api/src/billing-suspension-runtime.ts @@ -0,0 +1,62 @@ +import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare" +import { WorkerConfigProviderLayer, WorkerEnvironment } from "@maple/effect-cloudflare" +import { Cause, Effect, Layer } from "effect" +import { DatabasePgLive } from "./lib/DatabasePgLive" +import { Env } from "./lib/Env" +import { BillingSuspensionService } from "./services/BillingSuspensionService" + +// --------------------------------------------------------------------------- +// Per-invocation runtime for the daily billing-suspension reconcile cron. +// Mirrors `vcs-sync-runtime.ts`: its own light layer graph (NOT the fetch +// path's MainLive) so the cron invocation stays within the startup CPU budget. +// --------------------------------------------------------------------------- + +const telemetry = MapleCloudflareSDK.make({ + serviceName: "maple-api", + serviceNamespace: "backend", + repositoryUrl: "https://github.com/Makisuo/maple", +}) + +export const buildBillingSuspensionLayer = (_env: Record) => { + const ConfigLive = WorkerConfigProviderLayer + const EnvLive = Env.layer.pipe(Layer.provide(ConfigLive)) + const DatabaseLive = DatabasePgLive.pipe(Layer.provide(WorkerEnvironment.layer)) + const Base = Layer.mergeAll(EnvLive, DatabaseLive, WorkerEnvironment.layer) + + const ServiceLive = BillingSuspensionService.layer.pipe(Layer.provide(Base)) + + return ServiceLive.pipe(Layer.provideMerge(telemetry.layer), Layer.provideMerge(ConfigLive)) +} + +export const flushBillingTelemetry = (env: Record) => telemetry.flush(env) + +// The cron program: scan the overdue set, promote/clear per the policy. +export const runBillingSuspensionReconcile = Effect.gen(function* () { + const service = yield* BillingSuspensionService + const result = yield* service.runReconcile() + yield* Effect.annotateCurrentSpan({ + "billing.reconcile.scanned": result.scanned, + "billing.reconcile.suspended": result.suspended, + "billing.reconcile.cleared": result.cleared, + "billing.reconcile.outcome": "completed", + }) + yield* Effect.logInfo("[billing] suspension reconcile tick complete").pipe( + Effect.annotateLogs({ + scanned: result.scanned, + suspended: result.suspended, + cleared: result.cleared, + }), + ) +}).pipe( + // tapCause lets the cause propagate so `withSpan` marks the tick as Error. + Effect.tapCause((cause) => + Effect.annotateCurrentSpan({ "billing.reconcile.outcome": "failed" }).pipe( + Effect.flatMap(() => + Effect.logError("[billing] suspension reconcile tick failed").pipe( + Effect.annotateLogs({ error: Cause.pretty(cause) }), + ), + ), + ), + ), + Effect.withSpan("BillingSuspensionReconcile.tick"), +) diff --git a/apps/api/src/lib/AutumnClient.ts b/apps/api/src/lib/AutumnClient.ts new file mode 100644 index 00000000..ca10269d --- /dev/null +++ b/apps/api/src/lib/AutumnClient.ts @@ -0,0 +1,63 @@ +import { autumnHandler, type CustomerData } from "autumn-js/backend" +import { Effect, Schema } from "effect" +import { BillingUpstreamError } from "@maple/domain/http" + +// Shared, dependency-free primitives for speaking the internal `autumn-js/backend` +// contract. Extracted from billing.http.ts so non-HTTP callers (the billing +// reconcile cron, the Autumn webhook receiver) reuse the exact same call path +// instead of re-implementing it. The HTTP billing group still owns the per-org +// edge cache (`readCustomerCached`) — that stays in billing.http.ts. + +export type AutumnResult = Awaited> + +// `autumnHandler` matches its route by `method` + `path`, always POST against +// `${DEFAULT_PATH_PREFIX}/${route}` (= /api/autumn/) regardless of which +// Maple endpoint fronts it, so every call here speaks that internal contract. +export const AUTUMN_PATH_PREFIX = "/api/autumn" + +export const makeCallAutumn = + (secretKey: string | undefined) => + ( + route: string, + body: unknown, + customerId: string | undefined, + customerData?: CustomerData, + ): Effect.Effect => + secretKey === undefined + ? Effect.fail(new BillingUpstreamError({ message: "Billing is not configured" })) + : Effect.tryPromise({ + try: () => + autumnHandler({ + request: { url: `${AUTUMN_PATH_PREFIX}/${route}`, method: "POST", body }, + customerId, + customerData, + clientOptions: { secretKey }, + }), + catch: (error) => + new BillingUpstreamError({ + message: error instanceof Error ? error.message : String(error), + }), + }) + +// Surface a readable message for a non-2xx Autumn response (it carries a +// `{ message }` / `{ error }` body) so the client error isn't an opaque 502. +const upstreamMessage = (result: AutumnResult): string => { + const body = result.response as { message?: unknown; error?: unknown } | null + const message = body?.message ?? body?.error + return typeof message === "string" ? message : `Billing request failed (${result.statusCode})` +} + +export const ensureOk = (result: AutumnResult): Effect.Effect => + result.statusCode >= 200 && result.statusCode < 300 + ? Effect.succeed(result.response) + : Effect.fail(new BillingUpstreamError({ message: upstreamMessage(result) })) + +export const decodeUpstream = ( + schema: S, + value: unknown, +): Effect.Effect => + Schema.decodeUnknownEffect(schema)(value).pipe( + Effect.mapError( + (error) => new BillingUpstreamError({ message: `Unexpected billing response: ${error}` }), + ), + ) diff --git a/apps/api/src/lib/Env.ts b/apps/api/src/lib/Env.ts index 0ac4aab8..008db1e2 100644 --- a/apps/api/src/lib/Env.ts +++ b/apps/api/src/lib/Env.ts @@ -27,6 +27,7 @@ export interface EnvShape { readonly CLERK_JWT_KEY: Option.Option> readonly MAPLE_ORG_ID_OVERRIDE: Option.Option readonly AUTUMN_SECRET_KEY: Option.Option> + readonly AUTUMN_WEBHOOK_SECRET: Option.Option> readonly SD_INTERNAL_TOKEN: Option.Option> readonly INTERNAL_SERVICE_TOKEN: Option.Option> readonly EMAIL_FROM: string @@ -84,6 +85,7 @@ const envConfig = Config.all({ CLERK_JWT_KEY: optionalRedacted("CLERK_JWT_KEY"), MAPLE_ORG_ID_OVERRIDE: optionalString("MAPLE_ORG_ID_OVERRIDE"), AUTUMN_SECRET_KEY: optionalRedacted("AUTUMN_SECRET_KEY"), + AUTUMN_WEBHOOK_SECRET: optionalRedacted("AUTUMN_WEBHOOK_SECRET"), SD_INTERNAL_TOKEN: optionalRedacted("SD_INTERNAL_TOKEN"), INTERNAL_SERVICE_TOKEN: optionalRedacted("INTERNAL_SERVICE_TOKEN"), EMAIL_FROM: stringWithDefault("EMAIL_FROM", "Maple "), diff --git a/apps/api/src/routes/billing-webhook.http.ts b/apps/api/src/routes/billing-webhook.http.ts new file mode 100644 index 00000000..3380a6ed --- /dev/null +++ b/apps/api/src/routes/billing-webhook.http.ts @@ -0,0 +1,223 @@ +import { HttpRouter, type HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { Effect, Option, Redacted } from "effect" +import { BillingSuspensionService } from "../services/BillingSuspensionService" +import { Env } from "../lib/Env" + +// --------------------------------------------------------------------------- +// Public Autumn webhook receiver (`POST /api/billing/autumn/webhook`). Autumn +// delivers via Svix; we verify the `svix-*` headers against AUTUMN_WEBHOOK_SECRET +// (Web Crypto HMAC-SHA256, mirroring the GitHub webhook verifier) and, for a +// `billing.updated` event, re-derive the customer's overdue state from Autumn +// and reconcile its `org_billing_suspensions` row. NOT behind auth — authenticity +// is the signature. See docs/* and the suspension service for the policy. +// --------------------------------------------------------------------------- + +const ROUTE = "/api/billing/autumn/webhook" + +const textResponse = (body: string, status: number) => HttpServerResponse.text(body, { status }) + +const timingSafeEqual = (a: string, b: string): boolean => { + const ba = Buffer.from(a) + const bb = Buffer.from(b) + if (ba.length !== bb.length) return false + let mismatch = 0 + for (let i = 0; i < ba.length; i += 1) mismatch |= ba[i]! ^ bb[i]! + return mismatch === 0 +} + +// Verify a Svix signature: HMAC-SHA256 over `${id}.${timestamp}.${body}` keyed +// by the base64-decoded secret (the part after the `whsec_` prefix), base64 +// compared against any `v1,` token in the space-separated header. Returns +// false on any crypto failure rather than throwing. +export const verifySvixSignature = (input: { + readonly secret: string + readonly svixId: string + readonly svixTimestamp: string + readonly body: string + readonly signatureHeader: string +}) => + Effect.gen(function* () { + const rawSecret = input.secret.startsWith("whsec_") + ? input.secret.slice("whsec_".length) + : input.secret + const keyBytes = Buffer.from(rawSecret, "base64") + const key = yield* Effect.tryPromise({ + try: () => + crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, [ + "sign", + ]), + catch: () => "import_failed" as const, + }).pipe(Effect.option) + if (Option.isNone(key)) return false + + const signedContent = `${input.svixId}.${input.svixTimestamp}.${input.body}` + const mac = yield* Effect.tryPromise({ + try: () => crypto.subtle.sign("HMAC", key.value, new TextEncoder().encode(signedContent)), + catch: () => "sign_failed" as const, + }).pipe(Effect.option) + if (Option.isNone(mac)) return false + + const expected = Buffer.from(mac.value).toString("base64") + // Header: space-separated tokens like "v1, v1a,". + const candidates = input.signatureHeader.split(" ").map((token) => { + const comma = token.indexOf(",") + return comma === -1 ? token : token.slice(comma + 1) + }) + return candidates.some((candidate) => candidate.length > 0 && timingSafeEqual(expected, candidate)) + }) + +// Pull the org (Autumn customer_id) out of a `billing.updated` payload. Autumn +// nests the changed entity under `data`; tolerate the common id placements so a +// minor shape change doesn't silently drop events. +export const extractCustomerId = (event: unknown): string | null => { + if (typeof event !== "object" || event === null) return null + const data = (event as { data?: unknown }).data + const containers = [data, event].filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + for (const container of containers) { + const direct = container.customer_id ?? container.customerId + if (typeof direct === "string" && direct.length > 0) return direct + const customer = container.customer + if (typeof customer === "object" && customer !== null) { + const nested = (customer as { id?: unknown }).id + if (typeof nested === "string" && nested.length > 0) return nested + } + } + return null +} + +const safeJsonParse = (body: string): unknown => { + try { + return JSON.parse(body) + } catch { + return null + } +} + +export const BillingWebhookRouter = HttpRouter.use((router) => + Effect.gen(function* () { + const env = yield* Env + const service = yield* BillingSuspensionService + + yield* router.add("POST", ROUTE, (req: HttpServerRequest.HttpServerRequest) => + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "http.request.method": req.method, + "http.route": ROUTE, + }) + const headers = req.headers as Record + + if (Option.isNone(env.AUTUMN_WEBHOOK_SECRET)) { + yield* Effect.logWarning( + "[billing] webhook rejected: AUTUMN_WEBHOOK_SECRET not configured", + ) + yield* Effect.annotateCurrentSpan({ + "http.response.status_code": 401, + "otel.status_code": "Ok", + "billing.webhook.outcome": "rejected", + "billing.webhook.reason": "secret_not_configured", + }) + return textResponse("Webhook secret not configured", 401) + } + + const svixId = headers["svix-id"] + const svixTimestamp = headers["svix-timestamp"] + const svixSignature = headers["svix-signature"] + const bodyOpt = yield* req.text.pipe(Effect.option) + if (Option.isNone(bodyOpt) || bodyOpt.value.length === 0) { + yield* Effect.annotateCurrentSpan({ + "http.response.status_code": 400, + "otel.status_code": "Ok", + "billing.webhook.outcome": "rejected", + "billing.webhook.reason": "empty_body", + }) + return textResponse("Missing request body", 400) + } + if (!svixId || !svixTimestamp || !svixSignature) { + yield* Effect.annotateCurrentSpan({ + "http.response.status_code": 400, + "otel.status_code": "Ok", + "billing.webhook.outcome": "rejected", + "billing.webhook.reason": "missing_headers", + }) + return textResponse("Missing svix signature headers", 400) + } + + const valid = yield* verifySvixSignature({ + secret: Redacted.value(env.AUTUMN_WEBHOOK_SECRET.value), + svixId, + svixTimestamp, + body: bodyOpt.value, + signatureHeader: svixSignature, + }) + if (!valid) { + yield* Effect.annotateCurrentSpan({ + "http.response.status_code": 401, + "otel.status_code": "Ok", + "billing.webhook.outcome": "rejected", + "billing.webhook.reason": "signature_mismatch", + }) + return textResponse("Invalid signature", 401) + } + + const event = safeJsonParse(bodyOpt.value) + const eventType = + typeof event === "object" && event !== null + ? (event as { type?: unknown }).type + : undefined + if (eventType !== "billing.updated") { + yield* Effect.annotateCurrentSpan({ + "http.response.status_code": 200, + "otel.status_code": "Ok", + "billing.webhook.outcome": "ignored", + "billing.webhook.event_type": typeof eventType === "string" ? eventType : "unknown", + }) + return textResponse("ignored", 200) + } + + const orgId = extractCustomerId(event) + if (!orgId) { + yield* Effect.annotateCurrentSpan({ + "http.response.status_code": 200, + "otel.status_code": "Ok", + "billing.webhook.outcome": "ignored", + "billing.webhook.reason": "no_customer_id", + }) + return textResponse("ignored", 200) + } + + // Failure here returns 500 so Svix retries — the row insert must be + // reliable, otherwise the org would never be picked up by the cron. + return yield* service.refreshOverdueState(orgId).pipe( + Effect.flatMap(() => + Effect.annotateCurrentSpan({ + "http.response.status_code": 200, + "otel.status_code": "Ok", + "billing.webhook.outcome": "handled", + "billing.org_id": orgId, + }).pipe(Effect.as(textResponse("ok", 200))), + ), + Effect.catch((error) => + Effect.annotateCurrentSpan({ + "http.response.status_code": 500, + "otel.status_code": "Error", + "billing.webhook.outcome": "failed", + "billing.org_id": orgId, + }).pipe( + Effect.flatMap(() => + Effect.logError("[billing] webhook processing failed").pipe( + Effect.annotateLogs({ + orgId, + error: error instanceof Error ? error.message : String(error), + }), + ), + ), + Effect.as(textResponse("processing failed", 500)), + ), + ), + ) + }).pipe(Effect.withSpan("BillingWebhook.receive")), + ) + }), +) diff --git a/apps/api/src/routes/billing-webhook.test.ts b/apps/api/src/routes/billing-webhook.test.ts new file mode 100644 index 00000000..4ce1ca2a --- /dev/null +++ b/apps/api/src/routes/billing-webhook.test.ts @@ -0,0 +1,73 @@ +import { createHmac } from "node:crypto" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { extractCustomerId, verifySvixSignature } from "./billing-webhook.http" + +const KEY_BYTES = Buffer.alloc(24, 9) +const SECRET = `whsec_${KEY_BYTES.toString("base64")}` +const SVIX_ID = "msg_123" +const SVIX_TS = "1700000000" + +const sign = (body: string) => + `v1,${createHmac("sha256", KEY_BYTES).update(`${SVIX_ID}.${SVIX_TS}.${body}`).digest("base64")}` + +const verify = (input: { + secret?: string + body: string + signatureHeader: string +}) => + Effect.runPromise( + verifySvixSignature({ + secret: input.secret ?? SECRET, + svixId: SVIX_ID, + svixTimestamp: SVIX_TS, + body: input.body, + signatureHeader: input.signatureHeader, + }), + ) + +describe("verifySvixSignature", () => { + it("accepts a correctly signed payload", async () => { + const body = JSON.stringify({ type: "billing.updated" }) + expect(await verify({ body, signatureHeader: sign(body) })).toBe(true) + }) + + it("accepts when one of several signature tokens matches", async () => { + const body = JSON.stringify({ type: "billing.updated" }) + expect(await verify({ body, signatureHeader: `v1,deadbeef ${sign(body)}` })).toBe(true) + }) + + it("rejects a tampered body", async () => { + const signature = sign(JSON.stringify({ type: "billing.updated" })) + expect(await verify({ body: JSON.stringify({ type: "evil" }), signatureHeader: signature })).toBe( + false, + ) + }) + + it("rejects a signature made with the wrong secret", async () => { + const body = JSON.stringify({ type: "billing.updated" }) + const wrong = `v1,${createHmac("sha256", Buffer.alloc(24, 1)).update(`${SVIX_ID}.${SVIX_TS}.${body}`).digest("base64")}` + expect(await verify({ body, signatureHeader: wrong })).toBe(false) + }) +}) + +describe("extractCustomerId", () => { + it("reads data.customer_id", () => { + expect(extractCustomerId({ type: "billing.updated", data: { customer_id: "org_a" } })).toBe( + "org_a", + ) + }) + + it("reads nested data.customer.id", () => { + expect(extractCustomerId({ data: { customer: { id: "org_b" } } })).toBe("org_b") + }) + + it("reads a top-level customerId", () => { + expect(extractCustomerId({ customerId: "org_c" })).toBe("org_c") + }) + + it("returns null when no customer id is present", () => { + expect(extractCustomerId({ data: {} })).toBeNull() + expect(extractCustomerId(null)).toBeNull() + }) +}) diff --git a/apps/api/src/routes/billing.http.ts b/apps/api/src/routes/billing.http.ts index 3a6e594f..52155bfe 100644 --- a/apps/api/src/routes/billing.http.ts +++ b/apps/api/src/routes/billing.http.ts @@ -1,7 +1,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpServerRequest } from "effect/unstable/http" import { Effect, Option, Redacted, Schema } from "effect" -import { autumnHandler, type CustomerData } from "autumn-js/backend" +import type { CustomerData } from "autumn-js/backend" import { EdgeCacheService, type EdgeCacheServiceShape } from "@maple/query-engine/caching" import { isActivePlanSubscription } from "@maple/domain/billing" import { @@ -16,16 +16,10 @@ import { MapleApi, PreviewAttachResult, } from "@maple/domain/http" +import { type AutumnResult, decodeUpstream, ensureOk, makeCallAutumn } from "../lib/AutumnClient" import { Env } from "../lib/Env" import { AuthService, type AuthServiceShape } from "../services/AuthService" -type AutumnResult = Awaited> - -// `autumnHandler` matches its route by `method` + `path`, always POST against -// `${DEFAULT_PATH_PREFIX}/${route}` (= /api/autumn/) regardless of which -// Maple endpoint fronts it, so every call here speaks that internal contract. -const AUTUMN_PATH_PREFIX = "/api/autumn" - // getOrCreateCustomer fires on every page load (hot path) and its latency is // dominated by the upstream Autumn call. Cache its success response per org for // 5 minutes behind the shared edge cache (single-flight dedup collapses @@ -97,53 +91,6 @@ export const readCustomerCached = ( ), ) -const makeCallAutumn = - (secretKey: string | undefined) => - ( - route: string, - body: unknown, - customerId: string | undefined, - customerData?: CustomerData, - ): Effect.Effect => - secretKey === undefined - ? Effect.fail(new BillingUpstreamError({ message: "Billing is not configured" })) - : Effect.tryPromise({ - try: () => - autumnHandler({ - request: { url: `${AUTUMN_PATH_PREFIX}/${route}`, method: "POST", body }, - customerId, - customerData, - clientOptions: { secretKey }, - }), - catch: (error) => - new BillingUpstreamError({ - message: error instanceof Error ? error.message : String(error), - }), - }) - -// Surface a readable message for a non-2xx Autumn response (it carries a -// `{ message }` / `{ error }` body) so the client error isn't an opaque 502. -const upstreamMessage = (result: AutumnResult): string => { - const body = result.response as { message?: unknown; error?: unknown } | null - const message = body?.message ?? body?.error - return typeof message === "string" ? message : `Billing request failed (${result.statusCode})` -} - -const ensureOk = (result: AutumnResult): Effect.Effect => - result.statusCode >= 200 && result.statusCode < 300 - ? Effect.succeed(result.response) - : Effect.fail(new BillingUpstreamError({ message: upstreamMessage(result) })) - -const decodeUpstream = ( - schema: S, - value: unknown, -): Effect.Effect => - Schema.decodeUnknownEffect(schema)(value).pipe( - Effect.mapError( - (error) => new BillingUpstreamError({ message: `Unexpected billing response: ${error}` }), - ), - ) - // Enrich checkout (attach) with Clerk-resolved identity so the customer is // identified before Stripe and the buyer's email is pre-filled. Ported verbatim // from the retired proxy's ENRICHED_ROUTES handling. diff --git a/apps/api/src/services/BillingSuspensionPolicy.test.ts b/apps/api/src/services/BillingSuspensionPolicy.test.ts new file mode 100644 index 00000000..fb174b9b --- /dev/null +++ b/apps/api/src/services/BillingSuspensionPolicy.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "@effect/vitest" +import { BillingCustomer, BillingInvoice, BillingSubscription } from "@maple/domain/http" +import { + hasNeverPaid, + isPastDue, + OVERDUE_GRACE_MS, + shouldSuspend, +} from "./BillingSuspensionPolicy" + +const NOW = 1_700_000_000_000 + +const sub = (fields: Partial<{ pastDue: boolean; addOn: boolean; status: string }>) => + new BillingSubscription({ + planId: "startup", + status: fields.status ?? "active", + ...(fields.addOn !== undefined ? { addOn: fields.addOn } : {}), + ...(fields.pastDue !== undefined ? { pastDue: fields.pastDue } : {}), + }) + +const customer = (opts: { + subs: ReadonlyArray + invoices?: ReadonlyArray +}) => + new BillingCustomer({ + id: "org_x", + subscriptions: opts.subs, + ...(opts.invoices !== undefined ? { invoices: opts.invoices } : {}), + }) + +const invoice = (status: string) => new BillingInvoice({ stripeId: `in_${status}`, status }) + +describe("isPastDue", () => { + it("is true when a non-add-on subscription is past_due", () => { + expect(isPastDue(customer({ subs: [sub({ pastDue: true })] }))).toBe(true) + }) + + it("ignores add-on subscriptions", () => { + expect(isPastDue(customer({ subs: [sub({ pastDue: true, addOn: true })] }))).toBe(false) + }) + + it("is false when nothing is past_due", () => { + expect(isPastDue(customer({ subs: [sub({ pastDue: false })] }))).toBe(false) + }) +}) + +describe("hasNeverPaid", () => { + it("is true when there are no invoices", () => { + expect(hasNeverPaid(customer({ subs: [sub({})] }))).toBe(true) + }) + + it("is true when no invoice is paid", () => { + expect(hasNeverPaid(customer({ subs: [sub({})], invoices: [invoice("open")] }))).toBe(true) + }) + + it("is false once any invoice is paid", () => { + expect( + hasNeverPaid(customer({ subs: [sub({})], invoices: [invoice("open"), invoice("paid")] })), + ).toBe(false) + }) +}) + +describe("shouldSuspend", () => { + const pastDueNeverPaid = customer({ subs: [sub({ pastDue: true })], invoices: [invoice("open")] }) + + it("suspends a never-paid org overdue past the grace window", () => { + const decision = shouldSuspend({ + customer: pastDueNeverPaid, + overdueSince: NOW - OVERDUE_GRACE_MS - 1, + now: NOW, + }) + expect(decision.suspend).toBe(true) + expect(decision.overdueInvoiceId).toBe("in_open") + }) + + it("does not suspend before the grace window elapses", () => { + const decision = shouldSuspend({ + customer: pastDueNeverPaid, + overdueSince: NOW - 2 * 24 * 60 * 60 * 1000, // 2 days + now: NOW, + }) + expect(decision.suspend).toBe(false) + expect(decision.overdueInvoiceId).toBeNull() + }) + + it("does not suspend an org that has ever paid", () => { + const decision = shouldSuspend({ + customer: customer({ + subs: [sub({ pastDue: true })], + invoices: [invoice("open"), invoice("paid")], + }), + overdueSince: NOW - OVERDUE_GRACE_MS - 1, + now: NOW, + }) + expect(decision.suspend).toBe(false) + }) + + it("does not suspend once the subscription is no longer past_due", () => { + const decision = shouldSuspend({ + customer: customer({ subs: [sub({ pastDue: false })], invoices: [invoice("open")] }), + overdueSince: NOW - OVERDUE_GRACE_MS - 1, + now: NOW, + }) + expect(decision.suspend).toBe(false) + }) +}) diff --git a/apps/api/src/services/BillingSuspensionPolicy.ts b/apps/api/src/services/BillingSuspensionPolicy.ts new file mode 100644 index 00000000..7edc884d --- /dev/null +++ b/apps/api/src/services/BillingSuspensionPolicy.ts @@ -0,0 +1,57 @@ +import type { BillingCustomer } from "@maple/domain/http" + +// Pure decision logic for the "stop ingestion once 3 days overdue and never +// paid" policy. Kept free of Effect/DB so it can be unit-tested directly. + +// The overdue grace window: an org must be past_due for at least this long +// before ingestion is suspended. +export const OVERDUE_GRACE_MS = 3 * 24 * 60 * 60 * 1000 + +// Invoice statuses that count as "still owed" (an unpaid, finalized invoice). +const UNPAID_INVOICE_STATUSES: ReadonlySet = new Set(["open", "uncollectible", "past_due"]) + +// A non-add-on subscription currently flagged past_due by Autumn. `pastDue` is +// Autumn's canonical overdue signal — we don't reconstruct it from raw invoices. +export const isPastDue = (customer: BillingCustomer): boolean => + customer.subscriptions.some((sub) => sub.addOn !== true && sub.pastDue === true) + +// "Never paid" = no settled invoice on record. Requires the customer to have +// been fetched with `expand: ["invoices"]`; an absent/empty invoices array means +// no paid invoice exists, which is exactly the never-converted signup we target. +export const hasNeverPaid = (customer: BillingCustomer): boolean => { + const invoices = customer.invoices ?? [] + return !invoices.some((invoice) => invoice.status === "paid") +} + +// The Stripe id of the first still-owed invoice, for audit on the suspension row. +export const firstUnpaidInvoiceId = (customer: BillingCustomer): string | null => { + const invoices = customer.invoices ?? [] + const owed = invoices.find( + (invoice) => typeof invoice.status === "string" && UNPAID_INVOICE_STATUSES.has(invoice.status), + ) + return owed?.stripeId ?? null +} + +export interface SuspendDecision { + readonly suspend: boolean + readonly overdueInvoiceId: string | null +} + +// Should an already-overdue org be promoted to suspended? True only when it is +// still past_due, has never paid, and has been overdue for >= the grace window. +// `overdueSince` / `now` are epoch ms. +export const shouldSuspend = (input: { + readonly customer: BillingCustomer + readonly overdueSince: number + readonly now: number + readonly graceMs?: number +}): SuspendDecision => { + const graceMs = input.graceMs ?? OVERDUE_GRACE_MS + const elapsed = input.now - input.overdueSince + const suspend = + isPastDue(input.customer) && hasNeverPaid(input.customer) && elapsed >= graceMs + return { + suspend, + overdueInvoiceId: suspend ? firstUnpaidInvoiceId(input.customer) : null, + } +} diff --git a/apps/api/src/services/BillingSuspensionService.test.ts b/apps/api/src/services/BillingSuspensionService.test.ts new file mode 100644 index 00000000..8b2906ea --- /dev/null +++ b/apps/api/src/services/BillingSuspensionService.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, it } from "@effect/vitest" +import { BillingCustomer, BillingInvoice, BillingSubscription } from "@maple/domain/http" +import { Effect } from "effect" +import { Database } from "../lib/DatabaseLive" +import { + cleanupTestDbs, + createTestDb, + executeSql, + queryFirstRow, + type TestDb, +} from "../lib/test-pglite" +import { OVERDUE_GRACE_MS } from "./BillingSuspensionPolicy" +import { + applyOverdueState, + type FetchCustomer, + reconcileSuspensions, +} from "./BillingSuspensionService" + +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +const NOW = 1_700_000_000_000 + +const subscription = (pastDue: boolean) => + new BillingSubscription({ planId: "startup", status: "active", pastDue }) + +const customer = (opts: { pastDue: boolean; paid?: boolean }) => + new BillingCustomer({ + id: "org_x", + subscriptions: [subscription(opts.pastDue)], + invoices: opts.paid + ? [new BillingInvoice({ stripeId: "in_paid", status: "paid" })] + : [new BillingInvoice({ stripeId: "in_open", status: "open" })], + }) + +const fakeFetch = + (byOrg: Record): FetchCustomer => + (orgId) => + Effect.succeed(byOrg[orgId] ?? customer({ pastDue: false })) + +const seedOverdue = (db: TestDb, orgId: string, overdueSinceMs: number, suspendedAtMs?: number) => + executeSql( + db, + `INSERT INTO org_billing_suspensions + (org_id, overdue_since, suspended_at, overdue_invoice_id, reason, created_at, updated_at) + VALUES ($1, $2, $3, NULL, 'unpaid_overdue', $4, $4)`, + [ + orgId, + new Date(overdueSinceMs).toISOString(), + suspendedAtMs === undefined ? null : new Date(suspendedAtMs).toISOString(), + new Date(NOW).toISOString(), + ], + ) + +const readRow = (db: TestDb, orgId: string) => + queryFirstRow<{ org_id: string; suspended_at: string | null; overdue_invoice_id: string | null }>( + db, + "SELECT org_id, suspended_at, overdue_invoice_id FROM org_billing_suspensions WHERE org_id = $1", + [orgId], + ) + +const run = (db: TestDb, program: Effect.Effect) => + Effect.runPromise(program.pipe(Effect.provide(db.layer)) as Effect.Effect) + +// Migrations apply lazily when the Database layer is first built. Force that +// before any raw-SQL seeding so the table exists. +const ensureMigrated = (db: TestDb) => run(db, Effect.void) + +describe("applyOverdueState", () => { + it("inserts an overdue row when the customer is past_due", async () => { + const db = createTestDb(trackedDbs) + await run( + db, + Effect.gen(function* () { + const database = yield* Database + yield* applyOverdueState(database, "org_a", customer({ pastDue: true }), NOW) + }), + ) + const row = await readRow(db, "org_a") + expect(row?.org_id).toBe("org_a") + expect(row?.suspended_at).toBeNull() + }) + + it("clears the row when the customer is no longer past_due", async () => { + const db = createTestDb(trackedDbs) + await ensureMigrated(db) + await seedOverdue(db, "org_a", NOW - OVERDUE_GRACE_MS - 1) + await run( + db, + Effect.gen(function* () { + const database = yield* Database + yield* applyOverdueState(database, "org_a", customer({ pastDue: false }), NOW) + }), + ) + expect(await readRow(db, "org_a")).toBeUndefined() + }) +}) + +describe("reconcileSuspensions", () => { + it("suspends an overdue-≥3d, never-paid org", async () => { + const db = createTestDb(trackedDbs) + await ensureMigrated(db) + await seedOverdue(db, "org_overdue", NOW - OVERDUE_GRACE_MS - 1) + + const result = (await run( + db, + Effect.gen(function* () { + const database = yield* Database + return yield* reconcileSuspensions( + database, + fakeFetch({ org_overdue: customer({ pastDue: true }) }), + NOW, + ) + }), + )) as { scanned: number; suspended: number; cleared: number } + + expect(result).toEqual({ scanned: 1, suspended: 1, cleared: 0 }) + const row = await readRow(db, "org_overdue") + expect(row?.suspended_at).not.toBeNull() + expect(row?.overdue_invoice_id).toBe("in_open") + }) + + it("does not suspend before the grace window elapses", async () => { + const db = createTestDb(trackedDbs) + await ensureMigrated(db) + await seedOverdue(db, "org_recent", NOW - 2 * 24 * 60 * 60 * 1000) + + await run( + db, + Effect.gen(function* () { + const database = yield* Database + return yield* reconcileSuspensions( + database, + fakeFetch({ org_recent: customer({ pastDue: true }) }), + NOW, + ) + }), + ) + expect((await readRow(db, "org_recent"))?.suspended_at).toBeNull() + }) + + it("clears a suspended org once it has paid", async () => { + const db = createTestDb(trackedDbs) + await ensureMigrated(db) + await seedOverdue(db, "org_paid", NOW - OVERDUE_GRACE_MS - 1, NOW - 1000) + + const result = (await run( + db, + Effect.gen(function* () { + const database = yield* Database + return yield* reconcileSuspensions( + database, + fakeFetch({ org_paid: customer({ pastDue: false, paid: true }) }), + NOW, + ) + }), + )) as { scanned: number; suspended: number; cleared: number } + + expect(result.cleared).toBe(1) + expect(await readRow(db, "org_paid")).toBeUndefined() + }) +}) diff --git a/apps/api/src/services/BillingSuspensionService.ts b/apps/api/src/services/BillingSuspensionService.ts new file mode 100644 index 00000000..d6634f2b --- /dev/null +++ b/apps/api/src/services/BillingSuspensionService.ts @@ -0,0 +1,207 @@ +import { BillingCustomer, type BillingUpstreamError } from "@maple/domain/http" +import { orgBillingSuspensions } from "@maple/db" +import { eq } from "drizzle-orm" +import { Clock, Context, Effect, Layer, Option, Redacted } from "effect" +import { decodeUpstream, ensureOk, makeCallAutumn } from "../lib/AutumnClient" +import { Database, type DatabaseError, type DatabaseShape, toDatabaseError } from "../lib/DatabaseLive" +import { Env } from "../lib/Env" +import { isPastDue, shouldSuspend } from "./BillingSuspensionPolicy" + +const SUSPENSION_REASON = "unpaid_overdue" + +export interface ReconcileResult { + readonly scanned: number + readonly suspended: number + readonly cleared: number +} + +// Resolves an org's Autumn customer (optionally with invoices expanded). The +// real implementation calls Autumn; tests inject a fake. +export type FetchCustomer = ( + orgId: string, + expandInvoices: boolean, +) => Effect.Effect + +// --- DB primitives (Database-shape in, no Env/Autumn) ----------------------- + +const upsertOverdue = (database: DatabaseShape, orgId: string, nowMs: number) => + database + .execute((db) => + db + .insert(orgBillingSuspensions) + .values({ + orgId, + overdueSince: new Date(nowMs), + suspendedAt: null, + overdueInvoiceId: null, + reason: SUSPENSION_REASON, + createdAt: new Date(nowMs), + updatedAt: new Date(nowMs), + }) + // Idempotent: an existing row keeps its original overdueSince so the + // 3-day clock isn't reset by repeated past_due webhooks. + .onConflictDoNothing(), + ) + .pipe(Effect.mapError(toDatabaseError)) + +const markSuspended = ( + database: DatabaseShape, + orgId: string, + nowMs: number, + overdueInvoiceId: string | null, +) => + database + .execute((db) => + db + .update(orgBillingSuspensions) + .set({ suspendedAt: new Date(nowMs), overdueInvoiceId, updatedAt: new Date(nowMs) }) + .where(eq(orgBillingSuspensions.orgId, orgId)), + ) + .pipe(Effect.mapError(toDatabaseError)) + +const clearOrg = (database: DatabaseShape, orgId: string) => + database + .execute((db) => + db.delete(orgBillingSuspensions).where(eq(orgBillingSuspensions.orgId, orgId)), + ) + .pipe(Effect.mapError(toDatabaseError)) + +// --- Core logic (exported for tests) ---------------------------------------- + +// Webhook core: reconcile one org's overdue row from its (already-fetched) +// Autumn customer. past_due → ensure an overdue row; otherwise clear it. +export const applyOverdueState = ( + database: DatabaseShape, + orgId: string, + customer: BillingCustomer, + nowMs: number, +): Effect.Effect => + isPastDue(customer) + ? upsertOverdue(database, orgId, nowMs).pipe(Effect.asVoid) + : clearOrg(database, orgId).pipe(Effect.asVoid) + +// Cron core: scope is the overdue set only (rows already present), never a +// full-org scan. Promotes pending rows past the grace window and clears settled +// ones. Per-org failures are logged and skipped so one bad org can't abort the sweep. +export const reconcileSuspensions = ( + database: DatabaseShape, + fetchCustomer: FetchCustomer, + nowMs: number, +): Effect.Effect => + Effect.gen(function* () { + const rows = yield* database + .execute((db) => db.select().from(orgBillingSuspensions)) + .pipe(Effect.mapError(toDatabaseError)) + + let suspended = 0 + let cleared = 0 + + yield* Effect.forEach( + rows, + (row) => + Effect.gen(function* () { + const alreadySuspended = row.suspendedAt !== null + // Invoices are only needed to evaluate a pending promotion. + const customer = yield* fetchCustomer(row.orgId, !alreadySuspended) + + // Paid / reactivated → no longer past_due → clear (ingestion resumes). + if (!isPastDue(customer)) { + yield* clearOrg(database, row.orgId) + cleared += 1 + return + } + // Already suspended and still past_due → stay suspended. + if (alreadySuspended) return + + const decision = shouldSuspend({ + customer, + overdueSince: row.overdueSince.getTime(), + now: nowMs, + }) + if (decision.suspend) { + yield* markSuspended(database, row.orgId, nowMs, decision.overdueInvoiceId) + suspended += 1 + } + }).pipe( + Effect.catch((error) => + Effect.logError("[billing] reconcile failed for org").pipe( + Effect.annotateLogs({ + orgId: row.orgId, + error: error instanceof Error ? error.message : String(error), + }), + ), + ), + ), + { discard: true }, + ) + + yield* Effect.annotateCurrentSpan({ + "billing.reconcile.scanned": rows.length, + "billing.reconcile.suspended": suspended, + "billing.reconcile.cleared": cleared, + }) + return { scanned: rows.length, suspended, cleared } satisfies ReconcileResult + }) + +// Owns the `org_billing_suspensions` table. The Autumn webhook drives +// `refreshOverdueState` (maintains the overdue clock authoritatively per org); +// the daily cron drives `runReconcile` (promotes overdue ≥3d + never-paid to +// suspended, and clears rows whose org has paid). The ingest gateway reads the +// resulting `suspended_at` flag directly from Postgres — never from this service. +export class BillingSuspensionService extends Context.Service()( + "@maple/api/services/BillingSuspensionService", + { + make: Effect.gen(function* () { + const database = yield* Database + const env = yield* Env + const secretKey = Option.match(env.AUTUMN_SECRET_KEY, { + onNone: () => undefined, + onSome: (value) => Redacted.value(value), + }) + const callAutumn = makeCallAutumn(secretKey) + + const fetchCustomer: FetchCustomer = (orgId, expandInvoices) => + callAutumn( + "getOrCreateCustomer", + expandInvoices ? { expand: ["invoices"] } : {}, + orgId, + ).pipe( + Effect.flatMap(ensureOk), + Effect.flatMap((response) => decodeUpstream(BillingCustomer, response)), + ) + + // Webhook entry point: re-derive an org's overdue state from Autumn (the + // authoritative source) and reconcile its row. No-op when billing is + // unconfigured so the webhook still 200s. + const refreshOverdueState = Effect.fn("BillingSuspensionService.refreshOverdueState")( + function* (orgId: string) { + if (secretKey === undefined) return + const now = yield* Clock.currentTimeMillis + const customer = yield* fetchCustomer(orgId, false) + yield* applyOverdueState(database, orgId, customer, now) + }, + ) + + // Cron entry point. + const runReconcile = Effect.fn("BillingSuspensionService.runReconcile")(function* () { + if (secretKey === undefined) { + yield* Effect.logWarning( + "[billing] reconcile skipped: AUTUMN_SECRET_KEY not configured", + ) + return { scanned: 0, suspended: 0, cleared: 0 } satisfies ReconcileResult + } + const now = yield* Clock.currentTimeMillis + return yield* reconcileSuspensions(database, fetchCustomer, now) + }) + + return { refreshOverdueState, runReconcile } + }), + }, +) { + static readonly layer = Layer.effect(this, this.make) + + static readonly refreshOverdueState = (orgId: string) => + this.use((service) => service.refreshOverdueState(orgId)) + + static readonly runReconcile = () => this.use((service) => service.runReconcile()) +} diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index deb6ae10..331b5741 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -236,10 +236,30 @@ const handleQueue = async ( } } -// Cron handler (every 12h, see wrangler.jsonc `triggers.crons`): enqueue a -// periodic VCS sync per installation. Single cron expression — no `event.cron` -// dispatch needed. -const handleScheduled = async (env: Record, ctx: ExecutionContext): Promise => { +// Cron expressions (must match `crons` in alchemy.run.ts). Dispatched on +// `event.cron` since the worker now registers more than one schedule. +const BILLING_RECONCILE_CRON = "0 6 * * *" + +// Daily billing-suspension reconcile: promote overdue-≥3d-and-never-paid orgs to +// suspended, clear orgs that have paid. +const handleBillingReconcile = async ( + env: Record, + ctx: ExecutionContext, +): Promise => { + const { buildBillingSuspensionLayer, runBillingSuspensionReconcile, flushBillingTelemetry } = + await import("./billing-suspension-runtime") + try { + await runScheduledEffect(buildBillingSuspensionLayer(env), runBillingSuspensionReconcile, ctx) + } finally { + ctx.waitUntil(flushBillingTelemetry(env)) + } +} + +// Periodic VCS sync (every 12h): enqueue a refresh per installation. +const handleVcsScheduledSync = async ( + env: Record, + ctx: ExecutionContext, +): Promise => { const { buildVcsScheduledLayer, runScheduledSync, flushVcsTelemetry } = await import("./vcs-sync-runtime") try { await runScheduledEffect(buildVcsScheduledLayer(env), runScheduledSync, ctx) @@ -248,11 +268,25 @@ const handleScheduled = async (env: Record, ctx: ExecutionConte } } +// Cron handler — route by the firing schedule. The billing reconcile is matched +// explicitly; every other expression (i.e. the 12h VCS sync) falls through. +const handleScheduled = async ( + cron: string, + env: Record, + ctx: ExecutionContext, +): Promise => { + if (cron === BILLING_RECONCILE_CRON) { + await handleBillingReconcile(env, ctx) + return + } + await handleVcsScheduledSync(env, ctx) +} + export default { fetch: (request: Request, env: Record, ctx: ExecutionContext) => handle(request, env, ctx), queue: (batch: MessageBatch, env: Record, ctx: ExecutionContext) => handleQueue(batch, env, ctx), - scheduled: (_event: ScheduledController, env: Record, ctx: ExecutionContext) => - handleScheduled(env, ctx), + scheduled: (event: ScheduledController, env: Record, ctx: ExecutionContext) => + handleScheduled(event.cron, env, ctx), } diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index af677dd5..78b329f7 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -7,9 +7,10 @@ "dev": { "port": 3472, }, - // Periodic VCS sync backstop every 12h — handler: worker.ts `scheduled`. (Mirrored in alchemy.run.ts for deploy.) + // Cron schedules — handler: worker.ts `scheduled` (dispatched by event.cron). (Mirrored in alchemy.run.ts for deploy.) + // - "0 *\/12 * * *": VCS sync backstop. "0 6 * * *": daily billing-suspension reconcile. "triggers": { - "crons": ["0 */12 * * *"], + "crons": ["0 */12 * * *", "0 6 * * *"], }, // Local dev: wrangler connects the binding straight to localConnectionString // (the docker-compose Postgres; see `bun run db:up` + `bun run db:migrate:local`). diff --git a/apps/ingest/src/main.rs b/apps/ingest/src/main.rs index 5d6b06db..52321857 100644 --- a/apps/ingest/src/main.rs +++ b/apps/ingest/src/main.rs @@ -552,6 +552,10 @@ struct KeyRow { org_id: String, self_managed: bool, clickhouse_ready: bool, + // The owning org's ingestion is suspended for non-payment (a row in + // org_billing_suspensions with suspended_at set). Resolved in the same + // roundtrip as routing; enforced as a 402 before any payload is accepted. + ingest_suspended: bool, } #[derive(Clone, Debug)] @@ -562,6 +566,7 @@ struct ConnectorRow { dataset: String, self_managed: bool, clickhouse_ready: bool, + ingest_suspended: bool, } #[derive(Clone, Debug)] @@ -594,6 +599,7 @@ impl IngestKeyIdentity { key_id: self.key_id, self_managed: routing.self_managed, clickhouse_ready: routing.clickhouse_ready, + ingest_suspended: routing.ingest_suspended, } } } @@ -619,6 +625,7 @@ impl CloudflareConnectorIdentity { secret_key_id: self.secret_key_id, self_managed: routing.self_managed, clickhouse_ready: routing.clickhouse_ready, + ingest_suspended: routing.ingest_suspended, } } } @@ -627,6 +634,10 @@ impl CloudflareConnectorIdentity { struct OrgRouting { self_managed: bool, clickhouse_ready: bool, + // Billing suspension flag (see KeyRow). Lives on routing so it refreshes on + // the short org-routing TTL — a paid-up org resumes ingestion quickly, and a + // routing fetch failure defaults to false (fail-open, never blocks on a DB blip). + ingest_suspended: bool, } impl OrgRouting { @@ -634,6 +645,7 @@ impl OrgRouting { Self { self_managed: row.self_managed, clickhouse_ready: row.clickhouse_ready, + ingest_suspended: row.ingest_suspended, } } @@ -641,6 +653,7 @@ impl OrgRouting { Self { self_managed: row.self_managed, clickhouse_ready: row.clickhouse_ready, + ingest_suspended: row.ingest_suspended, } } } @@ -685,6 +698,9 @@ struct ResolvedIngestKey { // version (SCHEMA_VERSION) — NOT the Tinybird-coupled PROJECT_REVISION, so a // Tinybird-only schema change can't silently un-ready a BYO-CH org. clickhouse_ready: bool, + // The org is 3+ days overdue and has never paid — ingestion is hard-stopped + // with a 402 before any payload is accepted. See org_billing_suspensions. + ingest_suspended: bool, } #[derive(Clone)] @@ -699,6 +715,7 @@ struct ResolvedCloudflareConnector { // to the self-managed pool when the owning org has BYO Tinybird active. self_managed: bool, clickhouse_ready: bool, + ingest_suspended: bool, } #[derive(Clone, Copy)] @@ -1554,6 +1571,7 @@ async fn resolve_grpc_ingest_key( key_id: "sentinel".to_string(), self_managed: false, clickhouse_ready: false, + ingest_suspended: false, }); } @@ -2229,6 +2247,25 @@ async fn handle_signal_inner( "Authenticated" ); + // --- Billing suspension (dunning) --- + // Hard-stop ingestion for an org that is 3+ days overdue and has never paid. + // This flag is computed off-band by the API (Autumn webhook + daily reconcile) + // and persisted in org_billing_suspensions; the gateway only reads it. Checked + // before the Autumn entitlement gate and independent of AUTUMN_ENFORCE_LIMITS. + if resolved_key.ingest_suspended { + warn!( + org_id = %resolved_key.org_id, + "Ingestion suspended: unpaid invoice overdue" + ); + return Err(( + ApiError::new( + StatusCode::PAYMENT_REQUIRED, + "Ingestion suspended: unpaid invoice overdue", + ), + "billing_suspended", + )); + } + // --- Billing entitlement (per-signal) --- // Reject ingestion when the org has no active subscription or has exhausted // its hard-capped base-plan allotment for this signal. Fails open on any @@ -2404,6 +2441,23 @@ async fn handle_cloudflare_logpush_inner( Span::current().record("maple.ingest.self_managed", resolved.self_managed); Span::current().record("maple.ingest.clickhouse_ready", resolved.clickhouse_ready); + // Billing suspension (dunning) — same hard-stop as the OTLP path, before the + // per-feature entitlement gate and independent of AUTUMN_ENFORCE_LIMITS. + if resolved.ingest_suspended { + warn!( + org_id = %resolved.org_id, + connector_id, + "Cloudflare logpush suspended: unpaid invoice overdue" + ); + return Err(( + ApiError::new( + StatusCode::PAYMENT_REQUIRED, + "Ingestion suspended: unpaid invoice overdue", + ), + "billing_suspended", + )); + } + // Logpush bills the `logs` feature — gate it the same way as OTLP logs. if let Some(entitlements) = &state.autumn_entitlements { if !entitlements.is_allowed(&resolved.org_id, "logs").await { @@ -2527,6 +2581,7 @@ async fn handle_cloudflare_logpush_inner( key_id: resolved.secret_key_id.clone(), self_managed: resolved.self_managed, clickhouse_ready: resolved.clickhouse_ready, + ingest_suspended: resolved.ingest_suspended, }; let decoded = DecodedPayload::Logs(request); let response = match process_decoded_payload( @@ -3678,9 +3733,11 @@ impl KeyStore for PostgresKeyStore { let sql = format!( "SELECT k.org_id, \ COALESCE(s.sync_status = 'connected', false) AS self_managed, \ - COALESCE(s.sync_status = 'connected' AND s.schema_version = $1, false) AS clickhouse_ready \ + COALESCE(s.sync_status = 'connected' AND s.schema_version = $1, false) AS clickhouse_ready, \ + (b.suspended_at IS NOT NULL) AS ingest_suspended \ FROM org_ingest_keys k \ LEFT JOIN org_clickhouse_settings s ON s.org_id = k.org_id \ + LEFT JOIN org_billing_suspensions b ON b.org_id = k.org_id \ WHERE k.{hash_column} = $2 LIMIT 1" ); let client = self.client().await?; @@ -3695,6 +3752,7 @@ impl KeyStore for PostgresKeyStore { org_id: row.get("org_id"), self_managed: row.get("self_managed"), clickhouse_ready: row.get("clickhouse_ready"), + ingest_suspended: row.get("ingest_suspended"), })) } @@ -3709,9 +3767,11 @@ impl KeyStore for PostgresKeyStore { .query( "SELECT c.org_id, c.service_name, c.zone_name, c.dataset, \ COALESCE(s.sync_status = 'connected', false) AS self_managed, \ - COALESCE(s.sync_status = 'connected' AND s.schema_version = $1, false) AS clickhouse_ready \ + COALESCE(s.sync_status = 'connected' AND s.schema_version = $1, false) AS clickhouse_ready, \ + (b.suspended_at IS NOT NULL) AS ingest_suspended \ FROM cloudflare_logpush_connectors c \ LEFT JOIN org_clickhouse_settings s ON s.org_id = c.org_id \ + LEFT JOIN org_billing_suspensions b ON b.org_id = c.org_id \ WHERE c.id = $2 AND c.secret_hash = $3 AND c.enabled = true LIMIT 1", &[&revision, &connector_id, &secret_hash], ) @@ -3727,6 +3787,7 @@ impl KeyStore for PostgresKeyStore { dataset: row.get("dataset"), self_managed: row.get("self_managed"), clickhouse_ready: row.get("clickhouse_ready"), + ingest_suspended: row.get("ingest_suspended"), })) } @@ -3813,9 +3874,12 @@ impl KeyStore for PostgresKeyStore { let client = self.client().await?; let rows = client .query( - "SELECT COALESCE(sync_status = 'connected', false) AS self_managed, \ - COALESCE(sync_status = 'connected' AND schema_version = $1, false) AS clickhouse_ready \ - FROM org_clickhouse_settings WHERE org_id = $2 LIMIT 1", + "SELECT COALESCE(s.sync_status = 'connected', false) AS self_managed, \ + COALESCE(s.sync_status = 'connected' AND s.schema_version = $1, false) AS clickhouse_ready, \ + (b.suspended_at IS NOT NULL) AS ingest_suspended \ + FROM (SELECT $2::text AS org_id) o \ + LEFT JOIN org_clickhouse_settings s ON s.org_id = o.org_id \ + LEFT JOIN org_billing_suspensions b ON b.org_id = o.org_id LIMIT 1", &[&revision, &org_id], ) .await @@ -3826,6 +3890,7 @@ impl KeyStore for PostgresKeyStore { Ok(Some(OrgRouting { self_managed: row.get("self_managed"), clickhouse_ready: row.get("clickhouse_ready"), + ingest_suspended: row.get("ingest_suspended"), })) } @@ -3887,6 +3952,7 @@ impl KeyStore for StaticKeyStore { org_id: self.org_id.clone(), self_managed: false, clickhouse_ready: false, + ingest_suspended: false, })) } @@ -4232,6 +4298,7 @@ mod tests { key_id: "abc".to_string(), self_managed: false, clickhouse_ready: false, + ingest_suspended: false, }; enrich_resource_attributes(&mut attributes, &resolved); @@ -4319,6 +4386,7 @@ mod tests { secret_key_id: "secret".to_string(), self_managed: false, clickhouse_ready: false, + ingest_suspended: false, }; let record = serde_json::from_str::>( r#"{ @@ -4439,6 +4507,7 @@ mod tests { OrgRouting { self_managed: row.self_managed, clickhouse_ready: row.clickhouse_ready, + ingest_suspended: row.ingest_suspended, }, ); self.keys @@ -4454,6 +4523,7 @@ mod tests { OrgRouting { self_managed: row.self_managed, clickhouse_ready: row.clickhouse_ready, + ingest_suspended: row.ingest_suspended, }, ); self.connectors @@ -4823,6 +4893,7 @@ mod tests { org_id: "org_shared".to_string(), self_managed: false, clickhouse_ready: false, + ingest_suspended: false, }, ); @@ -4846,6 +4917,7 @@ mod tests { org_id: "org_byo".to_string(), self_managed: true, clickhouse_ready: true, + ingest_suspended: false, }, ); @@ -4860,6 +4932,53 @@ mod tests { assert!(resolved.clickhouse_ready); } + #[tokio::test] + async fn resolve_ingest_key_propagates_ingest_suspended_flag() { + let store = Arc::new(FakeKeyStore::default()); + store.insert_private( + "maple_sk_test_suspended", + KeyRow { + org_id: "org_overdue".to_string(), + self_managed: false, + clickhouse_ready: false, + ingest_suspended: true, + }, + ); + + let resolved = make_resolver(store) + .resolve_ingest_key("maple_sk_test_suspended") + .await + .expect("resolve should succeed") + .expect("key should be found"); + + // A suspended org still resolves (auth succeeds) — the 402 is enforced by + // handle_signal_inner off this flag, not by dropping the key. + assert_eq!(resolved.org_id, "org_overdue"); + assert!(resolved.ingest_suspended); + } + + #[tokio::test] + async fn resolve_ingest_key_is_not_suspended_by_default() { + let store = Arc::new(FakeKeyStore::default()); + store.insert_private( + "maple_sk_test_paid", + KeyRow { + org_id: "org_paid".to_string(), + self_managed: false, + clickhouse_ready: false, + ingest_suspended: false, + }, + ); + + let resolved = make_resolver(store) + .resolve_ingest_key("maple_sk_test_paid") + .await + .expect("resolve should succeed") + .expect("key should be found"); + + assert!(!resolved.ingest_suspended); + } + #[tokio::test] async fn resolve_ingest_key_keeps_stale_schema_on_managed_native_path() { let store = Arc::new(FakeKeyStore::default()); @@ -4869,6 +4988,7 @@ mod tests { org_id: "org_stale".to_string(), self_managed: true, clickhouse_ready: false, + ingest_suspended: false, }, ); @@ -4895,6 +5015,7 @@ mod tests { org_id: "org_transition".to_string(), self_managed: false, clickhouse_ready: false, + ingest_suspended: false, }, ); @@ -4912,6 +5033,7 @@ mod tests { OrgRouting { self_managed: true, clickhouse_ready: true, + ingest_suspended: false, }, ); tokio::time::sleep(Duration::from_millis(10)).await; @@ -4944,6 +5066,7 @@ mod tests { org_id: "org_d1_blip".to_string(), self_managed: false, clickhouse_ready: false, + ingest_suspended: false, }, ); @@ -4960,6 +5083,7 @@ mod tests { OrgRouting { self_managed: true, clickhouse_ready: true, + ingest_suspended: false, }, ); tokio::time::sleep(Duration::from_millis(10)).await; @@ -5007,6 +5131,7 @@ mod tests { dataset: "http_requests".to_string(), self_managed: false, clickhouse_ready: false, + ingest_suspended: false, }, ); let routing = make_routing_resolver(Arc::clone(&store), Duration::from_millis(5)); @@ -5034,6 +5159,7 @@ mod tests { OrgRouting { self_managed: true, clickhouse_ready: true, + ingest_suspended: false, }, ); tokio::time::sleep(Duration::from_millis(10)).await; @@ -5088,6 +5214,7 @@ mod tests { org_id: "org_forward_ready".to_string(), self_managed: false, clickhouse_ready: false, + ingest_suspended: false, }, ); let state = test_app_state( @@ -5128,6 +5255,7 @@ mod tests { OrgRouting { self_managed: true, clickhouse_ready: true, + ingest_suspended: false, }, ); store.insert_clickhouse_target( diff --git a/packages/db/drizzle/0003_lowly_fantastic_four.sql b/packages/db/drizzle/0003_lowly_fantastic_four.sql new file mode 100644 index 00000000..e8ac6051 --- /dev/null +++ b/packages/db/drizzle/0003_lowly_fantastic_four.sql @@ -0,0 +1,10 @@ +CREATE TABLE "org_billing_suspensions" ( + "org_id" text NOT NULL, + "overdue_since" timestamp with time zone NOT NULL, + "suspended_at" timestamp with time zone, + "overdue_invoice_id" text, + "reason" text, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "org_billing_suspensions_org_id_pk" PRIMARY KEY("org_id") +); diff --git a/packages/db/drizzle/meta/0003_snapshot.json b/packages/db/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..465cfe58 --- /dev/null +++ b/packages/db/drizzle/meta/0003_snapshot.json @@ -0,0 +1,5677 @@ +{ + "id": "fcf4be03-8be5-4562-81b6-cb3796c8dbb0", + "prevId": "bbf78dc1-583d-473f-9a6f-62296755db5d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_triage_runs": { + "name": "ai_triage_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_kind": { + "name": "incident_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "context_json": { + "name": "context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ai_triage_runs_incident_idx": { + "name": "ai_triage_runs_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_triage_runs_org_issue_idx": { + "name": "ai_triage_runs_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_triage_runs_org_created_idx": { + "name": "ai_triage_runs_org_created_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_triage_settings": { + "name": "ai_triage_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "max_runs_per_day": { + "name": "max_runs_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_delivery_events": { + "name": "alert_delivery_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_key": { + "name": "delivery_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_message": { + "name": "provider_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_delivery_events_org_idx": { + "name": "alert_delivery_events_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_org_incident_idx": { + "name": "alert_delivery_events_org_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_due_idx": { + "name": "alert_delivery_events_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_claim_idx": { + "name": "alert_delivery_events_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_delivery_attempt_idx": { + "name": "alert_delivery_events_delivery_attempt_idx", + "columns": [ + { + "expression": "delivery_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_destinations": { + "name": "alert_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config_json": { + "name": "config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_tag": { + "name": "secret_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_error": { + "name": "last_test_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_destinations_org_idx": { + "name": "alert_destinations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_destinations_org_enabled_idx": { + "name": "alert_destinations_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_destinations_org_name_idx": { + "name": "alert_destinations_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_incidents": { + "name": "alert_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_key": { + "name": "incident_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_name": { + "name": "rule_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comparator": { + "name": "comparator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_upper": { + "name": "threshold_upper", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_observed_value": { + "name": "last_observed_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_delivered_event_type": { + "name": "last_delivered_event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_notified_at": { + "name": "last_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_issue_id": { + "name": "error_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_incidents_org_idx": { + "name": "alert_incidents_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_status_idx": { + "name": "alert_incidents_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_rule_idx": { + "name": "alert_incidents_org_rule_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_issue_idx": { + "name": "alert_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "error_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_incident_key_idx": { + "name": "alert_incidents_incident_key_idx", + "columns": [ + { + "expression": "incident_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rule_states": { + "name": "alert_rule_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'__total__'" + }, + "consecutive_breaches": { + "name": "consecutive_breaches", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_healthy": { + "name": "consecutive_healthy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_value": { + "name": "last_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_rule_states_org_idx": { + "name": "alert_rule_states_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "alert_rule_states_org_id_rule_id_group_key_pk": { + "name": "alert_rule_states_org_id_rule_id_group_key_pk", + "columns": [ + "org_id", + "rule_id", + "group_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rules": { + "name": "alert_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notification_template_json": { + "name": "notification_template_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_names_json": { + "name": "service_names_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exclude_service_names_json": { + "name": "exclude_service_names_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tags_json": { + "name": "tags_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comparator": { + "name": "comparator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_upper": { + "name": "threshold_upper", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "window_minutes": { + "name": "window_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "minimum_sample_count": { + "name": "minimum_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_breaches_required": { + "name": "consecutive_breaches_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "consecutive_healthy_required": { + "name": "consecutive_healthy_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "renotify_interval_minutes": { + "name": "renotify_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "metric_name": { + "name": "metric_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metric_type": { + "name": "metric_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metric_aggregation": { + "name": "metric_aggregation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "apdex_threshold_ms": { + "name": "apdex_threshold_ms", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "query_builder_draft_json": { + "name": "query_builder_draft_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "raw_query_sql": { + "name": "raw_query_sql", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_by": { + "name": "group_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_ids_json": { + "name": "destination_ids_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "query_spec_json": { + "name": "query_spec_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reducer": { + "name": "reducer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sample_count_strategy": { + "name": "sample_count_strategy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "no_data_behavior": { + "name": "no_data_behavior", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_scheduled_at": { + "name": "last_scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_rules_org_idx": { + "name": "alert_rules_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_rules_org_enabled_idx": { + "name": "alert_rules_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_rules_org_name_idx": { + "name": "alert_rules_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_detector_settings": { + "name": "anomaly_detector_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sensitivity": { + "name": "sensitivity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "muted_signals_json": { + "name": "muted_signals_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_detector_states": { + "name": "anomaly_detector_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detector_key": { + "name": "detector_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_env": { + "name": "deployment_env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_breaches": { + "name": "consecutive_breaches", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_healthy": { + "name": "consecutive_healthy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_value": { + "name": "last_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "baseline_median": { + "name": "baseline_median", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "open_incident_id": { + "name": "open_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_resolved_at": { + "name": "last_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_incident_id": { + "name": "last_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "anomaly_detector_states_org_idx": { + "name": "anomaly_detector_states_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_detector_states_open_incident_idx": { + "name": "anomaly_detector_states_open_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "open_incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_detector_states_evaluated_idx": { + "name": "anomaly_detector_states_evaluated_idx", + "columns": [ + { + "expression": "last_evaluated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "anomaly_detector_states_org_id_detector_key_pk": { + "name": "anomaly_detector_states_org_id_detector_key_pk", + "columns": [ + "org_id", + "detector_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_incidents": { + "name": "anomaly_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detector_key": { + "name": "detector_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_env": { + "name": "deployment_env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_issue_id": { + "name": "error_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opened_value": { + "name": "opened_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "baseline_median": { + "name": "baseline_median", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "baseline_sigma": { + "name": "baseline_sigma", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_value": { + "name": "threshold_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_observed_value": { + "name": "last_observed_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolve_reason": { + "name": "resolve_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "triage_status": { + "name": "triage_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprints_json": { + "name": "fingerprints_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reopen_count": { + "name": "reopen_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reopened_at": { + "name": "last_reopened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "anomaly_incidents_org_status_idx": { + "name": "anomaly_incidents_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_triggered_idx": { + "name": "anomaly_incidents_org_triggered_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_triggered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_detector_idx": { + "name": "anomaly_incidents_org_detector_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detector_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_issue_idx": { + "name": "anomaly_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "error_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_email": { + "name": "created_by_email", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_org_id_idx": { + "name": "api_keys_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloudflare_logpush_connectors": { + "name": "cloudflare_logpush_connectors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dataset": { + "name": "dataset", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'http_requests'" + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_tag": { + "name": "secret_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cloudflare_logpush_connectors_org_idx": { + "name": "cloudflare_logpush_connectors_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloudflare_logpush_connectors_org_enabled_idx": { + "name": "cloudflare_logpush_connectors_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloudflare_logpush_connectors_secret_hash_unique": { + "name": "cloudflare_logpush_connectors_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_versions": { + "name": "dashboard_versions", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "change_kind": { + "name": "change_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_version_id": { + "name": "source_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "dashboard_versions_org_dashboard_idx": { + "name": "dashboard_versions_org_dashboard_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboard_versions_org_dashboard_version_unq": { + "name": "dashboard_versions_org_dashboard_version_unq", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "dashboard_versions_org_id_id_pk": { + "name": "dashboard_versions_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboards": { + "name": "dashboards", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "dashboards_org_updated_idx": { + "name": "dashboards_org_updated_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboards_org_name_idx": { + "name": "dashboards_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "dashboards_org_id_id_pk": { + "name": "dashboards_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.digest_subscriptions": { + "name": "digest_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "digest_subscriptions_org_user_idx": { + "name": "digest_subscriptions_org_user_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "digest_subscriptions_org_enabled_idx": { + "name": "digest_subscriptions_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.actors": { + "name": "actors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities_json": { + "name": "capabilities_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "actors_org_user_idx": { + "name": "actors_org_user_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "actors_org_agent_name_idx": { + "name": "actors_org_agent_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "actors_org_type_idx": { + "name": "actors_org_type_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_incidents": { + "name": "error_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_incidents_org_issue_idx": { + "name": "error_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_incidents_org_status_idx": { + "name": "error_incidents_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_events": { + "name": "error_issue_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_state": { + "name": "from_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_state": { + "name": "to_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issue_events_issue_idx": { + "name": "error_issue_events_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_events_actor_idx": { + "name": "error_issue_events_actor_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_events_type_idx": { + "name": "error_issue_events_type_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_states": { + "name": "error_issue_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_observed_occurrence_at": { + "name": "last_observed_occurrence_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "open_incident_id": { + "name": "open_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issue_states_org_idx": { + "name": "error_issue_states_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "error_issue_states_org_id_issue_id_pk": { + "name": "error_issue_states_org_id_issue_id_pk", + "columns": [ + "org_id", + "issue_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issues": { + "name": "error_issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'error'" + }, + "source_ref_json": { + "name": "source_ref_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_type": { + "name": "exception_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_message": { + "name": "exception_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_label": { + "name": "error_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "top_frame": { + "name": "top_frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_state": { + "name": "workflow_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'triage'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "severity_source": { + "name": "severity_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_actor_id": { + "name": "assigned_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_holder_actor_id": { + "name": "lease_holder_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by_actor_id": { + "name": "resolved_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snooze_until": { + "name": "snooze_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issues_org_fp_idx": { + "name": "error_issues_org_fp_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_workflow_idx": { + "name": "error_issues_org_workflow_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_severity_idx": { + "name": "error_issues_org_severity_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_last_seen_idx": { + "name": "error_issues_org_last_seen_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_assignee_idx": { + "name": "error_issues_org_assignee_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assigned_actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_lease_expiry_idx": { + "name": "error_issues_lease_expiry_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_notification_policies": { + "name": "error_notification_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "destination_ids_json": { + "name": "destination_ids_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "notify_on_first_seen": { + "name": "notify_on_first_seen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_regression": { + "name": "notify_on_regression", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_resolve": { + "name": "notify_on_resolve", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_transition_in_review": { + "name": "notify_on_transition_in_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_transition_done": { + "name": "notify_on_transition_done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_claim": { + "name": "notify_on_claim", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "min_occurrence_count": { + "name": "min_occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'warning'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_escalation_policies": { + "name": "issue_escalation_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rules_json": { + "name": "rules_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_escalations": { + "name": "issue_escalations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "issue_escalations_dedupe_idx": { + "name": "issue_escalations_dedupe_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_escalations_due_idx": { + "name": "issue_escalations_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_escalations_org_issue_idx": { + "name": "issue_escalations_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.investigations": { + "name": "investigations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'investigating'" + }, + "seeded_by": { + "name": "seeded_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "subject_json": { + "name": "subject_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "incident_kind": { + "name": "incident_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_json": { + "name": "report_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "diagnosed_at": { + "name": "diagnosed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "investigations_incident_idx": { + "name": "investigations_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"investigations\".\"incident_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_created_idx": { + "name": "investigations_org_created_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_issue_idx": { + "name": "investigations_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_status_idx": { + "name": "investigations_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_auth_states": { + "name": "oauth_auth_states", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initiated_by_user_id": { + "name": "initiated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "return_to": { + "name": "return_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_auth_states_expires_idx": { + "name": "oauth_auth_states_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_connections": { + "name": "oauth_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_email": { + "name": "external_user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "access_token_ciphertext": { + "name": "access_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_iv": { + "name": "access_token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_tag": { + "name": "access_token_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_ciphertext": { + "name": "refresh_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_iv": { + "name": "refresh_token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_tag": { + "name": "refresh_token_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_connections_org_provider_idx": { + "name": "oauth_connections_org_provider_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_connections_org_idx": { + "name": "oauth_connections_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_onboarding_state": { + "name": "org_onboarding_state", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_data_requested": { + "name": "demo_data_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "checklist_dismissed_at": { + "name": "checklist_dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "first_data_received_at": { + "name": "first_data_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "welcome_email_sent_at": { + "name": "welcome_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "connect_nudge_email_sent_at": { + "name": "connect_nudge_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stalled_email_sent_at": { + "name": "stalled_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activation_email_sent_at": { + "name": "activation_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_billing_suspensions": { + "name": "org_billing_suspensions", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "overdue_since": { + "name": "overdue_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "overdue_invoice_id": { + "name": "overdue_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_billing_suspensions_org_id_pk": { + "name": "org_billing_suspensions_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_attribute_mappings": { + "name": "org_ingest_attribute_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_context": { + "name": "source_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_ingest_attribute_mappings_org_idx": { + "name": "org_ingest_attribute_mappings_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_recommendation_issues": { + "name": "org_recommendation_issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "recommendation_key": { + "name": "recommendation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_key": { + "name": "canonical_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "org_recommendation_issues_org_idx": { + "name": "org_recommendation_issues_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_recommendation_issues_org_key_idx": { + "name": "org_recommendation_issues_org_key_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recommendation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_keys": { + "name": "org_ingest_keys", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key_hash": { + "name": "public_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_ciphertext": { + "name": "private_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_iv": { + "name": "private_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_tag": { + "name": "private_key_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_hash": { + "name": "private_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_rotated_at": { + "name": "public_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "private_rotated_at": { + "name": "private_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "org_ingest_keys_public_key_unique": { + "name": "org_ingest_keys_public_key_unique", + "columns": [ + { + "expression": "public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_ingest_keys_public_key_hash_unique": { + "name": "org_ingest_keys_public_key_hash_unique", + "columns": [ + { + "expression": "public_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_ingest_keys_private_key_hash_unique": { + "name": "org_ingest_keys_private_key_hash_unique", + "columns": [ + { + "expression": "private_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_ingest_keys_org_id_pk": { + "name": "org_ingest_keys_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_sampling_policies": { + "name": "org_ingest_sampling_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trace_sample_ratio": { + "name": "trace_sample_ratio", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "always_keep_error_spans": { + "name": "always_keep_error_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "always_keep_slow_spans_ms": { + "name": "always_keep_slow_spans_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_clickhouse_settings": { + "name": "org_clickhouse_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_url": { + "name": "ch_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_user": { + "name": "ch_user", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_password_ciphertext": { + "name": "ch_password_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_password_iv": { + "name": "ch_password_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_password_tag": { + "name": "ch_password_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_database": { + "name": "ch_database", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_clickhouse_settings_org_id_pk": { + "name": "org_clickhouse_settings_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_clickhouse_schema_apply_runs": { + "name": "org_clickhouse_schema_apply_runs", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_migration": { + "name": "current_migration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "steps_total": { + "name": "steps_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "steps_done": { + "name": "steps_done", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "applied_versions": { + "name": "applied_versions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skipped": { + "name": "skipped", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_clickhouse_schema_apply_runs_org_id_pk": { + "name": "org_clickhouse_schema_apply_runs_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrape_target_checks": { + "name": "scrape_target_checks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "scrape_target_checks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_target_key": { + "name": "sub_target_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "samples_scraped": { + "name": "samples_scraped", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "samples_post_relabel": { + "name": "samples_post_relabel", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scrape_target_checks_target_checked_idx": { + "name": "scrape_target_checks_target_checked_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scrape_target_checks_target_id_scrape_targets_id_fk": { + "name": "scrape_target_checks_target_id_scrape_targets_id_fk", + "tableFrom": "scrape_target_checks", + "tableTo": "scrape_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrape_targets": { + "name": "scrape_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'prometheus'" + }, + "discovery_config_json": { + "name": "discovery_config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scrape_interval_seconds": { + "name": "scrape_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "labels_json": { + "name": "labels_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "auth_credentials_ciphertext": { + "name": "auth_credentials_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_credentials_iv": { + "name": "auth_credentials_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_credentials_tag": { + "name": "auth_credentials_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_scrape_at": { + "name": "last_scrape_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_scrape_error": { + "name": "last_scrape_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scrape_targets_org_idx": { + "name": "scrape_targets_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scrape_targets_org_enabled_idx": { + "name": "scrape_targets_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_commits": { + "name": "vcs_commits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha": { + "name": "sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_email": { + "name": "author_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authored_at": { + "name": "authored_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "committed_at": { + "name": "committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_commits_repo_sha_idx": { + "name": "vcs_commits_repo_sha_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_commits_org_sha_idx": { + "name": "vcs_commits_org_sha_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_installations": { + "name": "vcs_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_installation_id": { + "name": "external_installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_installations_provider_external_idx": { + "name": "vcs_installations_provider_external_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_installations_org_idx": { + "name": "vcs_installations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_repositories": { + "name": "vcs_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_repo_id": { + "name": "external_repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "tracked_branch": { + "name": "tracked_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_repositories_org_repo_idx": { + "name": "vcs_repositories_org_repo_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repositories_org_idx": { + "name": "vcs_repositories_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repositories_installation_idx": { + "name": "vcs_repositories_installation_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_repository_branches": { + "name": "vcs_repository_branches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_repository_branches_repo_name_idx": { + "name": "vcs_repository_branches_repo_name_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repository_branches_org_idx": { + "name": "vcs_repository_branches_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 9352389b..83a3734a 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1782656894999, "tag": "0002_bizarre_triathlon", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1782846153383, + "tag": "0003_lowly_fantastic_four", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 382a0941..9c76710e 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -10,6 +10,7 @@ export * from "./escalations" export * from "./investigations" export * from "./oauth-connections" export * from "./onboarding" +export * from "./org-billing-suspensions" export * from "./org-ingest-attribute-mappings" export * from "./org-recommendation-issues" export * from "./org-ingest-keys" diff --git a/packages/db/src/schema/org-billing-suspensions.ts b/packages/db/src/schema/org-billing-suspensions.ts new file mode 100644 index 00000000..29b06fdf --- /dev/null +++ b/packages/db/src/schema/org-billing-suspensions.ts @@ -0,0 +1,29 @@ +import { pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core" + +// Per-org billing dunning state for the ingest gateway's "stop ingestion once +// 3 days overdue and never paid" policy. Written exclusively by the Autumn +// webhook receiver (`overdue_since`, on the `billing.updated` past_due signal) +// and the daily reconcile cron (`suspended_at`, once overdue ≥3d and the org +// has never paid an invoice). The ingest gateway reads this table during key +// resolution and 402s when `suspended_at IS NOT NULL`. A row with a null +// `suspended_at` is an org that is overdue but not yet suspended. +export const orgBillingSuspensions = pgTable( + "org_billing_suspensions", + { + orgId: text("org_id").notNull(), + // When Autumn first reported the subscription as past_due — the overdue clock. + overdueSince: timestamp("overdue_since", { withTimezone: true, mode: "date" }).notNull(), + // Set once the cron promotes the org to suspended (overdue ≥3d + never paid). + // Null = overdue-only. This column is the gateway's enforcement flag. + suspendedAt: timestamp("suspended_at", { withTimezone: true, mode: "date" }), + // The unpaid Stripe invoice id captured at suspension time (audit only). + overdueInvoiceId: text("overdue_invoice_id"), + reason: text("reason"), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).notNull(), + }, + (table) => [primaryKey({ columns: [table.orgId] })], +) + +export type OrgBillingSuspensionRow = typeof orgBillingSuspensions.$inferSelect +export type OrgBillingSuspensionInsert = typeof orgBillingSuspensions.$inferInsert diff --git a/packages/domain/src/http/billing.ts b/packages/domain/src/http/billing.ts index 115d7aff..16e53af0 100644 --- a/packages/domain/src/http/billing.ts +++ b/packages/domain/src/http/billing.ts @@ -42,11 +42,23 @@ export class BillingSubscription extends Schema.Class("Bill quantity: Schema.optionalKey(Schema.Number), }) {} +// Present only when the customer is fetched with `expand: ["invoices"]` (the +// reconcile cron does this to decide "never paid"). Models the consumed subset +// of Autumn's `GetCustomerInvoice`; `status === "paid"` is the paid-history signal. +export class BillingInvoice extends Schema.Class("BillingInvoice")({ + stripeId: Schema.optionalKey(Schema.NullOr(Schema.String)), + status: Schema.optionalKey(Schema.NullOr(Schema.String)), + total: Schema.optionalKey(Schema.NullOr(Schema.Number)), + createdAt: Schema.optionalKey(Schema.NullOr(Schema.Number)), + hostedInvoiceUrl: Schema.optionalKey(Schema.NullOr(Schema.String)), +}) {} + export class BillingCustomer extends Schema.Class("BillingCustomer")({ id: Schema.String, subscriptions: Schema.Array(BillingSubscription), balances: Schema.optionalKey(Schema.Record(Schema.String, BillingBalance)), flags: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + invoices: Schema.optionalKey(Schema.Array(BillingInvoice)), }) {} // ---- Plan catalog (listPlans) ---- From d1988d8b10e162b27ca73d2f1ff19ceacf4be31c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 3 Jul 2026 18:13:35 +0200 Subject: [PATCH 2/5] fix(billing): reject stale Svix webhook timestamps (anti-replay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifySvixSignature now rejects a delivery whose svix-timestamp is outside a ±5 minute window (the Svix library default) before checking the HMAC. Without this, an intercepted-but-validly-signed webhook could be replayed indefinitely since the signature itself never expires. The timestamp is part of the signed content, so an attacker can only replay the original stamp, not forge a fresh one. Threads the current time in via Clock so the check stays deterministic in tests; adds replay (stale), future-skew, and within-window cases. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/routes/billing-webhook.http.ts | 27 +++++++++++++++++---- apps/api/src/routes/billing-webhook.test.ts | 25 +++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/billing-webhook.http.ts b/apps/api/src/routes/billing-webhook.http.ts index 3380a6ed..45669330 100644 --- a/apps/api/src/routes/billing-webhook.http.ts +++ b/apps/api/src/routes/billing-webhook.http.ts @@ -1,5 +1,5 @@ import { HttpRouter, type HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { Effect, Option, Redacted } from "effect" +import { Clock, Effect, Option, Redacted } from "effect" import { BillingSuspensionService } from "../services/BillingSuspensionService" import { Env } from "../lib/Env" @@ -25,18 +25,33 @@ const timingSafeEqual = (a: string, b: string): boolean => { return mismatch === 0 } -// Verify a Svix signature: HMAC-SHA256 over `${id}.${timestamp}.${body}` keyed -// by the base64-decoded secret (the part after the `whsec_` prefix), base64 -// compared against any `v1,` token in the space-separated header. Returns -// false on any crypto failure rather than throwing. +// Svix rejects deliveries whose timestamp is outside a tolerance window to +// prevent replay of an intercepted (still validly-signed) webhook. 5 minutes +// matches the Svix library default. +const SVIX_TOLERANCE_SECONDS = 5 * 60 + +// Verify a Svix signature: first reject a timestamp outside the tolerance window +// (anti-replay), then HMAC-SHA256 over `${id}.${timestamp}.${body}` keyed by the +// base64-decoded secret (the part after the `whsec_` prefix), base64 compared +// against any `v1,` token in the space-separated header. Returns false on a +// stale/malformed timestamp or any crypto failure rather than throwing. export const verifySvixSignature = (input: { readonly secret: string readonly svixId: string readonly svixTimestamp: string readonly body: string readonly signatureHeader: string + readonly nowMs: number + readonly toleranceSeconds?: number }) => Effect.gen(function* () { + // Anti-replay: the timestamp is signed (tampering breaks the HMAC), so an + // attacker can only replay the original — reject once it's outside the window. + const timestampSeconds = Number(input.svixTimestamp) + if (!Number.isFinite(timestampSeconds)) return false + const toleranceMs = (input.toleranceSeconds ?? SVIX_TOLERANCE_SECONDS) * 1000 + if (Math.abs(input.nowMs - timestampSeconds * 1000) > toleranceMs) return false + const rawSecret = input.secret.startsWith("whsec_") ? input.secret.slice("whsec_".length) : input.secret @@ -144,12 +159,14 @@ export const BillingWebhookRouter = HttpRouter.use((router) => return textResponse("Missing svix signature headers", 400) } + const nowMs = yield* Clock.currentTimeMillis const valid = yield* verifySvixSignature({ secret: Redacted.value(env.AUTUMN_WEBHOOK_SECRET.value), svixId, svixTimestamp, body: bodyOpt.value, signatureHeader: svixSignature, + nowMs, }) if (!valid) { yield* Effect.annotateCurrentSpan({ diff --git a/apps/api/src/routes/billing-webhook.test.ts b/apps/api/src/routes/billing-webhook.test.ts index 4ce1ca2a..d0d732fe 100644 --- a/apps/api/src/routes/billing-webhook.test.ts +++ b/apps/api/src/routes/billing-webhook.test.ts @@ -7,6 +7,7 @@ const KEY_BYTES = Buffer.alloc(24, 9) const SECRET = `whsec_${KEY_BYTES.toString("base64")}` const SVIX_ID = "msg_123" const SVIX_TS = "1700000000" +const ON_TIME_MS = Number(SVIX_TS) * 1000 // same instant the message was stamped const sign = (body: string) => `v1,${createHmac("sha256", KEY_BYTES).update(`${SVIX_ID}.${SVIX_TS}.${body}`).digest("base64")}` @@ -15,6 +16,7 @@ const verify = (input: { secret?: string body: string signatureHeader: string + nowMs?: number }) => Effect.runPromise( verifySvixSignature({ @@ -23,6 +25,7 @@ const verify = (input: { svixTimestamp: SVIX_TS, body: input.body, signatureHeader: input.signatureHeader, + nowMs: input.nowMs ?? ON_TIME_MS, }), ) @@ -49,6 +52,28 @@ describe("verifySvixSignature", () => { const wrong = `v1,${createHmac("sha256", Buffer.alloc(24, 1)).update(`${SVIX_ID}.${SVIX_TS}.${body}`).digest("base64")}` expect(await verify({ body, signatureHeader: wrong })).toBe(false) }) + + it("rejects a replayed (stale) but validly-signed payload", async () => { + const body = JSON.stringify({ type: "billing.updated" }) + // Same valid signature, but "now" is 10 minutes after the stamped time. + expect( + await verify({ body, signatureHeader: sign(body), nowMs: ON_TIME_MS + 10 * 60 * 1000 }), + ).toBe(false) + }) + + it("rejects a timestamp too far in the future (clock skew / replay)", async () => { + const body = JSON.stringify({ type: "billing.updated" }) + expect( + await verify({ body, signatureHeader: sign(body), nowMs: ON_TIME_MS - 10 * 60 * 1000 }), + ).toBe(false) + }) + + it("accepts a timestamp within the tolerance window", async () => { + const body = JSON.stringify({ type: "billing.updated" }) + expect( + await verify({ body, signatureHeader: sign(body), nowMs: ON_TIME_MS + 4 * 60 * 1000 }), + ).toBe(true) + }) }) describe("extractCustomerId", () => { From 9a88e1af9fbb0ceac178dd0cb47f22dcfc248b22 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 3 Jul 2026 18:18:52 +0200 Subject: [PATCH 3/5] fix(ingest): include org_billing_suspensions join in startup probe The PostgresKeyStore::probe() startup health-check now mirrors the same LEFT JOIN org_billing_suspensions that fetch_ingest_key/fetch_connector use, so a binary newer than the applied schema (e.g. a rollback where the table is absent) fails fast at startup instead of passing the probe and erroring on the first real ingest request. Co-Authored-By: Claude Opus 4.8 --- apps/ingest/src/main.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/ingest/src/main.rs b/apps/ingest/src/main.rs index bf517714..70c5faa0 100644 --- a/apps/ingest/src/main.rs +++ b/apps/ingest/src/main.rs @@ -3740,15 +3740,19 @@ impl PostgresKeyStore { /// Startup gate — runs the production lookup query with a stub hash so any /// auth/schema/network/TLS issue exits the process instead of 503'ing every - /// request. + /// request. Mirrors every table/column `fetch_ingest_key` touches — including + /// the `org_billing_suspensions` join — so a binary newer than the applied + /// schema fails fast here rather than erroring on the first real request. async fn probe(&self) -> Result<(), String> { let client = self.client().await?; client .query( "SELECT k.org_id, \ - COALESCE(s.sync_status = 'connected', false) AS self_managed \ + COALESCE(s.sync_status = 'connected', false) AS self_managed, \ + (b.suspended_at IS NOT NULL) AS ingest_suspended \ FROM org_ingest_keys k \ LEFT JOIN org_clickhouse_settings s ON s.org_id = k.org_id \ + LEFT JOIN org_billing_suspensions b ON b.org_id = k.org_id \ WHERE k.private_key_hash = $1 LIMIT 1", &[&"__ingest_probe_no_match__"], ) From a1ef8ec4c45b5b5491be9d5c4e72fbf2050b0019 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 3 Jul 2026 18:25:28 +0200 Subject: [PATCH 4/5] fix(billing): use platform crypto.timingSafeEqual for webhook signature compare Replaces the hand-rolled timing-safe string comparison with node's crypto.timingSafeEqual over the decoded 32-byte HMAC digests. The remaining length check is on the decoded digest (public, fixed-size format), not on any secret, so it leaks nothing while eliminating the string-length oracle. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/routes/billing-webhook.http.ts | 22 +++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/api/src/routes/billing-webhook.http.ts b/apps/api/src/routes/billing-webhook.http.ts index 45669330..ebe373a2 100644 --- a/apps/api/src/routes/billing-webhook.http.ts +++ b/apps/api/src/routes/billing-webhook.http.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto" import { HttpRouter, type HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { Clock, Effect, Option, Redacted } from "effect" import { BillingSuspensionService } from "../services/BillingSuspensionService" @@ -16,14 +17,11 @@ const ROUTE = "/api/billing/autumn/webhook" const textResponse = (body: string, status: number) => HttpServerResponse.text(body, { status }) -const timingSafeEqual = (a: string, b: string): boolean => { - const ba = Buffer.from(a) - const bb = Buffer.from(b) - if (ba.length !== bb.length) return false - let mismatch = 0 - for (let i = 0; i < ba.length; i += 1) mismatch |= ba[i]! ^ bb[i]! - return mismatch === 0 -} +// Constant-time equality of two byte buffers via the platform primitive. The +// length check is on decoded digests (public, fixed 32-byte format) — not on any +// secret — so it leaks nothing; node's timingSafeEqual throws on unequal lengths. +const bytesEqual = (a: Buffer, b: Buffer): boolean => + a.length === b.length && timingSafeEqual(a, b) // Svix rejects deliveries whose timestamp is outside a tolerance window to // prevent replay of an intercepted (still validly-signed) webhook. 5 minutes @@ -72,13 +70,17 @@ export const verifySvixSignature = (input: { }).pipe(Effect.option) if (Option.isNone(mac)) return false - const expected = Buffer.from(mac.value).toString("base64") + const expectedBytes = Buffer.from(mac.value) // Header: space-separated tokens like "v1, v1a,". + // Compare the decoded 32-byte digests (not base64 strings) in constant time. const candidates = input.signatureHeader.split(" ").map((token) => { const comma = token.indexOf(",") return comma === -1 ? token : token.slice(comma + 1) }) - return candidates.some((candidate) => candidate.length > 0 && timingSafeEqual(expected, candidate)) + return candidates.some( + (candidate) => + candidate.length > 0 && bytesEqual(Buffer.from(candidate, "base64"), expectedBytes), + ) }) // Pull the org (Autumn customer_id) out of a `billing.updated` payload. Autumn From 6e392f44ac6be8ba537b278b0c5c36cc75f2d31e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 3 Jul 2026 20:01:19 +0200 Subject: [PATCH 5/5] fix(ingest): enforce billing suspension on gRPC and session-replay paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suspension 402 was only wired into the HTTP OTLP and Cloudflare logpush paths, so a suspended org could keep ingesting via gRPC OTLP or the session- replay endpoints. Add the same ingest_suspended guard at the two shared entry points: accept_grpc_decoded (covers traces/logs/metrics gRPC — returns FailedPrecondition since gRPC has no 402) and resolve_replay_key (covers replay meta + events — returns 402), before any payload is accepted or a browser session is metered. Co-Authored-By: Claude Opus 4.8 --- apps/ingest/src/main.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/ingest/src/main.rs b/apps/ingest/src/main.rs index 70c5faa0..bd5e5b2a 100644 --- a/apps/ingest/src/main.rs +++ b/apps/ingest/src/main.rs @@ -1549,6 +1549,18 @@ async fn accept_grpc_decoded( decoded: DecodedPayload, resolved: &ResolvedIngestKey, ) -> Result<(), tonic::Status> { + // Billing suspension (dunning) — mirror the HTTP OTLP / logpush hard-stop so a + // suspended org can't keep ingesting by switching to gRPC. gRPC has no 402, so + // surface FailedPrecondition ("active subscription" precondition not met). + if resolved.ingest_suspended { + warn!( + org_id = %resolved.org_id, + "gRPC ingestion suspended: unpaid invoice overdue" + ); + return Err(tonic::Status::failed_precondition( + "Ingestion suspended: unpaid invoice overdue", + )); + } let _org_inflight_permit = state .org_inflight_limiter .try_acquire(&resolved.org_id) @@ -1680,6 +1692,19 @@ async fn resolve_replay_key( .await .map_err(|_| ApiError::service_unavailable("Ingest authentication unavailable"))? .ok_or_else(|| ApiError::unauthorized("Invalid ingest key"))?; + // Billing suspension (dunning) — same hard-stop as the OTLP/logpush paths, at + // the shared replay auth boundary so meta + events are both blocked for a + // suspended org before any row is accepted (or a browser session metered). + if resolved.ingest_suspended { + warn!( + org_id = %resolved.org_id, + "Session replay suspended: unpaid invoice overdue" + ); + return Err(ApiError::new( + StatusCode::PAYMENT_REQUIRED, + "Ingestion suspended: unpaid invoice overdue", + )); + } Ok(Some(resolved)) }