Skip to content

feat(@neon/sdk)!: realign with the live OpenAPI spec — drop branches.recover, add members and logs - #408

Merged
andrelandgraf merged 9 commits into
mainfrom
feat/sdk-spec-refresh
Aug 9, 2026
Merged

feat(@neon/sdk)!: realign with the live OpenAPI spec — drop branches.recover, add members and logs#408
andrelandgraf merged 9 commits into
mainfrom
feat/sdk-spec-refresh

Conversation

@andrelandgraf

@andrelandgraf andrelandgraf commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Problem

SDK Spec Drift has been red for ten days and reports as a build failure, not as drift. The run dies at Build (typecheck + bundle), one step before the gate that would say what actually drifted:

src/neon/resources/branches.ts(7,2): error TS2724: '"../../client/sdk.gen.js"' has no exported
  member named 'recoverProjectBranch'. Did you mean 'recoverProject'?
src/neon/resources/branches.ts(388,14): error TS18046: 'data' is of type 'unknown'.

Both errors are one cause. The live spec no longer publishes POST /projects/{project_id}/branches/{branch_id}/recover, so recoverProjectBranch is not generated, and the unresolved import leaves the call's return type unknown — which is the second error.

Because Build runs before the gate, the run never printed the rest of the drift, and the drift is larger than the one error suggests. The live spec carries 168 operations against 163 committed: one removed, six added.

The failure history splits cleanly. From Jul 30 to Aug 3 the run failed at the drift gate with the correct "@neon/sdk is stale" message — that was the six additions arriving. From Aug 4 the removal moved the failure one step earlier, to Build, where it has stayed.

Diagnosis

The endpoint was not removed from the API. It was removed from the published spec. It still routes in production:

POST /projects/nonexistent-proj-123/branches/br-fake-999/recover
  → 404 {"request_id":"6f0c…","code":"","message":"project not found"}

POST /projects/nonexistent-proj-123/recover                    (known-good control)
  → 404 {"request_id":"2516…","code":"","message":"project not found"}

POST /projects/nonexistent-proj-123/branches/br-fake-999/totally_bogus
  → 404 this route does not exist

A route that does not exist answers in plain text. The recover route answers with the structured control-plane 404, identical to the control.

The load-bearing detail is what the spec kept. BranchRecoveryInfo, recoverable_until, include_deleted on GET /branches, and the hard_delete flag that describes "skipping the 7-day recovery window" are all still published. BranchRecoverResponse is still defined and is now referenced zero times. Only the path is gone, which is the shape of preview-gating applied to the path list rather than a feature being withdrawn. There is no alternate published spec: beta/, preview/, and bare v2.json all 404.

So the wrapper is removed rather than reimplemented. The SDK's contract is the published spec; a hand-written wrapper over an operation the spec does not declare would be the SDK asserting something the spec denies. It comes back when the spec does.

The user-facing interface

Removed — neon.branches.recover

The endpoint still answers, so callers have a direct path forward through the low-level client:

import type { BranchRecoverResponse } from "@neon/sdk";

const { data } = await neon.client.post<{ 200: BranchRecoverResponse }>({
	url: "/projects/{project_id}/branches/{branch_id}/recover",
	path: { project_id: projectId, branch_id: branchId },
});
const branch = data?.branch;

Verified against the built package: that call reaches POST https://console.neon.tech/api/v2/projects/p-1/branches/br-1/recover and returns the parsed body.

Two things about that snippet are load-bearing, and both are now in the README and the changeset. The { 200: … } response map is required — passing BranchRecoverResponse directly compiles and resolves to Branch | Endpoint[] | undefined, a union of the response's members. And the envelope carries the API's own error body, not a NeonError, because there is no generated function left for wrapRaw to wrap.

Nothing else on neon.branches changes. neon.projects.recover is a different endpoint — it recovers a deleted project, not a branch — and is untouched. The README says so explicitly, because it is now the only "recover" in the API reference and is the wrong thing for someone who just lost a branch.

Added — neon.projects.members

Per-project roles for members of the owning organization.

