Skip to content
dbSDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Management

The control plane. Create, inspect, update, and delete provider resources with one verb set, then bridge to the query client.

The management layer is dbSDK’s control plane. You bring a provider credential (a Supabase personal access token or a Neon API key) and get one stable verb set over the provider’s resources: create, list, get, update, delete, and wait. It is the provisioning and lifecycle half of dbSDK; the query client is the other half.

What it is not, stated plainly: there is no central dbSDK backend, no credential proxying, no automatic retry or replay of create or delete, and no silent fallback between providers. Every request goes from your process to the provider’s official API with your credential.

import { createManagement } from "dbsdk/management";
import { neonManagement } from "dbsdk/management/neon";

const management = createManagement({
  adapter: neonManagement({ apiKey: process.env.NEON_API_KEY! }),
});

The verb set

Verb What it does
create(spec) Creates a resource and returns a write result.
list(kind, query?) Lists resources of a kind.
get(ref) Fetches one resource.
update(spec) Patches a resource (per-kind availability).
delete(ref) Deletes a resource (per-kind availability).
wait(target) Polls until a write finishes; GET requests only.

Every call is checked against the adapter’s declared capabilities before dispatch: an unsupported kind, verb, or pagination argument fails with a ManagementError of code CAPABILITY and nothing is sent. Scope rules are enforced the same way: a Neon database must carry both projectId and branchId, because that is how Neon actually addresses databases.

Create, wait, connect, query

The full path on Neon, with the real option names:

import { createDatabase } from "dbsdk";
import { createManagement } from "dbsdk/management";
import { neon } from "dbsdk/neon";
import { neonManagement } from "dbsdk/management/neon";

const management = createManagement({
  adapter: neonManagement({ apiKey: process.env.NEON_API_KEY! }),
});

// 1. Create the project. The write may involve several provider
//    operations; the result carries them all.
const result = await management.create({
  kind: "project",
  name: "my-app",
});

// 2. Wait until every operation finished. wait() issues GET requests
//    only and never resubmits the create.
const project = await management.wait(result, { timeoutMs: 120_000 });

// 3. Credentials: Neon's create responses carry connection URIs and role
//    passwords (shown once by Neon). dbSDK surfaces them only in
//    result.secrets and redacts them from every raw payload and error.
const secret = result.secrets.find((s) => s.label === "connectionString");

// 4. An official connection URI on demand. By default the URI comes back
//    with the password segment redacted; `reveal: true` is the explicit
//    opt-in that returns the credential-bearing URI for connecting.
//    The default database is `neondb` and its default role is
//    `neondb_owner` — the ones a fresh project actually has.
const uri = await management.raw.connectionUri({
  projectId: project.id,
  databaseName: "neondb",
  roleName: "neondb_owner",
  reveal: true,
});

// 5. Bridge to the query client. The management credential and the SQL
//    connection string are different credentials; the URI from step 3 or
//    4 is the one the query adapter needs.
const db = createDatabase({
  adapter: neon({ connectionString: uri, transport: "http" }),
});
type NeonConnectionUriInput = {
  projectId: string;
  branchId?: string;      // omit for the project's default branch
  databaseName: string;   // required by the official endpoint
  roleName: string;       // required by the official endpoint
  pooled?: boolean;       // true returns the PgBouncer `-pooler` URI
  reveal?: boolean;       // explicit opt-in to receive the real password
};

Steps 1 to 4 run against the real Neon API with a real key; nothing here hides a host or invents an endpoint. Running them provisions a real project on your Neon account, so use a key scoped to a test organization while learning. The query in step 5 needs the project to be ready, which wait() establishes in step 2.

The same bridge exists on Supabase, assembled from official pieces rather than invented ones: the creation-time password (via secrets), the database host (via raw.databaseHost(projectRef)), and full branch credentials (via raw.branchConfig(...), secrets omitted unless you pass includeSecrets: true). You assemble Supabase’s documented direct connection string shape from those parts and hand it to the query adapter. A personal access token never acts as a database password, and the SDK never fabricates a URL from it.

Resources and scope

Resource kinds are a shared vocabulary, normalized across providers, with honest per-provider limits:

  • project: the top-level unit on both providers. A Supabase project is the PostgreSQL database; a Neon project contains branches that carry databases.
  • branch: a Neon branch. Neon branch writes are asynchronous; the result carries the provider operations that wait() polls.
  • database: a logical PostgreSQL database. Neon-scoped to a branch and addressed by name, exactly as Neon’s API does it. Supabase has no such resource: create({ kind: "database" }) on Supabase is refused with CAPABILITY because there is nothing for it to create.

The kind set is open: a provider adapter can declare its own kinds (with required scope fields in its capabilities), so a future adapter for a non-SQL service plugs in with its own vocabulary instead of fabricated SQL semantics.

type ResourceRef = {
  kind: string;
  id: string;
  projectId?: string;
  branchId?: string;
  scope?: Readonly<Record<string, string>>; // provider-defined kinds
};

type ManagementListQuery = {
  cursor?: string;
  limit?: number;
  projectId?: string;  // required for scoped list endpoints (Neon branches)
  branchId?: string;   // required for Neon database lists
  scope?: Readonly<Record<string, string>>;
};

