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
| Aspect | Config | Settings |
|---|---|---|
| Location | .noorm/state/state.enc | .noorm/settings.yml |
| Contains | Credentials, secrets | Build rules, paths, stages |
| Version control | Gitignored | Committed |
| Per-machine | Yes (encrypted) | No (shared) |
| CLI management | noorm config | noorm settings |
How is settings.yml found? noorm walks up the directory tree to find the project root. See Project Discovery for details.
Quick Start
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:
# 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: trueBuild Configuration
Control which folders are included in builds.
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
- experimentsKey behaviors:
includefilters which folders are processed (not execution order)excludeprevents folders from ever being processed- Execution order is always alphanumeric—use numeric prefixes to control sequence
- Paths are relative to the
paths.sqldirectory, 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.
const build = settings.getBuild()
// { include: ['01_tables', ...], exclude: ['archive', ...] }Path Configuration
Override default locations for schema and change files.
paths:
sql: ./db/sql
changes: ./db/changesDefaults:
| Path | Default |
|---|---|
sql | ./sql |
changes | ./changes |
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.
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-seedsMatch Conditions
All conditions in a rule are AND'd together—every specified condition must be true.
| Condition | Type | Description |
|---|---|---|
name | string | Config name (exact match) |
protected | boolean | Matches guarded(config) — true when access.user !== 'admin', regardless of the field's legacy name |
isTest | boolean | Test database flag |
type | 'local' | 'remote' | Connection type |
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 matchRule Evaluation Order
Rules are evaluated in order. Later rules can override earlier ones:
rules:
# First: include seeds for test databases
- match:
isTest: true
include:
- seeds
# Later: but exclude seeds for specific config
- match:
name: ci-test
exclude:
- seedsFor config ci-test with isTest: true, the second rule wins—seeds are excluded.
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:
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.
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 passwordStage Properties
| Property | Type | Description |
|---|---|---|
description | string | Human-readable description shown in CLI |
locked | boolean | If true, configs cannot be deleted (default: false) |
defaults | object | Default values when creating config from stage |
secrets | array | Required 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:
| Default | Behavior |
|---|---|
protected: true | Enforced 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: true | Cannot be overridden to false |
dialect | Cannot be changed after creation |
// 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:
# Required by ALL configs
secrets:
- key: ENCRYPTION_KEY
type: password
description: App-wide encryption keyStage secrets are defined within each stage:
stages:
prod:
secrets:
- key: DB_PASSWORD
type: password
description: Database password
required: true
- key: API_KEY
type: api_key
description: Admin API key
required: falseWhen 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:
| Type | Behavior |
|---|---|
string | Plain text input |
password | Masked input, no echo |
api_key | Masked input, validated format |
connection_string | Validated as URI |
// 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:
// 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.
strict:
enabled: true
stages:
- dev
- staging
- prodWhen enabled:
- User must have configs matching all required stages
noorm config addprompts to select from predefined stages- Operations fail if required stages are missing
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:
logging:
enabled: true
level: info
file: .noorm/state/noorm.log
maxSize: 10mb
maxFiles: 5| Property | Default | Description |
|---|---|---|
enabled | true | Enable file logging |
level | 'info' | Minimum level: silent, error, warn, info, verbose |
file | .noorm/state/noorm.log | Log file path |
maxSize | '10mb' | Rotate when size exceeded |
maxFiles | 5 | Rotated files to keep |
const logging = settings.getLogging()
// { enabled: true, level: 'info', file: '.noorm/state/noorm.log', ... }Teardown Configuration
Configure database reset and teardown behavior:
teardown:
preserveTables:
- AppSettings
- UserRoles
- AuditLog
postScript: sql/teardown/cleanup.sql| Property | Default | Description |
|---|---|---|
preserveTables | [] | Tables to always preserve during truncate operations |
postScript | null | SQL script to run after schema teardown (relative to project root) |
These settings are applied automatically when using teardown operations:
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.
ui:
mouse: false| Property | Default | Description |
|---|---|---|
mouse | true | Answer 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:
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:
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
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:
await settings.init() // Throws if file exists
await settings.init(true) // Force overwriteMutations
Changes are persisted immediately:
// 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:
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:
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:
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:
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:
- Stage defaults - Merged when creating config from a stage
- Build behavior -
include/excludefilter what files are processed - Rule evaluation - Rules checked against active config before each build
- Config deletion - Locked stages prevent config deletion
- Secret validation - Required secrets must be set before config is usable
- Strict mode - Requires configs for specified stages
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),
})