Skip to content

Settings

The Problem

Database projects need consistent behavior across team members. Build order matters—tables before views, views before functions. Some folders should only run in test environments. Production configs need extra safeguards.

Environment variables don't capture this complexity. And you can't just hardcode it—each developer might need slight variations.

noorm solves this with settings. A single .noorm/settings.yml file defines project-wide behavior: build order, conditional rules, team-defined config templates. It's version controlled and shared, unlike encrypted configs which stay per-machine.

Settings vs Config

AspectConfigSettings
Location.noorm/state/state.enc.noorm/settings.yml
ContainsCredentials, secretsBuild rules, paths, stages
Version controlGitignoredCommitted
Per-machineYes (encrypted)No (shared)
CLI managementnoorm confignoorm settings

How is settings.yml found? noorm walks up the directory tree to find the project root. See Project Discovery for details.

Quick Start

typescript
import { SettingsManager } from './core/settings'

const settings = new SettingsManager(process.cwd())
await settings.load()

// Get build configuration
const build = settings.getBuild()
console.log('Include:', build.include)

// Check if a stage exists
if (settings.hasStage('prod')) {
    const prodStage = settings.getStage('prod')
    console.log('Production:', prodStage?.description)
}

// Evaluate rules against a config
const paths = settings.getEffectiveBuildPaths({
    name: 'dev',
    type: 'local',
    isTest: false,
    access: { user: 'admin', agent: 'admin' },
})
console.log('Effective include:', paths.include)
console.log('Effective exclude:', paths.exclude)

File Structure

Settings file lives in .noorm/settings.yml:

yaml
# Build configuration
build:
    include:
        - tables
        - views
        - functions
    exclude:
        - archive

# Path overrides
paths:
    sql: ./db/sql
    changes: ./db/changes

# Conditional rules
rules:
    - match:
          isTest: true
      include:
          - seeds

# Team-defined templates
stages:
    dev:
        description: Local development
        defaults:
            dialect: postgres

# Strict mode
strict:
    enabled: false
    stages: []

# Logging
logging:
    enabled: true
    level: info
    file: .noorm/state/noorm.log

# Teardown behavior
teardown:
    preserveTables:
        - AppSettings
    postScript: sql/teardown/cleanup.sql

# Terminal UI behavior
ui:
    mouse: false  # default: true

Build Configuration

Control which folders are included in builds.

yaml
build:
    # Folders to include (filter only, not order)
    include:
        - 01_tables
        - 02_views
        - 03_functions
        - 04_seeds

    # Folders to exclude from all builds
    exclude:
        - archive
        - experiments

Key behaviors:

  • include filters which folders are processed (not execution order)
  • exclude prevents folders from ever being processed
  • Execution order is always alphanumeric—use numeric prefixes to control sequence
  • Paths are relative to the paths.sql directory, not the project root
  • Rules can dynamically modify these lists

Include paths are relative to paths.sql

With paths.sql: ./sql, an entry of 01_tables resolves to ./sql/01_tables. Writing sql/01_tables resolves to ./sql/sql/01_tables and matches nothing.

A non-matching entry is not an error — the build reports status: success with filesRun: 0 and exits 0. A misconfigured include is therefore indistinguishable from a successful build, and the missing schema only surfaces later. If a build succeeds having run zero files, check this first.

A leading ./ or a trailing / are likewise not stripped, and match nothing for the same reason. Use bare segment names.

typescript
const build = settings.getBuild()
// { include: ['01_tables', ...], exclude: ['archive', ...] }

Path Configuration

Override default locations for schema and change files.

yaml
paths:
    sql: ./db/sql
    changes: ./db/changes

Defaults:

PathDefault
sql./sql
changes./changes
typescript
const paths = settings.getPaths()
// { sql: './db/sql', changes: './db/changes' }

Environment overlay

Every accessor returns the resolved view: the parsed settings.yml document plus an overlay of NOORM_* environment variables, recomputed after each load and each mutation. NOORM_PATHS_SQL=./ci/sql therefore changes what getPaths() returns without touching the file.