// Org members and their effective project access, cursor-paginated
const { data: members } = await neon.projects.members.list(projectId).all();

const { data: grant } = await neon.projects.members.setRole(projectId, memberId, "editor");
// A downgrade can leave credentials the member still holds
if (grant?.credential_rotation_recommended) { /* rotate database credentials */ }
if (grant?.org_api_key_rotation_recommended) { /* rotate project-scoped org keys */ }

// Clears the explicit grant; the member keeps their org-role default
await neon.projects.members.removeRole(projectId, memberId);
Method Returns
list(projectId, query?) Paginated<ProjectMember>query: { limit? }
setRole(projectId, memberId, role, { confirmSelfDemotion? }?) ProjectMemberRoleResponse
removeRole(projectId, memberId, { confirmSelfLockout? }?) ProjectMemberRoleResponse

Added — neon.logs

Branch logs from Neon Functions, object storage, and Postgres computes. Private beta.

// Function errors in the last 6 hours, newest first
const { data: errors } = await neon.logs
	.query(projectId, branchId, { since: "6h", source: "function", severity_text: "ERROR" })
	.all();

// Discover what can be enumerated, then read one field's values
const { data: fields } = await neon.logs.fields(projectId, branchId);
// ["service_name", "severity_text", "scope_name", "entity_type"]

const { data: services } = await neon.logs.fieldValues(projectId, branchId, "service_name", { since: "24h" });
if (services?.is_truncated) {
	// an arbitrary subset — narrow `since` or `source` before filtering on it
}

fieldName must come from fields. The enumerable set and the filterable set overlap rather than nest — source filters but is not enumerable, entity_type is enumerable but does not filter.

Method Returns
query(projectId, branchId, input?) Paginated<ProjectBranchLogRecord>
fields(projectId, branchId) string[]
fieldValues(projectId, branchId, fieldName, query?) ProjectBranchLogFieldValuesResponse

Four design decisions worth reviewing

members is a sibling of permissions, not merged into it. They look alike and are not the same system. permissions shares a project with an individual by email and returns ProjectPermission. members acts on existing org members by member id and returns org role, project role, effective permission, and grant source. removeRole also does not remove access — the member's org-role default still applies — which is why the methods are setRole/removeRole rather than grant/revoke.

The two confirmation flags default to off. setProjectMemberRole takes confirm_self_demotion and removeProjectMemberRole takes confirm_self_lockout; both are sent only when the caller passes them, so a call cannot silently cost you access to your own project. They live on the options bag, following the existing WorkflowOptions extends CallOptions { pooled } precedent.

logs is top-level rather than neon.branches.logs. Every logs path is branch-scoped, but so are neon.storage, neon.functions, and neon.aiGateway, and all three are top-level namespaces taking (projectId, branchId, …). Logs is its own product surface with its own spec tag and error taxonomy, so it follows them.

The pagination on logs.query is where the ergonomic layer earns its place. The raw endpoint is a POST whose cursor lives in the body, and whose contract requires the time window and every filter to be repeated unchanged on each page or the results are wrong. Wrapping it in Paginated makes that impossible to get wrong: the filters are snapshotted when the list is built and only the cursor is threaded, so neither a partial resend nor a caller mutating the input object mid-walk can change the query. It ends the walk on is_truncated rather than on next_cursor, because next_cursor is present-but-empty on the last page.

A page marked is_truncated with no next_cursor has records that cannot be reached. That returns a client-kind NeonError through the result envelope rather than ending the walk, because ending it would present a partial result as the whole one. paginate() now passes an already-classified error through instead of re-deriving one from the HTTP status, which is what keeps an SDK-side fault from surfacing as a NeonApiError carrying status: 200.

fields returns a bare string[] while fieldValues returns the whole response. The asymmetry is driven by the payloads: the fields response carries nothing but the array, while is_truncated is what decides whether the values can be trusted — the spec's own wording is that a caller filtering on a truncated list "is choosing from an arbitrary subset". Unwrapping it would hide that.

