Supabase project configuration package built on Effect V4 Schema — owns the canonical
CliConfig schema, config file loading/saving, and JSON Schema generation.
Six supported import paths total (see ADR 0009's 2026-08-24 decision for the full rationale): four
module entrypoints (., ./io, ./effect, ./internal) plus two generated JSON Schema
artifacts (./schema.json, ./project-schema.json).
@supabase/config(.) — pure, browser/edge-safe surface.CliConfigSchema/ProjectConfigSchemaand their derived types, config encoding, sparse-config defaults, theProjectConfigconverters (toProjectConfig,fromConfigDocument,fromApiProjectConfig, …), and error classes. No file IO, no Effect-returning function, no@effect/platform-*/node:/bun:module anywhere in its transitive import graph.fromConfigDocumentalso accepts aCliConfigWithRawPresencepair (aCliConfigalongside which keys were actually present in the source document) — presence matters because the schema defaults every optional section, so the decodedCliConfigalone can't tell "explicitly set to the default" from "never set" (ADR 0021). CallunmappedApiFieldsafterfromApiProjectConfigif you care whether this package version understood the response. Also exports the config-diff classification engine —diffProjectConfig,ConfigChange/ConfigChangeClass/ConfigChangeCounts/ConfigChangeSet,DiffProjectConfigOptions— a pure, synchronous comparison between twoProjectConfigprojections (ADR 0022), promoted here from./internalnow that Studio is a second consumer.@supabase/config/io— a Promise-based file-IO facade for external, non-Effect Node/Bun consumers only. Resolved via package.json exports conditions (bun/node/browser/default). Has zero internal consumers by design — nothing inside this monorepo should import it.@supabase/config/effect— the Effect-native superset. Re-exports everything from.plus the Effect-returning config-loading/saving programs,CliConfigStore/cliConfigStoreLayer, project-environment resolution, andinferFunctionsManifest(discovers and validatessupabase/functions/*on disk).@supabase/config/internal(CLI-2234) — NOT covered by semver, and onlyapps/climay import it (enforced bysrc/monorepo-import-contract.unit.test.ts). Carriesapps/cli's own Go-parity call sites and contract-guard tests:loadCliConfig/resolveCliConfigValue/resolveCliConfigSubtree— the SAME runtime functions./effectexports, re-typed here to additionally accept the internal-onlygoViperCompatoption (InternalLoadCliConfigOptionsforloadCliConfig;resolveCliConfigValue/resolveCliConfigSubtree's own widened options type,InternalResolveCliConfigOptions, is package-internal and not itself re-exported) — plus the otherwise-internal registry data (AUTH_HOOK_NAMES,unmappedSecretApiPaths,projectConfigMappingRows,ProjectConfigMappingRow,ProjectConfigApiAttributes,ENV_CAPTURE_REGEX). It also carriessupabase config pull/diff's own support surface (CLI-2064): the format-preserving surgical editor (applyConfigEditsand itsConfigEdit/ConfigEditOutcome/ConfigEditRefusal/ConfigEditRefusalReason/ConfigEditValue/AppliedConfigEdittypes — the config diff engine itself,diffProjectConfigand itsConfigChange/ConfigChangeClass/ConfigChangeCounts/ConfigChangeSet/DiffProjectConfigOptionstypes, lives on., not here — see above),dualScopeProjectConfigPaths, the raw[remotes.*]helpersremoteNameForProjectRef/remoteProjectIdEntries, and the atomic single-file writerwriteCliConfigDocumentText/CliConfigWriteError— kept off./effect's public surface deliberately (no consumer outsideapps/clineeds them, and internal-only keeps the published semver surface unchanged). Anything here can change or vanish in any release.@supabase/config/schema.json— generated JSON Schema (draft 2020-12) forCliConfig(adist/build output).@supabase/config/project-schema.json(CLI-2234) — generated JSON Schema (draft 2020-12) forProjectConfig, derived fromProjectConfigSchema(src/project-config/project-schema.ts); adist/build output alongsideschema.json.
- A file anywhere in this monorepo that needs any Effect-native symbol (the
CliConfigStoreservice,loadCliConfig/saveCliConfig, project-environment resolution, functions-manifest inference, etc.) imports everything it needs from@supabase/config/effect— never mix that with importing the same package's default entrypoint in the same file. - A file that needs only pure symbols (the schema, types, encoding, sparse defaults, errors) imports
from
@supabase/config. @supabase/config/iois exclusively for external consumers outside this monorepo that aren't Effect-native. Do not add an internal consumer of it.@supabase/config/internalis forapps/cli's own Go-parity call sites and contract-guard tests only — a symbol that needs the internal-onlygoViperCompattypings, or the internal registry data, imports it from there; every other symbol in the same import statement stays on its public specifier (././effect). Enforced: every@supabase/config/internaloccurrence outside this package must be underapps/cli/.- Never deep-import this package's internals (e.g.
@supabase/config/src/io.ts). Only the six entrypoints above are supported import paths.
src/index.ts's transitive runtime import graph must never grow to include io.ts, paths.ts,
project.ts, functions-manifest.ts, bun.ts, node.ts, promise-facade.ts, effect.ts,
cli-config.layer.ts, or cli-config.service.ts — that would drag file-IO/Effect-platform
machinery into a graph bundlers (e.g. Studio) need to tree-shake as browser-safe.
src/entrypoint-purity.unit.test.ts enforces this by statically walking index.ts's real relative
import graph against a hardcoded allowlist, pins both entrypoints' exact runtime export surfaces, and
asserts the package.json exports map shape. Any change that grows the pure graph or the export
surface must update that test deliberately — it is not meant to be a silent pass.
pnpm --filter @supabase/config build (or pnpm run build from this package) runs
scripts/build.ts, in order:
- Removes any stale
dist/(a rename that leaves an orphaned compiled module behind must not ship), then compilessrc/todist/(tsc -p tsconfig.build.json) — the.js/.d.tsoutput everydist/types/defaultexport condition points at. - Renders both generated JSON Schema artifacts (
dist/schema.json,dist/project-schema.json) fromtoCliConfigJsonSchema()/toProjectConfigJsonSchema(), post-processed (viascripts/json-schema-postprocess.ts) to collapse Effect's non-finite-numberanyOfencoding back to a plainnumber/integernode and to add$id/title/description, then formatted throughoxfmt. - Verifies every
types/non-bundefaulttarget (plus both JSON artifacts) declared in package.json'sexportsmap actually exists on disk. - Runs a tree-shake probe: bundles a probe importing only
CliConfigSchemafrom the compileddist/index.jsfor abrowsertarget and asserts the output excludes registry-only code, proving the package.jsonsideEffects: falseclaim against real compiled output rather than merely asserting it — plus a positive-control probe (bundlingprojectConfigMappingRowsfromdist/internal.js) proving the registry-only marker is actually detectable by this bundling method before trusting its absence elsewhere as meaningful. - Runs a pack-and-install smoke test:
npm packs the real publish tarball (governed byfiles/.npmignore— the exact thingnpm publishwould ship), extracts it into a fresh, isolated consumer project, symlinks in the real, already pnpm-resolved runtime deps (network-free), and imports every entrypoint and JSON artifact through a realnodeprocess — catchingfiles/exportsdrift a workspace-link smoke test or atsc-only build would miss entirely.
dist/ is gitignored and rebuilt on demand — no build output is checked in. The public type
surface is instead enforced per-PR by export snapshots and purity walkers (see "Testing" below)
plus the repo-root pnpm check:config-api (tools/config-api-compare.ts), which diffs this
package's declaration output between the PR base and head commits and is advisory at PR time. The
hard gate is a release-time tarball diff — tools/config-release-gate.ts, run by the plan job in
.github/workflows/release-config.yml — see "Releases" below.
A .npmignore file exists at this package's root — even though its own rules exclude almost
nothing files in package.json doesn't already exclude — because npm's packlist walk otherwise
falls back to the ROOT .gitignore for this whole directory, and that file's bare dist line
prunes packages/config/dist/ from the walk entirely before files is ever consulted, silently
shipping a tarball with zero dist/** files. An .npmignore's mere presence (regardless of
content) stops npm from consulting .gitignore at all; files still governs what actually ships.
Verify npm pack --dry-run and pnpm pack --dry-run produce equivalent content after touching
either file.
Run tests from this package with bun --bun vitest run --project unit (plain node vitest is
broken here). Always run the relevant unit tests for what you changed before considering a task
done. Besides ordinary behavioral coverage, the following contract tests enforce this package's
own guarantees and must stay green after any entrypoint or type-surface change:
src/entrypoint-purity.unit.test.ts— the pure-graph invariant above (also walked separately forsrc/io-browser.ts, thebrowsercondition target for./io), plus pinned export-name snapshots for././effect/./internaland the package.jsonexportsmap shape.src/monorepo-import-contract.unit.test.ts— the "Monorepo import rule" above: no internal./ioconsumer, no deep@supabase/config/src/*import, and no@supabase/config/internalimport outsideapps/cli/— scanningapps/andpackages/while excluding this package's own directory.src/lib/resolve.unit.test.ts— behavioral coverage of the public sync resolvers.scripts/json-schema-postprocess.unit.test.ts/scripts/build-artifacts.unit.test.ts— the JSON Schema post-processingrenderJsonSchemaapplies (non-finite-numberanyOfcollapse,$id/title/description), the second against the real generated documents.
This package has its own release train, independent of the CLI's — a fix:/feat: commit
elsewhere in the monorepo never releases @supabase/config, and vice versa.
- Path-filtered conventional commits.
semantic-releasecomputes the next version from commits scoped topackages/config/viascripts/semantic-release-path-filter.ts. - Tag format:
config-v<version>— never collides with the CLI'sv<version>tags. - Stable-only, from
develop. No beta/alpha channel; every release publishes to npm under thelatestdist-tag. - Workflow:
.github/workflows/release-config.yml— aplanjob computes the version, packs the release tarball, and runs the type-surface gate against the declarations inside that tarball; a human approves theconfig-releaseGitHub environment (reviewing the plan job's step summary: release notes + type-surface diff); then an OIDC/provenance publish job publishes that exact tarball (npm publish <tgz> --ignore-scripts— no rebuild, no repack, no lifecycle scripts: the approved bytes are the published bytes). After publishing, the job pushes theconfig-v*tag, then verifies the version is registry-visible with the reviewed tarball's integrity and the expected dist-tag. package.json's committedversion(0.1.0) is a placeholder. The real version is stamped into the tarball at pack time (npm pkg set versionin the plan job) from the computed version — never hand-bump the committed field. Hand-pushing aconfig-v*tag is reserved for the documented recovery below.- Local dry runs:
scripts/release-plan.tsruns the plan locally without publishing;tools/config-release-gate.ts --tarballrehearses the type-surface gate locally.
- Symptom: npm holds a version whose
config-v*tag never reached origin, because a step afternpm publishfailed. - Why it must be fixed: the planner derives the next version from the last
config-v*tag, never from npm, so every later run re-plans the same version. As soon as any commit changespackages/config, the repacked bytes diverge from the published ones and the publish job's integrity guard refuses permanently. - Recovery option 1 (preferred, while available): re-run the failed publish job from the original run, so it reuses the reviewed artifact rather than rebuilding. Two limits close this option: release artifacts are kept 7 days, and re-running a workflow run discards the earlier attempt's artifacts.
- Recovery option 2: create the tag directly at the commit the published bytes were built
from. A full workflow re-run is not a substitute: semantic-release refuses to plan from any
commit behind the release branch's tip, so a re-run pinned to the original commit reports no
release and never reaches the publish job.
config-v*tags are protected by a repo ruleset whose only bypass actor is the releaser GitHub App, so this needs a repo admin to temporarily grant themselves bypass on that ruleset, push the annotated tag, then restore the ruleset and confirm it matches its prior state. Add the matching GitHub release with--latest=false, since a config release must never become the repo'slatestrelease.
The one-time go-live setup is complete. These are the standing invariants — verify them if a release fails unexpectedly, and restore them if repo or npm settings are ever rebuilt:
- The
config-releaseGitHub environment has required reviewers. An environment referenced by a workflow is auto-created WITHOUT protection rules — the plan job asserts the rule exists and refuses to plan a real release without it, so a stripped environment fails closed rather than publishing unreviewed. - npm trusted publishing is configured for the package (repo
supabase/cli, workflowrelease-config.yml, environmentconfig-release); noNPM_TOKENexists anywhere. Trusted publishing can only be configured on a package that already exists, so the package was seeded with a manually published0.0.0placeholder (nodist/), and the bootstrap token was revoked immediately after. - The baseline tag
config-v0.0.0matches that placeholder — the tag oracle and the registry must always agree on the last released version. With no baseline tag, semantic-release would cut1.0.0with release notes generated from the entire monorepo history — a whole-history changelog as both the approval artifact and the public GH release body — soscripts/release-plan.tsrefuses to plan in that state (escape hatch:CONFIG_RELEASE_ALLOW_NO_BASELINE=1). Seeding it was a hand-pushed tag, same as the recovery procedure above. - The "Protect
config-v*release tags" ruleset restricts creating, moving, and deletingconfig-v*tags to thesupabase-cli-releaserApp (the same App the release workflows mint tokens from). The lastconfig-v*tag is the version oracle: a stray hand-pushed tag permanently skews versioning, and a deleted tag wedges the next plan on an already-published version. - The
SLACK_RELEASE_WEBHOOKrepo secret (shared with the CLI release train — the cli-deployer-notifier Slack app) backs the release notifications: approval-needed ping, success, and failure/declined. If it's missing or rotated, the notify jobs fail and the run shows red, but the release itself still completes since nothing depends on the notify jobs.