Lists are scoped the way the provider addresses them: Neon’s branch list needs projectId on the query, and its database list needs both projectId and branchId. Missing scope fails before dispatch with CONFIGURATION.

What each provider supports today

The authoritative source at runtime is the adapter itself:

import { createManagement, describeManagementCapabilities } from "dbsdk/management";
import { neonManagement } from "dbsdk/management/neon";

const adapter = neonManagement({ apiKey: process.env.NEON_API_KEY! });
const management = createManagement({ adapter });

console.log(describeManagementCapabilities(adapter));
// {
//   providerId: "neon",
//   resourceKinds: ["project", "branch", "database"],
//   operations: { create: [...], list: [...], get: [...], update: [...], delete: [...] },
//   pagination: true,
//   asyncOperations: true,
//   statusPolling: ["branch"],
//   prerequisites: { ... },
//   resourceScopes: {},
//   evidence: { "create:project": "docs", ... }
// }
Neon (neonManagement) Supabase (supabaseManagement)
Create project yes; name required, region and organizationId optional; no plan and no caller password (Neon generates the role password) yes; name and an organization scope required (organizationId or providerOptions.organization_slug; the API’s organization_id is deprecated). A spec plan is refused with CONFIGURATION: the plan is chosen at the organization level
Branches yes; projectId required, creation is async, a read-write compute is added by default yes (the Environments API); branch create is synchronous
Databases yes; projectId + branchId + name + owner required; branch-scoped, addressed by name refused: the project is the database
Update / delete per kind, all declared kinds yes for project and branch
Pagination projects and branches paginate; the database list does not no server-side pagination anywhere
Async operations yes; wait() polls the operations endpoint no operations endpoint; wait() polls resource status
Credential Neon API key Supabase personal access token (sbp_...)

Availability in this table means “implemented and declared”. It never certifies your credentials, your plan tier, or that a specific live request will succeed; the provider answers for that, with its own errors normalized as described below.

One provider-specific nuance worth knowing before you write the create spec: Supabase’s current API does not accept a plan on project creation (the plan is chosen at the organization level), and the adapter refuses a spec that includes one with a CONFIGURATION error telling you to remove it. A spec region is still accepted and mapped to the official region_selection form, and the deprecated-but-accepted organization_id form is supported alongside providerOptions: { organization_slug }. Region codes come from the provider’s own endpoint (GET /v1/projects/available-regions); the adapter’s prerequisites state the current requirements.

Create results and secrets

type ManagementWriteResult = {
  resource: ManagementResource | null;
  operation: ManagementOperation | null;
  secrets: readonly ManagementSecret[]; // { label, value }
  indeterminate: boolean;
};

Secrets surface in exactly one place: secrets. On Supabase, the create endpoint requires a database password and never echoes it: when you omit password, the adapter generates a cryptographically random one and returns it through secrets (label password), shown once. If you pass your own, it is used but never echoed back. On Neon, connection URIs and role passwords from create responses arrive only through secrets.

Every raw provider payload is redacted before it reaches your code, and credential values are scrubbed from error messages. Treat everything in secrets like a password: store it, never log it.

Waiting, bounded

type WaitOptions = {
  signal?: AbortSignal;
  timeoutMs?: number;    // overall budget, default 300_000
  pollIntervalMs?: number; // default 2_000
  onStatus?: (update: ManagementResource | ManagementOperation) => void;
};

wait() issues GET requests only. It polls the provider’s operations endpoint where one exists (Neon) or the resource’s own status where one does not (Supabase), and it never resubmits a create, update, or delete. Rate-limited polls (429) are retried after the provider’s own hint, still inside the same overall budget. A budget overrun throws TIMEOUT; a caller abort throws ABORTED. Neither is indeterminate, because waiting performs no writes.

One precondition to know: waiting on a bare ResourceRef only works for kinds whose normalized status actually reflects provider lifecycle. Each adapter declares which kinds those are (statusPolling): Neon’s branch (the only Neon kind with a status field), and Supabase’s project and branch. wait() on a bare Neon project or database ref is refused before dispatch with CAPABILITY instead of hanging until the timeout: pass the write result or the operation returned by the write, which carries the provider operations to poll.

Errors

ManagementError carries a stable code: CAPABILITY, CONFIGURATION, AUTH, PERMISSION, NOT_FOUND, CONFLICT, RATE_LIMIT, VALIDATION, ABORTED, CONNECTION, TIMEOUT, PROVIDER, or UNKNOWN, plus the HTTP status, the adapter and resource identity, and retryable.

The mutation rule matches the query side: if a transport failure or a 5xx response happens during a create, update, or delete, the outcome cannot be known, so the error is marked indeterminate: true. The SDK never retries a mutation; you reconcile (for example, by listing or getting the resource before deciding what to do). Definitive 4xx rejections are not indeterminate.

Testing without a provider

dbsdk/management/testing ships a deterministic fixture adapter. It is not a simulator of any provider: it proves what the core client does with the results, errors, and capabilities an adapter declares, with every call recorded. See Testing and the examples in the repository.

Was this page helpful?