One thing deliberately not done: the spec documents two mutually exclusive pairs on logs.query (since vs start_time, and logql vs the seven content filters), and both remain runtime 400s rather than type errors. The input type is Omit<ProjectBranchLogsQueryRequest, "cursor">.

For logql the case is clear — encoding it means seven never arms and poor TS error messages for an escape hatch whose users know they have gone raw. For since vs start_time it is genuinely arguable: a two-arm union is four lines and converts a 400 into a compile error. The cost is that a caller holding a ProjectBranchLogsQueryRequest from elsewhere can no longer pass it straight through, and log filters are often built dynamically, which is exactly where a union is most awkward. Kept as a runtime rejection on that basis, and contained to one type if the tradeoff should go the other way. Note that branches.list and projects.list are precedent for the Omit<…, "cursor"> shape only — neither has mutually exclusive fields, so neither is precedent for this part.

Also in here

  • packages/cli/src/parameters.gen.ts — regenerated by packages/cli's own build step from the same spec. It is committed, so leaving it out makes the tree dirty after any build. The diff is entirely --help description text for existing flags: no flag added, removed, or retyped. Several descriptions that rendered as empty now have text.
  • packages/sdk/README.md — the branches table loses the recover row and gains the migration note above; neon.logs and neon.projects.members get sections. Required by AGENTS.md in the same PR as any ergonomic change.
  • Three README sections moved, not rewritten. neon.auth, neon.projects.permissions, and the new neon.projects.members were H3s sitting after the ## Raw layer (every endpoint, 1:1) heading, so the document outline read them as raw-only — the opposite of true. Pre-existing for the first two; this PR was adding a third instance, so all three moved back under ## API reference.
  • SetRoleOptions and RemoveRoleOptions are exported. They appear in public signatures, and without the export a caller who forwards a plain CallOptions compiles cleanly and can never reach the confirmation flags.
  • One pre-existing README example fixed, because this PR added a second instance of the same defect. The result envelope types error as the base NeonError, so a kind check does not make a subclass's own fields visible — the Errors section's if (error?.kind === "network") { error.reason } does not compile. It now narrows with instanceof NeonNetworkError, and the surrounding prose says why. Every code snippet added or touched in this PR was typechecked under --strict against the built package.
  • A stale doc comment on projects.recover that pointed at branches.recover.
  • Two changesets@neon/sdk major for the removal, neon patch for the CLI help text.

Verification

Branched from origin/main at d422449:

  • pnpm build — full recursive build, clean.
  • pnpm test:ci — 3874 passed across the monorepo.
  • pnpm --filter @neon/sdk test:ci — 130 passed.
  • pnpm --filter @neon/sdk test:types — 15 type tests, no type errors.
  • pnpm lint:ci — clean.
  • The drift gate itself: re-running the workflow's own spec:pullgenerategit diff --cached against this branch reports no diff. The job that has been red for ten days passes here.
  • CI on this PR: Build, Lint, Live Neon e2e, and all seven distributed-type checks pass.

Behaviours covered by the 15 new unit tests, against a stubbed network boundary using the repo's existing neonCapturing idiom:

  • logs.query walks pages and sends byte-identical filters on each, with only the cursor advancing
  • logs.query keeps the filters it was given when the caller mutates the input object after building the list
  • logs.query returns a client-kind error, rather than a silently partial result, when a page is truncated with no cursor to resume from
  • logs.query stops at the first untruncated page even when the response still echoes a cursor
  • logs.query sends an empty body when no filters are given
  • logs.fields unwraps the array; logs.fieldValues keeps is_truncated alongside the values
  • a branch without telemetry surfaces as NeonNotFoundError, not an empty result
  • members.list unwraps project_members and follows pagination.next
  • setRole and removeRole withhold their confirmation flags by default and send them only when acknowledged
  • setRole returns the credential- and API-key-rotation hints rather than just the role

neon.logs, exercised against real logs

The wrapper was run against a branch with real records, with limit: 2 to force the cursor path:

