Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e4bcd7d
feat(cost): PlanetScale as a cost source, read with a pasted service …
Makisuo Nov 12, 2026
b6eb880
docs(cost): what PlanetScale's invoice API serves, and how invoices b…
Makisuo Nov 12, 2026
61c0b52
fix(cost): pull PlanetScale's whole open month daily, and settle what…
Makisuo Nov 12, 2026
3da8cfc
Merge remote-tracking branch 'origin/cost/cloudflare-source' into cos…
Makisuo Nov 12, 2026
793955a
feat(cost): add, replace and remove a PlanetScale source on the Cost …
Makisuo Nov 12, 2026
8fd3ffb
fix(cost): keep PlanetScale's line in the provider list whole, and it…
Makisuo Nov 12, 2026
b605a63
feat(cost): the PlanetScale source row says its days are shares of a …
Makisuo Nov 12, 2026
92a25e8
Merge remote-tracking branch 'origin/cost/cloudflare-source' into cos…
Makisuo Nov 12, 2026
9e3f5f1
fix(cost): a PlanetScale refusal marks no single field, and a replace…
Makisuo Nov 12, 2026
a0b7d4d
fix(cost): re-read a PlanetScale month after it closes, also after pu…
Makisuo Nov 13, 2026
cc9bc9d
fix(cost): PlanetScale's open month no longer marks a month of days a…
Makisuo Nov 13, 2026
587d323
Merge remote-tracking branch 'origin/cost/cloudflare-source' into cos…
Makisuo Nov 13, 2026
00b9c74
test(cost): the PlanetScale row's actions are named by their provider
Makisuo Nov 13, 2026
a13cc6a
Merge remote-tracking branch 'origin/cost/cloudflare-source' into cos…
Makisuo Nov 13, 2026
50589a8
feat(cost): PlanetScale's mark and account name on the reworked Cost …
Makisuo Nov 13, 2026
ed549c5
fix(cost): keep the PlanetScale service token's ID out of telemetry
Makisuo Nov 13, 2026
0aefbad
feat(cost): the empty Cost page names PlanetScale among what a source…
Makisuo Nov 13, 2026
e5d9bbc
Merge remote-tracking branch 'origin/cost/cloudflare-source' into cos…
Makisuo Nov 13, 2026
d8ad880
fix(cost): a PlanetScale charge that names no database wears PlanetSc…
Makisuo Nov 13, 2026
ed89228
feat(cost): read PlanetScale invoices through the org's PlanetScale c…
Makisuo Nov 13, 2026
4bfca7c
feat(cost): add PlanetScale on the Cost page from the connection the …
Makisuo Nov 13, 2026
1624c50
docs(cost): PlanetScale connects through its integration, and what an…
Makisuo Nov 13, 2026
2fdb0a1
fix(cost): a PlanetScale cost read never revokes the shared grant, an…
Makisuo Nov 13, 2026
9d79106
fix(cost): read the PlanetScale connection again after a refused add
Makisuo Nov 13, 2026
9ba0699
docs(cost): what a PlanetScale cost read does on a 401, and when a ne…
Makisuo Nov 13, 2026
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
64 changes: 64 additions & 0 deletions apps/api/src/routes/internal/planetscale-cost.http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { HttpApiBuilder } from "effect/http-api"
import {
CurrentTenant,
IntegrationsForbiddenError,
MapleInternalApi,
PlanetScaleCostSourceAdded,
PlanetScaleCostSourceRemoved,
PlanetScaleCostStatus,
} from "@maple/domain/http"
import { Effect } from "effect"
import * as Policy from "@maple/domain/policy"
import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService"
import { PlanetScaleCostSourceService } from "@maple/backend/services/cost/PlanetScaleCostSourceService"

// A cost source is a connection like any other: the role that manages integrations manages it.
const requireIntegrationsManager = (roles: CurrentTenant.TenantSchema["roles"]) =>
Policy.check(
roles,
"integrations:manage",
(denial) => new IntegrationsForbiddenError({ message: denial.message }),
)

