Skip to content

SDK reference ​

Query a noorm-managed database from TypeScript, and run builds, changes, locks, and the vault from the same context.

bash
npm install @noormdev/sdk kysely pg

Install kysely and your dialect's driver as peers:

DialectDriverInstall
PostgreSQL (postgres)pgnpm install @noormdev/sdk kysely pg
MySQL (mysql)mysql2npm install @noormdev/sdk kysely mysql2
SQL Server (mssql)tediousnpm install @noormdev/sdk kysely tedious
SQLite (sqlite)better-sqlite3npm install @noormdev/sdk kysely better-sqlite3

Kysely is required; install only the driver you use. The SDK bundles Tarn for MySQL and MSSQL pooling, so do not install tarn separately.

Requires Node 22.13 or newer. In CommonJS, load the SDK with const { createContext } = require('@noormdev/sdk'). The CommonJS entry and ESM facade share one runtime, including constructors and noormObserver. The type declarations reference Kysely's ESM types: in CommonJS files compiled with module: node16 or later (node18, node20, nodenext), Kysely types you import there do not match ctx.kysely.

typescript
// scripts/open-orders.ts
import { createContext } from '@noormdev/sdk';

interface OrderToCash {
    sales_order: {
        party_id: number;
        order_no: number;
        placed_at: Date;
        status: string;
    };
}

const ctx = await createContext<OrderToCash>({ config: 'dev' });
await ctx.connect();

const applied = await ctx.noorm.changes.ff();

const open = await ctx.kysely
    .selectFrom('sales_order')
    .select(['party_id', 'order_no'])
    .where('status', '=', 'open')
    .execute();

console.log(`${applied.executed} changes applied, ${open.length} open orders`);

await ctx.disconnect();

createContext resolves a config and returns an unconnected Context. connect() opens the pool. changes.ff() applies every pending change and returns a BatchChangeResult. ctx.kysely is a Kysely instance typed by your schema interface. disconnect() closes the pool.

How is a context laid out? ​

Everything on ctx runs SQL. Everything noorm itself does hangs off ctx.noorm:

A context is either connected or not. connect() moves it to connected, disconnect() moves it back, and both are no-ops when already there. Calls that need the pool throw NotConnectedError otherwise:

AreaNeeds connect()Works without it
ctxkysely, transaction, proc, func, tvf, impersonatedialect, connected, withSchema, noorm
runpreview, file, files, dir, builddiscover
changesapply, revert, ff, next, rewind, status, pending, history, historyForChange, getFileHistorycreate, addFile, removeFile, renameFile, reorderFiles, delete, discover, parse, validate
db, lock, dtevery methodnone
vaultevery method except copycopy
transfernoneto and plan open their own connections to both configs
secrets, utilsnoneevery method
templatesnonerender; the vault tier of $.secrets resolves only when connected

Failures arrive as thrown errors, as fields on a result, or as returned data. Error handling lists which call does which. Access policy explains why a call can throw ProtectedConfigError before it touches the database.

createContext(options) ​

typescript
createContext<DB = unknown, Procs = object, Funcs = object, Tvfs = object>(
    options?: CreateContextOptions,
): Promise<Context<DB, Procs, Funcs, Tvfs>>

DB types ctx.kysely. Procs, Funcs, and Tvfs type proc(), func(), and tvf(); see Stored procedures, functions and TVFs.

OptionTypeDefaultEffect
configstring | falseNOORM_CONFIG, then the active config only when connection is absent, then inline/env-only modeStored config to use. false uses no stored config and ignores NOORM_CONFIG and the active config
namestringNOORM_NAME, then __inline__ or __env__Name of a context that uses no stored config. Throws when a stored config is selected
connectionCreateContextOptions['connection']—Non-persisted connection overrides, including runtime credential and PostgreSQL TLS callbacks, TLS certificates, pool options, and driver options
projectRootstringprocess.cwd()Directory that holds .noorm/ and the paths in settings.yml
requireTestbooleanfalseThrows RequireTestError unless the resolved config has isTest: true
channel'user' | 'agent''user'Which half of the config's access applies; see Access policy
stagestringthe stage named like the configDefaults below stored, environment, and inline values, plus a protected stage's access ceiling
yesbooleanfalsePre-confirms confirm cells on the user channel. Ignored on agent
freshStatebooleanfalseLoad independent state and settings snapshots from disk for this context

The context is not connected when it returns. Connection fields merge in order: built-in defaults, stage, stored config, environment, then connection.

The resolver selects a stored name before considering a synthetic config:

The later layer wins. Three sources can select a stored config, in this order:

OrderSourceApplies when
1config: '<name>'Always. config: false stops here and selects nothing
2NOORM_CONFIGconfig is unset
3The active configconfig and NOORM_CONFIG are unset and connection is absent

A selected name must exist, even when inline connection fields are complete. NOORM_CONFIG=<stored> with an inline connection uses the stored config as the base and puts the inline fields on top. That context keeps the stored config's access roles, and keeps its trusted isTest only while the inline fields leave the connection target unchanged. Without config and NOORM_CONFIG, connection skips the active stored config, including its connection fields, access roles, and isTest flag. config: false skips all three sources, so the context has no stored config. Stage defaults still apply through stage and NOORM_NAME, and NOORM_isTest, NOORM_ACCESS_* and the NOORM_CONNECTION_* variables still apply. requireTest: true rejects unless the remaining configuration layers explicitly supply isTest: true. A missing state file needs no identity key.

A synthetic config takes its name from the first of these that is set:

OrderSourceName
1name optionThe string passed
2NOORM_NAMEThe variable's value
3Neither__inline__ with an inline connection, otherwise __env__

The name is ctx.noorm.config.name and the configName on the context's events and errors. It is also the key for change history, run checksums, locks, pipeline history and local secrets: a database reopened under another name reports its applied changes as pending and runs its build again. Use one name per database.

Neither name nor NOORM_NAME selects a stored config. name with a selected stored config throws Cannot apply the name "<name>" to stored config "<config>", because a stored config's name decides its stage and its protected-stage ceiling. A synthetic config whose name a stored config has throws Cannot name a context "<name>": a stored config is named "<name>", because the context would read and write that config's secrets. This applies to name, to NOORM_NAME, and to the __inline__ and __env__ fallbacks. Select the stored config with config or NOORM_CONFIG=<name>, or use another name. With a stored config selected, NOORM_NAME can rename the context to a name no stored config has; naming another stored config throws An env or flag name cannot rename config "<selected>" to the name of stored config "<name>".

name adds restriction and selects no defaults. Both the name in use and the name it displaced (NOORM_NAME, or the __inline__ or __env__ fallback) are checked:

FoundEffect on the context
A protected stage under either nameIts permission ceiling applies
A stage declaring isTest: false under either nameThe context is not a trusted test config
A stored config under the NOORM_NAME or fallback nameThe context is a trusted test config only if that stored config has isTest: true and the same connection target

Stage defaults (isTest: true, access, connection fields) come from the stage option, else from the stage named by NOORM_NAME. A stage named by name supplies none.

Synthetic config names appear in errors and event payloads. With no state file, an inline or environment-only context needs no .noorm/ directory. Deploying an application covers packaging and pool sizing for that setup.

Stage connection defaults such as host, port, user, ssl, and password merge into connection before environment and inline overrides. ssl combines field by field across layers, and rejectUnauthorized is never inherited: it comes from the highest layer that sets ssl, so a stage rejectUnauthorized: false reaches only a config with no ssl of its own. Combining layers has the rules. Stage isTest is a default, and requireTest checks the final resolved value. Protected stages cap effective permission cells at policy-check time without changing assigned roles. Nothing in connection is saved.

For example, open a MySQL database without creating stored state:

typescript
import { createContext, type CreateContextOptions } from '@noormdev/sdk';

const connection: CreateContextOptions['connection'] = {
    dialect: 'mysql',
    host: 'db.example.com',
    database: 'orders',
    user: 'app',
    password: () => {
        const token = process.env.DB_TOKEN;
        if (!token) throw new Error('DB_TOKEN is required');
        return token;
    },
};

const ctx = await createContext({ connection });
await ctx.connect();
try {
    const rows = await ctx.kysely.selectFrom('sales_order').selectAll().execute();
    console.log(rows.length);
}
finally {
    await ctx.disconnect();
}

Runtime credentials ​

CreateContextOptions['connection'] is InlineConnection<DialectConnectionConfig>: partial connection settings whose pool is typed for the dialect, plus password?: string | (() => string | Promise<string>), a runtime PostgreSQL SSL verifier, and driver. InlineConnection and DialectConnectionConfig are exported. The internal RuntimeConnectionOptions holds the functions and driver separately from serializable config; it is not a package-root export. Persisted Config.connection.password remains a string.

DialectHow a new physical connection gets its password
PostgreSQLThe SDK hands the function to pg's native password option
MySQLThe Tarn pool awaits the function before creating a mysql2 connection. A string password goes through the same pool
MSSQLKysely's Tarn-backed connection factory awaits the function before creating a tedious connection
SQLiteCallable passwords are rejected by createContext()

The callback runs for physical authentication, including a replacement connection, rather than for each query or pool checkout. Rotating a token does not reauthenticate an existing session. disconnect() closes the SDK-owned pool; connecting again creates a new pool.

Reusing a pooled session does not call the credential provider again:

A runtime callback takes precedence over stored and environment passwords. The SDK removes the shadowed password from ctx.noorm.config.connection, without changing stored state. Functions cannot be supplied through YAML or environment variables and are never persisted.

Failure pathCaller seesObserver seesAutomatic retries
connect() where the password callback throwsThe callback's error, the same object, not reworded or wrappedconnection:error with configName and fixed error: 'Runtime credential connection failed', without server detailsNone
connect() with a password callback where the driver or server failsDatabaseConnectionError with a worded message, the original error as cause, and serverCode and serverMessage. An error with no code and no known explanation comes back unchangedThe same fixed payload as the row above, without serverCode or serverMessageNone, even for transient-looking messages
connect() with a static passwordDatabaseConnectionError under the same ruleconnection:error with the worded error, plus serverCode and serverMessageOnly the transient failures described in Lifecycle
Query on an established connectionThe query's errorNo event for the query itself; see Pool connection failuresNo query retries