The overlay is deliberately kept out of the persisted document — settings.yml is version controlled, so merging ambient env vars into it would commit whatever the shell exported. save() writes the document, never the resolved view. NOORM_CONNECTION_*, NOORM_IDENTITY_*, and the behavior variables (NOORM_CONFIG, NOORM_YES, NOORM_JSON, NOORM_HEADLESS, NOORM_DEBUG, NOORM_DEV, NOORM_CI_CONFIG_NAME, NOORM_LOGGER_DEBUG, NOORM_IDENTITY, NOORM_CHANNEL) are excluded from it.

The overlay is validated, not re-parsed, so an env value must already have the shape the field expects — a scalar where an array is required (NOORM_BUILD_INCLUDE=00_tables) fails validation rather than being coerced.

Stage-Based Rules

Rules conditionally include or exclude folders based on the active config's properties. This enables environment-specific behavior without maintaining separate build scripts.

yaml
rules:
    # Run seeds only in test environments
    - match:
          isTest: true
      include:
          - seeds

    # Skip destructive scripts on protected configs
    - match:
          protected: true
      exclude:
          - dangerous

    # Complex match: remote test databases
    - match:
          isTest: true
          type: remote
      exclude:
          - heavy-seeds

Match Conditions

All conditions in a rule are AND'd together—every specified condition must be true.

ConditionTypeDescription
namestringConfig name (exact match)
protectedbooleanMatches guarded(config)true when access.user !== 'admin', regardless of the field's legacy name
isTestbooleanTest database flag
type'local' | 'remote'Connection type
typescript
import { ruleMatches } from './core/settings'

const match = { isTest: true, type: 'local' }
const config = { name: 'test', type: 'local', isTest: true, access: { user: 'admin', agent: 'admin' } }

ruleMatches(match, config)  // true - both conditions match

Rule Evaluation Order

Rules are evaluated in order. Later rules can override earlier ones:

yaml
rules:
    # First: include seeds for test databases
    - match:
          isTest: true
      include:
          - seeds

    # Later: but exclude seeds for specific config
    - match:
          name: ci-test
      exclude:
          - seeds

For config ci-test with isTest: true, the second rule wins—seeds are excluded.

typescript
import { evaluateRules } from './core/settings'

const result = evaluateRules(rules, config)
// {
//     matchedRules: [rule1, rule2],
//     include: ['sql/other'],
//     exclude: ['sql/seeds']
// }

Effective Build Paths

Combine base build config with rule evaluation:

typescript
const { include, exclude } = settings.getEffectiveBuildPaths({
    name: 'dev',
    type: 'local',
    isTest: false,
    access: { user: 'admin', agent: 'admin' },
})

// Build only these paths
for (const path of include) {
    if (!exclude.includes(path)) {
        await buildPath(path)
    }
}

Stages

Stages are team-defined config templates. They provide defaults, enforce constraints, and define required secrets. When a developer creates a config from a stage, they get consistent baseline settings.

yaml
stages:
    dev:
        description: Local development database
        locked: false
        defaults:
            dialect: postgres
            host: localhost
            port: 5432
            database: myapp_dev

    prod:
        description: Production database
        locked: true
        defaults:
            dialect: postgres
            protected: true
        secrets:
            - key: DB_PASSWORD
              type: password
              description: Database password

Stage Properties

PropertyTypeDescription
descriptionstringHuman-readable description shown in CLI
lockedbooleanIf true, configs cannot be deleted (default: false)
defaultsobjectDefault values when creating config from stage
secretsarrayRequired secrets that must be set

Stage Defaults

Defaults provide initial values when creating a config. Users can override most values, but some are enforceable constraints:

DefaultBehavior
protected: trueEnforced as an access ceiling at config resolution (resolveConfig), not a value check here: resolved access is clamped to at most { user: 'operator', agent: 'viewer' } no matter what the config or the TUI sets
isTest: trueCannot be overridden to false
dialectCannot be changed after creation
typescript
// Get stage defaults
const defaults = settings.getStageDefaults('prod')
// { dialect: 'postgres', protected: true }

