State Management
The Problem
Database tools need to store sensitive information: connection credentials, API keys, encryption secrets. Storing these in plaintext config files is a security risk. Environment variables work but don't persist across sessions.
noorm encrypts all sensitive data in a single state file. This file travels with your project (gitignored) and unlocks with your private key.
The State File
All persistent data lives in .noorm/state/state.enc:
your-project/
└── .noorm/
├── .gitignore # Contains: state/
├── settings.yml # Team-shared settings (committed)
└── state/ # Gitignored directory
└── state.enc # Encrypted JSONThe file contains:
| Field | Purpose |
|---|---|
version | Package version that last wrote the file |
schemaVersion | Numeric state-schema version, keyed to the migrations in core/version/state |
knownUsers | Discovered team members' public keys |
activeConfig | Currently selected config name |
configs | Database configurations (credentials included) |
secrets | Per-config secrets for SQL templates |
globalSecrets | App-level secrets shared across configs |
Note: Identity is stored globally at ~/.noorm/, not in the project state file. See Identity for details.
Loading State
State must be loaded before use. This decrypts the file and applies any schema migrations.
import { StateManager } from './core/state'
const state = new StateManager(process.cwd())
// Check if state file exists before loading
if (state.exists()) {
await state.load()
}
// Now you can access configs, secrets, etc.
const config = state.getActiveConfig()
// Get state file path
const statePath = state.getStatePath()
// Get current schema version
const version = state.getVersion()Calling methods before load() throws an error. This prevents accidentally working with uninitialized state.
const state = new StateManager(process.cwd())
state.getConfig('dev') // throws: "StateManager not loaded"Config Operations
Configs define how to connect to databases. Each config has a unique name.
// Create or update a config
await state.setConfig('dev', {
name: 'dev',
type: 'local',
isTest: false,
access: { user: 'admin', agent: 'admin' },
connection: {
dialect: 'postgres',
host: 'localhost',
port: 5432,
database: 'myapp_dev',
user: 'postgres',
password: 'postgres',
},
})
// `paths` is not part of Config — sql/changes locations live in settings.yml
// Retrieve a config
const dev = state.getConfig('dev')
// List all configs with summary info
const configs = state.listConfigs()
// [{ name: 'dev', type: 'local', isTest: false, access: { user: 'admin', agent: 'admin' },
// isActive: true, dialect: 'postgres', database: 'myapp_dev' }]
// Delete a config (also removes its secrets).
// Pass a SettingsProvider to enforce locked-stage protection.
await state.deleteConfig('dev', settingsProvider)Active Config
One config is "active" at a time. Commands use this config by default.
await state.setActiveConfig('dev')
const name = state.getActiveConfigName() // 'dev'
const config = state.getActiveConfig() // full config objectSetting a non-existent config as active throws an error:
await state.setActiveConfig('nonexistent')
// throws: Config "nonexistent" does not existDeleting the active config clears the active selection:
await state.setActiveConfig('dev')
await state.deleteConfig('dev')
state.getActiveConfigName() // nullSecrets
Secrets store sensitive values used in SQL templates. They come in two flavors.
Config-Scoped Secrets
Tied to a specific config. Use these for database-specific credentials.
// Set a secret
await state.setSecret('prod', 'DB_PASSWORD', 'super-secret')
// Get a secret
const password = state.getSecret('prod', 'DB_PASSWORD')
// List secret keys (not values)
const keys = state.listSecrets('prod') // ['DB_PASSWORD']
// Get all secrets for a config
const all = state.getAllSecrets('prod') // { DB_PASSWORD: 'super-secret' }
// Delete a secret
await state.deleteSecret('prod', 'DB_PASSWORD')Secrets require the config to exist:
await state.setSecret('nonexistent', 'KEY', 'value')
// throws: Config "nonexistent" does not existDeleting a config removes its secrets:
await state.setSecret('dev', 'API_KEY', 'secret')
await state.deleteConfig('dev')
await state.setConfig('dev', { ... }) // recreate
state.getSecret('dev', 'API_KEY') // null (was deleted)Global Secrets
Shared across all configs. Use these for app-level credentials like API keys.
await state.setGlobalSecret('ANTHROPIC_API_KEY', 'sk-ant-...')
const key = state.getGlobalSecret('ANTHROPIC_API_KEY')
const allGlobal = state.getAllGlobalSecrets()
// List all global secret keys (not values)
const keys = state.listGlobalSecrets() // ['ANTHROPIC_API_KEY']
await state.deleteGlobalSecret('ANTHROPIC_API_KEY')Using Secrets in SQL Templates
Secrets are available in Eta templates via the $ context. The engine uses custom tags — {% / %}, not Eta's default <% / %> — and $ as the context variable name:
-- sql/users/create-readonly.sql.tmpl
CREATE USER {%~ $.secrets.READONLY_USER %}
WITH PASSWORD '{%~ $.secrets.READONLY_PASSWORD %}';
GRANT SELECT ON ALL TABLES TO {%~ $.secrets.READONLY_USER %};Global secrets use $.globalSecrets:
INSERT INTO integrations (name, api_key)
VALUES ('shared', '{%~ $.globalSecrets.SHARED_API_KEY %}');Known Users
Team members discovered during database sync are cached locally.
// Get all known users
const users = state.getKnownUsers()
// Find by identity hash
const user = state.getKnownUser('a3f2b1c9...')
// Find by email (returns array - one person may have multiple machines)
const aliceDevices = state.findKnownUsersByEmail('alice@example.com')
// Add discovered users
await state.addKnownUser({
identityHash: 'b4e3c2d8...',
name: 'Bob',
email: 'bob@example.com',
publicKey: 'x25519-hex...',
machine: 'bob-workstation',
os: 'linux 5.15.0',
lastSeen: new Date().toISOString(),
source: 'prod-db', // Config name where discovered
})
// Batch add (more efficient for sync operations)
await state.addKnownUsers([user1, user2, user3])Encryption
State is encrypted with AES-256-GCM. The encryption key derives from your private key using HKDF.
- Key never exists in plaintext on disk
- Tied to your identity, not the machine
- Can be regenerated from private key
const state = new StateManager(projectRoot, {
privateKey: yourPrivateKey,
})
// Set private key after construction
state.setPrivateKey(yourPrivateKey)
// Check if private key is available
if (state.hasPrivateKey()) {
await state.load()
}
// Re-read ~/.noorm/identity.key from disk — needed when the singleton was
// constructed before `noorm init` created the identity files
const loaded = await state.reloadPrivateKey()load() picks the key up from ~/.noorm/identity.key on its own when none was passed. A new project with no state file loads without a key at all; an existing state.enc without one throws Private key required to decrypt state.
The private key is stored separately in ~/.noorm/identity.key to solve the bootstrap problem: you need the key to decrypt state, but you can't store the key inside encrypted state.
Migrations
Two independent migration systems run inside load(), in this order:
| Order | System | Keyed on | Example |
|---|---|---|---|
| 1 | Schema-version migrations (core/version/state) | numeric schemaVersion | v2 maps the legacy protected boolean to access |
| 2 | Package-semver migration (core/state/migrations.ts) | version string | backfills missing top-level fields |
The order matters: the semver migration only knows State's own top-level fields and would drop anything the schema-version system owns — including schemaVersion itself — if it ran first.
await state.load()
// If either migration applied:
// - Missing fields get sensible defaults
// - state:migrated (semver) / version:state:migrated (schema) events emit
// - File re-persists with the new shapeThe semver migration carries unknown top-level fields through rather than rebuilding from an allowlist — an older binary rebuilding from a fixed list silently destroyed fields a newer one had added, and persisted the truncated object straight back.
One field is deliberately dropped rather than carried: legacy identity. It moved to ~/.noorm/, and pre-move state files hold key material in it, so re-persisting would keep a private key inside state.enc forever.
load() also repairs any config that reached the current schema version without a well-formed access (a hand-edited file, or one written via setConfig bypassing the zod path). This is the single place that backfill happens, so no downstream consumer needs its own fallback.
Import/Export
Backup and restore state files while keeping encryption intact.
// Export reads the file back verbatim; null when no state file exists yet
const backup = state.exportEncrypted()
if (backup) {
fs.writeFileSync('backup.enc', backup)
}
// Import (validates decryption before saving)
const restored = fs.readFileSync('backup.enc', 'utf8')
await state.importEncrypted(restored)Import validates the file can be decrypted before overwriting. If decryption fails, the current state remains unchanged. It throws if no private key is available.
Observer Events
State operations emit events for CLI feedback and debugging:
// State lifecycle
observer.on('state:loaded', ({ configCount, activeConfig, version }) => {
console.log(`Loaded ${configCount} configs, active: ${activeConfig}`)
})
observer.on('state:persisted', ({ configCount }) => {
console.log(`Saved ${configCount} configs`)
})
observer.on('state:migrated', ({ from, to }) => {
console.log(`Migrated state from ${from} to ${to}`)
})
// Config events
observer.on('config:created', ({ name }) => { ... })
observer.on('config:updated', ({ name, fields }) => { ... })
observer.on('config:deleted', ({ name }) => { ... })
observer.on('config:activated', ({ name, previous }) => { ... })
// Secret events
observer.on('secret:set', ({ configName, key }) => { ... })
observer.on('secret:deleted', ({ configName, key }) => { ... })
observer.on('global-secret:set', ({ key }) => { ... })
observer.on('global-secret:deleted', ({ key }) => { ... })
// Known user events (email and source config where user was discovered)
observer.on('known-user:added', ({ email, source }) => { ... })Singleton
Most call sites use the module singleton rather than constructing a manager:
import { getStateManager, initState } from './core/state'
const state = getStateManager(process.cwd()) // construct only; still needs load()
const loaded = await initState(process.cwd()) // construct + loadTesting
For tests, use custom paths to avoid polluting the project directory:
const state = new StateManager(tempDir, {
stateDir: '.test-state',
stateFile: 'test.enc',
})Reset between tests to ensure clean state:
import { resetStateManager } from './core/state'
beforeEach(() => {
resetStateManager()
})