The connection factory records what the callback throws and connect() rejects with exactly that value, so a provider's error class survives instanceof. This holds for a value that is not an Error too, such as a string or the undefined of a bare Promise.reject(); no login is attempted without a credential. A login the pool makes after connect() has no connect() caller: there, a rejection that is not an object reaches the failing query as an Error with the rejected value as its cause, and an object reaches it unchanged. Provider errors may contain secrets. Sanitize them before logging in application code; the fixed event payload does not change the thrown error. A connection with a callback is not retried, because a retry would ask the provider to issue another credential.

TLS certificates ​

A private CA and a client certificate work on PostgreSQL, MySQL and MSSQL. Name each by path and noorm reads the file each time a pool opens, on every connect attempt, so a rotated file needs no new context:

typescript
const ctx = await createContext<DB>({
    config: false,
    name: 'orders',
    projectRoot: '/srv/app',
    connection: {
        dialect: 'mysql',
        host: '10.0.0.5',
        database: 'orders',
        user: 'app',
        tlsServerName: 'db.internal.corp',
        ssl: { caFile: 'certs/corp-ca.pem' },
    },
});
ssl fieldMeaning
ca, cert, keyPEM text
caFile, certFile, keyFilePaths read into ca, cert and key each time a pool opens, on every connect attempt
rejectUnauthorizedfalse encrypts without validating the certificate

One field takes one form: ca with caFile fails with ssl.ca and ssl.caFile cannot both be set; use one. A relative path resolves from projectRoot, a leading ~/ from the home directory, and an absolute path is used as written.

A path that is not a readable regular file, or is empty, over 1 MiB, or holds no PEM block, rejects connect() with SslFileError, which carries field, path and code and never file content. connect() does not retry it, and connection:error carries its message.

typescript
import { attempt } from '@logosdx/utils';
import { SslFileError } from '@noormdev/sdk';

const [, err] = await attempt(() => ctx.connect());

if (err instanceof SslFileError) console.error(err.field, err.path, err.code);

Per-dialect behavior, tlsServerName rules, error hints and how stage, stored, environment and inline ssl combine are in the certificate reference.

PostgreSQL TLS identity ​

For PostgreSQL through an IP host, set ssl: { caFile } (or ca) and tlsServerName to the certificate's DNS identity. The CA validates the chain and the name validates the peer identity. On IP hosts the name also supplies SNI. For DNS dial hosts pg replaces SNI with the dial host, but noorm still verifies the configured name; select a dial host whose SNI reaches the intended certificate. Without tlsServerName, pg and Node use their normal host behavior. tlsServerName and the runtime checkServerIdentity callback need ssl on and certificate validation, and are rejected with rejectUnauthorized: false.

Inline SSL objects may provide checkServerIdentity(hostname, certificate) at runtime. It receives the configured TLS name when present, otherwise Node's hostname, and the peer certificate. Returning an error rejects the connection. This callback replaces Node's standard identity check and must implement any checks the application requires; it does not replace CA chain verification. It cannot be stored in YAML.

Pool connection failures ​

After connect() succeeds, the pool reports a connection it loses, or cannot open, as connection:error on the process-global noormObserver. The payload is { configName, phase, error } with a fixed error string. Driver messages and error objects are not forwarded to the observer.

phaseDialectsFixed errorEmitted when
idlePostgreSQLPostgreSQL idle pool connection failedAn idle pool client fails
idleMySQLMySQL idle pool connection failedAn established connection that is not checked out fails
checked-outPostgreSQLPostgreSQL checked-out connection failedA checked-out session fails
loginPostgreSQLPostgreSQL pool login failedA physical connect the pool makes after its first successful one fails
loginMySQLMySQL pool login failedSame
loginMSSQLMSSQL pool login failedSame

A failed connect() emits connection:error without phase; Runtime credentials lists that payload.

Idle. The pool removes or discards a failed client before reuse, and the replacement physical connection obtains credentials again. Active query failures reject to their callers and produce no idle event.

Checked-out. This includes pinned connections and transactions waiting for application work. The process stays alive, but the session is lost: subsequent queries on that connection reject, and the pool discards it on release. A replacement connection does not restore session advisory locks or the interrupted transaction.

Login. A pool opens more physical connections as demand grows and as old ones are replaced. When one of those logins fails (an expired credential, a throwing password callback, a server that stopped accepting connections), the query that needed the connection still rejects to its caller with the driver's error. The event exists because the first connect() has already returned, so no connect() caller is left to report the failure.

Pool options ​

connection.pool sizes and recycles the pool. PostgreSQL uses pg's pool. MySQL and MSSQL use Tarn, for a string password and a password callback alike.

pool optionPostgreSQL (pg Pool option)MySQL, MSSQL (Tarn option)SQLiteMeaning
minminminaccepted, unusedConnections the pool keeps open; default 0
maxmaxmaxaccepted, unusedMost connections the pool opens; default 10
idleTimeoutMsidleTimeoutMillis; zero disables the timeoutidleTimeoutMillis; must be greater than zerorejected; the idleTimeoutMillis alias is accepted, unusedMilliseconds a connection may sit idle before the pool closes it
maxLifetimeMsmaxLifetimeSeconds; converted exactly, so 1500 is 1.5 secondsrejectedrejectedMilliseconds a connection may live before the pool replaces it
maxUsesmaxUses; at least 1rejectedrejectedCheckouts after which the pool replaces a connection
allowExitOnIdleallowExitOnIdlerejectedrejectedLet the process exit while only idle connections remain
acquireTimeoutMsrejectedacquireTimeoutMillis; positive; default belowrejectedMilliseconds a checkout may wait for a free connection
createTimeoutMsrejectedcreateTimeoutMillis; positive; default belowrejectedMilliseconds opening one physical connection may take
reapIntervalMsrejectedreapIntervalMillis; positiverejectedMilliseconds between sweeps that close idle connections

Every value is an integer except allowExitOnIdle, which is a boolean. On PostgreSQL, MySQL and MSSQL, min above max fails validation with pool.min (<min>) must not be greater than pool.max (<max>).

pool.idleTimeoutMillis is a deprecated alias of pool.idleTimeoutMs. One source that sets both to different values fails validation. Across sources they are one setting: an inline or environment value under either name replaces a stored value under either name.

When acquireTimeoutMs or createTimeoutMs is unset on MySQL or MSSQL, noorm sets it to 30000, Tarn's own default, or to connectTimeoutMs plus 5000 when that is larger. A connectTimeoutMs above 30000 is therefore not cut off by the pool.

The options are accepted in a stored config, inline, and from the environment as NOORM_CONNECTION_POOL_<option>. Keep the camelCase of the option in the variable name:

bash
NOORM_CONNECTION_POOL_MAX=20
NOORM_CONNECTION_POOL_idleTimeoutMs=30000
NOORM_CONNECTION_POOL_maxLifetimeMs=900000

A key that is not a pool option fails validation with pool.<key> is not a pool option, from any of the three sources. The environment reader lowercases an all-caps segment, so NOORM_CONNECTION_POOL_MAXUSES arrives as maxuses and fails with a message that names NOORM_CONNECTION_POOL_maxUses. MIN and MAX are unaffected.

"Rejected" means the config fails validation with pool.<option> is not supported on <dialect>, whichever of the three sources supplied it. The inline type is narrower still: connection.pool is typed per dialect (PgPoolOpts, MysqlPoolOpts, MssqlPoolOpts, and min and max only for SQLite), so an unsupported option on an inline connection with a literal dialect is a compile error first. Each connected context owns a separate pool, so pool maxima are per context, not process-wide.

Session and keepalive controls ​

These connection settings can be stored, set in the environment, or passed inline:

noorm fieldDialectsDriver optionMeaning
statementTimeoutMsPostgreSQLstatement_timeoutServer statement limit in ms; zero disables
queryTimeoutMsPostgreSQLquery_timeoutClient wait limit in ms; zero disables
applicationNamePostgreSQLapplication_nameSession name; pg may inherit PGAPPNAME when omitted
keepAlivePostgreSQLkeepAliveEnable or disable TCP keepalive
keepAliveMySQLenableKeepAliveSame. mysql2 enables keepalive unless this is false
keepAliveInitialDelayMsPostgreSQLkeepAliveInitialDelayMillis; also sets keepAlive: true unless keepAlive is falseMilliseconds a socket stays idle before the first keepalive probe
keepAliveInitialDelayMsMySQLkeepAliveInitialDelay; also sets enableKeepAlive: true unless keepAlive is falseSame

Timeouts are nonnegative integers. Omitted statement and query limits are unlimited by pg default; a server may impose its own policy. These controls do not change connectTimeoutMs.

With keepAlive: true alone, the first probe waits for the operating system default, about two hours, so a socket left half-open by a failover is found that late. keepAliveInitialDelayMs sets the wait. MSSQL rejects it because tedious fixes the delay at 30 seconds, and SQLite rejects it because it has no socket.

Driver options ​

connection.driver passes options noorm does not model to the database driver. It is inline only: no environment variable or YAML field sets it, and noorm does not store it, add it to the resolved config, or put it in its own events. A driver that rejects a value can quote it in its error, and that error is the connect() failure and the connection:error text: driver: { charset: 'X' } on MySQL fails with Unknown charset 'X'.

typescript
const ctx = await createContext({
    connection: {
        dialect: 'postgres',
        host: 'db.example.com',
        database: 'orders',
        user: 'app',
        driver: { lock_timeout: 500 },
    },
});
Dialectdriver is merged intoLater entries win
PostgreSQLThe pg Pool configFlat config fields, pool, driver, then host, port, user, password, database, min, max, ssl, connectionTimeoutMillis
MySQLThe mysql2 connection optionsKeepalive fields, driver, then host, port, user, database, ssl, stream, connectTimeout
MSSQLThe tedious options blockdriver, then port, database, trustServerCertificate, cryptoCredentialsDetails, encrypt, serverName, connectTimeout
SQLiteNothingcreateContext() throws Driver options are not supported for SQLite

