Skip to main content

Store Settings

EverShop has two layers of configuration, and they answer different questions:

  • config/<env>.jsondeployment concerns. Database credentials, the active theme, session secrets, extension wiring. Set by a developer, changed by a redeploy.
  • The setting tablemerchant concerns. Currency, timezone, units, languages, catalog display, tax rounding, file storage. Changed in the admin at runtime, with no restart.

Over recent releases a number of values moved from the first layer to the second. If your extension reads them with getConfig(), it is reading a legacy fallback at best and failing to type-check at worst. This page documents the setting system and gives you the migration table to update against.

The setting table

Settings are a schemaless key/value store — there is no migration to write when you add one:

CREATE TABLE "setting" (
"setting_id" INT GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1) PRIMARY KEY,
"uuid" UUID NOT NULL DEFAULT gen_random_uuid (),
"name" varchar NOT NULL,
"value" text DEFAULT NULL,
"is_json" boolean NOT NULL DEFAULT FALSE,
CONSTRAINT "SETTING_UUID_UNIQUE" UNIQUE ("uuid"),
CONSTRAINT "SETTING_NAME_UNIQUE" UNIQUE ("name")
)

Two consequences follow from value being text:

  • Every value comes back as a string. A getter that must return a number or boolean has to coerce it. Core uses helpers from lib/util/coerce.ts (toInt, toFloat, toBoolean, toEnum) for exactly this.
  • Arrays and objects need is_json. They are stored JSON-stringified and parsed back on read.

The uniform resolution chain

Every setting getter in EverShop resolves in the same three steps:

DB setting → legacy config.json fallback → hard-coded default

export function getStoreCurrency(): string {
return getSettingSync<string>(
'storeCurrency',
getLegacyConfig('shop.currency', 'USD')
);
}

Read that inside-out: getLegacyConfig('shop.currency', 'USD') computes the fallback — the config value if the store still declares one, otherwise 'USD' — and getSettingSync overrides it with the storeCurrency row when one exists.

This ordering is what makes the config→setting move a non-breaking change. A store that never touches the admin keeps the exact behaviour it had when the value was config-driven; the moment a merchant saves the setting, the DB row wins.

One exception: file-storage credentials invert the order

The storage credential getters (getS3StorageConfig, getAzureStorageConfig, getGcsStorageConfig) resolve config → environment variable → setting, so config wins over the DB row. That is deliberate: an operator who pins S3 credentials in config or the environment must not have them silently overridden from the admin UI. getFileStorageConfigOverrides() reports which keys are currently pinned, which is how the admin form knows to render them read-only.

The provider selector is not inverted — getFileStorageProvider() follows the normal order (setting fileStorageconfig system.file_storage'local'). Two chains, deliberately opposed. See File Storage.

Sync vs async getters

This is the distinction that matters most when you call a getter.

GetterKindSetting keyFallback chain
getSettingSync(name, default)syncanycache only, never the DB
getStoreCurrency()syncstoreCurrencylegacy shop.currencyUSD
getStoreTimezone()syncstoreTimeZoneshop.timezoneUTC
getWeightUnit()syncweightUnitlegacy shop.weightUnitkg
getDimensionUnit()syncdimensionUnitlegacy shop.dimensionUnitcm
getSetting(name, default)asyncanylazy-loads the cache from the DB on first call
getStoreLanguage()asyncstoreLanguageshop.languageen
getEnabledLanguages()asyncstoreLanguages + default[defaultLocale]
getAdditionalLanguages()asyncderivedenabled minus default
getAdminLanguage()asyncadminLanguageen
getStoreName(), getStoreEmail(), getStoreDescription(), address gettersasyncstoreName, storeEmail, …→ parameter default / null

All of these import from @evershop/evershop/setting/services:

import {
getSetting,
getSettingSync,
getStoreCurrency,
getStoreTimezone,
getWeightUnit,
getDimensionUnit,
getStoreLanguage,
getEnabledLanguages,
refreshSetting
} from '@evershop/evershop/setting/services';

Why some getters are synchronous

The four scalar getters run in paths that cannot await:

  • the pricing formatter, which formats a money value inside a synchronous render;
  • cart and shipping serialization, called per line item;
  • Handlebars email helpers, which are synchronous by contract;
  • AJV schema builders, which construct schemas eagerly.

Making them async would not just be awkward — it would be actively harmful. getSetting() lazy-loads the cache with a real query, so an async getter in the cart-build path triggers a genuine pool.connect(). That turns previously DB-free code into DB-dependent code and breaks unit tests that run without a database.

getSettingSync avoids this by reading the in-memory cache only. When the cache is cold it returns the fallback rather than reaching for a connection:

export function getSettingSync<T>(name: string, defaultValue: T): T {
if (!setting) {
return defaultValue;
}
// ...
}

A cold cache therefore yields the config fallback — precisely the behaviour those paths had before the value moved to the database. Moving a value from config to a setting never adds a DB dependency to a previously synchronous path.

tip

The rule for your own code: prefer async getSetting. Reach for getSettingSync only when you are in a genuinely synchronous path, and accept that a cold cache gives you the default.