export const HttpPlanetScaleCostLive = HttpApiBuilder.group(MapleInternalApi, "planetScaleCost", (handlers) =>
Effect.gen(function* () {
const sources = yield* PlanetScaleCostSourceService

return handlers
.handle("status", () =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
const status = yield* sources.status(tenant.orgId)
return new PlanetScaleCostStatus(status)
}).pipe(Effect.withSpan("HttpPlanetScaleCost.status")),
)
.handle("add", ({ payload }) =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireIntegrationsManager(tenant.roles)
const added = yield* sources.add(tenant.orgId, payload.organization)
// Adding what is a source already changed nothing, so it leaves no entry.
if (added.created) {
yield* recordHttpAudit("cost_source.created", {
metadata: { provider: "planetscale", account: added.organization },
})
}
return new PlanetScaleCostSourceAdded({ organization: added.organization })
}).pipe(Effect.withSpan("HttpPlanetScaleCost.add")),
)
.handle("remove", () =>
Effect.gen(function* () {
const tenant = yield* CurrentTenant.Context
yield* requireIntegrationsManager(tenant.roles)
const removed = yield* sources.remove(tenant.orgId)
// Removing what is not there changed nothing, so it leaves no entry.
if (removed.removed) {
yield* recordHttpAudit("cost_source.deleted", {
metadata: { provider: "planetscale" },
})
}
return new PlanetScaleCostSourceRemoved(removed)
}).pipe(Effect.withSpan("HttpPlanetScaleCost.remove")),
)
}),
)
2 changes: 2 additions & 0 deletions apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { HttpAiTriageLive } from "@/routes/internal/ai-triage.http"
import { HttpCloudflareCostLive } from "@/routes/internal/cloudflare-cost.http"
import { HttpOpenRouterCostLive } from "@/routes/internal/openrouter-cost.http"
import { HttpOpenRouterKeyLive } from "@/routes/internal/openrouter-key.http"
import { HttpPlanetScaleCostLive } from "@/routes/internal/planetscale-cost.http"
import { HttpServiceMapWorldLive } from "@/routes/internal/service-map-world.http"
import { HttpAuthLive, HttpAuthPublicLive, HttpAuthSessionLive } from "@/routes/auth.http"
import { HttpBillingLive } from "@/routes/internal/billing.http"
Expand Down Expand Up @@ -167,6 +168,7 @@ const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe(
HttpDependencyScanLive,
HttpDigestLive,
HttpCloudflareCostLive,
HttpPlanetScaleCostLive,
HttpOpenRouterCostLive,
HttpOpenRouterKeyLive,
HttpServiceMapWorldLive,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/runtime/service-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { SupabaseLogsService } from "@maple/backend/services/integrations/Supaba
import { SupabaseOAuthService } from "@maple/backend/services/auth/SupabaseOAuthService"
import { CloudflareCostConnectionService } from "@maple/backend/services/cost/CloudflareCostConnectionService"
import { OpenRouterCostConnectionService } from "@maple/backend/services/cost/OpenRouterCostConnectionService"
import { PlanetScaleCostSourceService } from "@maple/backend/services/cost/PlanetScaleCostSourceService"
import { OrgOpenRouterKeyService } from "@maple/backend/services/org/OrgOpenRouterKeyService"
import { ServiceMapWorldService } from "@maple/backend/services/service-map/ServiceMapWorldService"
import { ScrapeTargetsService } from "@maple/backend/services/integrations/ScrapeTargetsService"
Expand Down Expand Up @@ -190,6 +191,7 @@ export const HttpServicesLive = Layer.mergeAll(
OrgOpenRouterKeyService.layer,
OpenRouterCostConnectionService.layer,
CloudflareCostConnectionService.layer,
PlanetScaleCostSourceService.layer,
ServiceMapWorldService.layer,
AuditLogService.layer,
WarehouseQueryService.layer,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lab/cost-fixture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe("cost lab fixture", () => {
.rows,
(row) => row.key,
),
).toEqual(["aws", "cloudflare"])
).toEqual(["aws", "cloudflare", "planetscale"])

// Filtered to OpenRouter there is nothing left: AWS cost must not show under that filter.
expect(labTotals(openRouterOnly).cost).toBeGreaterThan(0)
Expand Down
46 changes: 40 additions & 6 deletions apps/web/src/lab/cost-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "@maple/domain/http"
import { Array as Arr, Number as Num, Order, Record as Rec, Schema, String as Str } from "effect"
import type { CostGroupBy } from "../page/cost/derive"
import type { CredentialProvider } from "../page/cost/providers"
import type { RemovableProvider } from "../page/cost/providers"

// Synthetic cost for `/lab/cost`: invented services, regions and amounts on one made-up account.
// Nothing here is read by the product.
Expand Down Expand Up @@ -75,6 +75,21 @@ const productLine = (
drift,
})