A key in the last group of each row is overwritten by noorm's value. A key that selects the target, the credentials, or TLS is rejected instead: createContext() throws driver.<key> is not allowed on <dialect>. connect() throws the same error, and the SQLite one, for a Context constructed directly, before the driver opens a connection.

DialectRejected driver keys
All threehost, port, database, user, password, connectionString, ssl, __proto__
PostgreSQL, alsoClient, stream, connection
MySQL, alsouri, socketPath, stream, password1, password2, password3, passwordSha1, authPlugins, authSwitchHandler, flags, isServer
MSSQL, alsoserver, authentication, instanceName, connector, serverName, encrypt, trustServerCertificate, cryptoCredentialsDetails, fallbackToDefaultDb

Only the object's own enumerable keys are checked and passed on. Keys it inherits from a prototype are not passed to the driver.

The rejection exists because a trusted isTest config's access is bound to the connection target noorm resolved. A driver option that moved the connection, changed who logs in, or weakened TLS would carry that access to a database noorm never checked. createContext() throws connection.driver must be an object of driver options for any other value.

Environment overrides ​

VariableEffect
NOORM_CONFIGConfig name when options.config is unset. Ignored with config: false
NOORM_NAMEName of a synthetic config when options.name is unset. Selects no stored config, and throws when a stored config has that name
NOORM_CONNECTION_DIALECTpostgres, mysql, sqlite, or mssql. Any other value throws
NOORM_CONNECTION_DATABASEWith the dialect, switches on env-only mode
NOORM_CONNECTION_HOST, _PORT, _USER, _PASSWORD, _SSLOverride the matching connection field of the resolved config
NOORM_CONNECTION_SSL_caFile, _certFile, _keyFileOverride connection.ssl.caFile, .certFile and .keyFile; see TLS certificates
NOORM_CONNECTION_POOL_<option>Set a pool option
NOORM_CONNECTION_keepAliveInitialDelayMsSet connection.keepAliveInitialDelayMs; see Session and keepalive controls
NOORM_ACCESS_USER, NOORM_ACCESS_AGENTAccess roles of a config that has no stored record. Ignored when a stored config is selected, so the environment cannot change its roles
NOORM_PATHS_SQL, NOORM_PATHS_CHANGESOverride paths.sql and paths.changes from settings.yml
NOORM_IDENTITY_PRIVATE_KEY, _NAME, _EMAILCI identity. All three are required, otherwise they are ignored. They let the context decrypt state and vault keys with no key file on disk
NOORM_YESAny value except empty, 0, or false confirms confirm cells on the user channel, like yes: true

An env-only config takes the default access, { user: 'admin', agent: 'viewer' }, unless NOORM_ACCESS_USER and NOORM_ACCESS_AGENT set it.

ThrowsWhen
Config "<name>" not foundThe name matches no stored config
No config available...No selected name and no inline connection, and environment/stage fields cannot supply a dialect and database
Cannot name a context "<name>"...name equals the name of a stored config
Cannot apply the name "<name>" to stored config "<config>"...name was passed while a stored config is selected
connection.driver must be an object of driver optionsconnection.driver is not a plain object
driver.<key> is not allowed on <dialect>...connection.driver has a key that selects the target, the credentials, or TLS
Driver options are not supported for SQLiteconnection.driver was passed with a SQLite connection
No connection available for { config: false }...config: false, no inline connection, and environment/stage fields cannot supply a dialect and database
Cannot apply the name "<name>" to stored config "<config>"...name was passed and config, NOORM_CONFIG, or the active config selected a stored config
An error named ConfigValidationError (the class is not exported)The resolved config fails validation, for example a pool option its dialect rejects
RequireTestErrorrequireTest: true on a config without isTest: true
Private key required to decrypt stateA state.enc exists and no identity key or NOORM_IDENTITY_* is available

Context members ​

MemberReturnsNotes
kyselyKysely<DB>Standard Kysely. Re-derived on every access, so a reconnect never leaves a stale schema wrap. Not gated by access policy
dialectDialectpostgres, mysql, sqlite, or mssql
connectedbooleantrue between connect() and disconnect()
noormNoormOpsCreated on first access, one per context
connect(retryOptions?)Promise<void>See Lifecycle
disconnect()Promise<void>See Lifecycle
transaction(fn)Promise<T>See Transactions
proc(name, params?)Promise<T[]>See proc
func(name, params?, column)Promise<T>See func
tvf(name, params?)Promise<T[]>See tvf
impersonate(username, fn?)Promise<T> or Promise<ImpersonatedScope>See Impersonation
withSchema(name)ContextSee Schema scoping

ctx.noorm also exposes three read-only properties:

PropertyTypeHolds
configConfigThe resolved config
settingsSettingsProject settings: paths, build rules, stages, teardown
identityIdentityThe audit identity recorded on every change and lock

Lifecycle ​

typescript
await ctx.connect();

// Fail fast on an unreachable database.
await ctx.connect({ retries: 1, delay: 0 });

connect(retryOptions?) takes ConnectionRetryOptions, exported as a type:

FieldDefaultMeaning
retries3Attempts before giving up
delay1000Milliseconds before the first retry
backoff2Multiplier applied to the delay on each retry

For connections with runtime password callbacks, see Runtime credentials. Static-credential connections retry only messages containing econnrefused, etimedout, too many connections, or connection reset. Authentication, password, login, access-denied, missing-database, and missing-driver errors fail on the first attempt. After the last attempt, the factory throws the last error, as a DatabaseConnectionError where it can explain it.

disconnect() releases any impersonation scope you never reverted, destroys the pool, and clears the connection. Calling it while disconnected is a no-op. A disconnected context can connect() again.

Transactions ​

transaction(fn) hands fn a Kysely Transaction<DB> with proc, func, and tvf attached, and resolves with its return value. A throw inside fn rolls back, so do not swallow errors in the callback.

typescript
const cancelled = await ctx.transaction(async (trx) => {
    const open = await trx
        .selectFrom('sales_order')
        .select('order_no')
        .where('party_id', '=', 2)
        .where('status', '=', 'open')
        .execute();

    await trx
        .updateTable('sales_order')
        .set({ status: 'cancelled' })
        .where('party_id', '=', 2)
        .where('status', '=', 'open')
        .execute();

    return open.length;
});

Routine calls run on the transaction's connection, so they see transaction-scoped settings and roll back with the rest of fn.

typescript
import { sql } from 'kysely';

const open = await ctx.transaction(async (trx) => {
    await sql`SET LOCAL statement_timeout = '8s'`.execute(trx);

    return trx.tvf('party_open_orders', { party_id: 2 });
});
// SELECT * FROM "party_open_orders"("party_id" => $1), cancelled if it runs past 8 seconds
RuleBehavior
Handle typeTransactionScope is Transaction<DB> & RoutineCalls<Procs, Funcs, Tvfs>. A callback annotated (trx: Transaction<DB>) => … still compiles
SET LOCALPostgres syntax. It lasts until the transaction ends
Routine namesQualified with the context's schema, as on ctx
trx.proc on postgresLooks the name up in pg_catalog.pg_proc first: a procedure runs CALL, anything else runs SELECT * FROM name(...). No CALL is attempted against a name with no visible procedure, so calling a function does not abort the transaction. A name that is both a procedure and a function runs CALL, which fails with 42809 and aborts the transaction when it resolves to the function: arguments that match a function overload, or a function earlier on search_path than the procedure. trx.tvf reaches the function in every case
Derived handlestrx.withSchema(...), trx.withPlugin(...), and other Kysely instances derived from trx carry no proc, func, or tvf

Pinned connections ​

withConnection(fn) pins one pooled connection for the duration of fn and resolves with its return value. fn receives a Kysely<DB> bound to that connection and a PinnedConnection handle. Use it for work that must stay on one session, such as a session-level advisory lock.

connection.discard() marks the connection. When fn ends, the SDK closes the physical connection, and the pool opens a new one on demand. Closing it ends the server session, which releases any session-level locks it held.

typescript
import { sql } from 'kysely';
import { attempt } from '@logosdx/utils';

await ctx.withConnection(async (db, connection) => {
    await sql`select pg_advisory_lock(${lockKey})`.execute(db);

    const [, err] = await attempt(() => syncPrices(db));

    if (err) {
        connection.discard();
        throw err;
    }

    await sql`select pg_advisory_unlock(${lockKey})`.execute(db);
});
CaseResult
fn returns without discard()The connection goes back to the pool and is reused
fn throws without discard()Same. A thrown error does not discard the connection
discard() was calledThe connection is closed when fn ends, whether it returns or throws. No connection:error event is emitted
discard() called twiceSame as once
SQLitediscard() does nothing; there is one in-process connection
disconnect() while fn is runningdisconnect() completes, the pinned connection is closed, and withConnection rejects with DisconnectedError. fn itself is not interrupted
Context not connectedThrows NotConnectedError

ctx.kysely.connection().execute(fn) still works and always returns its connection to the pool.

Impersonation ​

impersonate(username, fn?) runs queries as another database principal. It borrows one connection from the pool, switches identity on it, and returns a scope whose kysely, proc, func, tvf, and transaction use that connection.

DialectSwitchRevert
mssqlEXECUTE AS USER = '<username>'REVERT
postgresSET ROLE '<username>'RESET ROLE
mysql, sqlitethrows ImpersonationError

A username may contain letters, digits, _, @, ., -, and \. Anything else throws ImpersonationError.

The two modes differ in who ends the borrow:

typescript
import { type ImpersonatedScope } from '@noormdev/sdk';

// Callback mode: reverts on the way out, including when fn throws.
const rows = await ctx.impersonate('app_readonly', (scope) =>
    scope.kysely.selectFrom('sales_order').selectAll().execute(),
);

// Explicit mode: you own the lifecycle.
const scope: ImpersonatedScope<OrderToCash> = await ctx.impersonate('app_readonly');
const orders = await scope.kysely.selectFrom('sales_order').selectAll().execute();
await scope.revert();
BehaviorDetail
Scope memberskysely, proc, func, tvf, transaction, revert. No noorm and no lifecycle methods; management operations stay on ctx.noorm, running as the context's own principal
Explicit mode holds a pooled connectionUntil revert() or ctx.disconnect(). disconnect() releases an unreverted scope without running the revert SQL, because it destroys the pool
revert()Idempotent after it succeeds. After a failure it rejects with the error and the scope is over: a later call does nothing
A failed switch or revert statementThe connection is closed instead of returned to the pool, in both modes, because its identity is then unknown. The error is thrown, and the next query opens a new connection. In callback mode a revert failure replaces an error fn threw
disconnect() during callback modeThe call rejects with DisconnectedError, as withConnection does