Cache warming

The cache is a module-level array, filled by refreshSetting(), and warmed in three places:

WhenWhereWhy
At bootmodules/setting/bootstrap.tsMakes the sync getters reliable from the first request onward. Runs in every process that loads bootstraps — HTTP, cron, subscriber.
After a savesaveSetting, after commitAn admin change takes effect immediately, with no restart.
Per event batchlib/event/event-manager.ts (beforeBatch)The subscriber process is long-lived; off-request emails and jobs pick up admin changes.

Boot-time warming is non-fatal by design. Bootstraps run before migrations, so on a brand-new database the setting table does not exist yet. That specific case — Postgres error code 42P01, undefined_table — logs at debug level so a fresh install does not look like a crash. Any other failure (connection refused, bad credentials) still logs as an error:

if ((e as { code?: string })?.code === '42P01') {
debug(
'Setting cache warm-up skipped: the setting table is not migrated yet (fresh install). The cache will load after migrations run.'
);
} else {
error(e);
}

Writing settings

POST /api/settings

One private endpoint upserts arbitrary keys. The payload is a flat object; every top-level key becomes a setting row. There is no payload schema, so you do not need to register anything to persist your extension's own settings.

curl -X POST https://yourstore.com/api/settings \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <admin-access-token>" \
-d '{"storeCurrency": "EUR", "weightUnit": "lb", "myExtensionApiKey": "abc123"}'

is_json is decided per value:

if (value !== null && typeof value === 'object') {
// JSON.stringify(value), is_json: 1
} else {
// value ?? '', is_json: 0
}

So arrays and objects round-trip correctly, while a scalar is stored as text. The explicit !== null guard matters: typeof null === 'object', so without it an absent optional field would be stored as the literal string "null" and shown as null in the admin form after saving.

All writes happen in one transaction, and refreshSetting() runs after the commit.

note

There is no GET /api/settings. Settings are read through the GraphQL Query.setting field, whose type is assembled from several modules (StoreSetting, ShippingSetting, BrandingSetting, CheckoutSetting, FileStorageSetting). Secret values — s3SecretAccessKey, azureStorageConnectionString, gcsServiceAccountKey — are masked in the GraphQL response.

Adding a setting from an extension

Because the table is schemaless and the endpoint takes arbitrary keys, adding a setting is just: POST the key, then read it back with a getter of your own that follows the standard chain.

extensions/my-extension/services/mySettings.ts
import { getSettingSync } from '@evershop/evershop/setting/services';
import { getConfig } from '@evershop/evershop/lib/util/getConfig';

export function getMyFeatureLimit(): number {
const raw = getSettingSync<string | number>(
'myFeatureLimit',
getConfig('myExtension.featureLimit', 50)
);
const parsed = Number.parseInt(String(raw), 10);
return Number.isNaN(parsed) ? 50 : parsed;
}

Note the coercion — the DB hands you a string even when the config default is a number.

config → setting migration table

If your extension reads any of these config keys, switch to the getter. The config key still works as a fallback, but it is no longer the source of truth, and a merchant editing the admin will not affect your code until you migrate.

Shop

Old config keyNowImport fromStatus of the config key
shop.currencygetStoreCurrency() — sync@evershop/evershop/setting/servicesRemoved from typed getConfig; untyped legacy fallback only
shop.weightUnitgetWeightUnit() — sync@evershop/evershop/setting/servicesRemoved from typed getConfig; untyped legacy fallback only
shop.dimensionUnitgetDimensionUnit() — sync@evershop/evershop/setting/servicesRemoved from typed getConfig; untyped legacy fallback only
shop.timezonegetStoreTimezone() — sync, for display@evershop/evershop/setting/servicesStill typed and still required — see the note below
shop.languagegetStoreLanguage() — async@evershop/evershop/setting/servicesStill typed; the locale system's synchronous fallback

The typed ConfigStructure now declares exactly three shop.* keys: language, timezone, homeUrl. getConfig('shop.currency') no longer type-checks — nor do the two unit keys. Call the getter instead.

The rule that decided what stayed: a shop.* key stays typed only if something reads it directly and operationally, not merely as a getter fallback. shop.timezone sets the database session timezone in connection.ts at connect time — before any query can run — so it fundamentally cannot be a DB setting. shop.language is read directly by the locale system as its synchronous fallback. Currency and the units were pure getter fallbacks, so they were removed.

Two different timezones

shop.timezone (config) is the operational timezone that gates the DB session. storeTimeZone (setting, read via getStoreTimezone()) is the display timezone used by date formatting. They are not interchangeable.

Catalog

Import from @evershop/evershop/catalog/services. All sync.

Old config keyGetterSetting keyDefault
catalog.showOutOfStockProductgetShowOutOfStockProducts()catalogShowOutOfStockProductfalse
catalog.collectionPageSizegetCollectionPageSize()catalogCollectionPageSize20 (clamped to at least 1)
catalog.product.image.width / .heightgetProductImageDimensions()catalogProductImageWidth, catalogProductImageHeight1200 / 1200

