zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Build an application

Config FileSupported

zmdb.config.ts is the build-tool and database-command configuration file. @zmdb/compiler/config owns discovery and loading; @zmdb/compiler/config/contract owns defineConfig and its authoring types. The product exposes these APIs through @zmdb/core/config. Loading discovers one file, executes it with Node, validates its data fields and returns absolute paths.

It does not initialise an application. Repositories still receive an explicit driver, and importing @zmdb/core does not read the filesystem.

A minimal config#

// zmdb.config.ts
import { postgres } from '@zmdb/postgres';
import { defineConfig } from '@zmdb/compiler/config/contract';

export default defineConfig({
  schema: ['src/**/*.schema.ts'],
  dialect: postgres,
});

defineConfig is an identity function for type inference and completion. Validation happens in loadConfig, including for a module that exports a plain object without calling defineConfig. The identity and author-facing types live in a dependency-light contract module; discovery, execution, validation, defaults, path resolution and caching live only in the loader behind this same public entry.

import { loadConfig } from '@zmdb/compiler/config';

const config = await loadConfig();

config.configPath; // absolute selected config file
config.project; // absolute tsconfig path
config.schemaFiles; // absolute files, expanded eagerly
config.outDir; // absolute migration output directory

The shipped generate, embed, migrate, rollback, status, push, check, upgrade, export, pull, client generate, and studio commands consume this loader; codegen uses it when a config is present. The configured plugin from @zmdb/compiler or @zmdb/core/compiler also loads these project and naming settings. A direct compileProject caller supplies them explicitly. zmdb new project emits the product config import and a build adapter using @zmdb/core/compiler; its generated runtime entry never imports the loader.

The resolved path is observable#

Commands print the absolute selected config before human-readable database work. This transcript came from the SQLite fixture; only its temporary directory was shortened to /workspace/shop:

$ yarn zmdb check
/workspace/shop/zmdb.config.ts
check passed

Under --json, the same path is the top-level config value. An explicit --config path and a discovered path therefore have the same observable result after resolution.

Fields#

FieldTypeDefaultResolution
schema`string \readonly string[]`requiredglobs relative to the config file
dialectSqlDialectrequiredexplicitly imported database-product object
projectstring./tsconfig.jsonrelative to the config file
outstring./migrationsrelative to the config file
naming`'snake_case' \'snake_case_plural'`absentresolved once for reflection
namingStrategyNamingStrategyabsentcustom strategy; wins over naming
driver`() => ToolingDriver \Promise<ToolingDriver>`absentstructural callable boundary with the same dialect object
migrations.tablestring_zmdb_migrations
migrations.schemastringdialect defaultPostgreSQL family only
introspect{ schemas?, include?, exclude? }command-specificnames/globs, not filesystem paths
http.contracts`string \readonly string[]`absent<path>#<export> from the project
http.openApi.outstringrequired with HTTPgenerated .json, relative to config
http.client.outstringrequired with HTTPgenerated .ts, relative to config

loadConfig also returns resolvedNaming: the selected built-in singleton, the custom namingStrategy by identity, or an empty identity strategy. Every database command passes that object into schema reflection. The configured compiler plugin and zmdb codegen pass the same value to the compiler APIs. A custom compiler script can call loadConfig and pass config.project and config.resolvedNaming to compileProject, as shown in Code Generation.

Every glob must match at least one file, and every matched file must belong to the configured TypeScript project. A match outside the project is an error rather than a silently omitted table.

import { postgres } from '@zmdb/core/postgres';
import { defineConfig } from '@zmdb/core/config';

export default defineConfig({
  schema: ['src/accounts.schema.ts', 'src/billing/**/*.schema.ts'],
  dialect: postgres,
  project: './tsconfig.build.json',
  out: './database/migrations',
  migrations: {
    table: '_app_migrations',
    schema: 'app',
  },
  introspect: {
    schemas: ['public', 'app'],
    exclude: ['audit_*'],
  },
});

