feat(@neon/sdk)!: realign with the live OpenAPI spec — drop branches.recover, add members and logs - #408
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
SDK Spec Drifthas been red for ten days and reports as a build failure, not as drift. The run dies atBuild (typecheck + bundle), one step before the gate that would say what actually drifted:Both errors are one cause. The live spec no longer publishes
POST /projects/{project_id}/branches/{branch_id}/recover, sorecoverProjectBranchis not generated, and the unresolved import leaves the call's return typeunknown— which is the second error.Because
Buildruns 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/sdkis stale" message — that was the six additions arriving. From Aug 4 the removal moved the failure one step earlier, toBuild, 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:
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_deletedonGET /branches, and thehard_deleteflag that describes "skipping the 7-day recovery window" are all still published.BranchRecoverResponseis 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 barev2.jsonall 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.recoverThe endpoint still answers, so callers have a direct path forward through the low-level client:
Verified against the built package: that call reaches
POST https://console.neon.tech/api/v2/projects/p-1/branches/br-1/recoverand 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 — passingBranchRecoverResponsedirectly compiles and resolves toBranch | Endpoint[] | undefined, a union of the response's members. And the envelope carries the API's own error body, not aNeonError, because there is no generated function left forwrapRawto wrap.Nothing else on
neon.brancheschanges.neon.projects.recoveris 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.membersPer-project roles for members of the owning organization.
list(projectId, query?)Paginated<ProjectMember>—query:{ limit? }setRole(projectId, memberId, role, { confirmSelfDemotion? }?)ProjectMemberRoleResponseremoveRole(projectId, memberId, { confirmSelfLockout? }?)ProjectMemberRoleResponseAdded —
neon.logsBranch logs from Neon Functions, object storage, and Postgres computes. Private beta.
fieldNamemust come fromfields. The enumerable set and the filterable set overlap rather than nest —sourcefilters but is not enumerable,entity_typeis enumerable but does not filter.query(projectId, branchId, input?)Paginated<ProjectBranchLogRecord>fields(projectId, branchId)string[]fieldValues(projectId, branchId, fieldName, query?)ProjectBranchLogFieldValuesResponseFour design decisions worth reviewing
membersis a sibling ofpermissions, not merged into it. They look alike and are not the same system.permissionsshares a project with an individual by email and returnsProjectPermission.membersacts on existing org members by member id and returns org role, project role, effective permission, and grant source.removeRolealso does not remove access — the member's org-role default still applies — which is why the methods aresetRole/removeRolerather thangrant/revoke.The two confirmation flags default to off.
setProjectMemberRoletakesconfirm_self_demotionandremoveProjectMemberRoletakesconfirm_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 existingWorkflowOptions extends CallOptions { pooled }precedent.logsis top-level rather thanneon.branches.logs. Every logs path is branch-scoped, but so areneon.storage,neon.functions, andneon.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.queryis 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 inPaginatedmakes 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 onis_truncatedrather than onnext_cursor, becausenext_cursoris present-but-empty on the last page.A page marked
is_truncatedwith nonext_cursorhas records that cannot be reached. That returns aclient-kindNeonErrorthrough 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 aNeonApiErrorcarryingstatus: 200.fieldsreturns a barestring[]whilefieldValuesreturns the whole response. The asymmetry is driven by the payloads: the fields response carries nothing but the array, whileis_truncatedis 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(sincevsstart_time, andlogqlvs the seven content filters), and both remain runtime 400s rather than type errors. The input type isOmit<ProjectBranchLogsQueryRequest, "cursor">.For
logqlthe case is clear — encoding it means sevenneverarms and poor TS error messages for an escape hatch whose users know they have gone raw. Forsincevsstart_timeit 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 aProjectBranchLogsQueryRequestfrom 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 thatbranches.listandprojects.listare precedent for theOmit<…, "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 bypackages/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--helpdescription 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 therecoverrow and gains the migration note above;neon.logsandneon.projects.membersget sections. Required byAGENTS.mdin the same PR as any ergonomic change.neon.auth,neon.projects.permissions, and the newneon.projects.memberswere 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.SetRoleOptionsandRemoveRoleOptionsare exported. They appear in public signatures, and without the export a caller who forwards a plainCallOptionscompiles cleanly and can never reach the confirmation flags.erroras the baseNeonError, so akindcheck does not make a subclass's own fields visible — the Errors section'sif (error?.kind === "network") { error.reason }does not compile. It now narrows withinstanceof NeonNetworkError, and the surrounding prose says why. Every code snippet added or touched in this PR was typechecked under--strictagainst the built package.projects.recoverthat pointed atbranches.recover.@neon/sdkmajor for the removal,neonpatch for the CLI help text.Verification
Branched from
origin/mainatd422449: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.spec:pull→generate→git diff --cachedagainst this branch reports no diff. The job that has been red for ten days passes here.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
neonCapturingidiom:logs.querywalks pages and sends byte-identical filters on each, with only the cursor advancinglogs.querykeeps the filters it was given when the caller mutates the input object after building the listlogs.queryreturns aclient-kind error, rather than a silently partial result, when a page is truncated with no cursor to resume fromlogs.querystops at the first untruncated page even when the response still echoes a cursorlogs.querysends an empty body when no filters are givenlogs.fieldsunwraps the array;logs.fieldValueskeepsis_truncatedalongside the valuesNeonNotFoundError, not an empty resultmembers.listunwrapsproject_membersand followspagination.nextsetRoleandremoveRolewithhold their confirmation flags by default and send them only when acknowledgedsetRolereturns the credential- and API-key-rotation hints rather than just the roleneon.logs, exercised against real logsThe wrapper was run against a branch with real records, with
limit: 2to force the cursor path:A real multi-page cursor walk with the filters replayed per page, and records matching
ProjectBranchLogRecordfield 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:logs/queryaws-us-east-2200on 50 of 51 branches; 4 returned records, 2 returned503aws-us-east-1404 telemetry_not_enabled, 21 of 21aws-us-west-2404 telemetry_not_enabled, 17 of 17Inside the enabled region, two of the three
sourcevalues emit: every record came fromfunctionorstorage, and an explicitsource: "pg_endpoint"filter returned zero on all four projects that had logs.Four findings from that changed the PR:
sourceis not an enumerable field.fieldValues(…, "source")answers400 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 usesservice_nameand states the distinction, sincesourceis still a valid filter.minimum_severitycan 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 atseverity_textinstead.404 telemetry_not_enabledis 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 unavailableis 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 retries503, 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 e2epassing on this PR covers the pre-existing surfaces against the refreshed types.For your attention
neonctl branches recover,branches list --include-deleted, andbranches delete --hard-deleteon top ofbranches.recover. The--include-deletedand--hard-deletehalves are unaffected — both parameters are still in the spec. Only therecoversubcommand 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 typedBranchand aNeonErrorfor its error output, and the low-level envelope gives neither without hand-written mapping.@neon/sdkgoes 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.include_deleted, and thehard_deletewording 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.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, andrequestTimeoutMsis unbounded by default. That is generic to every paginated method in the SDK, not somethinglogsintroduces, and no such response has been observed. Not fixed here because the fix belongs inpaginate()and would widen this PR past its concern.