Not migrated, still config-only: catalog.crossSell.recomputeSchedule, catalog.crossSell.recomputeEnabled, catalog.crossSell.maxOrderKeys.

Pricing and tax

Pricing getters import from @evershop/evershop/checkout/services. All sync.

Old config keyGetterSetting keyDefault
pricing.roundinggetPriceRounding()pricingRoundinground
pricing.precisiongetPricePrecision()pricingPrecision2
pricing.tax.roundinggetTaxRounding()taxRoundinground
pricing.tax.precisiongetTaxPrecision()taxPrecision2
pricing.tax.round_levelgetTaxRoundLevel()taxRoundLevelunit (total | line | unit)
pricing.tax.price_including_taxgetPriceIncludingTax()priceIncludingTaxfalse

The four tax getters live in modules/tax/services/taxSettings.ts and are not on the public export map — there is no @evershop/evershop/tax/services. Read the setting keys directly with getSettingSync if you need them from an extension.

note

The inline defaults in taxSettings.ts intentionally differ from the tax module's bootstrap config defaults (total / true). The inline value is the last-resort default when neither a setting nor a config value exists.

Checkout

Old config keyGetterImport fromSetting key
checkout.allowGuestCheckoutgetAllowGuestCheckout() — sync, default true@evershop/evershop/checkout/servicesallowGuestCheckout

checkout.showShippingNote has not moved; it is still config-only.

File storage and uploads

Config keys system.file_storage and system.s3.* / system.azure.* / system.gcs.* map to setting rows managed on the admin file-storage screen. The getters live in modules/cms/services/storage/storageConfig.ts and are not on the public export map.

Old config keySetting key(s)Getter
system.file_storagefileStoragegetFileStorageProvider() — sync
system.s3.*s3Bucket, s3Region, s3AccessKeyId, s3SecretAccessKey, s3Endpoint, s3ForcePathStyle, s3BaseUrlgetS3StorageConfig() — async
system.azure.*azureStorageConnectionString, azureStorageContainerName, azureContainerAccess, azureBaseUrlgetAzureStorageConfig() — async
system.gcs.*gcsBucket, gcsServiceAccountKey, gcsBaseUrlgetGcsStorageConfig() — async
system.upload_allowed_mime_typesuploadAllowedMimeTypesread in getMulter
system.upload_max_file_sizeuploadMaxFileSizeread in getMulter (config wins)

The S3, Azure, and GCS getters also read AWS-style environment variables (AWS_BUCKET_NAME, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_S3_ENDPOINT, AWS_S3_FORCE_PATH_STYLE) between config and the setting. Remember the inverted precedence for those three: config → env → setting. getFileStorageProvider() is not inverted.

File Storage documents the provider services, the base-URL contract, and how to write a custom storage provider.

Branding: themeConfig.logo was removed outright

themeConfig.logo is gone from ConfigStructure with no fallback. Unlike the currency and unit keys — which kept an untyped legacy read — a themeConfig.logo block in config.json is now ignored entirely. getConfig('themeConfig.logo') does not type-check and there is no compatibility path.

The logo is now three setting rows, saved from the admin store-settings screen:

Old config pathSetting key
themeConfig.logo.srclogo
themeConfig.logo.widthlogoWidth
themeConfig.logo.heightlogoHeight

Related branding keys that are settings and never had a config equivalent: favicon, socialSharingImage, gaMeasurementId. Themes read all of these through the GraphQL BrandingSetting type rather than from config.

themeConfig still exists and still holds headTags (links, metas, scripts, bases) and copyRight.

danger

A theme that still reads themeConfig.logo renders no logo — it does not fall back and it does not warn. This is the one migration on this page that silently changes behaviour, so grep your themes for it.

Units are relabel, not convert

weightUnit and dimensionUnit are display labels only. Product and package weights and dimensions are stored as unit-less decimals (product.weight, package_length, package_width, package_height, package_weight).

Changing the unit reinterprets those numbers; it does not convert them. A stored 2.5 stays 2.5 — it is simply relabeled from 2.5 kg to 2.5 lb. There is no data migration, and switching units silently changes what your catalog means.

The Weight and Dimension GraphQL types wrap a raw number and attach the unit plus a formatted text from the getters. Shipping code normalizes the unit string into carrier-specific vocabularies at the boundary.

Gotchas

  • The DB hands you strings. getSetting/getSettingSync return value verbatim unless is_json is set. Coerce numbers and booleans.
  • A cold cache returns the default, silently. In a unit test with no database, getSettingSync gives you the config fallback. That is intentional, but it means a test asserting on a DB-backed setting must warm the cache or stub the getter.
  • getSetting lazy-loads on first call. The first async read in a cold process performs a query. Do not put it inside a tight loop; read once and pass the value down.
  • Language settings are async. Unlike currency and units, getStoreLanguage() and getEnabledLanguages() must be awaited — they normalize locale codes and merge lists.
  • There is no GET /api/settings. Read through GraphQL.
  • storeLanguages cannot be set from the admin in 2.2.1. The multiselect is commented out; use the settings API. See Multi-Language Stores.

See also



Support us


EverShop is an open-source project that relies on community support. If you find our project useful, please consider sponsoring us.