// Read the protected default directly — the ceiling itself is applied later,
// by resolveConfig. There is no stageEnforcesProtected() helper.
if (defaults.protected === true) {
    // This stage's configs get clamped to at most operator/viewer access
}

// The isTest constraint does have a helper
if (settings.stageEnforcesIsTest('prod')) {
    // Configs linked to this stage cannot set isTest: false
}

Required Secrets

Secrets can be required at two levels: universal (required by all configs) and stage-specific (required only by configs matching that stage).

Universal secrets are defined at the root level of settings:

yaml
# Required by ALL configs
secrets:
    - key: ENCRYPTION_KEY
      type: password
      description: App-wide encryption key

Stage secrets are defined within each stage:

yaml
stages:
    prod:
        secrets:
            - key: DB_PASSWORD
              type: password
              description: Database password
              required: true

            - key: API_KEY
              type: api_key
              description: Admin API key
              required: false

When you use getRequiredSecrets(stageName), universal and stage secrets are merged. A config named prod will require both universal secrets and prod-stage secrets.

Secret types control CLI input behavior:

TypeBehavior
stringPlain text input
passwordMasked input, no echo
api_keyMasked input, validated format
connection_stringValidated as URI
typescript
// Get required secrets for a stage (includes universal + stage-specific)
const secrets = settings.getRequiredSecrets('prod')
// [{ key: 'ENCRYPTION_KEY', ... }, { key: 'DB_PASSWORD', ... }]

// Get only universal secrets
const universal = settings.getUniversalSecrets()
// [{ key: 'ENCRYPTION_KEY', type: 'password', description: '...' }]

Locked Stages

Locked stages prevent config deletion:

typescript
// Check if stage is locked
if (settings.isStageLockedByName('prod')) {
    console.error('Cannot delete production config')
}

Strict Mode

Strict mode requires certain stages to have configs before operations can run. This prevents accidental operations against wrong databases.

yaml
strict:
    enabled: true
    stages:
        - dev
        - staging
        - prod

When enabled:

  • User must have configs matching all required stages
  • noorm config add prompts to select from predefined stages
  • Operations fail if required stages are missing
typescript
if (settings.isStrictModeEnabled()) {
    const required = settings.getRequiredStages()
    // ['dev', 'staging', 'prod']

    for (const stage of required) {
        if (!state.hasConfig(stage)) {
            throw new Error(`Missing required stage: ${stage}`)
        }
    }
}

Logging Configuration

Configure file-based logging:

yaml
logging:
    enabled: true
    level: info
    file: .noorm/state/noorm.log
    maxSize: 10mb
    maxFiles: 5
PropertyDefaultDescription
enabledtrueEnable file logging
level'info'Minimum level: silent, error, warn, info, verbose
file.noorm/state/noorm.logLog file path
maxSize'10mb'Rotate when size exceeded
maxFiles5Rotated files to keep
typescript
const logging = settings.getLogging()
// { enabled: true, level: 'info', file: '.noorm/state/noorm.log', ... }

Teardown Configuration

Configure database reset and teardown behavior:

yaml
teardown:
    preserveTables:
        - AppSettings
        - UserRoles
        - AuditLog
    postScript: sql/teardown/cleanup.sql
PropertyDefaultDescription
preserveTables[]Tables to always preserve during truncate operations
postScriptnullSQL script to run after schema teardown (relative to project root)

These settings are applied automatically when using teardown operations:

typescript
import { truncateData, teardownSchema } from './core/teardown'

// There is no getTeardown() accessor — read it off the resolved settings object
const teardown = settings.settings.teardown

// preserveTables from settings are automatically included
const result = await truncateData(db, dialect, {
    preserve: teardown?.preserveTables,
})

// postScript runs after teardown
const teardownResult = await teardownSchema(db, dialect, {
    preserveTables: teardown?.preserveTables,
    postScript: teardown?.postScript,
})

Use cases:

  • preserveTables - Lookup tables, configuration tables, or audit logs that should never be truncated
  • postScript - Re-seed essential data, reset sequences, or run cleanup SQL after teardown

UI Configuration

Affects noorm ui only; headless commands ignore this section.

yaml
ui:
    mouse: false
