Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@
"@distilled.cloud/core": "1.0.0-rc.2",
"@effect/platform-bun": "catalog:effect",
"@maple-dev/effect-sdk": "workspace:*",
"@maple/alerting-core": "workspace:*",
"@maple/auth": "workspace:*",
"@maple/cache": "workspace:*",
"@maple/db": "workspace:*",
"@maple/domain": "workspace:*",
"@maple/effect-cloudflare": "workspace:*",
"@maple/email": "workspace:*",
"@maple/eventing-core": "workspace:*",
"@maple/infra": "workspace:*",
"@maple/llm": "workspace:*",
"@maple/query-engine": "workspace:*",
Expand Down
42 changes: 26 additions & 16 deletions apps/api/src/planetscale-webhook-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,41 @@
import type { MessageBatch } from "@cloudflare/workers-types"
import { afterEach, assert, describe, it } from "@effect/vitest"
import { Effect, Layer } from "effect"
import { OrgId } from "@maple/domain/http"
import { Effect, Layer, Schema } from "effect"
import { Database, DatabaseError } from "@/platform/DatabaseLive"
import { cleanupTestDbs, createTestDb, queryFirstRow, type TestDb } from "@/platform/test-pglite"
import { processPlanetScaleWebhookBatch } from "./planetscale-webhook-runtime"
import { projectPlanetScaleWebhookEvent } from "./services/integrations/planetscale/webhook-events"
import type { PlanetScaleWebhookJob } from "./services/integrations/planetscale/PlanetScaleWebhookQueue"

const trackedDbs: TestDb[] = []

afterEach(() => cleanupTestDbs(trackedDbs))

