feat(config+cli): lifecycle hooks, toNeonBranchName + git→Neon CLI workflow (Preview) - #221
Draft
andrelandgraf wants to merge 6 commits into
Draft
feat(config+cli): lifecycle hooks, toNeonBranchName + git→Neon CLI workflow (Preview)#221andrelandgraf wants to merge 6 commits into
andrelandgraf wants to merge 6 commits into
Conversation
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.
7 tasks
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.
# Conflicts: # packages/env/src/lib/env.ts
…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.
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.
Overview
Background:
neon.tsandbranch()neon.tsis a config-as-code file for a Neon project (thinkvite.config.ts, but for your database branches). It default-exports a policy built withdefineConfig({ … })from@neondatabase/config, describing a branch's desired state:auth,dataApi, and apreviewblock (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.What this PR adds
Lifecycle hooks — an imperative companion to
branch(). Wherebranch()declares what a branch should look like, hooks do things — run migrations, seed data, send a notification — at the realcheckout/deploymoments. Hooks never run duringplan/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), atoNeonBranchNamehelper, and the CLI that drives all of it (packages/cli): theneonctl gitworkflow plus the hook invocations oncheckout/deploy.Declarative vs. imperative
auth/dataApi/previewbranch()plan/status/applyhooks(new)checkout/deployKeeping hooks out of
plan/status/inspectis what lets the diff stay deterministic and the typed env stay accurate.The hooks API
A policy may add a
hooksblock, keyed by the CLI command it brackets. Each phase has abeforeand anafter: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.createdtells you whether this checkout created the branch or selected an existing one.deploy.before({ branch, git })/deploy.after({ branch, env, result, git })— bracketneonctl deploy(apply the policy).resultis 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=1set) with the resolved Neon env injected, so an accidental interactive command fails fast instead of hanging.beforehooks influence/abort;afterhooks 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.createdfact, not a separate event.Example
Exact, policy-aware
envtypingThe
aftercontexts are generic over the policy, soenvis the exactNeonEnv<typeof config>:env.postgres.databaseUrl/databaseUrlUnpooledare always present, andenv.auth/env.dataApi/env.storage/env.aiGatewayare 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/envalready depends on@neondatabase/config), the canonicalNeonEnv<C>type family lives in@neondatabase/config— it's a pure shape derived fromConfig, so it belongs alongside the policy types — and@neondatabase/envre-exports it.@neondatabase/env's public surface and runtime (fetchEnv/parseEnv/toEntries) are unchanged.The
gitcontextEvery hook receives a read-only
gitobject describing the surrounding repository (available,branch,sha,isDirty,defaultBranch,triggeredByGitHook, …). Hooks can read git but never drive it — there is intentionally nogit.checkout(). The CLI populates this object (below).toNeonBranchNametoNeonBranchName(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 (passpreserveSlashes: falsefor a single flat token), falls back tobranch, and clamps length. The same helper backs the CLI's default git → Neon mapping.The CLI workflow (
neonctl git)Install a managed
post-checkouthook once, and from then on plaingitdrives everything:neonctl gitsub-commands:install/uninstall(manage the hook),sync(check out the Neon branch for the current git branch;--pullto fast-forward first),status(read-only git + mapping facts), andcleanup(prune stale.neonmappings;--prune-neon-branchesalso deletes orphaned Neon branches — never the default or a protected one, confirmed/--yesinteractively, refused non-interactively).What this PR implements
@neondatabase/config: thehookspolicy field + types (Hooks<C>, thecheckout/deploybefore/aftercontexts,GitContext,HookBranch,Hook,ShellHook), the zod schema (schemas.hooks), threaded throughdefineConfig; the canonicalNeonEnv<C>type family (re-exported by@neondatabase/env); andtoNeonBranchName.@neondatabase/config-runtime:runHook/runShellHook— execute a hook's function or shell form (shell runs non-interactively with the Neon env injected);HookExecutionErrorcarries the failing command + exit code.packages/cli(neonctl):.neongit{ follow, map }block (preserved byset-context/link);neonctl git install/uninstall/sync/status/cleanup+ the managedpost-checkouthook; read-only git-context plumbing (readGitContext); hook invocation at the existingcheckout/deployseams with the resolvedNeonEnvpassed toafterhooks even under--no-env-pull. Consumes@neondatabase/config+@neondatabase/config-runtimeas in-repoworkspace:*deps (no morepnpm linkstand-in).minorfor@neondatabase/config,@neondatabase/config-runtime, andneonctl;patchfor@neondatabase/env(the re-export).Test plan
@neondatabase/configunit tests + type tests, including newbranch-name,hooks, and exact-NeonEnv<C>suites; public value/type surfaces locked viav1.test.ts/v1.test-d.ts.@neondatabase/config-runtimetests, including the runner against real child processes: output streaming, env injection, forcedCI=1, sequential abort, non-zero-exitHookExecutionError, stdin-not-inherited.@neondatabase/envunit + type tests unchanged and green (verifying the re-export is transparent), reconciled with theforcePathStyle/NEON_STORAGE_REGIONremovals now thatNeonStorageEnvis canonical in@neondatabase/config.packages/cli: real temp-git-repo + temp-.neontests (git facts/detached-HEAD, managed-hook install/refresh/conflict/remove,gitPullno-upstream + fast-forward,.neongit preservation + mapping helpers,set-contextpreserving the git block, the env mapping,partitionBranchesToPrune);checkout/configcommand suites green;tsc --noEmitclean.biome ci --error-on-warningsclean (CLI is biome-excluded by design); all packages build.@neondatabase/config-runtime/drizzle) against a real Neon branch.Follow-ups
@neondatabase/config-runtime/drizzlehelpers (runMigrations,getPendingMigrations,isUpToDate,migrateHook) with real-Postgres end-to-end tests.neonctl git install --pullbake the fast-forward into the generated hook for users who want auto-pull on every checkout.