PropertyDefaultDescription
mousetrueAnswer clicks and wheel notches in lists and result grids

There is no settings-screen toggle and no accessor on SettingsManager. What an absent flag means is decided in exactly one place, isMouseEnabled in src/core/settings/defaults.ts, and every reader goes through it:

typescript
export const DEFAULT_UI_MOUSE = true

export function isMouseEnabled(settings) {
    return settings?.ui?.mouse ?? DEFAULT_UI_MOUSE
}

UiConfigSchema takes the same constant for mouse's zod default, so ui: {} and no ui section at all mean the same thing. Only a written false disables it.

The TUI adds one condition on top, and it is about timing rather than meaning:

typescript
const { settings } = useSettings()

<MouseProvider enabled={settings !== null && isMouseEnabled(settings)}>

null is not loaded yet, not absent. Without that check, a project that wrote mouse: false would spend every startup with tracking on and its text selection broken, for a setting it explicitly wrote. So the enable sequence goes out once the managers have finished loading rather than at render().

On by default because the feature is worth having, but it is a trade: any terminal mouse-tracking mode takes click-drag text selection away from the terminal unless the user holds a modifier (Option on macOS Terminal and iTerm2, Shift elsewhere). A user who hits that reports it as "text selection stopped working", which is why the help screen (?) names ui.mouse: false directly. See docs/tui.md for what the mouse does.

SettingsManager API

The manager handles loading, saving, and accessing settings.

Loading Settings

typescript
const settings = new SettingsManager(projectRoot, {
    settingsDir: '.noorm',      // Optional override
    settingsFile: 'settings.yml' // Optional override
})

// Load from file (or use defaults if missing)
await settings.load()

// Check if loaded
if (!settings.isLoaded) {
    throw new Error('Settings not loaded')
}

Initialization

Create a new settings file with defaults:

typescript
await settings.init()          // Throws if file exists
await settings.init(true)      // Force overwrite

Mutations

Changes are persisted immediately:

typescript
// Set a stage
await settings.setStage('staging', {
    description: 'Staging environment',
    locked: true,
    defaults: { dialect: 'postgres', protected: false }
})

// Remove a stage
await settings.removeStage('old-stage')

// Add a rule
await settings.addRule({
    match: { isTest: true },
    include: ['sql/seeds']
})

// Remove a rule by index
await settings.removeRule(0)

// Update build config
await settings.setBuild({
    include: ['sql/tables', 'sql/views'],
    exclude: ['sql/archive']
})

// Update paths config
await settings.setPaths({
    sql: './db/sql',
    changes: './db/changes'
})

// Update strict mode config
await settings.setStrict({
    enabled: true,
    stages: ['dev', 'staging', 'prod']
})

// Update logging config
await settings.setLogging({
    enabled: true,
    level: 'verbose',
    file: '.noorm/debug.log'
})

// Update teardown config
await settings.setTeardown({
    preserveTables: ['AppSettings'],
    postScript: 'sql/teardown/cleanup.sql'
})

// Manage universal secrets (required by all stages).
// `type` must be a SecretType: 'string' | 'password' | 'api_key' | 'connection_string'
await settings.addUniversalSecret({ key: 'API_KEY', type: 'api_key' })
await settings.updateUniversalSecret('API_KEY', { key: 'API_KEY', type: 'password' })
await settings.removeUniversalSecret('API_KEY')

// Manage stage-specific secrets
await settings.addStageSecret('prod', { key: 'DB_PASSWORD', type: 'password' })
await settings.updateStageSecret('prod', 'DB_PASSWORD', { key: 'DB_PASSWORD', required: false })
await settings.removeStageSecret('prod', 'DB_PASSWORD')

// Evaluate rules programmatically
const result = settings.evaluateRules({
    name: 'dev',
    type: 'local',
    isTest: true,
    access: { user: 'admin', agent: 'admin' }
})
// { matchedRules: [...], include: [...], exclude: [...] }

Singleton Pattern

For convenience, use the singleton:

typescript
import { getSettingsManager, resetSettingsManager } from './core/settings'