migrations.schema is available to the Postgres family and is refused for the MySQL family, SQLite and SQL Server. It is never ignored.

HTTP artifact generation#

HTTP generation is explicit and inert:

import { postgres } from '@zmdb/core/postgres';
import { defineConfig } from '@zmdb/core/config';

export default defineConfig({
  schema: './src/schema.ts',
  dialect: postgres,
  project: './tsconfig.json',
  http: {
    contracts: ['./src/accounts.contract.ts#ACCOUNTS_HTTP_CONTRACT', './src/billing.contract.ts#BILLING_HTTP_CONTRACT'],
    openApi: { out: './generated/openapi.json' },
    client: { out: './generated/http-client.generated.ts' },
  },
});

Every contract spec requires an export name. Contract files must belong to project; duplicate path/export pairs are rejected. The OpenAPI and client outputs must have .json and .ts extensions, respectively, and must resolve to different files. Loading this config does not boot the application, and the config has no base URL, credential, authentication, timeout, retry, or deployment field.

loadConfig resolves the contract files and both outputs to absolute paths. zmdb client generate then loads the configured exports once and emits OpenAPI and the client as sibling artifacts; it does not read OpenAPI back as generation input. Use --check in CI and --watch for dependency-aware regeneration. The complete flow is in Generated HTTP Client.

Discovery#

An explicit path wins:

await loadConfig({ cwd: '/workspace/orders', path: './config/database.ts' });

Without path, discovery checks this order in the starting directory and then walks upward:

  1. zmdb.config.ts
  2. zmdb.config.mjs
  3. zmdb.config.js

The walk stops at the first directory containing package.json. A command run inside one monorepo package therefore cannot silently select a config above that package boundary. There is no cascade or merge: the first selected file is the whole configuration.

path resolves against cwd. Paths written inside the selected module resolve against that module's directory, so running the same command from a nested directory does not change its schema, project, or migration output.

Loading TypeScript#

The loader uses Node 26's native type stripping:

await import(pathToFileURL(configPath));

That keeps a second bundler out of config loading, with three deliberate limits:

If a project needs custom resolution, use a Node loader hook through NODE_OPTIONS=--import .... A failed import reports the absolute config path, the original error and its cause; missing-module errors also explain the .js-specifier case.

Validation#

Plain data is checked by a generated @zmdb/validator validator. Errors name the field, including nested paths such as introspect.include.

Functions and imported dialect objects cannot be validated as plain data. The loader separates those runtime boundaries and checks them explicitly:

The following example demonstrates callable-boundary validation and the custom strategy path:

import { postgres } from '@zmdb/core/postgres';
import { defineConfig } from '@zmdb/core/config';

export default defineConfig({
  schema: 'src/**/*.schema.ts',
  dialect: postgres,
  driver: () => import('./src/database.js').then(module => module.driver),
  namingStrategy: {
    table: declared => declared.toLowerCase(),
    column: (property, { table }) => `${table}_${property}`.toLowerCase(),
  },
});

When both naming and namingStrategy are present, the custom object wins. That choice is made while loading the config, not once per table or query.

The driver is a thunk so the CLI can avoid opening a database for commands that only inspect declarations. When a database command invokes it, the returned ToolingDriver must carry the same explicit dialect object as the config. check opens it only for the live-drift check; with no driver configured, that check is reported as skipped.

Process-local cache#

loadConfig caches by absolute config path for the lifetime of the process. Repeated callers share one module evaluation and one resolved result. Two packages with two config paths receive separate entries; there is no cross-package ambient config.

Application configuration remains explicit#

The application does not automatically read this file. Construct the driver you want and pass it to repositories or the DI container. The config thunk can delegate to that same application module, keeping one source of connection truth without introducing an implicit initialisation step.

Repository verification rejects another exported defineConfig, loadConfig, ResolvedConfig, or related project-config declaration outside the canonical owner and its approved facade. It also rejects runtime imports from the dependency-light authoring module.

---

See also: Generated HTTP Client · CLI Overview · Configuration · Writing a Driver