Skip to content

Configuration

The Problem

A database tool needs connection details: host, port, credentials, paths. But where should these come from? Hardcoding is inflexible. Environment variables alone don't persist. Config files risk credential leaks.

noorm solves this with layered configuration. Multiple sources merge together with clear precedence. Sensitive data stays encrypted. Environment variables enable CI/CD without changing stored configs.

Configuration Sources

Configs come from five sources, merged in priority order:

PrioritySourcePurpose
1 (highest)CLI flagsOne-time overrides
2Environment variablesCI/CD, per-session tweaks
3Stored configYour saved database configs
4Stage defaultsTeam-defined templates
5 (lowest)DefaultsSensible fallbacks

Higher priority sources override lower ones. This means you can set a base config, override the host via environment for CI, and override the port via CLI for a specific run.

typescript
import { resolveConfig } from './core/config'

// Merges all sources into final config
const config = resolveConfig(state, {
    name: 'dev',
    flags: { connection: { port: 5433 } },  // Override just the port
})

Config Structure

A complete config defines everything needed to connect and run:

typescript
interface Config {
    name: string              // Unique identifier: 'dev', 'staging', 'prod'
    type: 'local' | 'remote'  // Connection type
    isTest: boolean           // Test database flag
    protected: boolean        // Requires confirmation for dangerous ops

    connection: {
        dialect: 'postgres' | 'mysql' | 'sqlite' | 'mssql'
        host?: string         // Required for non-SQLite, defaults to 'localhost'
        port?: number         // Default by dialect
        database: string      // Database name or file path
        user?: string
        password?: string
        ssl?: boolean | SSLConfig
        pool?: { min?: number, max?: number }  // Defaults to { min: 0, max: 10 }
    }

    paths: {
        schema: string        // Path to schema files
        changes: string    // Path to change files
    }

    identity?: string         // Override audit identity
}

Environment Variables

Every config property maps to an environment variable using nested naming. Underscores separate nesting levels:

NOORM_{PATH}_{TO}_{VALUE}  →  { path: { to: { value: '' } } }

Connection variables:

VariableConfig PathNotes
NOORM_CONNECTION_DIALECTconnection.dialectpostgres, mysql, sqlite, mssql
NOORM_CONNECTION_HOSTconnection.host
NOORM_CONNECTION_PORTconnection.portAuto-parsed as integer
NOORM_CONNECTION_DATABASEconnection.database
NOORM_CONNECTION_USERconnection.user
NOORM_CONNECTION_PASSWORDconnection.passwordKept as string
NOORM_CONNECTION_SSLconnection.sslUse 'true'/'false'
NOORM_CONNECTION_POOL_MINconnection.pool.min
NOORM_CONNECTION_POOL_MAXconnection.pool.max

Path variables:

VariableConfig Path
NOORM_PATHS_SQLpaths.sql
NOORM_PATHS_CHANGESETSpaths.changes

Top-level variables:

VariableConfig PathNotes
NOORM_NAMEname
NOORM_TYPEtype'local' or 'remote'
NOORM_PROTECTEDprotectedUse 'true'/'false'
NOORM_IDENTITYidentity
NOORM_isTestisTestcamelCase preserved

Note: For camelCase properties like isTest, preserve the case: NOORM_isTest (not NOORM_IS_TEST).

Behavior variables (not merged into config):

VariablePurpose
NOORM_CONFIGWhich config to use
NOORM_YESSkip confirmations
NOORM_JSONJSON output mode
bash
# CI/CD example: use stored config with overridden host
export NOORM_CONFIG=staging
export NOORM_CONNECTION_HOST=db.ci-runner.local
noorm run build

Config Resolution

The resolver determines which config to use and merges all sources.

typescript
const config = resolveConfig(state, options)

Resolution follows this flow:

  1. Determine config name from (in order):

    • options.name (explicit)
    • NOORM_CONFIG env var
    • Active config in state
  2. If no name found, check if env vars provide enough to run:

    • Need at least NOORM_CONNECTION_DIALECT and NOORM_CONNECTION_DATABASE
    • If yes, build config from env only (named __env__)
    • If no, return null
  3. Load stored config by name (throws if not found)

  4. Merge sources: defaults ← stage ← stored ← env ← flags

  5. Validate the merged result

typescript
// Explicit name
resolveConfig(state, { name: 'production' })

// From NOORM_CONFIG
process.env.NOORM_CONFIG = 'staging'
resolveConfig(state)  // uses 'staging'

// From active config
state.setActiveConfig('dev')
resolveConfig(state)  // uses 'dev'

// Env-only (CI mode)
process.env.NOORM_CONNECTION_DIALECT = 'postgres'
process.env.NOORM_CONNECTION_DATABASE = 'ci_test'
resolveConfig(state)  // creates __env__ config

Validation

Configs are validated using Zod schemas. Key rules:

  • name - Required, alphanumeric with hyphens/underscores
  • connection.dialect - Must be one of the four supported
  • connection.host - Required for non-SQLite
  • connection.port - Integer 1-65535
  • connection.database - Required

Default ports by dialect:

DialectDefault Port
postgres5432
mysql3306
mssql1433
sqliteN/A
typescript
import { validateConfig, parseConfig } from './core/config'

// Throws on invalid config
validateConfig(config)

// Returns config with defaults applied
const full = parseConfig(partial)

