Skip to content

feat(config+cli): lifecycle hooks, toNeonBranchName + git→Neon CLI workflow (Preview) - #221

Draft
andrelandgraf wants to merge 6 commits into
mainfrom
feat/lifecycle-hooks
Draft

feat(config+cli): lifecycle hooks, toNeonBranchName + git→Neon CLI workflow (Preview)#221
andrelandgraf wants to merge 6 commits into
mainfrom
feat/lifecycle-hooks

Conversation

@andrelandgraf

@andrelandgraf andrelandgraf commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Draft. Adds neon.ts lifecycle hooks and a git → Neon branch workflow. Now that the CLI lives in this monorepo, this PR carries both the foundational library layer (@neondatabase/config + @neondatabase/config-runtime) and the CLI consumer that wires it up (packages/cli) — what was previously split out as "PR2". The Drizzle helpers (PR3) still build on top. (Ported the CLI half from neondatabase/neonctl#574, which is closed in favor of this.)

Overview

Background: neon.ts and branch()

neon.ts is a config-as-code file for a Neon project (think vite.config.ts, but for your database branches). It default-exports a policy built with defineConfig({ … }) from @neondatabase/config, describing a branch's desired state:

  • Static service togglesauth, dataApi, and a preview block (functions, buckets, AI Gateway) declare what exists on a branch.
  • branch(target) => ({ … }) — an existing, pure closure for per-branch tuning (protected, ttl, parent, compute settings). It returns the desired settings for a given branch and is evaluated whenever the policy is read (plan / status / apply), so by contract it has no side effects.
// neon.ts as it exists today
export default defineConfig({
  auth: true,
  branch: (branch) => ({ protected: branch.name === "main" }),
});

What this PR adds

Lifecycle hooks — an imperative companion to branch(). Where branch() declares what a branch should look like, hooks do things — run migrations, seed data, send a notification — at the real checkout / deploy moments. Hooks never run during plan / status / inspect, so the declarative layer stays side-effect free.

This PR adds the hooks contract (types + schema, in @neondatabase/config), the runner that executes them (in @neondatabase/config-runtime), a toNeonBranchName helper, and the CLI that drives all of it (packages/cli): the neonctl git workflow plus the hook invocations on checkout / deploy.

Declarative vs. imperative

Layer Purpose Side effects? Evaluated during
auth / dataApi / preview what exists on a branch none (static) type-level + diff
branch() per-branch desired tuning none (pure) plan / status / apply
hooks (new) do things (migrate, seed, notify) yes (imperative) only real checkout / deploy

Keeping hooks out of plan / status / inspect is what lets the diff stay deterministic and the typed env stay accurate.

The hooks API

A policy may add a hooks block, keyed by the CLI command it brackets. Each phase has a before and an after:

  • checkout.before({ inputName, git }) — runs before the branch is resolved. May rename (return { name }) or abort (throw / a shell hook exiting non-zero).
  • checkout.after({ branch, env, git }) — runs after the branch is checked out and its env is resolved. branch.created tells you whether this checkout created the branch or selected an existing one.
  • deploy.before({ branch, git }) / deploy.after({ branch, env, result, git }) — bracket neonctl deploy (apply the policy). result is what the apply changed.

A hook value is either a function (ctx) => … or a shell command (a string, or an array run in sequence). Shell hooks run non-interactively (stdin detached, CI=1 set) with the resolved Neon env injected, so an accidental interactive command fails fast instead of hanging. before hooks influence/abort; after hooks observe.

Phases are keyed by the command, not by effect — whether a checkout created the branch or selected an existing one is read from the branch.created fact, not a separate event.

Example

import { defineConfig, toNeonBranchName } from "@neondatabase/config/v1";
import type { CheckoutBeforeContext, HookBranch } from "@neondatabase/config/v1";
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";

const MIGRATIONS_DIR = "./drizzle";

/** Apply committed migrations against a branch's direct (unpooled) connection. */
async function runMigrations(databaseUrl: string): Promise<void> {
  const sql = postgres(databaseUrl, { max: 1 });
  try {
    await migrate(drizzle(sql), { migrationsFolder: MIGRATIONS_DIR });
  } finally {
    await sql.end();
  }
}

/** Only auto-migrate a branch you own — never a shared/protected one (e.g. main). */
function shouldMigrateOnCheckout(branch: HookBranch): boolean {
  if (branch.isProtected || branch.isDefault) {
    return false;
  }
  return branch.created;
}

/** Optional: customize the Neon branch name derived from a git branch. */
function neonBranchNameFor({ inputName, git }: CheckoutBeforeContext): string {
  if (!git.triggeredByGitHook || !git.branch) {
    return inputName; // manual `neonctl checkout <name>` — honor it as typed
  }
  if (git.branch === "main" || git.branch === "master") {
    return "main";
  }
  return toNeonBranchName(git.branch, { prefix: "preview/" });
}

export default defineConfig({
  auth: true,
  branch: (branch) => ({ protected: branch.name === "main" }),

  hooks: {
    checkout: {
      before: (ctx) => ({ name: neonBranchNameFor(ctx) }),
      after: async ({ branch, env }) => {
        if (!shouldMigrateOnCheckout(branch)) {
          console.log(`Skipping migrations for ${branch.name}.`);
          return;
        }
        console.log(`Applying migrations to ${branch.name}…`);
        await runMigrations(env.postgres.databaseUrlUnpooled);
      },
    },

    deploy: {
      after: async ({ env }) => {
        await runMigrations(env.postgres.databaseUrlUnpooled);
      },
    },
  },
});

Exact, policy-aware env typing

The after contexts are generic over the policy, so env is the exact NeonEnv<typeof config>: env.postgres.databaseUrl / databaseUrlUnpooled are always present, and env.auth / env.dataApi / env.storage / env.aiGateway are present iff the policy enables those services — accessing a namespace the policy doesn't enable is a compile error.

To make that possible without a dependency cycle (@neondatabase/env already depends on @neondatabase/config), the canonical NeonEnv<C> type family lives in @neondatabase/config — it's a pure shape derived from Config, so it belongs alongside the policy types — and @neondatabase/env re-exports it. @neondatabase/env's public surface and runtime (fetchEnv / parseEnv / toEntries) are unchanged.

The git context

Every hook receives a read-only git object describing the surrounding repository (available, branch, sha, isDirty, defaultBranch, triggeredByGitHook, …). Hooks can read git but never drive it — there is intentionally no git.checkout(). The CLI populates this object (below).

toNeonBranchName

toNeonBranchName(input, opts?) derives a valid, stable Neon branch name from an arbitrary string. It lowercases, reduces each path segment to [a-z0-9-], preserves / hierarchy (pass preserveSlashes: false for a single flat token), falls back to branch, and clamps length. The same helper backs the CLI's default git → Neon mapping.

The CLI workflow (neonctl git)

Install a managed post-checkout hook once, and from then on plain git drives everything:

neonctl git install                 # writes a sentinel-guarded post-checkout hook (honors core.hooksPath)
git checkout -b feature/billing
  └─ [automatic] post-checkout hook → NEON_GIT_HOOK=1 neonctl git sync --quiet
       ├─ (opt-in --pull) git pull --ff-only        # bring local code + migrations up to date
       ├─ resolve Neon branch = git.map[…] ?? toNeonBranchName(gitBranch)
       ├─ neonctl checkout <neon-branch>             # creates it from the neon.ts policy if absent
       │    ├─ checkout.before({ inputName, git })   → (optional) rename / abort
       │    └─ checkout.after({ branch, env, git })  → e.g. migrate(env.postgres.databaseUrlUnpooled)
       └─ record the git → Neon mapping in .neon
  → .env now points at an isolated, migrated branch matching your code branch

neonctl git sub-commands: install / uninstall (manage the hook), sync (check out the Neon branch for the current git branch; --pull to fast-forward first), status (read-only git + mapping facts), and cleanup (prune stale .neon mappings; --prune-neon-branches also deletes orphaned Neon branches — never the default or a protected one, confirmed/--yes interactively, refused non-interactively).

What this PR implements

  • @neondatabase/config: the hooks policy field + types (Hooks<C>, the checkout / deploy before/after contexts, GitContext, HookBranch, Hook, ShellHook), the zod schema (schemas.hooks), threaded through defineConfig; the canonical NeonEnv<C> type family (re-exported by @neondatabase/env); and toNeonBranchName.
  • @neondatabase/config-runtime: runHook / runShellHook — execute a hook's function or shell form (shell runs non-interactively with the Neon env injected); HookExecutionError carries the failing command + exit code.
  • packages/cli (neonctl): .neon git { follow, map } block (preserved by set-context / link); neonctl git install/uninstall/sync/status/cleanup + the managed post-checkout hook; read-only git-context plumbing (readGitContext); hook invocation at the existing checkout / deploy seams with the resolved NeonEnv passed to after hooks even under --no-env-pull. Consumes @neondatabase/config + @neondatabase/config-runtime as in-repo workspace:* deps (no more pnpm link stand-in).
  • Changesets: minor for @neondatabase/config, @neondatabase/config-runtime, and neonctl; patch for @neondatabase/env (the re-export).

Test plan

  • @neondatabase/config unit tests + type tests, including new branch-name, hooks, and exact-NeonEnv<C> suites; public value/type surfaces locked via v1.test.ts / v1.test-d.ts.
  • @neondatabase/config-runtime tests, including the runner against real child processes: output streaming, env injection, forced CI=1, sequential abort, non-zero-exit HookExecutionError, stdin-not-inherited.
  • @neondatabase/env unit + type tests unchanged and green (verifying the re-export is transparent), reconciled with the forcePathStyle / NEON_STORAGE_REGION removals now that NeonStorageEnv is canonical in @neondatabase/config.
  • packages/cli: real temp-git-repo + temp-.neon tests (git facts/detached-HEAD, managed-hook install/refresh/conflict/remove, gitPull no-upstream + fast-forward, .neon git preservation + mapping helpers, set-context preserving the git block, the env mapping, partitionBranchesToPrune); checkout / config command suites green; tsc --noEmit clean.
  • biome ci --error-on-warnings clean (CLI is biome-excluded by design); all packages build.
  • End-to-end git → checkout → migrate loop — lands with PR3 (@neondatabase/config-runtime/drizzle) against a real Neon branch.

Follow-ups

  • PR3 (neon-pkgs): @neondatabase/config-runtime/drizzle helpers (runMigrations, getPendingMigrations, isUpToDate, migrateHook) with real-Postgres end-to-end tests.
  • Optionally let neonctl git install --pull bake the fast-forward into the generated hook for users who want auto-pull on every checkout.

Introduce the imperative companion to the pure `branch()` closure: a top-level
`hooks` policy field with `checkout` and `deploy` phases, each exposing a
`before` (influence/abort) and `after` (observe) hook. A hook is a function
`(ctx) => …` or a shell command (string/array). Hooks are read by the runtime
only on the real `checkout` / `deploy` commands — never during plan/status/
inspect — so the diff engine and typed env stay sound.

- @neondatabase/config: hook types (Hooks, Checkout/Deploy {before,after}
  contexts, GitContext, HookBranch, HookEnv, Hook, ShellHook), zod schema
  (schemas.hooks), carried through defineConfig; new toNeonBranchName/slugify
  helper for deriving valid Neon branch names from arbitrary strings (shared
  with the CLI's git → Neon mapping).
- @neondatabase/config-runtime: runHook/runShellHook execute a hook's function
  or shell form. Shell hooks run non-interactively (stdin detached, CI=1) with
  Neon env injected; HookExecutionError carries the failing command + exit code.

Tests: branch-name units, hooks schema/defineConfig acceptance + rejection, and
the runner against real child processes (output streaming, env injection, CI=1,
sequential abort, non-zero-exit error, stdin-not-inherited). Public value/type
surfaces locked via v1.test.ts / v1.test-d.ts snapshots.
Match the existing `define-config.test-d.ts` rigor (beyond the `v1.test-d.ts`
presence tripwires) for the new surface:

- config/hooks.test-d.ts: exact shapes of GitContext / HookEnv / HookBranch and
  every per-phase context (checkout/deploy × before/after), the function-vs-shell
  union, the checkout.before rename/abort contract, contextual inference inside
  defineConfig, and negatives (unknown phase/key, non-hook value, wrong-typed
  rename, reading a field absent from a phase context).
- config-runtime/run-hook.test-d.ts: runHook's generic return typing (function ⇒
  Promise<Result | undefined>, async unwrap, shell ⇒ Promise<unknown>),
  runShellHook ⇒ Promise<void>, and RunHookOptions env allowing string|undefined.
  Adds a `test:types` script for parity with @neondatabase/config.
slugify was just `toNeonBranchName(x, { preserveSlashes: false })` — redundant
surface. Removed the export, tests, type tests, README/changeset mentions; the
flat-token behavior stays available via `toNeonBranchName(x, { preserveSlashes:
false })` (covered by a branch-name unit test).
…cle, no dup)