branch: br-gentle-pond-ajdg5561
fields: ["service_name","severity_text","scope_name","entity_type"]
page1 records: 2  cursor present: true
iterated records: 15  by source: {"storage":15}
fieldValues service_name -> {"values":["neon-storage/assets"],"is_truncated":false}
fieldValues severity_text -> {"values":["INFO"],"is_truncated":false}
fieldValues scope_name  -> {"values":["neon.storage.s3.put"],"is_truncated":false}
fieldValues entity_type -> {"values":["storage"],"is_truncated":false}

A real multi-page cursor walk with the filters replayed per page, and records matching ProjectBranchLogRecord field for field. Both exclusivity rules reject exactly as specced (400 conflicting_time_range, 400 conflicting_filters), which is the evidence behind leaving them as runtime errors.

What the endpoints serve today

Branch logs are part of the platform beta and scoped to its regions, so most branches answer 404. Across 92 branches on two accounts, availability tracks region exactly:

Region logs/query
aws-us-east-2 200 on 50 of 51 branches; 4 returned records, 2 returned 503
aws-us-east-1 404 telemetry_not_enabled, 21 of 21
aws-us-west-2 404 telemetry_not_enabled, 17 of 17

Inside the enabled region, two of the three source values emit: every record came from function or storage, and an explicit source: "pg_endpoint" filter returned zero on all four projects that had logs.

Four findings from that changed the PR:

  • source is not an enumerable field. fieldValues(…, "source") answers 400 unknown_field; the enumerable set is the four names above. The README example used "source" and would have failed for anyone who copied it. It now uses service_name and states the distinction, since source is still a valid filter.
  • minimum_severity can be rejected outright"minimum_severity is not supported by this branch's log backend" on a storage branch, despite being a documented filter. The README points at severity_text instead.
  • 404 telemetry_not_enabled is an ordinary outcome, not a fault. It is what the beta's region scoping produces, and a project's region is fixed at creation, so the docs tell callers to handle it rather than treat it as an error.
  • 503 telemetry backend unavailable is persistent — three attempts five seconds apart, on both Neon Functions projects in the enabled region. Not in the endpoint's documented responses. The SDK already retries 503, so client behaviour is right; the backend is down for those branches.

Still not verified live: neon.projects.members, which needs an org-owned project with per-project role management enabled. No such project was reachable, so it is exercised only at the wire-shape level. Live Neon e2e passing on this PR covers the pre-existing surfaces against the refreshed types.

For your attention

  • This blocks #269. It is approved and waiting, and it builds neonctl branches recover, branches list --include-deleted, and branches delete --hard-delete on top of branches.recover. The --include-deleted and --hard-delete halves are unaffected — both parameters are still in the spec. Only the recover subcommand loses its wrapper. The low-level call above will reach the endpoint for it, but it is not a drop-in: that CLI path wants a typed Branch and a NeonError for its error output, and the low-level envelope gives neither without hand-written mapping.
  • @neon/sdk goes to 2.0.0 for a removal that is expected to be temporary. The alternative is holding the whole refresh — including six operations and a ten-day-red job — until the spec is fixed, on a timeline nobody here controls.
  • The spec removal itself is unexplained. Dropping the path while keeping every recovery schema, include_deleted, and the hard_delete wording leaves the published spec documenting a recovery window with no documented way to recover. Worth raising with whoever owns v2 spec publishing; it is not fixable from this repo.
  • The Build-before-gate ordering is unchanged. It is defensible — a regeneration that does not compile should be loud — but the cost is that a run in this state reports one symptom and never prints what drifted. Left alone here to keep the PR to one concern.
  • paginate() has no cursor-cycle detection, so a server that repeats a cursor forever would spin, and requestTimeoutMs is unbounded by default. That is generic to every paginated method in the SDK, not something logs introduces, and no such response has been observed. Not fixed here because the fix belongs in paginate() and would widen this PR past its concern.

…recover, add members and logs

The live spec stopped publishing POST /projects/{project_id}/branches/{branch_id}/recover,
so the generated client no longer carries recoverProjectBranch and the hand-written
branches.recover wrapper stopped typechecking. The endpoint still answers in production,
so the wrapper comes back when the spec does.