const settings = getSettingsManager(process.cwd())
await settings.load()

// In tests, reset between tests
resetSettingsManager()

Default Settings

When no settings.yml exists, noorm uses these defaults:

typescript
import { DEFAULT_SETTINGS, createDefaultSettings } from './core/settings'

// Read-only reference
console.log(DEFAULT_SETTINGS)
// {
//     build: { include: [], exclude: [] },   // empty include = all files under paths.sql
//     paths: { sql: './sql', changes: './changes' },
//     rules: [],
//     stages: {},
//     strict: { enabled: false, stages: [] },
//     logging: { enabled: true, level: 'info', file: '.noorm/state/noorm.log', maxSize: '10mb', maxFiles: 5 },
// }
// No `teardown` key: TeardownConfig is optional and has no defaults.

// Create fresh copy (avoids shared references)
const fresh = createDefaultSettings()

Always use createDefaultSettings() when you need a mutable copy. Direct spreading { ...DEFAULT_SETTINGS } creates shallow copies with shared array/object references.

Validation

Settings are validated on load using Zod schemas:

typescript
import {
    validateSettings,
    parseSettings,
    SettingsValidationError
} from './core/settings'

try {
    // Throws if invalid
    validateSettings(data)

    // Or parse with defaults applied
    const settings = parseSettings(data)
}
catch (err) {
    if (err instanceof SettingsValidationError) {
        console.error('Invalid settings:', err.errors)
    }
}

Validation includes:

  • Required fields present
  • Valid types for all properties
  • Stage secret definitions have valid types
  • Rule conditions use valid properties
  • No empty match conditions in rules

Observer Events

Settings operations emit events:

typescript
import { observer } from './core/observer'

observer.on('settings:loaded', ({ path, settings, fromFile }) => {
    console.log(`Loaded settings from ${path}`)
})

observer.on('settings:saved', ({ path }) => {
    console.log(`Saved settings to ${path}`)
})

observer.on('settings:initialized', ({ path, force }) => {
    console.log(`Initialized settings at ${path}`)
})

observer.on('settings:stage-set', ({ name, stage }) => {
    console.log(`Set stage: ${name}`)
})

observer.on('settings:stage-removed', ({ name }) => {
    console.log(`Removed stage: ${name}`)
})

observer.on('settings:rule-added', ({ rule }) => {
    console.log('Added rule')
})

observer.on('settings:rule-removed', ({ index, rule }) => {
    console.log(`Removed rule at index ${index}`)
})

observer.on('settings:build-updated', ({ build }) => {
    console.log('Updated build config')
})

observer.on('settings:paths-updated', ({ paths }) => {
    console.log('Updated paths config')
})

observer.on('settings:strict-updated', ({ strict }) => {
    console.log('Updated strict mode config')
})

observer.on('settings:logging-updated', ({ logging }) => {
    console.log('Updated logging config')
})

observer.on('settings:teardown-updated', ({ teardown }) => {
    console.log('Updated teardown config')
})

// Secret mutations carry their scope, and the stage name when scope is 'stage'
observer.on('settings:secret-added', ({ secret, scope, stageName }) => { ... })
observer.on('settings:secret-updated', ({ key, secret, scope, stageName }) => { ... })
observer.on('settings:secret-removed', ({ key, scope, stageName }) => { ... })

Integration with Config Resolution

Settings affect config operations:

  1. Stage defaults - Merged when creating config from a stage
  2. Build behavior - include/exclude filter what files are processed
  3. Rule evaluation - Rules checked against active config before each build
  4. Config deletion - Locked stages prevent config deletion
  5. Secret validation - Required secrets must be set before config is usable
  6. Strict mode - Requires configs for specified stages
typescript
import { resolveConfig, SettingsProvider } from './core/config/resolver'
import { getSettingsManager } from './core/settings'

const settings = getSettingsManager(process.cwd())
await settings.load()

// resolveConfig takes a SettingsProvider, not the SettingsManager itself.
// Omit `stage` to auto-link a stage whose name matches the config name.
const config = resolveConfig(state, {
    name: 'prod',
    settings: new SettingsProvider(settings),
})