Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
c92fac5
Merge remote-tracking branch 'origin/cost/cloudflare-source' into cos…
Makisuo Nov 13, 2026
9691dc6
docs(cost): the account label comment names the organization rule once
Makisuo Nov 13, 2026
ac8396b
fix(cost): a PlanetScale charge filed under the provider's name wears…
Makisuo Nov 13, 2026
773e11d
Merge remote-tracking branch 'origin/cost/cloudflare-source' into cos…
Makisuo Nov 13, 2026
87c338c
fix(cost): a PlanetScale branch is a resource with its database, so t…
Makisuo Nov 13, 2026
cd8f430
docs(cost): wrap the account label comment
Makisuo Nov 13, 2026
e5982ae
Merge remote-tracking branch 'origin/cost/cloudflare-source' into cos…
Makisuo Nov 13, 2026
86c42a8
Merge remote-tracking branch 'origin/cost/cloudflare-source' into cos…
Makisuo Nov 13, 2026
235fa03
chore(cost): lab states for the GitHub step while its read is out and…
Makisuo Nov 13, 2026
82a25d4
refactor(cost): one connection step for the GitHub, Vercel and Planet…
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 @@ -15,6 +15,7 @@ import { HttpCloudflareCostLive } from "@/routes/internal/cloudflare-cost.http"
import { HttpGithubCostLive } from "@/routes/internal/github-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 { HttpVercelCostLive } from "@/routes/internal/vercel-cost.http"
import { HttpAuthLive, HttpAuthPublicLive, HttpAuthSessionLive } from "@/routes/auth.http"
Expand Down Expand Up @@ -172,6 +173,7 @@ const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe(
HttpDigestLive,
HttpCloudflareCostLive,
HttpGithubCostLive,
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 @@ -69,6 +69,7 @@ import { SupabaseOAuthService } from "@maple/backend/services/auth/SupabaseOAuth
import { CloudflareCostConnectionService } from "@maple/backend/services/cost/CloudflareCostConnectionService"
import { GithubCostSourceService } from "@maple/backend/services/cost/GithubCostSourceService"
import { OpenRouterCostConnectionService } from "@maple/backend/services/cost/OpenRouterCostConnectionService"
import { PlanetScaleCostSourceService } from "@maple/backend/services/cost/PlanetScaleCostSourceService"
import { VercelCostSourceService } from "@maple/backend/services/cost/VercelCostSourceService"
import { OrgOpenRouterKeyService } from "@maple/backend/services/org/OrgOpenRouterKeyService"
import { ServiceMapWorldService } from "@maple/backend/services/service-map/ServiceMapWorldService"
Expand Down Expand Up @@ -194,6 +195,7 @@ export const HttpServicesLive = Layer.mergeAll(
OpenRouterCostConnectionService.layer,
CloudflareCostConnectionService.layer,
GithubCostSourceService.layer,
PlanetScaleCostSourceService.layer,
VercelCostSourceService.layer,
ServiceMapWorldService.layer,
AuditLogService.layer,
Expand Down
65 changes: 59 additions & 6 deletions apps/web/src/lab/cost-fixture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe("cost lab fixture", () => {
.rows,
(row) => row.key,
),
).toEqual(["aws", "vercel", "cloudflare", "github"])
).toEqual(["aws", "vercel", "cloudflare", "github", "planetscale"])

// Filtered to OpenRouter there is nothing left: AWS cost must not show under that filter.
expect(labTotals(openRouterOnly).cost).toBeGreaterThan(0)
Expand All @@ -46,7 +46,13 @@ describe("cost lab fixture", () => {

test("Cloudflare is added waiting for its first sync, and a removed one takes its cost with it", () => {
const cloudflare = (added: boolean | null, from = LAB_SOURCES.synced) => {
const changes = { openRouter: null, cloudflare: added, github: {}, vercel: null }
const changes = {
openRouter: null,
cloudflare: added,
github: {},
vercel: null,
planetScale: null,
}
return {
sources: Arr.map(
Arr.filter(
Expand All @@ -70,6 +76,7 @@ describe("cost lab fixture", () => {
cloudflare: null,
github: changes,
vercel: null,
planetScale: null,
})
return {
sources: Arr.map(
Expand All @@ -86,6 +93,7 @@ describe("cost lab fixture", () => {
cloudflare: null,
github: changes,
vercel: null,
planetScale: null,
}),
"github",
),
Expand Down Expand Up @@ -125,13 +133,13 @@ describe("cost lab fixture", () => {
labBreakdown({ ...window, without: ["vercel"], groupBy: "provider", sortBy: "cost" }).rows,
(row) => row.key,
),
).toEqual(["aws", "openrouter", "cloudflare", "github"])
).toEqual(["aws", "openrouter", "cloudflare", "github", "planetscale"])
expect(labTotals({ days: 30, filters: { provider: ["vercel"] }, without: ["vercel"] }).cost).toBe(0)
})

test("a Vercel source added again after its removal is a new one that waits for its first sync", () => {
const after = (vercel: boolean | null) => {
const changes = { openRouter: null, cloudflare: null, github: {}, vercel }
const changes = { openRouter: null, cloudflare: null, github: {}, vercel, planetScale: null }
const sources = labSourcesWith(LAB_SOURCES.synced, changes)
return {
vercel: Arr.map(
Expand All @@ -149,10 +157,43 @@ describe("cost lab fixture", () => {
expect(after(true)).toEqual({ vercel: ["example-team pending"], others, costRemoved: true })
})

test("grouped by resource, cost splits by GitHub repository and Vercel project, and the rest has none", () => {
test("a PlanetScale source added again after its removal is a new one that waits for its first sync", () => {
const after = (planetScale: boolean | null) => {
const changes = { openRouter: null, cloudflare: null, github: {}, vercel: null, planetScale }
const sources = labSourcesWith(LAB_SOURCES.synced, changes)
return {
planetScale: Arr.map(
Arr.filter(sources.sources, (source) => source.provider === "planetscale"),
(source) => `${source.accountId} ${source.status}`,
),
others: Arr.filter(sources.sources, (source) => source.provider !== "planetscale").length,
costRemoved: Arr.contains(labRemovedProviders(changes), "planetscale"),
}
}
const others = LAB_SOURCES.synced.sources.length - 1
expect(after(null)).toEqual({ planetScale: ["example-data ok"], others, costRemoved: false })
expect(after(false)).toEqual({ planetScale: [], others, costRemoved: true })
expect(after(true)).toEqual({ planetScale: ["example-data pending"], others, costRemoved: true })
// Its cost leaves every read with it, also under a filter naming it.
expect(labTotals({ ...window, without: ["planetscale"] }).cost).toBeLessThan(labTotals(window).cost)
expect(
labTotals({ days: 30, filters: { provider: ["planetscale"] }, without: ["planetscale"] }).cost,
).toBe(0)
})

test("grouped by resource, cost splits by GitHub repository, Vercel project and PlanetScale branch, and the rest has none", () => {
expect(
Arr.map(labBreakdown({ ...window, groupBy: "resource", sortBy: "cost" }).rows, (row) => row.key),
).toEqual(["", "example-web", "example-api", "example-docs", "example-site"])
).toEqual([
"",
"example-web",
"example-orders/main",
"example-api",
"example-docs",
"example-search/main",
"example-orders/staging",
"example-site",
])
// Narrowed to a provider, only its own resources are left.
expect(
Arr.map(
Expand All @@ -165,5 +206,17 @@ describe("cost lab fixture", () => {
(row) => row.key,
),
).toEqual(["example-api", "", "example-site"])
// A branch carries its database, so the two `main` branches stay apart.
expect(
Arr.map(
labBreakdown({
days: 30,
filters: { provider: ["planetscale"] },
groupBy: "resource",
sortBy: "cost",
}).rows,
(row) => row.key,
),
).toEqual(["example-orders/main", "example-search/main", "example-orders/staging"])
})
})
58 changes: 53 additions & 5 deletions apps/web/src/lab/cost-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,25 @@ const cloudflareLine = (
drift,
})

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

/**
* A day's share of a branch's monthly invoice line: the database is the service, the SKU the
* usage type, the database's branch the resource.
*/
const branchLine = (database: string, branch: string, sku: string, daily: number, drift: number): Line => ({
cloud_service: database,
provider: "planetscale",
service_category: "Databases",
region: "global",
usage_type: sku,
resource: `${database}/${branch}`,
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 @@ -185,6 +204,10 @@ const LINES: ReadonlyArray<Line> = [
),
// The plan's monthly fee, as Vercel spreads it over the days: it belongs to no project.
productLine("Example Pro Plan", "Other", "global", "", "", 0.67, 0.0),
branchLine("example-orders", "main", "PS_20_AWS_ARM", 9.7, 0.0),
branchLine("example-orders", "main", "egress", 0.6, 0.2),
branchLine("example-orders", "staging", "PS_10_AWS_ARM", 1.1, 0.0),
branchLine("example-search", "main", "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 @@ -398,6 +421,17 @@ const cloudflareSource = source({
requestsThisMonth: 0,
})

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

/** A source that was just added: it waits for its first sync. */
const justAdded = (entry: SourceWire): SourceWire => ({
...entry,
Expand Down Expand Up @@ -425,14 +459,16 @@ export const labGithubLogin = (installationId: string): string =>
)

/**
* What the lab's reader did since the state loaded. `openRouter`, `cloudflare` and `vercel`: added
* (true), removed (false) or neither (null). `github`: an installation's id to whether it is a source now.
* What the lab's reader did since the state loaded. `openRouter`, `cloudflare`, `vercel` and
* `planetScale`: added (true), removed (false) or neither (null). `github`: an installation's id
* to whether it is a source now.
*/
export type LabSourceChanges = Readonly<{
openRouter: boolean | null
cloudflare: boolean | null
github: Readonly<Record<string, boolean>>
vercel: boolean | null
planetScale: boolean | null
}>

/**
Expand All @@ -447,7 +483,9 @@ export const labRemovedProviders = (changes: LabSourceChanges): ReadonlyArray<Co
(provider === "cloudflare" && changes.cloudflare === false) ||
(provider === "github" && Rec.has(changes.github, LAB_GITHUB_INSTALLATION)) ||
// A team added again is a new source: it has no cost until a sync the lab does not model.
(provider === "vercel" && changes.vercel !== null),
(provider === "vercel" && changes.vercel !== null) ||
// The same holds for a PlanetScale organization added again.
(provider === "planetscale" && changes.planetScale !== null),
)

/**
Expand All @@ -465,7 +503,9 @@ export const labSourcesWith = (
? changes.cloudflare !== false
: entry.provider === "vercel"
? changes.vercel === null
: entry.provider !== "github" || !Rec.has(changes.github, entry.connectionRef),
: entry.provider === "planetscale"
? changes.planetScale === null
: entry.provider !== "github" || !Rec.has(changes.github, entry.connectionRef),
)
const has = (provider: CostProvider) => Arr.some(kept, (entry) => entry.provider === provider)
return decodeSources({
Expand All @@ -474,6 +514,7 @@ export const labSourcesWith = (
...(changes.openRouter === true && !has("openrouter") ? [justAdded(openRouterSource)] : []),
...(changes.cloudflare === true && !has("cloudflare") ? [justAdded(cloudflareSource)] : []),
...(changes.vercel === true ? [justAdded(vercelSource)] : []),
...(changes.planetScale === true ? [justAdded(planetScaleSource)] : []),
...Arr.map(
Arr.filter(LAB_GITHUB_ORGS, (org) => changes.github[org.installationId] === true),
(org) =>
Expand Down Expand Up @@ -525,7 +566,14 @@ export const labGithubInstallations = (sources: CostSourcesResponse): GithubCost

export const LAB_SOURCES = {
synced: decodeSources({
sources: [source({}), openRouterSource, githubSource, vercelSource, cloudflareSource],
sources: [
source({}),
openRouterSource,
githubSource,
vercelSource,
cloudflareSource,
planetScaleSource,
],
}),
none: decodeSources({ sources: [] }),
pending: decodeSources({
Expand Down
Loading
Loading