Schema scoping ​

withSchema<SDB, SProcs, SFuncs, STvfs>(name) returns a Context scoped to one schema. It wraps Kysely's own withSchema; it is not a new connection.

typescript
const billing = ctx.withSchema<BillingDB, BillingProcs>('billing');

await billing.kysely.selectFrom('invoice').selectAll().execute();
// select * from "billing"."invoice", typed against BillingDB

await billing.proc('post_invoices', { party_id: 2 });
// CALL "billing"."post_invoices"("party_id" => $1)

await billing.proc('sales.close_order', [2, 1]);
// CALL "sales"."close_order"($1, $2), a name with a dot is used as given

await ctx.kysely.selectFrom('sales_order').selectAll().execute();
// the parent is untouched, no prefix

BillingDB and BillingProcs are interfaces you declare, like OrderToCash in the first example. The routine generics default to the parent's.

RuleBehavior
LifecycleThe derived context shares the parent's connection, pool, and held-connection set. connect() and disconnect() on either act on both
Name checkname must match letters, digits, _, and -. Anything else, including ., throws Invalid schema name immediately, before any connection is touched
Replace, not stackctx.withSchema('a').withSchema('b') resolves against b only
Routine namesproc, func, and tvf prefix a name that has no . with the schema. A name with a . passes through
transactionInherits the schema
impersonatescope.kysely and the Kysely builder on scope.transaction stay schema-qualified. Routine methods inside a scope (scope.proc/func/tvf and the same methods on the scope.transaction handle) call the builders with the name as given, so qualify routine names yourself
Raw sql`...`Not rewritten. Unqualified identifiers resolve against the connection's default schema, so qualify them by hand
ctx.noormNot affected. Pass schema to db.describe* explicitly

There is no schema field on the config, and no per-dialect gating. The qualifier means whatever Kysely's withSchema means for the active dialect: a schema on postgres and mssql, a database on mysql, an attached database name on sqlite.

Stored procedures, functions and TVFs ​

proc(), func(), and tvf() call routines by name, with the SQL built for the active dialect and every value bound as a parameter. They are not gated by access policy. Declare each routine your database defines as an [Args, Return] tuple and pass the maps as generics:

typescript
// src/db.ts
import { createContext, type TvpValue } from '@noormdev/sdk';

interface NewLine { product_id: number; qty: number }

interface Procs {
    place_order: [{ party_id: number; lines: TvpValue<NewLine> }, { order_no: number }];
    set_order_status: [{ party_id: number; order_no: number; status?: string }, void];
    close_order: [[number, number], void];
    archive_closed_orders: void;
}

interface Funcs {
    party_balance: [{ party_id: number }, { balance: number }];
    line_total: [[number, number], { total: number }];
    schema_version: [void, { v: string }];
}

interface Tvfs {
    party_open_orders: [{ party_id: number }, { order_no: number; status: string }];
}

export const ctx = await createContext<OrderToCash, Procs, Funcs, Tvfs>({ config: 'dev' });

The routine names above are examples. A tuple's first element types the arguments, and the second types the rows returned:

EntryArgumentsReturn
[{ k: T }, R]object, named parametersR
[[A, B], R]array, positional parametersR
[Args, void]as abovevoid
[void, R]noneR
voidnoneunknown

A routine map you do not provide leaves the matching method uncallable at the type level. Each method takes a second generic to override the inferred return type, such as ctx.proc<'set_order_status', MyRow>(...). Names are quoted per dialect ("name", [name], `name`), and a schema-qualified name splits on its first . and quotes each half. In the tables below, placeholders are written $1; the driver supplies its own markers.

proc(name, params?) ​

Calls a stored procedure and returns its result-set rows as Promise<T[]>.

DialectNamedPositionalNo params
mssqlEXEC name @k = $1EXEC name $1EXEC name
postgresCALL name(k => $1)CALL name($1)CALL name()
mysqlCALL name($1), values in key orderCALL name($1)CALL name()
sqlitethrowsthrowsthrows
typescript
await ctx.proc('set_order_status', { party_id: 2, order_no: 1, status: 'paid' });
await ctx.proc('close_order', [2, 1]);
await ctx.proc('archive_closed_orders');

On postgres, proc() retries as SELECT * FROM name(...) when CALL fails with SQLSTATE 42809 or 42883. One method therefore reaches both CREATE PROCEDURE and CREATE FUNCTION objects. Inside a transaction a failed CALL would abort it, so trx.proc decides up front instead; see Transactions.

Parameter handling and NULL semantics ​