Protected Configs

Production databases need safeguards. Protected configs require confirmation for dangerous operations and block some entirely.

typescript
const config = {
    name: 'prod',
    protected: true,
    // ...
}

Action classification:

ActionProtected Behavior
change:runRequires confirmation
change:revertRequires confirmation
change:ffRequires confirmation
change:nextRequires confirmation
run:buildRequires confirmation
run:fileRequires confirmation
run:dirRequires confirmation
db:createRequires confirmation
db:destroyBlocked entirely
config:rmRequires confirmation

Check protection before executing:

typescript
import { checkProtection } from './core/config'

const check = checkProtection(config, 'change:run')

if (!check.allowed) {
    console.error(check.blockedReason)
    process.exit(1)
}

if (check.requiresConfirmation) {
    const input = await prompt(`Type "${check.confirmationPhrase}" to confirm:`)
    if (input !== check.confirmationPhrase) {
        process.exit(1)
    }
}

// Proceed with action

Skip confirmations in CI with NOORM_YES=1:

bash
export NOORM_YES=1
noorm change run  # No prompt, even on protected config

Stages

Stages are team-defined config templates from settings.yml. They provide defaults and enforce constraints.

yaml
# .noorm/settings.yml
stages:
    prod:
        description: Production database
        locked: true           # Cannot delete this config
        defaults:
            dialect: postgres
            protected: true    # Cannot be overridden to false
        secrets:
            - key: DB_PASSWORD
              type: password
              required: true

When resolving a config linked to a stage, stage defaults merge in:

typescript
const config = resolveConfig(state, {
    name: 'prod',
    stage: 'prod',  // Required when passing settings - must explicitly specify stage name
    settings: settingsManager,
})
// Stage defaults applied, then stored config, then env, then flags

Note: When passing settings, you must also pass stage explicitly. Auto-detection of stage from config name is not yet implemented.

Config Completeness

A config is "complete" when all required secrets (from its stage) are set. Incomplete configs have limited functionality.

typescript
import { checkConfigCompleteness } from './core/config'

// With explicit stage name (recommended)
const check = checkConfigCompleteness(config, state, settings, 'prod')

// Without stage name - only works if stage name matches config name exactly
const check = checkConfigCompleteness(config, state, settings)

if (!check.complete) {
    console.log('Missing secrets:', check.missingSecrets)
    console.log('Constraint violations:', check.violations)
}

Home Screen Status

The home screen displays setup status for all stage-linked configs:

Stage Configs:
  ✓ dev
  ✓ staging
    prod     ✗ secrets (2)
  • indicates all required secrets are set
  • ✗ secrets (N) shows how many secrets are missing

This helps track which environments are ready to use and which need secret values configured.

Stage constraints that can't be violated:

ConstraintBehavior
protected: true in defaultsCannot set protected: false
isTest: true in defaultsCannot set isTest: false
locked: trueConfig cannot be deleted
typescript
import { canDeleteConfig } from './core/config'

// Basic usage
const { allowed, reason } = canDeleteConfig('prod', settings)

// With explicit stage name (optional 3rd parameter)
const { allowed, reason } = canDeleteConfig('prod', settings, 'production')

if (!allowed) {
    console.error(reason)  // "Config 'prod' is linked to a locked stage..."
}

CI/CD Mode

In CI pipelines, configs can be built entirely from environment variables:

bash
# GitHub Actions example
env:
    NOORM_CONNECTION_DIALECT: postgres
    NOORM_CONNECTION_HOST: ${{ secrets.DB_HOST }}
    NOORM_CONNECTION_DATABASE: ${{ secrets.DB_NAME }}
    NOORM_CONNECTION_USER: ${{ secrets.DB_USER }}
    NOORM_CONNECTION_PASSWORD: ${{ secrets.DB_PASSWORD }}
    NOORM_YES: 1

steps:
    - run: noorm run build

Minimum required env vars:

  • NOORM_CONNECTION_DIALECT
  • NOORM_CONNECTION_DATABASE
typescript
// Check if in CI mode
import { isCi, shouldSkipConfirmations } from './core/environment'

if (isCi()) {
    // Running in CI environment
}

if (shouldSkipConfirmations()) {
    // NOORM_YES is set
}

Observer Events

Config operations emit events:

typescript
observer.on('config:created', ({ name }) => {
    console.log(`Created config: ${name}`)
})

observer.on('config:updated', ({ name, fields }) => {
    console.log(`Updated ${name}: ${fields.join(', ')}`)
})

observer.on('config:deleted', ({ name }) => {
    console.log(`Deleted config: ${name}`)
})

observer.on('config:activated', ({ name, previous }) => {
    console.log(`Switched from ${previous} to ${name}`)
})

Config Summary

For listings, use ConfigSummary which omits sensitive connection details:

typescript
const summaries = state.listConfigs()
// [
//     { name: 'dev', type: 'local', isTest: false, protected: false, isActive: true, dialect: 'postgres', database: 'dev_db' },
//     { name: 'prod', type: 'remote', isTest: false, protected: true, isActive: false, dialect: 'postgres', database: 'prod_db' },
// ]

The ConfigSummary interface:

typescript
interface ConfigSummary {
    name: string
    type: 'local' | 'remote'
    isTest: boolean
    protected: boolean
    isActive: boolean
    dialect: Dialect
    database: string
}