Skip to content

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 JSON

The file contains:

FieldPurpose
versionPackage version that last wrote the file
schemaVersionNumeric state-schema version, keyed to the migrations in core/version/state
knownUsersDiscovered team members' public keys
activeConfigCurrently selected config name
configsDatabase configurations (credentials included)
secretsPer-config secrets for SQL templates
globalSecretsApp-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.

typescript
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.

typescript
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.

typescript
// 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.

typescript
await state.setActiveConfig('dev')

const name = state.getActiveConfigName()  // 'dev'
const config = state.getActiveConfig()    // full config object

Setting a non-existent config as active throws an error:

typescript
await state.setActiveConfig('nonexistent')
// throws: Config "nonexistent" does not exist

Deleting the active config clears the active selection:

typescript
await state.setActiveConfig('dev')
await state.deleteConfig('dev')
state.getActiveConfigName()  // null

Secrets

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.

typescript
// 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:

typescript
await state.setSecret('nonexistent', 'KEY', 'value')
// throws: Config "nonexistent" does not exist

Deleting a config removes its secrets:

typescript
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.

typescript
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
-- 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:

sql
INSERT INTO integrations (name, api_key)
VALUES ('shared', '{%~ $.globalSecrets.SHARED_API_KEY %}');

Known Users

Team members discovered during database sync are cached locally.

typescript
// 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
typescript
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:

OrderSystemKeyed onExample
1Schema-version migrations (core/version/state)numeric schemaVersionv2 maps the legacy protected boolean to access
2Package-semver migration (core/state/migrations.ts)version stringbackfills 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.

typescript
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 shape

The 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.

typescript
// 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:

typescript
// 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:

typescript
import { getStateManager, initState } from './core/state'

const state = getStateManager(process.cwd())  // construct only; still needs load()
const loaded = await initState(process.cwd()) // construct + load

Testing

For tests, use custom paths to avoid polluting the project directory:

typescript
const state = new StateManager(tempDir, {
    stateDir: '.test-state',
    stateFile: 'test.enc',
})

Reset between tests to ensure clean state:

typescript
import { resetStateManager } from './core/state'

beforeEach(() => {
    resetStateManager()
})