Every key present in a params object is sent. The SDK never drops a key, and a value of undefined or null is bound as SQL NULL (sql.ts builds one @key = <value> per Object.entries pair, and Kysely's mssql driver binds both as NVarChar NULL).

MSSQL applies a parameter's DEFAULT only when the parameter is omitted from the call. status: undefined against @status text = 'open' sends @status = NULL, overwrites the default, and fails a NOT NULL column.

Zod schemaA missing key parses toThe SDK sends
z.string()rejects before the callnothing
z.string().nullable()null when the value is null@key = NULL
z.string().optional()undefined@key = NULL, the key is still present
z.string().default('open')'open'@key = 'open'

.optional() reads like "leave the parameter alone" and means "send NULL". Two patterns keep the SQL default working:

typescript
// Pattern A: put the default in the schema.
const StatusChange = z.object({
    party_id: z.number(),
    order_no: z.number(),
    status: z.string().default('open'),
});

// Pattern B: leave the key out when the value is absent.
const params: Record<string, unknown> = { party_id: 2, order_no: 1 };
if (status !== undefined) {
    params.status = status;
}
await ctx.proc('set_order_status', params);

For a column that stores NULL, { note: undefined } and { note: null } are the same call. Use undefined for "the input did not carry this field" and null for "clear it".

func(name, params?, column) ​

Calls a database function and returns the first row, { [column]: value }, or null when there is none. The last argument is the result column alias.

DialectNamedPositionalNo params
mssqlDECLARE @__result sql_variant; EXEC @__result = name @k = $1; SELECT @__result AS colSELECT name($1) AS colSELECT name() AS col
postgresSELECT name(k => $1) AS colSELECT name($1) AS colSELECT name() AS col
mysqlthrowsSELECT name($1) AS colSELECT name() AS col
sqlitethrowsthrowsthrows

T-SQL has no named arguments in SELECT, which is why mssql uses EXEC @var = name. mysql throws MySQL does not support named parameters in function calls. Use positional parameters (array) instead. rather than reorder an object into positional form.

typescript
const { balance } = await ctx.func('party_balance', { party_id: 2 }, 'balance');
const { total } = await ctx.func('line_total', [3, 49], 'total');
const { v } = await ctx.func('schema_version', 'v');

tvf(name, params?) ​

Calls a table-valued function and returns its rows as Promise<T[]>. mssql and postgres only; mysql and sqlite throw.

DialectNamedPositionalNo params
mssqlSELECT * FROM name($1), values in key orderSELECT * FROM name($1)SELECT * FROM name()
postgresSELECT * FROM name(k => $1)SELECT * FROM name($1)SELECT * FROM name()

A T-SQL FROM clause takes positional arguments only. On mssql, declare the parameters as an array when order matters, because an object is flattened in key order.

typescript
const open = await ctx.tvf('party_open_orders', { party_id: 2 });

Table-valued parameters ​

tvp(typeName, rows) passes an array of rows to an mssql routine as a table-valued parameter. Other dialects throw Table-valued parameters (TVP) are only supported on MSSQL. Kysely's driver cannot bind a TVP, so the SDK emits a batch that declares a table variable of your table type, inserts the rows into it, and passes the variable. Every value stays a bound parameter.

typescript
import { tvp } from '@noormdev/sdk';

const lines: NewLine[] = [
    { product_id: 10, qty: 1 },
    { product_id: 11, qty: 2 },
];

const [{ order_no }] = await ctx.proc('place_order', {
    party_id: 2,
    lines: tvp('order_line_input', lines),
});

TvpValue<T> keeps the row type through the signature, so the compiler checks the columns. A TVP works in proc(), func(), tvf(), and in scope.proc() and its siblings:

MethodStatement shape
procDECLARE @__tvp_<key> <type>; INSERT INTO @__tvp_<key> ([col], ...) VALUES (...), (...); EXEC [name] @k = $1, @<key> = @__tvp_<key>
functhe same preamble, then DECLARE @__result sql_variant; EXEC @__result = [name] ...; SELECT @__result AS [col]
tvfthe same preamble, then SELECT * FROM [name]($1, @__tvp_<key>)

With positional parameters the variable is @__tvp_<index>.

RuleBehavior
Type nameA schema-qualified name works: tvp('dbo.order_line_input', rows). An empty name throws TVP type name is required.
Row keysEvery row must have the first row's keys, or tvp() throws TVP row <i> has mismatched keys.... Columns come from the first row's keys
Empty TVPtvp('order_line_input', []) declares the variable and inserts nothing
Several TVPsAllowed in one call
Parameter limitmssql allows 2,100 bound parameters per batch. The SDK counts rows x columns per TVP plus scalar parameters, and throws TVP parameter count (n) exceeds MSSQL limit of 2100... before sending. Split the rows and call again

Access policy ​

Each config grants a role to two channels: access: { user, agent }. Roles include built-in viewer, operator, and admin and project-defined names; agent: false hides the config from agents. The default is { user: 'admin', agent: 'viewer' }. A protected stage caps each effective user cell at the original operator matrix and each agent cell at the original viewer matrix, without changing the assigned role. Access roles covers setting them.

Malformed stored access is preserved and rejected rather than replaced with a fallback role. A human can repair it, or an unresolved role assignment, without deleting the config or its secrets using noorm config repair-access <name> --user <role> --agent <role|false> --yes. Agents cannot use this recovery path; a valid user role must still permit config:write when only the agent role is unresolved.

createContext({ channel }) picks which half applies. Each permission resolves to allow, deny, or confirm, and the SDK has no prompt to answer a confirm:

Matrix celluser channelagent channel
allowrunsruns
denythrowsthrows
confirmthrows unless yes: true or NOORM_YES is setthrows, and yes is never read

A gated method checks before it does any work and throws ProtectedConfigError (configName, operation). Raw ctx.kysely queries, proc, func, tvf, impersonate, and transaction are not gated. These are the permissions that SDK methods check; the Gate column of each namespace table names the one each method uses:

Guarded methods require the resolved config's non-enumerable policy context. Spreading or serializing a config loses it, and a detached config is denied. Vault operations require vault:read when they unseal an existing vault key, including identity enrollment; fresh vault initialization can use write-only access. Writing a secret does not require secret:read.

Permissionvieweroperatoradmin
run:build, run:file, run:dirdenyconfirmallow
pipeline:rundenyconfirmallow

| change:run, change:ff, change:revert | deny | confirm | allow | | change:rm | deny | confirm | confirm | | db:reset | deny | confirm | allow | | db:truncate | deny | confirm | confirm | | db:teardown | deny | deny | confirm | | lock:acquire | deny | allow | allow | | lock:force | deny | confirm | confirm | | secret:read | deny | allow | allow | | secret:write | deny | confirm | allow | | vault:read | deny | allow | allow | | vault:write | deny | confirm | allow | | vault:propagate | deny | confirm | confirm | | transfer:plan | deny | allow | allow | | explore | allow | allow | allow |

Five exceptions to the plain rule:

  • explore is checked on the agent channel only; the user channel always passes.
  • On a config with isTest: true, every role on both channels gets the admin cell, protected stage included, and the db:create, db:reset, db:truncate, db:teardown, and db:destroy cells read allow. An agent context can build, reset and tear down its test database without confirmation. Admin's other confirm cells (config:write, vault:propagate and the like) still need a human. Transfer and vault destinations and dt.importFile follow the same rule. Where the flag counts is in access roles.
  • db.truncate and db.teardown with dryRun: true check for denial but skip confirmation, so a role that may run the operation can always look first. A role that may not run it cannot preview it either. db.previewTeardown() follows the same rule for db:teardown.
  • Denials raised below the SDK seam throw a plain Error with the same message, not ProtectedConfigError. That covers run.preview, transfer.plan, and vault.copy. Match on the message there.
  • transfer.to and transfer.plan check the destination config, not the source, because the destination is the write target. vault.copy checks vault:read on the source and vault:write on the destination, and requires destination vault:read when unsealing an existing destination key.

ctx.noorm namespaces ​

NamespaceDoes
runExecutes and previews SQL files, and builds the schema
pipelinesRuns named SQL workflows and reads their independent history
changesScaffolds, applies, reverts, and reports changes
dbExplores the schema, truncates, tears down, and resets
lockHolds the per-config lock that serializes schema operations
secretsReads and writes config-scoped secrets in local encrypted state
vaultReads and writes team secrets stored in the database
templatesRenders a .sql.tmpl file without executing it
transferCopies data between two configs
dtExports and imports .dt data files
utilsChecksums a file and probes a connection

ctx.noorm.pipelines ​

The context must be connected. Run and dry run both enforce pipeline:run; the SDK does not prompt. history() and getRun() read the run record the way change history does, so on the agent channel they require explore.

MethodReturnsNotes
run(slug, { input?, dryRun?, signal? }?)Promise<PipelineDryRunResult | PipelineRunResult>Validates typed JSON input and runs fresh. Preflight, policy, and busy errors throw; admitted workflow failures return a structured result.
history(slug, { limit? }?)Promise<NoormPipelineRun[]>Scoped to the current config, newest ID first. Default 20; integer limits 1 through 100.
getRun(runId)Promise<PipelineHistoryRecord | null>Positive safe integer ID. Returns { run, steps } in declaration order, or null for a missing or foreign-config run.

Results distinguish execution status from transaction outcome and retain primary and secondary errors. They do not return domain query rowsets. Raw input and rendered SQL are not stored in history. See SQL pipelines for input, transaction, and recovery semantics.

run creates noorm's tracking tables when the database has none and migrates an older tracking schema first. run with dryRun: true neither creates nor migrates them. history and getRun never write: on a database without tracking tables they return [] and null, and on tables that need a migration they throw tracking tables are at schema version N and this version needs M. Reading pipeline history does not migrate them: a role that can write must run a command once first.

Results

TypeFields
PipelineDryRunResultdryRun: true, runId: null, status: 'success', transactionOutcome: 'no_effects', slug, mode, transaction, concurrency, singleRun, steps: PipelineStepMetadata[]
PipelineRunResultdryRun: false, runId, status, transactionOutcome, startedAt, finishedAt, durationMs, the plan fields above, steps: PipelineStepResult[], primaryError, secondaryErrors
PipelineStepResultstepNo, name, filepath, checksum, needs, status, transactionOutcome, startedAt, finishedAt, durationMs, error, skipReason?
PipelineErrorphase (execute, commit, rollback, audit, cleanup or interrupted), message, stepNo?, code?
PipelineHistoryRecord{ run: NoormPipelineRun, steps: NoormPipelineStep[] }

message is fixed text, never driver output. code carries a database error code, or TRANSACTION_ESCAPED or OWNERSHIP_UNVERIFIED when the runtime ownership check fails (see pipeline recovery).

Values

FieldValues
run statuspending, running, success, failed, partial, interrupted, unknown
step statuspending, running, success, failed, skipped, interrupted, unknown
transactionOutcomepending, committed, rolled_back, partial, no_effects, unknown; a step also reports not_started
modelinear, graph
transactionpipeline, step, none

Errors

ErrorWhen
PipelineBusyErrorA singleRun pipeline already has an active run for this config. Exported; thrown by run before history is created
ProtectedConfigErrorThe config's access policy denies or needs confirmation for the operation's permission, such as run:dir or pipeline:run
VersionMismatchErrorThe database schema is newer than this client
other ErrorAn unknown slug, an invalid definition, or invalid input rejects before admission. The class, name and properties of these errors are not part of the API. The message is human-readable and may include the slug, input names or step IDs, never input values; do not parse it. Call with dryRun: true to validate a definition and input before a real run. A failed run does not throw; it resolves with status and primaryError

ctx.noorm.run ​

Relative paths resolve against projectRoot.

MethodReturnsGateNotes
discover(dirpath?)Promise<string[]>noneAbsolute, sorted paths of .sql and .sql.tmpl files. Defaults to settings.paths.sql. Throws Failed to read directory for a missing one
preview(filepaths, output?)Promise<FileResult[]>run:file, deny onlyRenders without executing. SQL lands in renderedSql. A file that fails to load or render returns status: 'failed' with error. output writes the combined text to a file; a write failure emits an error event
file(filepath, options?)Promise<FileResult>run:fileSkips an unchanged file with skipReason: 'unchanged'
files(filepaths, options?)Promise<BatchResult>run:dirRuns in the order given
dir(dirpath, options?)Promise<BatchResult>run:dirRuns every SQL file under the directory, sorted by path. A missing directory returns status: 'failed' with error
build(options?)Promise<BatchResult>run:buildRuns the schema directory. Applies settings.build.include, build.exclude, and settings.rules

file, files, dir and build create noorm's tracking tables when the database has none, and migrate an older tracking schema, before they run. A build therefore works on a database noorm has never touched. dryRun and preview skip this and leave the database unchanged: a database with no tracking tables passes, and tables that need a migration throw tracking tables are at schema version N and this version needs M. A dry run does not migrate them: run a command without --dry-run first, such as "noorm change history". The message uses the CLI's wording; from the SDK, call the method once without dryRun. ctx.noorm.changes writes, lock.acquire, lock.withLock, vault.init and db.reset() do the same. All of them throw VersionMismatchError when the tracking schema is newer than this client, and MigrationError when a tracking migration fails. On PostgreSQL and MSSQL the first run needs permission to create the noorm schema.

preview resolves every secret into the returned text, so treat its output as plaintext secrets.

typescript
const result = await ctx.noorm.run.build({ force: true });

if (result.status !== 'success') {
    for (const file of result.files.filter((f) => f.status === 'failed')) {
        console.error(`${file.filepath}: ${file.error}`);
    }
    process.exit(1);
}

await ctx.noorm.run.dir('db/sql/09_seeds');
OptionTypeDefaultApplies toEffect
forcebooleanfalseallRe-run files even when unchanged
dryRunbooleanfalseallRender to disk, execute nothing
abortOnErrorbooleantrueRunOptionsStop at the first failure
concurrencynumber1RunOptionsFiles in parallel. 1 keeps DDL sequential
previewbooleanfalseRunOptionsRender without executing or writing history
outputstring | nullnullRunOptionsFile that receives preview output

build() takes BuildOptions (force, dryRun). The other run methods take RunOptions (all six).

Run result shape ​

FileResult is what run.file returns and what each entry of BatchResult.files holds:

FieldTypeSet when
filepathstringalways
checksumstringalways; empty when the file could not be read
status'success' | 'failed' | 'skipped'always
errorstringstatus is 'failed'
skipReason'unchanged' | 'already-run'status is 'skipped'
durationMsnumberexecuted and failed files
renderedSqlstringpreview mode
outputPathstringa dry run that wrote rendered SQL to disk

BatchResult:

FieldTypeMeaning
status'success' | 'failed' | 'partial'Overall outcome
filesFileResult[]One per file
filesRun, filesSkipped, filesFailednumberCounts
durationMsnumberTotal time
changeIdnumberTracking-table id, when recorded
errorstringThe batch failed before any file ran
unmatchedIncludestring[]build() only: include entries that matched no file
unmatchedExcludestring[]build() only: exclude entries that matched no file

A build over zero files still succeeds, so unmatchedInclude is how a mistyped pattern shows up. A mistyped exclude fences off nothing and the held-back files run, so check unmatchedExclude too.

ctx.noorm.changes ​

MethodReturnsGateNotes
create(options)Promise<Change>noneCreates <date>-<slug>/ with change/, revert/, changelog.md, and a 001_<slug>.sql stub in each folder. Throws Change already exists: <name>
addFile(change, folder, options)Promise<Change>noneAdds the next numbered file to 'change' or 'revert'
removeFile(change, folder, filename)Promise<Change>none
renameFile(change, folder, oldFilename, newDescription)Promise<Change>noneKeeps the numeric prefix
reorderFiles(change, folder, newOrder)Promise<Change>noneRenumbers by the order of filenames given
delete(change)Promise<void>change:rmRemoves the folder from disk
discover()Promise<Change[]>noneEvery parseable change. Folders that fail to parse are dropped and reported as error events
parse(name)Promise<Change>noneThrows ChangeNotFoundError, ChangeValidationError, or ManifestReferenceError
validate(change)voidnoneThrows ChangeValidationError on duplicate filenames in a folder
apply(name, options?)Promise<ChangeResult>change:runAn applied, unchanged change returns success with no files
revert(name, options?)Promise<ChangeResult>change:revertThrows ChangeNotAppliedError for a never-applied change, with dryRun and preview too
ff(options?)Promise<BatchChangeResult>change:ffApplies every pending change in name order
next(count = 1, options?)Promise<BatchChangeResult>change:runApplies the next count pending changes
rewind(target, options?)Promise<BatchChangeResult>change:revertReverts newest first. target is a change name (reverts it and everything after) or a number of changes
status()Promise<ChangeListItem[]>noneEvery change, merged from disk and database, sorted by name
pending()Promise<ChangeListItem[]>noneThe items a forward run would pick up
history(limit?)Promise<ChangeHistoryRecord[]>exploreExecution records. [] on a database with no tracking tables. Never migrates: on tables that need a migration it throws tracking tables are at schema version N and this version needs M. Reading change history does not migrate them: a role that can write must run a command once first.
historyForChange(name, limit?)Promise<ChangeHistoryRecord[]>exploreOne change's records, newest first. Same no-tables and older-tables results as history
getFileHistory(operationId)Promise<FileHistoryRecord[]>explorePer-file records behind one history id

apply and revert take the config's lock for the length of each change, with reason Change: <name> or Revert: <name>. A lock held by another identity throws LockAcquireError, and an empty change throws ChangeValidationError, out of ff, next, and rewind as well. Per-file failures come back on the result as status: 'failed' or 'partial'.

typescript
const change = await ctx.noorm.changes.create({ description: 'order-status' });
// db/changes/2026-10-20-order-status/{change,revert}/001_order-status.sql

await ctx.noorm.changes.addFile(change, 'change', {
    name: 'rebuild-views',
    type: 'txt',
    paths: ['02_views/01_open_orders.sql'],
});

const dry = await ctx.noorm.changes.ff({ dryRun: true });
const result = await ctx.noorm.changes.ff();

if (result.status !== 'success') {
    console.error(result.error ?? result.changes.filter((c) => c.status === 'failed'));
}

A txt file is a manifest: one path per line, relative to paths.sql, that runs those db/sql/ files in place. Forward and revert covers when to use one.

apply, revert, ff, next and rewind create noorm's tracking tables when the database has none, as the run methods do.

OptionTypeDefaultApplies toEffect
forcebooleanfalseallSkip the already-applied check
dryRunbooleanfalseallRender to tmp/, leave the tracking tables untouched. Throws the dry-run versions error on tables that need a migration, as preview does
previewbooleanfalseallEmit rendered SQL without writing
outputstring | nullnullallFile that receives preview output
abortOnErrorbooleantrueff, next, rewindStop at the first failure

ChangeOptions holds the first four. BatchChangeOptions adds abortOnError and is not exported. AddFileOptions is { name: string; type: 'sql' \| 'txt'; paths?: string[]; content?: string }; CreateChangeOptions is { description: string; date?: Date }.

A change moves between five statuses. pending() returns pending, reverted, and stale, plus a folder that never ran and fails to parse:

On PostgreSQL a failed change rolls back its history rows, so it keeps its previous status. On mysql, mssql, and sqlite the failure is recorded as failed.

An unparseable folder, such as a manifest that names a db/sql/ file that no longer exists, comes back from status() and pending() as status: 'failed' and isNew: true, with errorMessage holding the parse error. ff and next fail at that change when they would run it, instead of skipping it. If the change already ran, it keeps its status and the batch adds a warning. A folder with neither change/ nor revert/ is not a change and is ignored.

ChangeListItem:

FieldTypeMeaning
namestringFolder name
path, date, descriptionstring, Date | null, stringFrom disk. Absent when orphaned
changeFiles, revertFiles, hasChangelogChangeFile[], ChangeFile[], booleanFrom disk. Absent when orphaned or unparseable
status'pending' | 'success' | 'failed' | 'reverted' | 'stale'Latest recorded status
appliedAt, appliedBy, revertedAtDate | null, string | null, Date | nullFrom the tracking tables
errorMessagestring | nullFailure message or parse error
appliedHistoryIdnumber | nullHistory id behind appliedAt; the true apply order
isNewbooleanOn disk with no record
orphanedbooleanRecorded in the database, folder gone

ChangeResult: name, direction ('change' \| 'revert'), status, files (each with filepath, checksum, status, skipReason?, durationMs?, error?, renderedSql?), durationMs, error?, operationId?.

BatchChangeResult: status ('success' \| 'failed' \| 'partial'), changes (ChangeResult[]), executed, skipped, failed, durationMs, error?, warnings?. error is set when the batch failed before any change could report, such as a rewind name that matches no applied change. warnings carries a missing changes directory and applied changes whose files can no longer be read.

ChangeHistoryRecord: id, name, direction, status, executedAt, executedBy, durationMs, errorMessage, checksum. FileHistoryRecord: id, changeId, filepath, fileType, checksum, status, skipReason, errorMessage, durationMs; the type is not exported.

ctx.noorm.db ​

Every db method needs a connection. The explore methods and previewTeardown() are gated as described in Access policy.

MethodReturns
overview()ExploreOverview: counts for tables, views, procedures, functions, types, indexes, foreignKeys, triggers, locks, connections
listTables()TableSummary[]: name, schema?, columnCount, rowCountEstimate?
describeTable(name, schema?)TableDetail | null: columns, indexes, foreignKeys, and the summary fields
listViews() / describeView(name, schema?)ViewSummary[] / ViewDetail | null
listProcedures() / describeProcedure(name, schema?)ProcedureSummary[] / ProcedureDetail | null
listFunctions() / describeFunction(name, schema?)FunctionSummary[] / FunctionDetail | null
listTypes() / describeType(name, schema?)TypeSummary[] / TypeDetail | null
listIndexes()IndexSummary[]
listForeignKeys()ForeignKeySummary[]
previewTeardown()TeardownPreview: toDrop, toPreserve, statements

Each describe* returns null when the object does not exist. ColumnDetail carries name, dataType, isNullable, defaultValue?, isPrimaryKey, ordinalPosition.

typescript
const detail = await ctx.noorm.db.describeTable('order_line');

for (const col of detail?.columns ?? []) {
    console.log(`${col.name}: ${col.dataType}`);
}

The destructive methods:

MethodReturnsGateWhat it does
truncate(options?)Promise<TruncateResult>db:truncateWipes data, keeps the schema. Disables foreign keys, deletes or truncates each target, re-enables them
teardown(options?)Promise<TeardownResult>db:teardownDrops every object except noorm's own tables (change tracking, locks, vault, identities), and marks applied changes stale
reset()Promise<BatchResult>db:resetChecks db:reset, run:build, the tracking schema and the schema directory before teardown, then run.build({ force: true }). Resolves with that build's result; throws ResetBuildError when the build does not succeed
typescript
await ctx.noorm.db.truncate({ preserve: ['party_type', 'product'] });
await ctx.noorm.db.truncate({ only: ['order_line', 'sales_order'] });

const plan = await ctx.noorm.db.teardown({ dryRun: true });
await ctx.noorm.db.teardown({ preserveSchemas: ['app_private'] });

TruncateOptions:

OptionTypeDefaultEffect
preservestring[]settings.teardown.preserveTablesTables to leave alone
onlystring[]allTruncate only these. The inverse of preserve
restartIdentitybooleantrueRestart identity and auto-increment sequences
dryRunbooleanfalseReturn the SQL in statements, execute nothing
policyDbPolicyContextnoneInternal gate context; the SDK already gates, so leave it unset

TeardownOptions is not exported. From the SDK, two fields take effect and the rest come from settings or are dropped:

FieldFrom the SDK
dryRunread from your options
preserveSchemasread from your options. Leaves whole schemas alone, which preserveTables cannot express: excluding app_private.secrets by name would also spare public.secrets
preserveTables, postScriptfilled from settings.teardown; a value you pass is ignored
configName, executedByset by the SDK, so teardown marks applied changes stale and records a reset
keepViews, keepFunctions, keepProcedures, keepTypes, policyignored

reset() takes no options and ignores settings.teardown.preserveTables: it rebuilds from db/sql/, so a surviving table would collide with its CREATE TABLE. It works on a database with no tracking tables, because it creates them. Before it drops anything, reset() throws VersionMismatchError on a newer tracking schema and an Error when it cannot read the schema directory. When the rebuild fails the schema is already gone, so reset() throws ResetBuildError, whose result is the failed BatchResult and whose message names the failing file.

TruncateResult: truncated, preserved, statements, durationMs. TeardownResult: dropped (tables, views, functions, procedures, types, foreignKeys), preserved, statements, durationMs, and when they apply postScriptResult, staleCount, resetRecordId. TeardownPreview.toDrop groups names by the same six kinds; there is no flat list.

Drop order is foreign keys, procedures, functions, views, tables, then types, and mssql truncate disables constraints one table at a time. Teardown internals gives the reasons, and the mssql strategy covers truncate.

ctx.noorm.lock ​

One lock row per config serializes schema operations across every noorm user of that database. A lock expires on its own.

MethodReturnsGateNotes
acquire(options?)Promise<Lock>lock:acquireCreates the tracking tables when the database has none. The same identity can acquire again and extends the lock. Another holder throws LockAcquireError unless wait is set
release()Promise<void>lock:acquireThrows when no lock exists or another identity holds it
status()Promise<LockStatus>none{ isLocked, lock }. Unlocked, and creates nothing, on a database with no tracking tables. Deletes an expired lock first, only for a caller with lock:acquire. Throws ProtectedConfigError on a config hidden from the channel. On tables that need a migration it throws the versions error ending Reading lock status does not migrate them: a role that can write must run a command once first.
withLock(fn, options?)Promise<T>lock:acquireCreates the tracking tables when the database has none. Acquires, runs fn, releases in a finally. A failed release emits an error event instead of throwing
forceRelease()Promise<ForceReleaseResult>lock:forceDeletes the lock whoever holds it. { released, holder }; holder is null when nothing was held

ForceReleaseResult is not exported. The result object is always truthy, so branch on released.

typescript
await ctx.noorm.lock.withLock(
    () => ctx.noorm.run.dir('db/sql/09_seeds'),
    { timeout: 120_000, wait: true, reason: 'seed products' },
);

const { released, holder } = await ctx.noorm.lock.forceRelease();
LockOptionsTypeDefaultMeaning
timeoutnumber300000Milliseconds until the lock expires
waitbooleanfalsePoll until the lock is free
waitTimeoutnumber30000Most milliseconds to wait. Used only with wait
pollIntervalnumber1000Milliseconds between polls. Used only with wait
reasonstringnoneShown to whoever the lock blocks
dialectDialectthe config'sSet by the SDK; a value you pass is replaced

Lock is { lockedBy, lockedAt, expiresAt, reason? }. release() throws LockNotFoundError or LockOwnershipError, neither exported; match on err.name.

ctx.noorm.secrets ​

Config-scoped secrets live in local encrypted state, not the database. No connection is needed. Global secrets are not reachable here.

MethodReturnsGateNotes
get(key)string | undefinedsecret:readSynchronous. undefined, not null, for an unset key
list()string[]secret:readSynchronous. Key names only; values are never returned
set(key, value)Promise<void>secret:write
delete(key)Promise<void>secret:write
typescript
await ctx.noorm.secrets.set('PAYMENT_GATEWAY_KEY', process.env.GATEWAY_KEY!);
const key = ctx.noorm.secrets.get('PAYMENT_GATEWAY_KEY');

ctx.noorm.vault ​

The vault holds team secrets in the database, encrypted with identity keypairs. init, status, set, get, getAll, propagate, and copy need a cryptographic identity at ~/.noorm/identity.json or in NOORM_IDENTITY_*. privateKey is the identity's private key as hex, the same value as NOORM_IDENTITY_PRIVATE_KEY.

MethodReturnsGateNotes
init()Promise<Buffer | null>vault:writeThe vault key on first init, null when already initialized. Safe to call at startup
status()Promise<VaultStatus>noneisInitialized, hasAccess, secretCount, usersWithAccess, usersWithoutAccess
set(key, value, privateKey)Promise<void>vault:write, plus vault:read to unseal the keyThrows VaultAccessError when the key yields no vault key
get(key, privateKey)Promise<string | null>vault:readnull when absent or without access
getAll(privateKey)Promise<Record<string, VaultSecret>>vault:read{} without access
list()Promise<string[]>vault:readKey names; no private key needed
exists(key)Promise<boolean>vault:readDoes not decrypt
delete(key)Promise<boolean>vault:write
propagate(privateKey)Promise<VaultPropagationResult>vault:propagate, plus vault:read to unseal the keyGrants the vault key to identities without it. Run it after adding a teammate
copy(destConfig, keys, privateKey, options?)Promise<VaultCopyResult>vault:read source, vault:write destination, plus vault:read when unsealing an existing destination keykeys is a list or 'all'. Initializes the destination vault when needed

vault:initialized fires on first init only. Neither read throws on missing access: get returns null and getAll returns {}.

typescript
const privateKey = process.env.NOORM_IDENTITY_PRIVATE_KEY!;

await ctx.noorm.vault.init();
await ctx.noorm.vault.set('PAYMENT_GATEWAY_KEY', 'sk-live-...', privateKey);

const grant = await ctx.noorm.vault.propagate(privateKey);
if (grant.failed.length > 0) {
    throw new Error(`Vault access not granted to ${grant.failed.map((f) => f.email).join(', ')}`);
}

await ctx.noorm.vault.copy(stagingConfig, ['PAYMENT_GATEWAY_KEY'], privateKey, { dryRun: true });
ResultFields
VaultSecretkey, value, setBy, createdAt, updatedAt
VaultPropagationResultpropagatedTo (identity hashes), alreadyHadAccess (count), failed (identityHash, email, error)
VaultCopyResultcopied, skipped (exists and not forced), errors ({ key, error }); all key lists, not counts

VaultCopyOptions is { force?: boolean; dryRun?: boolean; channel?: Channel }. force overwrites existing destination secrets. dryRun runs the full preflight and reports what would happen without writing. The SDK sets channel from the context.

ctx.noorm.templates ​

render(filepath) renders a .sql.tmpl file and returns it without executing. No connection is needed, but the vault tier of $.secrets resolves only when the context is connected.

MethodReturnsGate
render(filepath)Promise<TemplateResult>run:file, deny only, throws ProtectedConfigError
typescript
const { sql, isTemplate, durationMs } = await ctx.noorm.templates.render(
    'db/sql/09_seeds/01_products.sql.tmpl',
);

TemplateResult is { sql: string; isTemplate: boolean; durationMs?: number }. A plain .sql file comes back unchanged with isTemplate: false. Rendering resolves every secret tier into sql and runs the template's helpers, so the output is plaintext secrets.

ctx.noorm.transfer ​

Both methods take the destination Config and open their own connections, so neither context needs connect(). Source and destination must be postgres, mysql, or mssql; sqlite throws.

MethodReturnsGateNotes
to(destConfig, options?)Promise<TransferResult>db:reset on the destinationThrows on failure. A dry run is gated the same way
plan(destConfig, options?)Promise<TransferPlan>transfer:plan on the destinationTable order, row estimates, warnings. Throws on failure
typescript
const staging = await createContext({ config: 'staging' });
const qa = await createContext({ config: 'qa' });

const result = await staging.noorm.transfer.to(qa.noorm.config, {
    tables: ['party', 'product', 'sales_order', 'order_line'],
    onConflict: 'skip',
});

if (!result.fkChecksRestored) {
    console.error('Foreign key checks are still off on qa');
}
TransferOptionsTypeDefaultEffect
tablesstring[]all user tablesTables to copy
onConflict'fail' | 'skip' | 'update' | 'replace''fail'Primary key conflict strategy
batchSizenumber1000Rows per batch for cross-server transfers
disableForeignKeysbooleantrueDisable FK checks during the copy
preserveIdentitybooleantrueKeep identity and auto-increment values
truncateFirstbooleanfalseTruncate destination tables first
dryRunbooleanfalseValidate only
exportPathstringnoneWrite a .dt file instead of inserting
passphrasestringnoneEncrypts a .dtzx export
channelChannelthe context'sSet by the SDK

TransferResult: status ('success' \| 'partial' \| 'failed'), tables (table, status, rowsTransferred, rowsSkipped, durationMs, error?), totalRows, durationMs, fkChecksRestored. Check fkChecksRestored: status does not change when re-enabling foreign keys fails, so it is the only signal that referential integrity may still be off.

TransferPlan: tables in dependency order (name, schema?, rowCount, hasIdentity, identityColumn?, primaryKey, columns, dependsOn, columnTypes?), sameServer, estimatedRows, warnings, crossDialect, sourceDialect, destinationDialect.

ctx.noorm.dt ​

A .dt file holds one table in noorm's portable format. The extension picks the form: .dt plain, .dtz compressed, .dtzx encrypted with a passphrase.

MethodReturnsGate
exportTable(tableName, filepath, options?)Promise<{ rowsWritten, bytesWritten }>none
importFile(filepath, options?)Promise<{ rowsImported, rowsSkipped }>db:reset

Both throw on failure.

typescript
await ctx.noorm.dt.exportTable('product', './exports/product.dtz');
await ctx.noorm.dt.exportTable('product', './exports/product.dtzx', { passphrase });
await ctx.noorm.dt.importFile('./exports/product.dtz', { onConflict: 'skip' });
OptionApplies toTypeDefaultEffect
passphrasebothstringnone.dtzx encryption or decryption
batchSizebothnumber1000Rows per batch
schemaexportstringnoneSchema or namespace, such as public
onConflictimportConflictStrategy'fail'Primary key conflict strategy
truncateimportbooleanfalseTruncate the target table first

ctx.noorm.utils ​

MethodReturnsNotes
checksum(filepath)Promise<string>SHA-256 of the file. Works offline
testConnection()Promise<{ ok: boolean; error?: string }>Probes the config without connecting the context. Reports failure as data, not an exception
typescript
const probe = await ctx.noorm.utils.testConnection();
if (!probe.ok) console.error(probe.error);

Events ​

Core operations emit events on noormObserver, an ObserverEngine<NoormEvents>. It is one process-global bus, not one per context, so a process that creates several contexts sees all of their events on the same stream.

typescript
import { noormObserver } from '@noormdev/sdk';

const stop = noormObserver.on('file:after', (event) => {
    console.log(`${event.filepath}: ${event.status} in ${event.durationMs}ms`);
});

noormObserver.on(/^change:/, ({ event, data }) => console.log(event, data));

stop();

on returns a function that removes the listener, and a regular expression subscribes to every matching event. Start the process with NOORM_DEBUG=1 to log each event name to stderr.

Context-scoped events carry configName, so filter on it when you run more than one context. Several do not, so check the payload type first:

FamilyEventsconfigName
Filefile:before, file:after, file:skip, file:progress, file:dry-runfile:before only
Build and runbuild:start, build:complete, run:file, run:dir, run:filesrun:* only
Changechange:created, change:start, change:file, change:complete, change:skipno
Locklock:acquiring, lock:acquired, lock:released, lock:blocked, lock:expiredyes
Connectionconnection:open, connection:close, connection:erroryes
Secretsecret:set, secret:deletedyes
Teardownteardown:start, teardown:progress, teardown:error, teardown:completeno
Templatetemplate:render, template:load, template:helpersno
Vaultvault:initialized, vault:secret:created, vault:secret:updated, vault:secret:deleted, vault:propagated, vault:copy:starting, vault:copy:completedno
Transfertransfer:planning, transfer:plan:ready, transfer:starting, transfer:table:before, transfer:table:progress, transfer:table:after, transfer:complete, db:dual:connecting, db:dual:connected, db:dual:disconnecting, db:dual:disconnected, db:dual:cleanup-warningno
DTdt:export:start, dt:export:progress, dt:export:complete, dt:import:start, dt:import:schema, dt:import:progress, dt:import:complete, dt:stream:start, dt:stream:progress, dt:stream:complete, dt:validate:result, dt:modify:start, dt:modify:progress, dt:modify:complete, and per-stage dt:export:loaded, dt:export:processed, dt:export:saved, dt:import:loaded, dt:import:processed, dt:import:savedno
Errorserror: { source, error, context? }no

connection:error carries one of two payload shapes:

Emitted forphaseerrorserverCode, serverMessage
A failed connect()absentThe worded failure, or Runtime credential connection failed when the connection has a password callbackPresent for a DatabaseConnectionError on a static password; absent with a callback
A pool failure after the first connectidle, checked-out, or loginA fixed string per dialect; see Pool connection failuresAbsent

error reports problems the SDK absorbs instead of throwing: an unparseable change folder during discover(), a failed lock release in withLock, an unwritable preview output path. NoormEvents also types families the CLI and TUI emit: state, config, identity, app, router, version, logger, sql-terminal, settings, update, and init. Teardown events give the teardown payloads.

Error handling ​

A call either returns its value or throws a named, instanceof-matchable error. There are no [value, error] tuples on this surface. Wrap a call in attempt() from @logosdx/utils only to translate, recover, or observe the error; otherwise let it propagate. These calls report differently:

CallReports
run.*, changes.ff, changes.next, changes.rewindPer-item failures on the result (result.files[].error, result.error). A call that returns is not proof of success, so check status
utils.testConnection(){ ok, error? }, by design
vault.get, vault.getAll, vault.initnull, {}, and null for missing access or an existing vault
transaction(fn)fn must throw to roll back
ctx.kysely queriesWhatever the driver throws
typescript
import { attempt } from '@logosdx/utils';
import { createContext, RequireTestError, ProtectedConfigError } from '@noormdev/sdk';

const [ctx, err] = await attempt(() => createContext({ config: 'prod', requireTest: true }));

if (err instanceof RequireTestError) {
    console.error(`${err.configName} is not a test database`);
}

const [, wipeErr] = await attempt(() => ctx!.noorm.db.truncate());

if (wipeErr instanceof ProtectedConfigError) {
    console.error(`Denied ${wipeErr.operation} on ${wipeErr.configName}`);
}

Every class below is exported from @noormdev/sdk:

ErrorThrown whenCarries
RequireTestErrorrequireTest: true and the config lacks isTest: trueconfigName
NotConnectedErrorA call that needs the pool ran before connect() or after disconnect()
ProtectedConfigErrorThe access policy denied the call, or it needs a confirmation yes did not supplyconfigName, operation
ResetBuildErrordb.reset() tore the database down and the rebuild did not succeedresult
VersionMismatchErrorThe database's tracking schema is newer than this clientlayer, current, expected
MigrationErrorA tracking schema migration failedlayer, version, cause
DisconnectedErrordisconnect() ran while a withConnection callback was still running
DatabaseConnectionErrorconnect() failed and the factory could explain the driver's or server's error; see Runtime credentialsserverCode, serverMessage, cause
VaultAccessErrorvault.set() got a key that yields no vault keyconfigName
ImpersonationErrorimpersonate() on mysql or sqlite, or with an invalid username
LockAcquireErrorAnother identity holds the lockconfigName, holder, heldSince, expiresAt, reason
LockExpiredErrorThe lock expired during an operationconfigName, identity, expiredAt
ChangeValidationErrorvalidate() found duplicates, a change has no files, or its files are empty or only template placeholderschangeName, issue
ChangeNotFoundErrorparse, apply, or revert named a change that is not on diskchangeName
ChangeOrphanedErrorapply or revert named a change recorded in the database whose folder is gonechangeName
ChangeNotAppliedErrorrevert of a change that was never appliedchangeName
ChangeAlreadyAppliedErrorExported; no SDK method throws it todaychangeName, appliedAt
ManifestReferenceErrorA .txt manifest names a file that is not under paths.sqlmanifestPath, missingPath

Exported types and values ​

GroupNames
Factory and classescreateContext, Context, NoormOps, ChangesNamespace, RunNamespace, DbNamespace, LockNamespace, VaultNamespace, SecretsNamespace, TemplatesNamespace, TransferNamespace, DtNamespace, UtilsNamespace
Core typesCreateContextOptions, Config, Settings, Identity, Dialect, Channel, ConfigAccess, Role
ConnectionInlineConnection, DialectConnectionConfig, PinnedConnection, ConnectionRetryOptions, PoolOpts, PgPoolOpts, TarnPoolOpts, MysqlPoolOpts, MssqlPoolOpts
RunRunOptions, BuildOptions, FileResult, BatchResult
ChangesChange, ChangeFile, ChangeFileType, CreateChangeOptions, AddFileOptions, ChangeOptions, ChangeResult, BatchChangeResult, ChangeListItem, ChangeHistoryRecord
ExploreExploreOverview, TableSummary, TableDetail, ViewSummary, ViewDetail, ProcedureSummary, ProcedureDetail, FunctionSummary, FunctionDetail, TypeSummary, TypeDetail, IndexSummary, ForeignKeySummary, ColumnDetail, ParameterDetail
Database operationsTruncateOptions, TruncateResult, TeardownResult, TeardownPreview
LocksLock, LockStatus, LockOptions
TemplatesTemplateResult
VaultVaultSecret, VaultStatus, VaultCopyOptions, VaultCopyResult, VaultPropagationResult
Transfer and DTTransferOptions, TransferPlan, TransferTablePlan, TransferResult, TransferTableResult, ConflictStrategy, ExportOptions, ImportOptions
Routinestvp, TvpValue, ImpersonatedScope, RoutineCalls, TransactionScope, ExtractArgs, ExtractReturn
EventsnoormObserver, NoormEvents, NoormEventNames
ErrorsRequireTestError, ProtectedConfigError, SslFileError, NotConnectedError, ResetBuildError, DisconnectedError, DatabaseConnectionError, VersionMismatchError, MigrationError, VaultAccessError, ImpersonationError, LockAcquireError, LockExpiredError, ChangeValidationError, ChangeNotFoundError, ChangeAlreadyAppliedError, ChangeNotAppliedError, ChangeOrphanedError, ManifestReferenceError

Four shapes that public signatures name are not exported: BatchChangeOptions (changes.ff, next, rewind), FileHistoryRecord (changes.getFileHistory), TeardownOptions (db.teardown), and ForceReleaseResult (lock.forceRelease). Declare them inline, or derive them with Parameters<> and Awaited<ReturnType<>>.

What bites ​

SymptomCauseFix
ProtectedConfigError: requires confirmation on db.truncate() against your own dev configThe default admin role still confirms db:truncate, db:teardown, change:rm, vault:propagate, and lock:force, and the SDK cannot promptCreate the context with yes: true, or set NOORM_YES=1. On the agent channel nothing unblocks it
NotConnectedErrorA query, db, lock, vault, or dt call ran before connect()Call await ctx.connect() first; see the table in How is a context laid out?
ff() returned and nothing appliedA failed change comes back as status: 'failed', and a missing changes directory is only a warningCheck result.status, result.error, and result.warnings
ChangeValidationError: Files are empty or contain only template placeholderschanges.create scaffolds stubs and apply refuses themWrite the SQL into the change/ file first
build() succeeds and ran nothingA mistyped build.include matched no fileRead result.unmatchedInclude and result.unmatchedExclude
pending() shows an item with status: 'failed' and isNew: trueThe folder cannot be parsed, such as a manifest naming a deleted db/sql/ fileRead errorMessage. discover() omits the same folder
{ note: undefined } overwrote a procedure's DEFAULTundefined is sent as NULL, and mssql applies a default only to an omitted parameterSee Parameter handling and NULL semantics
func() throws on mysqlNamed parameters are not supported in function callsPass an array
A connection stays out of the poolAn explicit impersonate() scope was never revertedCall scope.revert(), or prefer callback mode. disconnect() also releases it
withSchema did not qualify a raw fragmentsql`...` bypasses the query-builder pluginsQualify identifiers by hand inside raw SQL
One context's handler fires for another tenant's eventsnoormObserver is process-globalFilter on event.configName, where the payload has it
Vault operations require a cryptographic identityNo ~/.noorm/identity.json and no NOORM_IDENTITY_*Run noorm identity init, or set the three CI variables
Private key required to decrypt state in CIA state.enc exists and no key is availableSet NOORM_IDENTITY_PRIVATE_KEY, _NAME, and _EMAIL, or use env-only mode
Cannot apply the name "..." to stored config "..."name was passed while NOORM_CONFIG or the active config selected a stored configPass config: false with name, or drop name
pool.idleTimeoutMs must be greater than zero on mysql (or mssql)Tarn cannot disable its idle timeout. A stored idleTimeoutMillis: 0 on these dialects was accepted and ignored beforeSet a positive value, or remove the field
pool.<key> is not a pool optionThe key is misspelled, or came from an all-caps environment variable segment, which is lowercasedUse the camelCase name the message gives, such as NOORM_CONNECTION_POOL_maxUses
pool.min (<min>) must not be greater than pool.max (<max>)A stored, environment, or inline min exceeds max, counting the default max of 10Lower min or raise max
driver.<key> is not allowed on <dialect> at createContext()connection.driver named a key that selects the target, credentials, or TLSSet the flat connection field instead; see Driver options
A provider's error class no longer matches after connect() failsOnly an error the password callback itself throws keeps its identity. A driver or server failure on that connection is a DatabaseConnectionErrorMatch DatabaseConnectionError and read cause
A viewer's context cannot read secrets or the vaultsecret:read and vault:read are denied to viewerUse a context on a role that holds them

Next ​