Skip to content

feat(cost): PlanetScale as a cost source, read through its integration - #2544

Open
Makisuo wants to merge 22 commits into
cost/cloudflare-sourcefrom
cost/planetscale-source
Open

Makisuo wants to merge 22 commits into
cost/cloudflare-sourcefrom
cost/planetscale-source

Conversation

@Makisuo

@Makisuo Makisuo commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #2541 (the Cloudflare cost source). It adds no migration. Retarget to main once #2541 merges.

What changed

PlanetScale is a cost source on /cost, read through the PlanetScale integration the org already has. Nothing is pasted. Add source lists PlanetScale, its step shows the connected organization, and one click adds it. Maple then shows what PlanetScale invoices per database and branch.

  • Connection. The source is a cost_sources row that points at the org's planetscale_connections row, with the connection's organization as its account. Adding makes the row, removing deletes it. The connection itself is never changed from the Cost page.
  • Authorization. Invoices are read with the integration's OAuth grant, through the grant's own token handling: a 401 forces one refresh, and a fresh token refused too stamps the grant revoked, after which no request is made.
  • Scope. organization:read_invoices is added to the default of PLANETSCALE_OAUTH_SCOPES (it is ticked on Maple's OAuth application). A grant made before this lacks it. The stored grant records its scopes, so the page says "Reconnect PlanetScale to grant access to invoices" without a request, an add is refused under its own error code, and a pull is held as not ready. A 403 from the API reads the same.
  • Reconnect, rebind, disconnect. Authorizing the same organization again keeps the source and makes it due at the next tick. Binding another organization removes the source, as does a disconnect, both in the connection's own transaction. The sync also removes a source whose connection is gone or reads another organization.
  • Invoices become days. PlanetScale has no daily figures. Each invoice line is spread evenly over the days of its month on which its branch existed. A prorated discount line is spread the same way, so a branch's days net out to what it cost. The shares of a line add up to its subtotal.
  • Rows. The database is the service, the SKU the usage type, the branch the resource. Only today and yesterday are marked not final, as with the other providers.
  • Sync. Twelve months back, through today. A pull is one invoice list plus one line-item read per month in its window. The daily pull takes the whole open month, and through the tenth the month before it too.
  • API. GET, PUT and DELETE /internal/cost/planetscale. An add reads one page of the invoice list first and names the organization the dialog showed: one bound since is refused. Audit entries are cost_source.created and cost_source.deleted.
  • Web. The dialog's PlanetScale step has four faces: the organization with Add source, a connection to reconnect, no connection, and a refused add with its reason inline. The Sources row offers Manage connection and Remove, and says its days are even shares of a monthly invoice that can change until the invoice closes. On the reworked page a PlanetScale account reads as "PlanetScale " and its databases wear the PlanetScale mark.

Decisions worth a look

  • A grant PlanetScale reported no scopes for is not ruled out by the stored scopes. The read itself then answers, and a 403 says to reconnect.
  • The daily pull takes the whole open month, not the last four days: one new line changes the share of every day of the month. Through the tenth it also takes the month before, since a closed month still gains lines.
  • After pulls stopped over a month end, the next weekly pull goes back to the month before the last day pulled, when that month was last read before it closed.
  • The open month does not mark its days as not final. A day is drawn lighter when any source marks it, so a month of marked days dimmed every provider's bars.
  • A month is capped at 5,000 line items and refused as too large beyond that. A blank amount is refused instead of read as zero.
  • The audit actions are named created / deleted, as on the GitHub cost source branch. The Vercel branch names them added / removed: one of the two has to change when they meet.

Verified

  • Backend, against a fake PlanetScale that serves the OAuth token endpoint and the invoice API: the adapter (refresh on 401, revoked grant, missing scope without a request, 403, another org's connection), the source service (add once, mismatch refused, reconnect code, outage, rollout, a binding that moves during the check), the client and the normalizer. 43 tests in those four files.
  • Through the real connection service: a reconnect of the same organization keeps the source and makes it due, binding another organization removes it, a disconnect removes it.
  • The sync: no source for a connection the org did not add, the pull windows, the removal of a source whose connection is gone or reads another organization. sync-plan keeps both month-end cases from review.
  • Web: cost.scene.test.ts covers each face of the step, the refused add with and without the reconnect link, the row and the removal. marks.test.ts, credential-tracing.test.ts and the lab fixture test pass (58 tests with the scene tests).
  • No PlanetScale command carries a credential: credential-tracing.test.ts runs them through the web tracer on its default policy, and the two providers that still paste a credential export no part of it.
  • Typecheck is clean for domain, backend, apps/api and apps/web. Lint is clean with the Effect config on the changed files. The anticipated-errors list is regenerated for the new error.
  • Live: the invoice client and the normalizer ran against the three newest invoices of a real organization, with a service token that has only read_invoices. Both closed months' daily rows add up to their line-item sums, and a window across two invoices reads both and fills every day.

Not verified

  • The OAuth path itself runs in tests only. Maple's one PlanetScale OAuth application redirects to production, so the reconnect flow cannot run on a preview. The live check used a service token: the endpoints and shapes are the same, only the authorization differs.
  • That PlanetScale's token response reports read_invoices in its scopes the way the stored scopes are read. If it reports none, the read decides.
  • That a grant without the scope answers 403. The wrong service tokens I tried answered 404.

Screenshots

Lab states with synthetic data.

Before After
Cost page, light
Grouped by provider, light
Sources panel, light
Sources panel, dark
Add source, light
Add source, dark
Add source, mobile

The PlanetScale step:

State Light Dark Mobile
Connected, ready to add
Connected without invoice access
Not connected
Add refused
Remove source

More of the page after:

Cost page, dark Cost page, mobile Grouped by provider, dark Sources panel, mobile

…token

An org's PlanetScale spend is read from its invoices: one list of invoices, then
one line-item read per month. An invoice is a month, so each line item is spread
evenly over the days of the month on which its branch existed, and a prorated
discount line nets into the same day, database, branch and SKU as its charge.
The database is the service, the SKU the usage type, the branch the resource.
The open month's invoice is listed and its lines already carry the whole month,
so its days so far are a share of it, marked as not final.

The org saves a service token (its ID and the token) and the organization's name
through PUT /internal/cost/planetscale. Maple checks it with PlanetScale first:
the invoice list, then the newest invoice's line items, the two reads a pull
makes. The pair is stored sealed in cost_connections as the Authorization header
PlanetScale takes, and the organization is the source's account. A replacement
must be for the same organization, and makes a failing source due again.

PlanetScale answers 404 for a token it does not know as well as for an
organization the token cannot see, so that refusal names both, on save and on a
pull. A token without invoice access is told which access to add.

The sync profile reads twelve months back and through today. No migration: the
provider joins COST_CONNECTION_PROVIDERS and the shared reconcile.
… a late payment left open

A day's share depends on every line of its month, so re-pulling only the last
four days left the earlier days of the open month on the shares of an older
pull until the weekly settle. A provider's profile now says what its head is,
and PlanetScale's is the newest day's whole month, at no extra request.

A month's days were not final for as long as its invoice was unpaid, and no
pull comes back to a month after the tenth of the next one. An unpaid invoice
whose month ended more than ten days ago is now written final.

Listing invoices stops at a page holding one from wholly before the window, so
an invoice that overlaps its neighbour is not missed. A blank amount is refused
instead of read as zero. A month is capped at 5,000 line items, which the pull
timeout leaves time to read.
…t/planetscale-source

# Conflicts:
#	docs/cloud-cost-plan.md
#	packages/backend/src/services/cost/cost-connection-sources.ts
…page

Add source lists PlanetScale. Its form asks for the organization and the service
token's ID before the token: the shared credential step now takes the plain
fields a provider declares before its credential, shown as typed, and hands them
to the save with it. Nothing is sent until every field is filled, and a field
too long for the request is refused in the dialog, naming which.

Replacing the token starts the organization from the source that exists, since
a replacement must be for the same one. The row in the list and the form's
description say that PlanetScale days are a monthly invoice spread evenly over
the days each branch ran.

The sentence naming the providers whose requests are free listed three names
with two ands. Provider names are now joined as a list, there and in the
first-sync wait.

The cost lab has PlanetScale in its fixture and a state for each dialog.
…s mark in the text colour

The line under PlanetScale ended in an ellipsis before it said the invoices are
spread over days. It is shorter now, and the form's description says the rest.
The mark was drawn muted where the other one-colour mark is not.
…monthly invoice

The form says it once, when the source is added. Beside the figures, where a
flat line across a month needs explaining, the row now says it too.
@coderabbitai

coderabbitai Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 804b5634-56c0-4477-96da-8193dad8ddaa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@maple-review-bot

This comment has been minimized.

… locks the organization

A refusal can be about the organization, the token's ID or the token, but it
stood under the token and marked that field wrong. With several fields it now
stands under them all and marks none.

On a replace the organization was editable, though only the source's own could
be saved. It is shown, not asked.

The token ID field named a description that was not on the page. A field's
description is referenced only when it has one, and the token ID now says where
PlanetScale shows it. The list line, the form and the source row say more
plainly that a day is an even share of a month's invoice line.
@maple-review-bot

This comment has been minimized.

…lls stopped over a month end

Two cases left a closed month on what a pull saw while it was open.

The head started at the new month on the first, so the closed month kept its
last snapshot until the weekly settle: a branch deleted on the 30th after that
day's pull showed its prorated discount up to a week late. A month head now
covers the previous month too through the tenth.

A settle after pulls had failed across a month end started at the previous
month, saw no gap above the watermark, and never went back to the month before
the watermark's, which the last settle had read before it closed. A settle of a
month head now starts there when the last settle was before the eleventh of the
watermark's month.

Providers whose head is their trailing days plan as before.
…s not final

A day is drawn lighter and counted in "not final yet" when any source marks
it. PlanetScale marked every day of its unpaid month, so an org with another
provider beside it saw nearly all its bars lighter all month, and a note that
the last 28 days were not final.

PlanetScale now marks today and yesterday only, as the other providers mark
their newest days. That the open month's shares can move until the invoice
closes is said on the source's row. The rule that wrote an unpaid month final
after ten days has nothing left to decide and is gone, with the invoice's paid
flag.
@maple-review-bot

This comment has been minimized.

…t/planetscale-source

# Conflicts:
#	apps/web/src/page/cost/view.ts
…page

A PlanetScale account reads as "PlanetScale <organization>": the organization
is a name its reader knows, so it stays in the label as an AWS account's number
does. Its databases wear the PlanetScale mark in the table, the legend, the
chips and the rail. A database's name is lowercase, where an AWS service's has
a capital or a space, and it counts as PlanetScale's only where the org has
that source: without one a lowercase service keeps the mark it had.
The web tracer puts a Command's arguments on its span, and its default policy
exports any top-level argument whose name ends in Id. The save command took the
token's ID as tokenId, so half of the stored credential would have been written
to telemetry in clear on every save.

The organization, the token's ID and the token now travel as one object
argument, which the tracer never exports, and the ID is named tokenIdentifier
inside it. A test runs every pasted-credential provider's save and remove
command through the tracer on its default policy and finds no part of what was
typed in the span's attributes.

The scene tests follow the new argument, and gain the PlanetScale account's row
on the reworked page: named by its organization, drilling into its databases.
@maple-review-bot

This comment has been minimized.

…t/planetscale-source

# Conflicts:
#	apps/web/src/page/cost/marks.test.ts
#	apps/web/src/page/cost/marks.ts
@maple-review-bot

This comment has been minimized.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread apps/web/src/page/cost/marks.ts Resolved
…ale's mark

A plan or support line carries a capital or a space, so it fell through to the AWS logo in an org that reads only PlanetScale.
@maple-review-bot

maple-review-bot Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 10/10 · safe to merge. One info note on duplicated error mapping; the invoice spread, token flow, refusal mapping and replace path are all pinned by provider and connection tests.
1 note · tests covered · risk medium · 4/4 new units observable

Adds PlanetScale as a cost source: invoices spread over the days each branch existed, a token-checked save/replace flow, the internal route and the Cost page wiring. Safe to merge. The VcsWebhookEnqueueFailure errors in http-graph.ts are webhook enqueue paths this change does not touch.

  • PlanetScaleCostProvider reads monthly invoices and spreads each line over its branch's days
  • cost_connections gains a planetscale token, checked with PlanetScale before it is sealed
  • sync-plan.ts adds the month head and reopens so a closed month is re-read
  • The add-source form declares plain identifier fields per provider before its credential

Findings

🔵 Note · F2 · toStoreFailure duplicates loadFailureOf's store-error mapping

maintainability · packages/backend/src/services/cost/providers/planetscale/PlanetScaleCostProvider.ts:12-15

The same two-reason mapping already lives in CostConnectionStore.ts:29 as loadFailureOf, which the Cloudflare provider imports for exactly this. If CostConnectionStoreError gains a reason, or changes which one means auth, only that copy follows and a credential that will not open is reported as an upstream failure.

Drop `toStoreFailure` and map with the shared helper: `Effect.mapError(loadFailureOf("planetscale"))`. The database message becomes "Cost connection database error", as Cloudflare's.
🤖 Prompt to fix this finding with an AI agent
Findings from an automated review of commit d8ad88062c97e8aa5e8c370defec450baacbfd33. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F2 · Note · maintainability · packages/backend/src/services/cost/providers/planetscale/PlanetScaleCostProvider.ts:12-15
`toStoreFailure` duplicates `loadFailureOf`'s store-error mapping
The same two-reason mapping already lives in `CostConnectionStore.ts:29` as `loadFailureOf`, which the Cloudflare provider imports for exactly this. If `CostConnectionStoreError` gains a reason, or changes which one means `auth`, only that copy follows and a credential that will not open is reported as an upstream failure.
Suggested fix: Drop `toStoreFailure` and map with the shared helper: `Effect.mapError(loadFailureOf("planetscale"))`. The database message becomes "Cost connection database error", as Cloudflare's.

Fixed since the last review

  • ✅ F1 · A PlanetScale charge that names no database wears the AWS mark

Production impact

Open errors in the changed files (1)
Issue Service Occurrences File
@maple/api/routes/VcsWebhookEnqueueFailure (2 issues) maple-api 9 apps/api/src/runtime/http-graph.ts

After this merges, Maple checks whether they stop.

d8ad880 · What was checked, coverage and telemetry details are on the Maple / review check. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

…onnection

A PlanetScale cost source no longer takes a pasted service token. It reads
invoices with the OAuth grant of the PlanetScale integration the org already
has: the source is a cost_sources row that points at the connection, with the
connection's organization as its account. Adding makes the row, removing
deletes it, and nothing is stored in cost_connections.

The grant's own machinery fetches the access token: a 401 goes through its
refresh, and a fresh token refused too stamps the grant revoked, after which
no request is made. Maple's OAuth application now asks for the invoice scope.
A grant made before that says so in its stored scopes, so a pull on it is held
as not ready without a request, with "Reconnect PlanetScale to grant Maple
access to invoices." A 403 from the API maps to the same.

GET, PUT and DELETE /internal/cost/planetscale replace the token endpoints. An
add reads one page of the invoice list first, refuses an organization other
than the one connected, and answers a refusal only a reconnect fixes under its
own code, so the page can link to the connection.

The source follows the connection. Authorizing the same organization again
keeps the source and makes it due at once, since the new grant may carry the
scope. Binding another organization removes it, as does a disconnect, both in
the connection's own transaction, and the sync removes one whose connection is
gone or reads another organization.

The invoice client, the spread and the sync profile are unchanged. The
pasted-token service, its endpoints and PlanetScale's place among the
pasted-credential providers are gone.
…org already has

The dialog's PlanetScale step is no longer a form. It reads the org's PlanetScale
connection and shows the organization with an Add source button. Nothing is
typed. A connection authorized before Maple asked for invoices says to
reconnect and links to the PlanetScale connection, an org with none is sent to
connect it, and a refused add stays in the step with its reason, with the
reconnect link only when a reconnect fixes it.

The Sources row offers Manage connection and Remove, with no token to replace.
Removing keeps the connection.

The shared credential step is back to one field per provider: the plain fields
before the secret and the locked organization are gone with their only user,
as is PlanetScale's entry among the pasted-credential providers. The telemetry
test covers the two providers that still paste a credential, and that no
PlanetScale command carries one.

The cost lab has a state for each face of the step. An audit entry for a cost
source links to the Cost page.
@Makisuo Makisuo changed the title feat(cost): PlanetScale as a cost source, read with a pasted service token feat(cost): PlanetScale as a cost source, read through its integration Nov 13, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant