SDK reference
Query a noorm-managed database from TypeScript, and run builds, changes, locks, and the vault from the same context.
npm install @noormdev/sdk kysely pgInstall kysely and your dialect's driver as peers:
| Dialect | Driver | Install |
|---|---|---|
PostgreSQL (postgres) | pg | npm install @noormdev/sdk kysely pg |
MySQL (mysql) | mysql2 | npm install @noormdev/sdk kysely mysql2 |
SQL Server (mssql) | tedious | npm install @noormdev/sdk kysely tedious |
SQLite (sqlite) | better-sqlite3 | npm 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.
// 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:
| Area | Needs connect() | Works without it |
|---|---|---|
ctx | kysely, transaction, proc, func, tvf, impersonate | dialect, connected, withSchema, noorm |
run | preview, file, files, dir, build | discover |
changes | apply, revert, ff, next, rewind, status, pending, history, historyForChange, getFileHistory | create, addFile, removeFile, renameFile, reorderFiles, delete, discover, parse, validate |
db, lock, dt | every method | none |
vault | every method except copy | copy |
transfer | none | to and plan open their own connections to both configs |
secrets, utils | none | every method |
templates | none | render; 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)
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.
| Option | Type | Default | Effect |
|---|---|---|---|
config | string | false | NOORM_CONFIG, then the active config only when connection is absent, then inline/env-only mode | Stored config to use. false uses no stored config and ignores NOORM_CONFIG and the active config |
name | string | NOORM_NAME, then __inline__ or __env__ | Name of a context that uses no stored config. Throws when a stored config is selected |
connection | CreateContextOptions['connection'] | — | Non-persisted connection overrides, including runtime credential and PostgreSQL TLS callbacks, TLS certificates, pool options, and driver options |
projectRoot | string | process.cwd() | Directory that holds .noorm/ and the paths in settings.yml |
requireTest | boolean | false | Throws RequireTestError unless the resolved config has isTest: true |
channel | 'user' | 'agent' | 'user' | Which half of the config's access applies; see Access policy |
stage | string | the stage named like the config | Defaults below stored, environment, and inline values, plus a protected stage's access ceiling |
yes | boolean | false | Pre-confirms confirm cells on the user channel. Ignored on agent |
freshState | boolean | false | Load 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:
| Order | Source | Applies when |
|---|---|---|
| 1 | config: '<name>' | Always. config: false stops here and selects nothing |
| 2 | NOORM_CONFIG | config is unset |
| 3 | The active config | config 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:
| Order | Source | Name |
|---|---|---|
| 1 | name option | The string passed |
| 2 | NOORM_NAME | The variable's value |
| 3 | Neither | __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:
| Found | Effect on the context |
|---|---|
| A protected stage under either name | Its permission ceiling applies |
A stage declaring isTest: false under either name | The context is not a trusted test config |
A stored config under the NOORM_NAME or fallback name | The 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:
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.
| Dialect | How a new physical connection gets its password |
|---|---|
| PostgreSQL | The SDK hands the function to pg's native password option |
| MySQL | The Tarn pool awaits the function before creating a mysql2 connection. A string password goes through the same pool |
| MSSQL | Kysely's Tarn-backed connection factory awaits the function before creating a tedious connection |
| SQLite | Callable 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 path | Caller sees | Observer sees | Automatic retries |
|---|---|---|---|
connect() where the password callback throws | The callback's error, the same object, not reworded or wrapped | connection:error with configName and fixed error: 'Runtime credential connection failed', without server details | None |
connect() with a password callback where the driver or server fails | DatabaseConnectionError with a worded message, the original error as cause, and serverCode and serverMessage. An error with no code and no known explanation comes back unchanged | The same fixed payload as the row above, without serverCode or serverMessage | None, even for transient-looking messages |
connect() with a static password | DatabaseConnectionError under the same rule | connection:error with the worded error, plus serverCode and serverMessage | Only the transient failures described in Lifecycle |
| Query on an established connection | The query's error | No event for the query itself; see Pool connection failures | No 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:
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 field | Meaning |
|---|---|
ca, cert, key | PEM text |
caFile, certFile, keyFile | Paths read into ca, cert and key each time a pool opens, on every connect attempt |
rejectUnauthorized | false 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.
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.
phase | Dialects | Fixed error | Emitted when |
|---|---|---|---|
idle | PostgreSQL | PostgreSQL idle pool connection failed | An idle pool client fails |
idle | MySQL | MySQL idle pool connection failed | An established connection that is not checked out fails |
checked-out | PostgreSQL | PostgreSQL checked-out connection failed | A checked-out session fails |
login | PostgreSQL | PostgreSQL pool login failed | A physical connect the pool makes after its first successful one fails |
login | MySQL | MySQL pool login failed | Same |
login | MSSQL | MSSQL pool login failed | Same |
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 option | PostgreSQL (pg Pool option) | MySQL, MSSQL (Tarn option) | SQLite | Meaning |
|---|---|---|---|---|
min | min | min | accepted, unused | Connections the pool keeps open; default 0 |
max | max | max | accepted, unused | Most connections the pool opens; default 10 |
idleTimeoutMs | idleTimeoutMillis; zero disables the timeout | idleTimeoutMillis; must be greater than zero | rejected; the idleTimeoutMillis alias is accepted, unused | Milliseconds a connection may sit idle before the pool closes it |
maxLifetimeMs | maxLifetimeSeconds; converted exactly, so 1500 is 1.5 seconds | rejected | rejected | Milliseconds a connection may live before the pool replaces it |
maxUses | maxUses; at least 1 | rejected | rejected | Checkouts after which the pool replaces a connection |
allowExitOnIdle | allowExitOnIdle | rejected | rejected | Let the process exit while only idle connections remain |
acquireTimeoutMs | rejected | acquireTimeoutMillis; positive; default below | rejected | Milliseconds a checkout may wait for a free connection |
createTimeoutMs | rejected | createTimeoutMillis; positive; default below | rejected | Milliseconds opening one physical connection may take |
reapIntervalMs | rejected | reapIntervalMillis; positive | rejected | Milliseconds 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:
NOORM_CONNECTION_POOL_MAX=20
NOORM_CONNECTION_POOL_idleTimeoutMs=30000
NOORM_CONNECTION_POOL_maxLifetimeMs=900000A 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 field | Dialects | Driver option | Meaning |
|---|---|---|---|
statementTimeoutMs | PostgreSQL | statement_timeout | Server statement limit in ms; zero disables |
queryTimeoutMs | PostgreSQL | query_timeout | Client wait limit in ms; zero disables |
applicationName | PostgreSQL | application_name | Session name; pg may inherit PGAPPNAME when omitted |
keepAlive | PostgreSQL | keepAlive | Enable or disable TCP keepalive |
keepAlive | MySQL | enableKeepAlive | Same. mysql2 enables keepalive unless this is false |
keepAliveInitialDelayMs | PostgreSQL | keepAliveInitialDelayMillis; also sets keepAlive: true unless keepAlive is false | Milliseconds a socket stays idle before the first keepalive probe |
keepAliveInitialDelayMs | MySQL | keepAliveInitialDelay; also sets enableKeepAlive: true unless keepAlive is false | Same |
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'.
const ctx = await createContext({
connection: {
dialect: 'postgres',
host: 'db.example.com',
database: 'orders',
user: 'app',
driver: { lock_timeout: 500 },
},
});| Dialect | driver is merged into | Later entries win |
|---|---|---|
| PostgreSQL | The pg Pool config | Flat config fields, pool, driver, then host, port, user, password, database, min, max, ssl, connectionTimeoutMillis |
| MySQL | The mysql2 connection options | Keepalive fields, driver, then host, port, user, database, ssl, stream, connectTimeout |
| MSSQL | The tedious options block | driver, then port, database, trustServerCertificate, cryptoCredentialsDetails, encrypt, serverName, connectTimeout |
| SQLite | Nothing | createContext() 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.
| Dialect | Rejected driver keys |
|---|---|
| All three | host, port, database, user, password, connectionString, ssl, __proto__ |
| PostgreSQL, also | Client, stream, connection |
| MySQL, also | uri, socketPath, stream, password1, password2, password3, passwordSha1, authPlugins, authSwitchHandler, flags, isServer |
| MSSQL, also | server, 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
| Variable | Effect |
|---|---|
NOORM_CONFIG | Config name when options.config is unset. Ignored with config: false |
NOORM_NAME | Name of a synthetic config when options.name is unset. Selects no stored config, and throws when a stored config has that name |
NOORM_CONNECTION_DIALECT | postgres, mysql, sqlite, or mssql. Any other value throws |
NOORM_CONNECTION_DATABASE | With the dialect, switches on env-only mode |
NOORM_CONNECTION_HOST, _PORT, _USER, _PASSWORD, _SSL | Override the matching connection field of the resolved config |
NOORM_CONNECTION_SSL_caFile, _certFile, _keyFile | Override connection.ssl.caFile, .certFile and .keyFile; see TLS certificates |
NOORM_CONNECTION_POOL_<option> | Set a pool option |
NOORM_CONNECTION_keepAliveInitialDelayMs | Set connection.keepAliveInitialDelayMs; see Session and keepalive controls |
NOORM_ACCESS_USER, NOORM_ACCESS_AGENT | Access 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_CHANGES | Override paths.sql and paths.changes from settings.yml |
NOORM_IDENTITY_PRIVATE_KEY, _NAME, _EMAIL | CI 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_YES | Any 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.
| Throws | When |
|---|---|
Config "<name>" not found | The 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 options | connection.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 SQLite | connection.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 |
RequireTestError | requireTest: true on a config without isTest: true |
Private key required to decrypt state | A state.enc exists and no identity key or NOORM_IDENTITY_* is available |
Context members
| Member | Returns | Notes |
|---|---|---|
kysely | Kysely<DB> | Standard Kysely. Re-derived on every access, so a reconnect never leaves a stale schema wrap. Not gated by access policy |
dialect | Dialect | postgres, mysql, sqlite, or mssql |
connected | boolean | true between connect() and disconnect() |
noorm | NoormOps | Created 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) | Context | See Schema scoping |
ctx.noorm also exposes three read-only properties:
| Property | Type | Holds |
|---|---|---|
config | Config | The resolved config |
settings | Settings | Project settings: paths, build rules, stages, teardown |
identity | Identity | The audit identity recorded on every change and lock |
Lifecycle
await ctx.connect();
// Fail fast on an unreachable database.
await ctx.connect({ retries: 1, delay: 0 });connect(retryOptions?) takes ConnectionRetryOptions, exported as a type:
| Field | Default | Meaning |
|---|---|---|
retries | 3 | Attempts before giving up |
delay | 1000 | Milliseconds before the first retry |
backoff | 2 | Multiplier 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.
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.
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| Rule | Behavior |
|---|---|
| Handle type | TransactionScope is Transaction<DB> & RoutineCalls<Procs, Funcs, Tvfs>. A callback annotated (trx: Transaction<DB>) => … still compiles |
SET LOCAL | Postgres syntax. It lasts until the transaction ends |
| Routine names | Qualified with the context's schema, as on ctx |
trx.proc on postgres | Looks 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 handles | trx.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.
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);
});| Case | Result |
|---|---|
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 called | The connection is closed when fn ends, whether it returns or throws. No connection:error event is emitted |
discard() called twice | Same as once |
| SQLite | discard() does nothing; there is one in-process connection |
disconnect() while fn is running | disconnect() completes, the pinned connection is closed, and withConnection rejects with DisconnectedError. fn itself is not interrupted |
| Context not connected | Throws 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.
| Dialect | Switch | Revert |
|---|---|---|
mssql | EXECUTE AS USER = '<username>' | REVERT |
postgres | SET ROLE '<username>' | RESET ROLE |
mysql, sqlite | throws ImpersonationError |
A username may contain letters, digits, _, @, ., -, and \. Anything else throws ImpersonationError.
The two modes differ in who ends the borrow:
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();| Behavior | Detail |
|---|---|
| Scope members | kysely, 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 connection | Until 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 statement | The 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 mode | The 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.
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 prefixBillingDB and BillingProcs are interfaces you declare, like OrderToCash in the first example. The routine generics default to the parent's.
| Rule | Behavior |
|---|---|
| Lifecycle | The derived context shares the parent's connection, pool, and held-connection set. connect() and disconnect() on either act on both |
| Name check | name must match letters, digits, _, and -. Anything else, including ., throws Invalid schema name immediately, before any connection is touched |
| Replace, not stack | ctx.withSchema('a').withSchema('b') resolves against b only |
| Routine names | proc, func, and tvf prefix a name that has no . with the schema. A name with a . passes through |
transaction | Inherits the schema |
impersonate | scope.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.noorm | Not 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:
// 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:
| Entry | Arguments | Return |
|---|---|---|
[{ k: T }, R] | object, named parameters | R |
[[A, B], R] | array, positional parameters | R |
[Args, void] | as above | void |
[void, R] | none | R |
void | none | unknown |
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[]>.
| Dialect | Named | Positional | No params |
|---|---|---|---|
| mssql | EXEC name @k = $1 | EXEC name $1 | EXEC name |
| postgres | CALL name(k => $1) | CALL name($1) | CALL name() |
| mysql | CALL name($1), values in key order | CALL name($1) | CALL name() |
| sqlite | throws | throws | throws |
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 schema | A missing key parses to | The SDK sends |
|---|---|---|
z.string() | rejects before the call | nothing |
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:
// 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.
| Dialect | Named | Positional | No params |
|---|---|---|---|
| mssql | DECLARE @__result sql_variant; EXEC @__result = name @k = $1; SELECT @__result AS col | SELECT name($1) AS col | SELECT name() AS col |
| postgres | SELECT name(k => $1) AS col | SELECT name($1) AS col | SELECT name() AS col |
| mysql | throws | SELECT name($1) AS col | SELECT name() AS col |
| sqlite | throws | throws | throws |
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.
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.
| Dialect | Named | Positional | No params |
|---|---|---|---|
| mssql | SELECT * FROM name($1), values in key order | SELECT * FROM name($1) | SELECT * FROM name() |
| postgres | SELECT * 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.
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.
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:
| Method | Statement shape |
|---|---|
proc | DECLARE @__tvp_<key> <type>; INSERT INTO @__tvp_<key> ([col], ...) VALUES (...), (...); EXEC [name] @k = $1, @<key> = @__tvp_<key> |
func | the same preamble, then DECLARE @__result sql_variant; EXEC @__result = [name] ...; SELECT @__result AS [col] |
tvf | the same preamble, then SELECT * FROM [name]($1, @__tvp_<key>) |
With positional parameters the variable is @__tvp_<index>.
| Rule | Behavior |
|---|---|
| Type name | A schema-qualified name works: tvp('dbo.order_line_input', rows). An empty name throws TVP type name is required. |
| Row keys | Every 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 TVP | tvp('order_line_input', []) declares the variable and inserts nothing |
| Several TVPs | Allowed in one call |
| Parameter limit | mssql 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 cell | user channel | agent channel |
|---|---|---|
allow | runs | runs |
deny | throws | throws |
confirm | throws unless yes: true or NOORM_YES is set | throws, 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.
| Permission | viewer | operator | admin |
|---|---|---|---|
run:build, run:file, run:dir | deny | confirm | allow |
pipeline:run | deny | confirm | allow |
| 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:
exploreis checked on theagentchannel only; theuserchannel always passes.- On a config with
isTest: true, every role on both channels gets the admin cell, protected stage included, and thedb:create,db:reset,db:truncate,db:teardown, anddb:destroycells readallow. Anagentcontext can build, reset and tear down its test database without confirmation. Admin's otherconfirmcells (config:write,vault:propagateand the like) still need a human. Transfer and vault destinations anddt.importFilefollow the same rule. Where the flag counts is in access roles. db.truncateanddb.teardownwithdryRun: truecheck 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 fordb:teardown.- Denials raised below the SDK seam throw a plain
Errorwith the same message, notProtectedConfigError. That coversrun.preview,transfer.plan, andvault.copy. Match on the message there. transfer.toandtransfer.plancheck the destination config, not the source, because the destination is the write target.vault.copychecksvault:readon the source andvault:writeon the destination, and requires destinationvault:readwhen unsealing an existing destination key.
ctx.noorm namespaces
| Namespace | Does |
|---|---|
run | Executes and previews SQL files, and builds the schema |
pipelines | Runs named SQL workflows and reads their independent history |
changes | Scaffolds, applies, reverts, and reports changes |
db | Explores the schema, truncates, tears down, and resets |
lock | Holds the per-config lock that serializes schema operations |
secrets | Reads and writes config-scoped secrets in local encrypted state |
vault | Reads and writes team secrets stored in the database |
templates | Renders a .sql.tmpl file without executing it |
transfer | Copies data between two configs |
dt | Exports and imports .dt data files |
utils | Checksums 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.
| Method | Returns | Notes |
|---|---|---|
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
| Type | Fields |
|---|---|
PipelineDryRunResult | dryRun: true, runId: null, status: 'success', transactionOutcome: 'no_effects', slug, mode, transaction, concurrency, singleRun, steps: PipelineStepMetadata[] |
PipelineRunResult | dryRun: false, runId, status, transactionOutcome, startedAt, finishedAt, durationMs, the plan fields above, steps: PipelineStepResult[], primaryError, secondaryErrors |
PipelineStepResult | stepNo, name, filepath, checksum, needs, status, transactionOutcome, startedAt, finishedAt, durationMs, error, skipReason? |
PipelineError | phase (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
| Field | Values |
|---|---|
run status | pending, running, success, failed, partial, interrupted, unknown |
step status | pending, running, success, failed, skipped, interrupted, unknown |
transactionOutcome | pending, committed, rolled_back, partial, no_effects, unknown; a step also reports not_started |
mode | linear, graph |
transaction | pipeline, step, none |
Errors
| Error | When |
|---|---|
PipelineBusyError | A singleRun pipeline already has an active run for this config. Exported; thrown by run before history is created |
ProtectedConfigError | The config's access policy denies or needs confirmation for the operation's permission, such as run:dir or pipeline:run |
VersionMismatchError | The database schema is newer than this client |
other Error | An 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.
| Method | Returns | Gate | Notes |
|---|---|---|---|
discover(dirpath?) | Promise<string[]> | none | Absolute, 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 only | Renders 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:file | Skips an unchanged file with skipReason: 'unchanged' |
files(filepaths, options?) | Promise<BatchResult> | run:dir | Runs in the order given |
dir(dirpath, options?) | Promise<BatchResult> | run:dir | Runs every SQL file under the directory, sorted by path. A missing directory returns status: 'failed' with error |
build(options?) | Promise<BatchResult> | run:build | Runs 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.
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');| Option | Type | Default | Applies to | Effect |
|---|---|---|---|---|
force | boolean | false | all | Re-run files even when unchanged |
dryRun | boolean | false | all | Render to disk, execute nothing |
abortOnError | boolean | true | RunOptions | Stop at the first failure |
concurrency | number | 1 | RunOptions | Files in parallel. 1 keeps DDL sequential |
preview | boolean | false | RunOptions | Render without executing or writing history |
output | string | null | null | RunOptions | File 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:
| Field | Type | Set when |
|---|---|---|
filepath | string | always |
checksum | string | always; empty when the file could not be read |
status | 'success' | 'failed' | 'skipped' | always |
error | string | status is 'failed' |
skipReason | 'unchanged' | 'already-run' | status is 'skipped' |
durationMs | number | executed and failed files |
renderedSql | string | preview mode |
outputPath | string | a dry run that wrote rendered SQL to disk |
BatchResult:
| Field | Type | Meaning |
|---|---|---|
status | 'success' | 'failed' | 'partial' | Overall outcome |
files | FileResult[] | One per file |
filesRun, filesSkipped, filesFailed | number | Counts |
durationMs | number | Total time |
changeId | number | Tracking-table id, when recorded |
error | string | The batch failed before any file ran |
unmatchedInclude | string[] | build() only: include entries that matched no file |
unmatchedExclude | string[] | 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
| Method | Returns | Gate | Notes |
|---|---|---|---|
create(options) | Promise<Change> | none | Creates <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> | none | Adds the next numbered file to 'change' or 'revert' |
removeFile(change, folder, filename) | Promise<Change> | none | |
renameFile(change, folder, oldFilename, newDescription) | Promise<Change> | none | Keeps the numeric prefix |
reorderFiles(change, folder, newOrder) | Promise<Change> | none | Renumbers by the order of filenames given |
delete(change) | Promise<void> | change:rm | Removes the folder from disk |
discover() | Promise<Change[]> | none | Every parseable change. Folders that fail to parse are dropped and reported as error events |
parse(name) | Promise<Change> | none | Throws ChangeNotFoundError, ChangeValidationError, or ManifestReferenceError |
validate(change) | void | none | Throws ChangeValidationError on duplicate filenames in a folder |
apply(name, options?) | Promise<ChangeResult> | change:run | An applied, unchanged change returns success with no files |
revert(name, options?) | Promise<ChangeResult> | change:revert | Throws ChangeNotAppliedError for a never-applied change, with dryRun and preview too |
ff(options?) | Promise<BatchChangeResult> | change:ff | Applies every pending change in name order |
next(count = 1, options?) | Promise<BatchChangeResult> | change:run | Applies the next count pending changes |
rewind(target, options?) | Promise<BatchChangeResult> | change:revert | Reverts newest first. target is a change name (reverts it and everything after) or a number of changes |
status() | Promise<ChangeListItem[]> | none | Every change, merged from disk and database, sorted by name |
pending() | Promise<ChangeListItem[]> | none | The items a forward run would pick up |
history(limit?) | Promise<ChangeHistoryRecord[]> | explore | Execution 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[]> | explore | One change's records, newest first. Same no-tables and older-tables results as history |
getFileHistory(operationId) | Promise<FileHistoryRecord[]> | explore | Per-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'.
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.
| Option | Type | Default | Applies to | Effect |
|---|---|---|---|---|
force | boolean | false | all | Skip the already-applied check |
dryRun | boolean | false | all | Render to tmp/, leave the tracking tables untouched. Throws the dry-run versions error on tables that need a migration, as preview does |
preview | boolean | false | all | Emit rendered SQL without writing |
output | string | null | null | all | File that receives preview output |
abortOnError | boolean | true | ff, next, rewind | Stop 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:
| Field | Type | Meaning |
|---|---|---|
name | string | Folder name |
path, date, description | string, Date | null, string | From disk. Absent when orphaned |
changeFiles, revertFiles, hasChangelog | ChangeFile[], ChangeFile[], boolean | From disk. Absent when orphaned or unparseable |
status | 'pending' | 'success' | 'failed' | 'reverted' | 'stale' | Latest recorded status |
appliedAt, appliedBy, revertedAt | Date | null, string | null, Date | null | From the tracking tables |
errorMessage | string | null | Failure message or parse error |
appliedHistoryId | number | null | History id behind appliedAt; the true apply order |
isNew | boolean | On disk with no record |
orphaned | boolean | Recorded 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.
| Method | Returns |
|---|---|
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.
const detail = await ctx.noorm.db.describeTable('order_line');
for (const col of detail?.columns ?? []) {
console.log(`${col.name}: ${col.dataType}`);
}The destructive methods:
| Method | Returns | Gate | What it does |
|---|---|---|---|
truncate(options?) | Promise<TruncateResult> | db:truncate | Wipes data, keeps the schema. Disables foreign keys, deletes or truncates each target, re-enables them |
teardown(options?) | Promise<TeardownResult> | db:teardown | Drops every object except noorm's own tables (change tracking, locks, vault, identities), and marks applied changes stale |
reset() | Promise<BatchResult> | db:reset | Checks 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 |
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:
| Option | Type | Default | Effect |
|---|---|---|---|
preserve | string[] | settings.teardown.preserveTables | Tables to leave alone |
only | string[] | all | Truncate only these. The inverse of preserve |
restartIdentity | boolean | true | Restart identity and auto-increment sequences |
dryRun | boolean | false | Return the SQL in statements, execute nothing |
policy | DbPolicyContext | none | Internal 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:
| Field | From the SDK |
|---|---|
dryRun | read from your options |
preserveSchemas | read from your options. Leaves whole schemas alone, which preserveTables cannot express: excluding app_private.secrets by name would also spare public.secrets |
preserveTables, postScript | filled from settings.teardown; a value you pass is ignored |
configName, executedBy | set by the SDK, so teardown marks applied changes stale and records a reset |
keepViews, keepFunctions, keepProcedures, keepTypes, policy | ignored |
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.
| Method | Returns | Gate | Notes |
|---|---|---|---|
acquire(options?) | Promise<Lock> | lock:acquire | Creates 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:acquire | Throws 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:acquire | Creates 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:force | Deletes 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.
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();LockOptions | Type | Default | Meaning |
|---|---|---|---|
timeout | number | 300000 | Milliseconds until the lock expires |
wait | boolean | false | Poll until the lock is free |
waitTimeout | number | 30000 | Most milliseconds to wait. Used only with wait |
pollInterval | number | 1000 | Milliseconds between polls. Used only with wait |
reason | string | none | Shown to whoever the lock blocks |
dialect | Dialect | the config's | Set 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.
| Method | Returns | Gate | Notes |
|---|---|---|---|
get(key) | string | undefined | secret:read | Synchronous. undefined, not null, for an unset key |
list() | string[] | secret:read | Synchronous. Key names only; values are never returned |
set(key, value) | Promise<void> | secret:write | |
delete(key) | Promise<void> | secret:write |
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.
| Method | Returns | Gate | Notes |
|---|---|---|---|
init() | Promise<Buffer | null> | vault:write | The vault key on first init, null when already initialized. Safe to call at startup |
status() | Promise<VaultStatus> | none | isInitialized, hasAccess, secretCount, usersWithAccess, usersWithoutAccess |
set(key, value, privateKey) | Promise<void> | vault:write, plus vault:read to unseal the key | Throws VaultAccessError when the key yields no vault key |
get(key, privateKey) | Promise<string | null> | vault:read | null when absent or without access |
getAll(privateKey) | Promise<Record<string, VaultSecret>> | vault:read | {} without access |
list() | Promise<string[]> | vault:read | Key names; no private key needed |
exists(key) | Promise<boolean> | vault:read | Does not decrypt |
delete(key) | Promise<boolean> | vault:write | |
propagate(privateKey) | Promise<VaultPropagationResult> | vault:propagate, plus vault:read to unseal the key | Grants 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 key | keys 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 {}.
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 });| Result | Fields |
|---|---|
VaultSecret | key, value, setBy, createdAt, updatedAt |
VaultPropagationResult | propagatedTo (identity hashes), alreadyHadAccess (count), failed (identityHash, email, error) |
VaultCopyResult | copied, 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.
| Method | Returns | Gate |
|---|---|---|
render(filepath) | Promise<TemplateResult> | run:file, deny only, throws ProtectedConfigError |
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.
| Method | Returns | Gate | Notes |
|---|---|---|---|
to(destConfig, options?) | Promise<TransferResult> | db:reset on the destination | Throws on failure. A dry run is gated the same way |
plan(destConfig, options?) | Promise<TransferPlan> | transfer:plan on the destination | Table order, row estimates, warnings. Throws on failure |
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');
}TransferOptions | Type | Default | Effect |
|---|---|---|---|
tables | string[] | all user tables | Tables to copy |
onConflict | 'fail' | 'skip' | 'update' | 'replace' | 'fail' | Primary key conflict strategy |
batchSize | number | 1000 | Rows per batch for cross-server transfers |
disableForeignKeys | boolean | true | Disable FK checks during the copy |
preserveIdentity | boolean | true | Keep identity and auto-increment values |
truncateFirst | boolean | false | Truncate destination tables first |
dryRun | boolean | false | Validate only |
exportPath | string | none | Write a .dt file instead of inserting |
passphrase | string | none | Encrypts a .dtzx export |
channel | Channel | the context's | Set 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.
| Method | Returns | Gate |
|---|---|---|
exportTable(tableName, filepath, options?) | Promise<{ rowsWritten, bytesWritten }> | none |
importFile(filepath, options?) | Promise<{ rowsImported, rowsSkipped }> | db:reset |
Both throw on failure.
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' });| Option | Applies to | Type | Default | Effect |
|---|---|---|---|---|
passphrase | both | string | none | .dtzx encryption or decryption |
batchSize | both | number | 1000 | Rows per batch |
schema | export | string | none | Schema or namespace, such as public |
onConflict | import | ConflictStrategy | 'fail' | Primary key conflict strategy |
truncate | import | boolean | false | Truncate the target table first |
ctx.noorm.utils
| Method | Returns | Notes |
|---|---|---|
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 |
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.
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:
| Family | Events | configName |
|---|---|---|
| File | file:before, file:after, file:skip, file:progress, file:dry-run | file:before only |
| Build and run | build:start, build:complete, run:file, run:dir, run:files | run:* only |
| Change | change:created, change:start, change:file, change:complete, change:skip | no |
| Lock | lock:acquiring, lock:acquired, lock:released, lock:blocked, lock:expired | yes |
| Connection | connection:open, connection:close, connection:error | yes |
| Secret | secret:set, secret:deleted | yes |
| Teardown | teardown:start, teardown:progress, teardown:error, teardown:complete | no |
| Template | template:render, template:load, template:helpers | no |
| Vault | vault:initialized, vault:secret:created, vault:secret:updated, vault:secret:deleted, vault:propagated, vault:copy:starting, vault:copy:completed | no |
| Transfer | transfer: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-warning | no |
| DT | dt: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:saved | no |
| Errors | error: { source, error, context? } | no |
connection:error carries one of two payload shapes:
| Emitted for | phase | error | serverCode, serverMessage |
|---|---|---|---|
A failed connect() | absent | The worded failure, or Runtime credential connection failed when the connection has a password callback | Present for a DatabaseConnectionError on a static password; absent with a callback |
| A pool failure after the first connect | idle, checked-out, or login | A fixed string per dialect; see Pool connection failures | Absent |
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:
| Call | Reports |
|---|---|
run.*, changes.ff, changes.next, changes.rewind | Per-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.init | null, {}, and null for missing access or an existing vault |
transaction(fn) | fn must throw to roll back |
ctx.kysely queries | Whatever the driver throws |
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:
| Error | Thrown when | Carries |
|---|---|---|
RequireTestError | requireTest: true and the config lacks isTest: true | configName |
NotConnectedError | A call that needs the pool ran before connect() or after disconnect() | |
ProtectedConfigError | The access policy denied the call, or it needs a confirmation yes did not supply | configName, operation |
ResetBuildError | db.reset() tore the database down and the rebuild did not succeed | result |
VersionMismatchError | The database's tracking schema is newer than this client | layer, current, expected |
MigrationError | A tracking schema migration failed | layer, version, cause |
DisconnectedError | disconnect() ran while a withConnection callback was still running | |
DatabaseConnectionError | connect() failed and the factory could explain the driver's or server's error; see Runtime credentials | serverCode, serverMessage, cause |
VaultAccessError | vault.set() got a key that yields no vault key | configName |
ImpersonationError | impersonate() on mysql or sqlite, or with an invalid username | |
LockAcquireError | Another identity holds the lock | configName, holder, heldSince, expiresAt, reason |
LockExpiredError | The lock expired during an operation | configName, identity, expiredAt |
ChangeValidationError | validate() found duplicates, a change has no files, or its files are empty or only template placeholders | changeName, issue |
ChangeNotFoundError | parse, apply, or revert named a change that is not on disk | changeName |
ChangeOrphanedError | apply or revert named a change recorded in the database whose folder is gone | changeName |
ChangeNotAppliedError | revert of a change that was never applied | changeName |
ChangeAlreadyAppliedError | Exported; no SDK method throws it today | changeName, appliedAt |
ManifestReferenceError | A .txt manifest names a file that is not under paths.sql | manifestPath, missingPath |
Exported types and values
| Group | Names |
|---|---|
| Factory and classes | createContext, Context, NoormOps, ChangesNamespace, RunNamespace, DbNamespace, LockNamespace, VaultNamespace, SecretsNamespace, TemplatesNamespace, TransferNamespace, DtNamespace, UtilsNamespace |
| Core types | CreateContextOptions, Config, Settings, Identity, Dialect, Channel, ConfigAccess, Role |
| Connection | InlineConnection, DialectConnectionConfig, PinnedConnection, ConnectionRetryOptions, PoolOpts, PgPoolOpts, TarnPoolOpts, MysqlPoolOpts, MssqlPoolOpts |
| Run | RunOptions, BuildOptions, FileResult, BatchResult |
| Changes | Change, ChangeFile, ChangeFileType, CreateChangeOptions, AddFileOptions, ChangeOptions, ChangeResult, BatchChangeResult, ChangeListItem, ChangeHistoryRecord |
| Explore | ExploreOverview, TableSummary, TableDetail, ViewSummary, ViewDetail, ProcedureSummary, ProcedureDetail, FunctionSummary, FunctionDetail, TypeSummary, TypeDetail, IndexSummary, ForeignKeySummary, ColumnDetail, ParameterDetail |
| Database operations | TruncateOptions, TruncateResult, TeardownResult, TeardownPreview |
| Locks | Lock, LockStatus, LockOptions |
| Templates | TemplateResult |
| Vault | VaultSecret, VaultStatus, VaultCopyOptions, VaultCopyResult, VaultPropagationResult |
| Transfer and DT | TransferOptions, TransferPlan, TransferTablePlan, TransferResult, TransferTableResult, ConflictStrategy, ExportOptions, ImportOptions |
| Routines | tvp, TvpValue, ImpersonatedScope, RoutineCalls, TransactionScope, ExtractArgs, ExtractReturn |
| Events | noormObserver, NoormEvents, NoormEventNames |
| Errors | RequireTestError, 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
| Symptom | Cause | Fix |
|---|---|---|
ProtectedConfigError: requires confirmation on db.truncate() against your own dev config | The default admin role still confirms db:truncate, db:teardown, change:rm, vault:propagate, and lock:force, and the SDK cannot prompt | Create the context with yes: true, or set NOORM_YES=1. On the agent channel nothing unblocks it |
NotConnectedError | A 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 applied | A failed change comes back as status: 'failed', and a missing changes directory is only a warning | Check result.status, result.error, and result.warnings |
ChangeValidationError: Files are empty or contain only template placeholders | changes.create scaffolds stubs and apply refuses them | Write the SQL into the change/ file first |
build() succeeds and ran nothing | A mistyped build.include matched no file | Read result.unmatchedInclude and result.unmatchedExclude |
pending() shows an item with status: 'failed' and isNew: true | The folder cannot be parsed, such as a manifest naming a deleted db/sql/ file | Read errorMessage. discover() omits the same folder |
{ note: undefined } overwrote a procedure's DEFAULT | undefined is sent as NULL, and mssql applies a default only to an omitted parameter | See Parameter handling and NULL semantics |
func() throws on mysql | Named parameters are not supported in function calls | Pass an array |
| A connection stays out of the pool | An explicit impersonate() scope was never reverted | Call scope.revert(), or prefer callback mode. disconnect() also releases it |
withSchema did not qualify a raw fragment | sql`...` bypasses the query-builder plugins | Qualify identifiers by hand inside raw SQL |
| One context's handler fires for another tenant's events | noormObserver is process-global | Filter on event.configName, where the payload has it |
Vault operations require a cryptographic identity | No ~/.noorm/identity.json and no NOORM_IDENTITY_* | Run noorm identity init, or set the three CI variables |
Private key required to decrypt state in CI | A state.enc exists and no key is available | Set 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 config | Pass 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 before | Set a positive value, or remove the field |
pool.<key> is not a pool option | The key is misspelled, or came from an all-caps environment variable segment, which is lowercased | Use 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 10 | Lower min or raise max |
driver.<key> is not allowed on <dialect> at createContext() | connection.driver named a key that selects the target, credentials, or TLS | Set the flat connection field instead; see Driver options |
A provider's error class no longer matches after connect() fails | Only an error the password callback itself throws keeps its identity. A driver or server failure on that connection is a DatabaseConnectionError | Match DatabaseConnectionError and read cause |
| A viewer's context cannot read secrets or the vault | secret:read and vault:read are denied to viewer | Use a context on a role that holds them |
Next
- Building your SDK: wire a database package into an application
- Deploying an application: env-only mode, one context per process
- Access roles: set
accessper config - Forward and revert: what
ff,next, andrewinddo - Teardown internals: drop order and the mssql truncate strategy