The same refresh adds six operations. Three are wrapped as neon.projects.members
(list/setRole/removeRole) for org members' per-project roles, and three as a new
top-level neon.logs (query/fields/fieldValues) for branch logs.

BREAKING CHANGE: neon.branches.recover is removed.
… unresumable page

The endpoint returns wrong results unless every page repeats the filters unchanged.
The paginator read the caller's input object per page, so mutating it after building
the Paginated changed the query mid-walk. The filters are now snapshotted once.

A page marked is_truncated with no next_cursor has records that cannot be reached.
That previously ended the walk, presenting a partial result as the whole one; it now
surfaces an error through the result envelope.
…esumable-page error correctly

The removal left no trace in the API reference, so a caller hitting the type error and
searching for "recover" found only projects.recover, which recovers a project rather
than a branch. The branches section now says the wrapper is gone, shows the low-level
call that still reaches the endpoint, and says what that envelope does not give back.
The documented form needs a { 200: … } response map; passing the response type directly
resolves to a union of its members.

The truncated-page guard returned an error built from the HTTP response, which made an
SDK-side fault surface as a NeonApiError carrying status 200. It is now a client-kind
NeonError, and paginate passes an already-classified error through instead of
re-deriving one from the status.

Also: SetRoleOptions and RemoveRoleOptions are exported, so a caller forwarding options
can reach the confirmation flags; the three ergonomic namespaces documented after the
raw-layer heading move back under the API reference; and the logs section records the
six-hour fieldValues window, what logql actually replaces, and how the walk fails.
…ective access

project_role shares its type with setRole's argument, so reading it back after a call
looks like confirmation of what the member can do. It confirms only that the grant
landed; the organization-role default can still exceed it.
…oints

Probing production changed two claims that were taken from the spec rather than observed.

fieldValues(..., "source") answers 400 unknown_field on every branch tried: source is a
filter on both calls but is not an enumerable field, and the enumerable set is
service_name, severity_text, scope_name and entity_type. The example used "source" and
would have failed for anyone who copied it.

A branch with nothing to serve answers 200 with an empty logs array, not the 404 with
reason telemetry_not_enabled that the spec defines, so that is no longer asserted as the
behaviour. A branch whose telemetry backend is down answers 503, which the client already
retries on.
…tually occur

Telemetry is region-gated and off in most regions, so 404 telemetry_not_enabled is the
common answer rather than an empty result. Across thirteen branches on two accounts the
split was nine 404s, three 503s, and one empty 200; none returned a record.
… endpoints

Branch logs are part of the platform beta and scoped to its regions, so a 404 with
reason telemetry_not_enabled is an ordinary outcome for a branch outside them rather
than a fault to report. A project's region is fixed at creation, so callers handle it
rather than retry it.

Inside an enabled region the spec is wider than the backend: of the three source values
only function and storage were observed emitting, and minimum_severity can be rejected
as unsupported by a branch's log backend where severity_text still works.
…te handling

telemetry_not_enabled is a permanent property of a branch outside the beta's regions and
is an ordinary outcome to design around. branch_not_found is a wrong id or a key without
access. Telling callers to absorb every 404 would have swallowed the second. Both arrive
as NeonNotFoundError and reason is not lifted onto the error, so the README shows reading
it off the raw body.

Also: the headline query example filtered on minimum_severity, which the paragraph above
it documents as rejectable, so it now carries the caveat inline; the rejection's status
and error kind are named; and the enumerable and filterable field sets overlap rather
than nest, since entity_type is enumerable but is not a filter.
…or fields

The result envelope types error as the base NeonError, so a kind check does not make
NeonApiError.body or NeonNetworkError.reason visible and the examples using them did not
compile. The new branch-logs example and the pre-existing network-error example both
narrow with instanceof now, and the Errors section says why a kind check is not enough.

Reading reason off a 404 body costs a type guard because the body is unknown. The example
carries that cost rather than teaching a cast.
@andrelandgraf
andrelandgraf merged commit 4497de8 into main Aug 9, 2026
11 checks passed
@andrelandgraf
andrelandgraf deleted the feat/sdk-spec-refresh branch August 9, 2026 09:59
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