/** The PlanetScale organization the lab's source reads. */
export const LAB_PLANETSCALE_ORGANIZATION = "example-org"

/** A day's share of a branch's monthly invoice line: the database is the service, the SKU the usage type. */
const branchLine = (database: string, sku: string, daily: number, drift: number): Line => ({
cloud_service: database,
provider: "planetscale",
service_category: "Databases",
region: "global",
usage_type: sku,
account: LAB_PLANETSCALE_ORGANIZATION,
daily,
drift,
})

/** `daily` is the charge per day today; `drift` how much it grew or shrank over the last 30 days. */
const LINES: ReadonlyArray<Line> = [
line("Example Compute", "Compute", "north-1", "north-1:instance-hours", 61.2, 0.18),
Expand Down Expand Up @@ -102,6 +117,9 @@ const LINES: ReadonlyArray<Line> = [
productLine("Durable Objects", "Compute", "Durable Objects Compute Duration", 5.1, 0.12),
productLine("R2", "Storage", "R2 Storage", 3.6, 0.05),
productLine("D1", "Databases", "D1 Rows Read", 1.2, -0.08),
branchLine("example-orders", "PS_20_AWS_ARM", 9.7, 0.0),
branchLine("example-orders", "egress", 0.6, 0.2),
branchLine("example-search", "PS_10_AWS_ARM", 1.3, 0.0),
]

/** The day `offset` days before the lab's today, as `YYYY-MM-DD`. */
Expand Down Expand Up @@ -280,10 +298,24 @@ const cloudflareSource = source({
requestsThisMonth: 0,
})

const LAB_CREDENTIAL_SOURCES = { openrouter: openRouterSource, cloudflare: cloudflareSource } as const
/** The PlanetScale source: invoices back to the twelfth month before this one, and no billed requests. */
const planetScaleSource = source({
id: "0190f2b3-0000-7000-8000-00000000c062",
provider: "planetscale",
accountId: LAB_PLANETSCALE_ORGANIZATION,
backfillDate: "2025-11-01",
lastSyncedAt: LAB_NOW_MS - 90 * 60_000,
requestsThisMonth: 0,
})

/** What the lab's reader did to each pasted-credential source since the state loaded; null for nothing. */
export type LabSourceChanges = Readonly<Record<CredentialProvider, boolean | null>>
const LAB_ADDED_SOURCES = {
openrouter: openRouterSource,
cloudflare: cloudflareSource,
planetscale: planetScaleSource,
} as const

/** What the lab's reader did to each source the page adds and removes, since the state loaded; null for nothing. */
export type LabSourceChanges = Readonly<Record<RemovableProvider, boolean | null>>

/**
* `sources` after the lab's reader added a provider (true: it waits for its first sync) or removed
Expand All @@ -302,7 +334,7 @@ export const labSourcesWith = (
sources: [
...Schema.encodeSync(CostSourcesResponse)(current).sources,
{
...LAB_CREDENTIAL_SOURCES[provider],
...LAB_ADDED_SOURCES[provider],
status: "pending",
watermarkDate: null,
backfillDate: null,
Expand All @@ -315,7 +347,9 @@ export const labSourcesWith = (
})

export const LAB_SOURCES = {
synced: decodeSources({ sources: [source({}), openRouterSource, cloudflareSource] }),
synced: decodeSources({
sources: [source({}), openRouterSource, cloudflareSource, planetScaleSource],
}),
none: decodeSources({ sources: [] }),
pending: decodeSources({
sources: [
Expand Down
107 changes: 97 additions & 10 deletions apps/web/src/lab/cost-lab.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
PLANETSCALE_COST_RECONNECT_CODE,
PlanetScaleCostSourceAdded,
PlanetScaleCostSourceRemoved,
PlanetScaleCostStatus,
} from "@maple/domain/http"
import * as Policy from "@maple/domain/policy"
import { toLoadFailure } from "@maple/views/data/load-failure"
import * as Field from "@maple/views/ui/field"
Expand All @@ -14,8 +20,11 @@ import {
FetchCostSources,
FetchCostTimeseries,
FetchCostTotals,
AddPlanetScaleCostSource,
FetchPlanetScaleCostStatus,
ReadCostGrants,
RemoveCloudflareCostToken,
RemovePlanetScaleCostSource,
RemoveOpenRouterCostKey,
ResolveWindow,
SaveCloudflareCostToken,
Expand All @@ -33,6 +42,7 @@ import { urlToAppRoute } from "../route"
import { perDimension } from "../page/cost/derive"
import {
LAB_NOW_MS,
LAB_PLANETSCALE_ORGANIZATION,
LAB_SOURCES,
LAB_TODAY,
type LabSourceChanges,
Expand Down Expand Up @@ -67,6 +77,11 @@ export const LabState = Schema.Literals([
"token-refused",
"replace-token",
"remove-cloudflare",
"add-planetscale",
"planetscale-reconnect",
"planetscale-not-connected",
"planetscale-refused",
"remove-planetscale",
"member",
"member-onramp",
])
Expand All @@ -88,6 +103,7 @@ export const Model = Schema.Struct({
changes: Schema.Struct({
openrouter: Schema.NullOr(Schema.Boolean),
cloudflare: Schema.NullOr(Schema.Boolean),
planetscale: Schema.NullOr(Schema.Boolean),
}),
cost: CostModel,
})
Expand All @@ -100,7 +116,7 @@ type Return = Update.Return<Model, Message, Resources>
/** What the lab answers from: the state it loaded in, and what the reader did to its sources since. */
type World = Readonly<{ state: LabState; changes: LabSourceChanges }>

const NO_CHANGES: LabSourceChanges = { openrouter: null, cloudflare: null }
const NO_CHANGES: LabSourceChanges = { openrouter: null, cloudflare: null, planetscale: null }
type CostCommand = Command.Command<CostMessage, never, Resources>

const searchFor = (state: LabState): Search =>
Expand All @@ -120,7 +136,11 @@ const startsEmpty = (state: LabState): boolean =>
state === "add-openrouter" ||
state === "key-refused" ||
state === "add-cloudflare" ||
state === "token-refused"
state === "token-refused" ||
state === "add-planetscale" ||
state === "planetscale-reconnect" ||
state === "planetscale-not-connected" ||
state === "planetscale-refused"

/** What the reader did before the state's screenshot: the clicks that open its dialog. */
const openingOf = (state: LabState): ReadonlyArray<CostMessage> => {
Expand Down Expand Up @@ -157,6 +177,18 @@ const openingOf = (state: LabState): ReadonlyArray<CostMessage> => {
return [CostMessage.ClickedReplaceCredential({ provider: "cloudflare" })]
case "remove-cloudflare":
return [CostMessage.ClickedRemoveSource({ provider: "cloudflare" })]
case "add-planetscale":
case "planetscale-reconnect":
case "planetscale-not-connected":
return [CostMessage.ClickedAddSource(), CostMessage.ClickedAddPlanetScale()]
case "planetscale-refused":
return [
CostMessage.ClickedAddSource(),
CostMessage.ClickedAddPlanetScale(),
CostMessage.ClickedConfirmAddPlanetScale(),
]
case "remove-planetscale":
return [CostMessage.ClickedRemoveSource({ provider: "planetscale" })]
// Without Clerk the page starts with every permission: the member states take them away.
case "member":
case "member-onramp":
Expand Down Expand Up @@ -207,6 +239,25 @@ const timeseriesArgs = argsOf(TimeseriesInput)
const breakdownArgs = argsOf(BreakdownInput)
const facetsArgs = argsOf(FacetsInput)

/** PlanetScale took the stored grant's word for its scopes, then refused the invoice read. */
const refusedInvoices = {
...toLoadFailure("refused"),
code: PLANETSCALE_COST_RECONNECT_CODE,
message: "Reconnect PlanetScale to grant Maple access to invoices.",
}

/** What the org's PlanetScale connection is, by the state the lab loaded in. */
const planetScaleStatusFor = (state: LabState) =>
new PlanetScaleCostStatus(
state === "planetscale-not-connected"
? { organization: null, invoiceAccess: false, added: false }
: {
organization: LAB_PLANETSCALE_ORGANIZATION,
invoiceAccess: state !== "planetscale-reconnect",
added: false,
},
)

const removedAnswer = (provider: CredentialProvider) =>
CostMessage.CompletedRemoveCredential({ provider, result: Result.succeed(undefined) })

Expand Down Expand Up @@ -323,6 +374,32 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op
return Option.some(removedAnswer("openrouter"))
case RemoveCloudflareCostToken.name:
return Option.some(removedAnswer("cloudflare"))
case FetchPlanetScaleCostStatus.name:
return Option.map(keyArgs(command.args), ({ key }) =>
CostMessage.CompletedLoadPlanetScaleStatus({
key,
result: Result.succeed(planetScaleStatusFor(state)),
}),
)
case AddPlanetScaleCostSource.name:
return Option.some(
CostMessage.CompletedAddPlanetScale({
result:
state === "planetscale-refused"
? Result.fail(refusedInvoices)
: Result.succeed(
new PlanetScaleCostSourceAdded({
organization: LAB_PLANETSCALE_ORGANIZATION,
}),
),
}),
)
case RemovePlanetScaleCostSource.name:
return Option.some(
CostMessage.CompletedRemovePlanetScale({
result: Result.succeed(new PlanetScaleCostSourceRemoved({ removed: true })),
}),
)
default:
return Option.none()
}
Expand All @@ -331,15 +408,25 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op
type Step = Readonly<{ model: CostModel; commands: ReadonlyArray<CostCommand> }>
type Settled = Step & Pick<World, "changes">

/** A saved credential adds its provider to the sources the lab answers with next, a removal takes it out. */
/** What a successful answer did to the lab's sources: added a provider's (true), removed it (false). */
const changeOf = (answer: CostMessage): Partial<LabSourceChanges> => {
switch (answer._tag) {
case "CompletedSaveCredential":
return Result.isSuccess(answer.result) ? { [answer.provider]: true } : {}
case "CompletedRemoveCredential":
return Result.isSuccess(answer.result) ? { [answer.provider]: false } : {}
case "CompletedAddPlanetScale":
return Result.isSuccess(answer.result) ? { planetscale: true } : {}
case "CompletedRemovePlanetScale":
return Result.isSuccess(answer.result) ? { planetscale: false } : {}
default:
return {}
}
}

/** A source added on the page joins the sources the lab answers with next, a removal takes it out. */
const changesAfter = (changes: LabSourceChanges, answers: ReadonlyArray<CostMessage>): LabSourceChanges =>
Arr.reduce(answers, changes, (current, answer) =>
answer._tag === "CompletedSaveCredential" && Result.isSuccess(answer.result)
? { ...current, [answer.provider]: true }
: answer._tag === "CompletedRemoveCredential" && Result.isSuccess(answer.result)
? { ...current, [answer.provider]: false }
: current,
)
Arr.reduce(answers, changes, (current, answer) => ({ ...current, ...changeOf(answer) }))

/**
* Runs the page's reads against the fixture until none is left: an answer can ask for more (the
Expand Down
Loading
Loading