const job: PlanetScaleWebhookJob = {
kind: "planetscale-webhook",
orgId: "org_1",
connectionId: "connection_1",
payload: {
const orgId = Schema.decodeUnknownSync(OrgId)("org_1")

const makeJob = (
payload: PlanetScaleWebhookJob["payload"] = {
event: "branch.out_of_memory",
organization: "acme",
database: "shop",
resource: { name: "main" },
},
): PlanetScaleWebhookJob => ({
kind: "planetscale-webhook",
orgId,
connectionId: "connection_1",
payload,
receivedAt: 1_000,
}
event: projectPlanetScaleWebhookEvent({
orgId,
connectionId: "connection_1",
payload,
receivedAt: 1_000,
}),
})

const job = makeJob()

const makeBatch = (body: unknown) => {
let acknowledged = false
Expand Down Expand Up @@ -86,10 +100,7 @@ describe("PlanetScale webhook queue consumer", () => {

it.effect("writes a lifecycle event to the timeline but not to the issue hub", () => {
const testDb = createTestDb(trackedDbs)
const delivery = makeBatch({
...job,
payload: { ...job.payload, event: "branch.ready" },
})
const delivery = makeBatch(makeJob({ ...job.payload, event: "branch.ready" }))
return Effect.gen(function* () {
yield* processPlanetScaleWebhookBatch(delivery.batch)
assert.isTrue(delivery.acknowledged())
Expand Down Expand Up @@ -138,14 +149,13 @@ describe("PlanetScale webhook queue consumer", () => {

it.effect("carries the deploy-request number so redelivery dedupes", () => {
const testDb = createTestDb(trackedDbs)
const delivery = makeBatch({
...job,
payload: {
const delivery = makeBatch(
makeJob({
...job.payload,
event: "deploy_request.schema_applied",
resource: { number: 42 },
},
})
}),
)
return Effect.gen(function* () {
yield* processPlanetScaleWebhookBatch(delivery.batch)
const event = yield* Effect.promise(() =>
Expand Down
13 changes: 12 additions & 1 deletion apps/api/src/planetscale-webhook-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,20 @@ export const processPlanetScaleWebhookBatch = (batch: MessageBatch<unknown>) =>
),
),
onSuccess: (job) => {
const classified = classifyPlanetScaleEvent(job.payload.event)
const event = job.event
const eventData =
typeof event.data === "object" &&
event.data !== null &&
!Array.isArray(event.data)
? (event.data as { readonly [key: string]: unknown })
: null
const eventName =
typeof eventData?.event === "string" ? eventData.event : job.payload.event
const classified = classifyPlanetScaleEvent(eventName)
const annotateJob = Effect.annotateCurrentSpan({
orgId: job.orgId,
"maple.event.id": event.id,
"maple.event.type": event.type,
"maple.planetscale.connection_id": job.connectionId,
"maple.planetscale.webhook.event": job.payload.event,
})
Expand Down
11 changes: 7 additions & 4 deletions apps/api/src/routes/v1/planetscale-webhook.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,11 +187,13 @@ describe("PlanetScaleWebhookRouter", () => {
assert.strictEqual(jobs[0]?.orgId, "org_1")
assert.strictEqual(jobs[0]?.connectionId, CONNECTION_ID)
assert.strictEqual(jobs[0]?.payload.event, "branch.out_of_memory")
assert.strictEqual(jobs[0]?.event.type, "dev.maple.planetscale.webhook.received.v1")
assert.strictEqual(jobs[0]?.event.tenantid, "org_1")
}).pipe(Effect.ensuring(Effect.promise(dispose)))
}).pipe(Effect.provide(testDb.layer))
})

it.effect("enqueues lifecycle events too, and still drops genuinely unknown ones", () => {
it.effect("enqueues every verified factual event before downstream classification", () => {
const testDb = createTestDb(trackedDbs)
const jobs: PlanetScaleWebhookJob[] = []
return Effect.gen(function* () {
Expand Down Expand Up @@ -260,15 +262,16 @@ describe("PlanetScaleWebhookRouter", () => {
assert.strictEqual(branchReady.status, 202)
assert.strictEqual(jobs.length, 2)

// Forward-compatibility must not become "enqueue everything": an
// event neither side knows is acknowledged and dropped.
// Unknown provider facts also enter the typed event layer. The current
// issue/timeline consumer may ignore them, but other consumers can opt in.
const unknown = yield* post({
event: "branch.some_future_event",
organization: "acme",
database: "shop",
})
assert.strictEqual(unknown.status, 202)
assert.strictEqual(jobs.length, 2)
assert.strictEqual(jobs.length, 3)
assert.strictEqual(jobs[2]?.payload.event, "branch.some_future_event")
}).pipe(Effect.ensuring(Effect.promise(dispose)))
}).pipe(Effect.provide(testDb.layer))
})
Expand Down
29 changes: 21 additions & 8 deletions apps/api/src/routes/v1/planetscale-webhook.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Env } from "@/platform/Env"
import {
classifyPlanetScaleEvent,
decodePlanetScaleWebhookPayload,
projectPlanetScaleWebhookEvent,
verifyPlanetScaleSignature,
} from "@/services/integrations/planetscale/webhook-events"
import { PlanetScaleWebhookQueue } from "@/services/integrations/planetscale/PlanetScaleWebhookQueue"
Expand Down Expand Up @@ -165,17 +166,33 @@ export const PlanetScaleWebhookRouter = HttpRouter.use((router) =>
return textResponse("ok", 200)
}

// Both issue-worthy and timeline-only events go through the queue: the
// durable retry is what makes a missed deploy marker recoverable.
if (classified.action === "issue" || classified.action === "timeline") {
// Every verified factual event is normalized and projected before the
// durable queue boundary. The queued CloudEvent is the stable contract;
// payload remains temporarily for parity with the existing consumers.
{
const now = yield* Clock.currentTimeMillis
const orgId = decodeOrgIdSync(connection.orgId)
const event = yield* Effect.try({
try: () =>
projectPlanetScaleWebhookEvent({
orgId,
connectionId,
payload,
receivedAt: now,
}),
catch: () =>
new PlanetScaleWebhookUnavailable({
body: "Webhook event projection unavailable",
}),
})
const enqueued = yield* webhookQueue
.send({
kind: "planetscale-webhook",
orgId: decodeOrgIdSync(connection.orgId),
orgId,
connectionId,
payload,
receivedAt: now,
event,
})
.pipe(
Effect.tapError((error) =>
Expand All @@ -200,10 +217,6 @@ export const PlanetScaleWebhookRouter = HttpRouter.use((router) =>
event: payload.event,
}),
)
} else {
yield* Effect.logInfo("PlanetScale webhook lifecycle event acknowledged").pipe(
Effect.annotateLogs({ orgId: connection.orgId, event: payload.event }),
)
}

yield* Effect.annotateCurrentSpan({
Expand Down
Loading