Resolve the HookEnv typing properly. The `NeonEnv<C>` type family is a pure,
runtime-free shape derived from `Config`, so it moves to its canonical home in
`@neondatabase/config`; `@neondatabase/env` re-exports it (public surface
unchanged, verified by its existing type tests) and keeps the runtime that
produces it. This breaks the `config` → `env` cycle that previously forced a
structural `HookEnv` stand-in — with zero duplication (single source of truth).

- config: new `lib/env.ts` defining `NeonEnv` + namespace parts (`NeonPostgresEnv`,
  `NeonAuthEnv`, `NeonDataApiEnv`, `NeonStorageEnv`, `NeonAiGatewayEnv`,
  `NeonBranchEnv`, `FunctionSlugOf`, `NeonFunctionEnv`), reusing `ServiceEnabled`.
- Hook contexts are now generic over the policy: `CheckoutAfterContext<C>` /
  `DeployAfterContext<C>` expose `env: NeonEnv<C>`; `Hooks<C>` / `CheckoutHooks<C>`
  / `DeployHooks<C>` thread `C`; `Config.hooks` and `defineConfig` bind it to the
  inferred policy. `HookEnv` is removed.
- env: drop the local `NeonEnv` definitions; import + re-export from config.
- config-runtime: re-export the `NeonEnv` family in place of `HookEnv`.

Type tests prove exactness: a bare policy's `env` is exactly `postgres | branch`;
`auth: true` ⇒ `env.auth` typed; an unenabled namespace is a compile error.
…Preview)

Port the git → Neon workflow and lifecycle-hook plumbing from neonctl
(neondatabase/neonctl#574) into packages/cli now that the CLI lives in the
monorepo:

- New `neonctl git` command group (install / uninstall / sync / status /
  cleanup) driving a sentinel-guarded post-checkout hook.
- checkout / deploy invoke the policy's lifecycle hooks at their existing
  seams; the resolved `NeonEnv` is passed to `after` hooks even under
  --no-env-pull.
- `.neon` gains a git `{ follow, map }` block, preserved by set-context / link.
- Consume the hooks contract from @neondatabase/config + config-runtime as
  in-repo workspace:* deps (drops the prior pnpm-link stand-in), and reconcile
  the storage env with the forcePathStyle removal (#225) now that
  NeonStorageEnv is canonical in @neondatabase/config.
@andrelandgraf andrelandgraf changed the title feat(config): lifecycle hooks + toNeonBranchName helper (Preview) feat(config+cli): lifecycle hooks, toNeonBranchName + git→Neon CLI workflow (Preview) Jun 24, 2026
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