ObjectStackObjectStack

Database Drivers

Configuration reference for supported database drivers

ObjectStack supports multiple database backends through a unified driver interface. Drivers can be selected in two ways:

  1. Env var only — auto-inferred from the URL scheme (the simplest path):

    export OS_DATABASE_URL=mongodb://localhost:27017/myapp   # → MongoDB
    export OS_DATABASE_URL=postgres://user:pass@host/db      # → PostgreSQL
    export OS_DATABASE_URL=mysql://user:pass@host/db         # → MySQL
    export OS_DATABASE_URL=file:./data/app.db                # → SQLite
    pnpm dev

    The CLI inspects the connection-string scheme and picks the right driver automatically. You only need to set OS_DATABASE_DRIVER when you want to override the inference (e.g. force sqlite for an ambiguous path).

  2. Programmatic — register a driver instance as a kernel plugin via DriverPlugin. This is the escape hatch for pre-built or auxiliary drivers — the CLI itself no longer boots the primary this way: it declares the default datasource as a definition and connects it through the shared datasource path (#3826, described further down this page):

    import { DriverPlugin } from '@objectstack/runtime';
    import { SqlDriver } from '@objectstack/driver-sql';
    
    await kernel.use(
      new DriverPlugin(
        new SqlDriver({
          client: 'pg',
          connection: process.env.OS_DATABASE_URL,
        }),
      ),
    );

URL → Driver Inference Table

URL patternInferred drivernpm package
mongodb://…, mongodb+srv://…MongoDB@objectstack/driver-mongodb
postgres://…, postgresql://…PostgreSQL (Knex pg)@objectstack/driver-sql + pg
mysql://…, mysql2://…MySQL (Knex mysql2)@objectstack/driver-sql + mysql2
libsql://…, http(s)://*.turso.…Turso / libSQL@objectstack/driver-turso (optional — install it yourself)
wasm-sqlite://…, *.wasm.dbSQLite (pure-JS WASM)@objectstack/driver-sqlite-wasm
file:…, sqlite:…, :memory:, *.db / *.sqliteSQLite (Knex better-sqlite3)@objectstack/driver-sql + better-sqlite3
(unset, dev mode)SQLite (native, falling back to WASM, then in-memory)@objectstack/driver-sql / -sqlite-wasm / -memory

Turso / libSQL needs one extra install. libsql:// and *.turso.io URLs are inferred — by the CLI (os serve / os start / os dev) and by the standalone runtime stack the one-shot commands and embedders boot through (os migrate, createStandaloneStack) alike. But @objectstack/driver-turso is an optional install — it pulls in @libsql/client plus native bindings, so it is not part of a default install:

npm install @objectstack/driver-turso

Without it the boot fails loudly with that exact command; it never degrades to SQLite, which would start the server against an empty local database while your libSQL data stayed untouched. Pass the token with --database-auth-token (OS_DATABASE_AUTH_TOKEN, or the vendor's own TURSO_AUTH_TOKEN).

Supported Drivers

Drivernpm packageClassExplicit OS_DATABASE_DRIVER value
PostgreSQL@objectstack/driver-sql (peer: pg)SqlDriverpostgres | postgresql | pg
MySQL@objectstack/driver-sql (peer: mysql2)SqlDrivermysql | mysql2 (supported, with three dialect caveats — see below)
SQLite@objectstack/driver-sql (peer: better-sqlite3)SqlDriversqlite | sql
SQLite (WASM)@objectstack/driver-sqlite-wasmSqliteWasmDriversqlite-wasm | wasm-sqlite | wasm
MongoDB@objectstack/driver-mongodbMongoDBDrivermongodb | mongo (single-tenant only — see below)
Turso / libSQL@objectstack/driver-turso (optional install — see the callout above)TursoDriverturso | libsql
Memory@objectstack/driver-memoryInMemoryDrivermemory

All SQL flavours (PostgreSQL / MySQL / SQLite) are served by a single @objectstack/driver-sql package — there is no separate driver-postgres, driver-mysql, or driver-sqlite package. Pick the Knex client name (pg / mysql2 / better-sqlite3) when you instantiate SqlDriver.

config is validated per driver

A datasource's config is driver-specific — a SQLite filename and a Postgres host share no shape — so the datasource schema keeps that slot open at the top level and parses it against the contract for the driver you named. Each built-in driver ships that contract as a zod schema, exported from @objectstack/spec/data:

driverContractKeys
postgres | postgresql | pgPostgresConfigSchemaurl, host, port, database, username, password, ssl, schema, applicationName, statementTimeout, autoMigrate
mysql | mysql2 | mariadbMysqlConfigSchemaurl, host, port, database, username, password, ssl, autoMigrate
sqlite | sqlite3SqliteConfigSchemafilename, autoMigrate
sqlite-wasm | wasm-sqliteSqliteWasmConfigSchemafilename, persist
mongo | mongodbMongoConfigSchemaurl, host, port, database, username, password, authSource, options
memory | in-memoryMemoryConfigSchemainitialData, strictMode, persistence

An unrecognised key is rejected with its correction, at authoring time and in the Setup → Datasources wizard alike:

Unrecognized key(s) on this postgres datasource's config: `hostname`.
Did you mean `hostname` → `host`?

This matters more than a typical typo check, because the failure it replaces was silent: a misspelled key was dropped, the driver fell back to its own defaults, and the datasource connected to localhost while every signal — the parse, the save, the connection probe — reported success.

Two things live outside config, because they are not driver-specific:

  • Pool sizing — the pool block on the datasource (min, max, idleTimeoutMillis, connectionTimeoutMillis), honoured by the pooled drivers: postgres and mysql pass all four to Knex, and mongodb maps min / max — and only those two — onto the client's minPoolSize / maxPoolSize. Everywhere else the block is an authoring error, rejected by the Setup wizard when you save and by the boot when a declared datasource carries one, rather than dropped:

    DriverVerdict on pool
    postgres, mysqlall four keys honoured
    mongodbmin / max honoured; idleTimeoutMillis and connectionTimeoutMillis rejected by name
    sqlite, sqlite-wasmwhole block rejected
    memorywhole block rejected
    turso / libsqlwhole block rejected

    Each arm says why in its own terms. A SQLite connection strategy is owned by the driver (one connection per database, because a second connection to :memory: opens a separate, empty one); memory opens no connection at all; neither libSQL transport pools — a file: url runs that same local SQLite engine and a libsql:// url is a remote request transport capped by config.concurrency. Every one of these used to be dropped in silence: an app-crm datasource asking for max: 5 measurably ran on one connection (#5714), and a mongo datasource asking for max: 20, idleTimeoutMillis: 30000 got the first and lost the second without a word (#7243) — the half-honoured case, which is the hardest to notice. The fix is always to delete the rejected keys; they were reaching nothing either way.

  • TLS certificates — the ssl block on the datasource (enabled, rejectUnauthorized, ca, cert, key). Inside config, ssl is the on/off boolean shorthand.

  • schemaMode — the ADR-0015 ownership mode, declared next to driver.

A plugin-contributed driver (com.vendor.snowflake) has no contract in this repo, so its config is left unvalidated rather than judged against a shape the platform does not have.

The same schemas are projected to JSON Schema for DriverDefinitionSchema.configSchema and for GET /api/v1/datasources/drivers, which the Studio connection form renders — so the form offers exactly the fields the validator accepts.

Secret-shaped keys with no binder slot: accepted at-rest risk

The datasource secret binder injects exactly one secret per datasource — the login password, resolved through external.credentialsRef (or the connection form's password field) into sys_secret at connect time. A small number of other config keys are secret-shaped too, but the binder has no second slot to put them in, and the client library that owns them — not ObjectStack — is what makes them secrets:

DriverKeyWhat it carries
mongo | mongodboptions.proxyPasswordSOCKS5 proxy password
mongo | mongodboptions.tlsCertificateKeyFilePasswordTLS key-file passphrase
mongo | mongodboptions.keyTLS private key material (PEM)
mongo | mongodboptions.passphraseTLS key passphrase
mongo | mongodboptions.authMechanismProperties.AWS_SESSION_TOKENAWS STS session token (MONGODB-AWS auth)
turso | libsqlencryptionKeyAES-256 key for the local database file

Unlike password / authToken (typed never in their schemas — the parse refuses them outright), these six keys are writable: the parse accepts them, and they are stored at rest in sys_metadata as plain text, right alongside the rest of the datasource row. The protection that exists today is on the READ side only — every one of them is stripped before a datasource record is ever served back over the admin API or shown in the Setup UI (PASSTHROUGH_SECRET_PATHS / STILL_WRITABLE_CREDENTIAL_KEYS in datasource-credential-redaction.ts, shipped in #9040). That stops the value round-tripping through a read; it is not encryption at rest, and an operator with direct access to the metadata store can still read the plain-text value.

options.authMechanismProperties.AWS_SESSION_TOKEN is writable for a different reason than the other five: it isn't refused at the write door because the MongoDB client itself throws on it under authMechanism: 'MONGODB-AWS' (MongoAPIError: AWS_SESSION_TOKEN cannot be provided…, driver v7 requires AWS SDK-sourced credentials) — a spec-level refusal would just be naming a remedy the client already enforces — and under any other auth mechanism nothing reads it at all. Either way, a stored value is accepted, held in sys_metadata as plain text, and redacted on read exactly like the rest of this table.

This is a deliberate, documented trade-off (#9124), not an oversight. The binder has exactly one named slot. Refusing proxyPassword, tlsCertificateKeyFilePassword, key, passphrase, or turso's encryptionKey at write time would remove the only way to configure an authenticated SOCKS5 proxy, a passphrase-protected TLS key, or a locally-encrypted database file — a capability the client genuinely honours, with no working refusal remedy. (AWS_SESSION_TOKEN isn't part of that trade-off — see above.) Restart condition: the first real deployment that needs an authenticated proxy or a passphrase-protected key converts that group into named binder-slot support — one mechanism covering those five keys — rather than the current per-key accept-and-document posture.

Startup: a driver that cannot connect aborts the boot

ObjectQLEngine.init() connects every registered driver during kernel bootstrap. If any connect() rejects, the boot is refused (#3741) — the error names each failed driver and its cause, and objectstack serve exits 1.

Booting without a reachable datasource would produce a server that reports itself started, answers health checks, and then fails every request with an error nothing like the database is unreachable — while the schema sync that follows init() issues DDL against a datasource that isn't there.

The reason is not that the connection can never come back. Client libraries do re-establish connections on their own — the MongoDB driver's topology monitor reconnects, and knex/pg opens a fresh connection per acquire (verified in #3759). What never comes back is the boot sequence that was skipped: nothing re-runs the schema sync, so those objects can be left with no tables even after the database returns.

The same guard covers declared datasources whose objects have no fallback — see When auto-connect fails (#3758).

The standalone default datasource itself is now a declared definition (#3826): the stack translates OS_DATABASE_URL into { driver, config } and connects it at boot through the same datasource connection path — one failure verdict, one escape hatch, and a real status for the primary DB in Setup → Datasources. The name default is host-reserved: an app bundle declaring a datasource with that name is rejected at load.

OS_ALLOW_DRIVER_CONNECT_FAILURE=1 boots anyway, in an explicitly degraded state announced by a DEGRADED BOOT banner. Queries to a failed driver fail until the datasource becomes reachable, and its boot-time schema sync is skipped for good. Do not set it in production.

Writing a driver: connect() is where you refuse to start

Because the rejection now propagates, throwing from connect() is the supported way for a driver to veto the boot — not only for an unreachable socket, but for any fatal startup condition: an unsupported server version, a missing capability, an incompatible deployment mode, a licence check. Validation that needs a live connection belongs here rather than in the constructor.

async connect(): Promise<void> {
  await this.pool.connect();
  const { version } = await this.probeServerVersion();
  if (major(version) < 14) {
    // Aborts bootstrap — the operator sees this message, not a 500 per request.
    throw new Error(`PostgreSQL ${version} is unsupported; this driver requires 14+.`);
  }
}

Checks that need no connection can still run in the constructor, which fails even earlier — that is where MongoDBDriver puts its tenancy guard.

PostgreSQL (via @objectstack/driver-sql)

pnpm add @objectstack/driver-sql pg

SqlDriver's constructor accepts a Knex Knex.Config object, passing it through unchanged except for two connect-timeout defaults described below.

import { SqlDriver } from '@objectstack/driver-sql';

new SqlDriver({
  client: 'pg',
  connection: {
    host: 'db.example.com',
    port: 5432,
    user: 'admin',
    password: process.env.DB_PASSWORD,
    database: 'myapp',
    ssl: true,
  },
  pool: { min: 2, max: 10 },
});

Or via env var alone (driver inferred from postgres:// scheme):

export OS_DATABASE_URL=postgres://admin:secret@db.example.com:5432/myapp

Connect timeouts

A database endpoint that accepts the TCP connection but never completes the handshake — an overloaded instance, a half-open firewall, a load balancer mid-failover — makes every query wait rather than fail. Left to Knex's own defaults that wait is 30 seconds per query on the request path, and with a small pool.max a handful of them saturate the pool. So SqlDriver supplies two defaults (#3769):

SettingDefaultPurpose
connection.connectionTimeoutMillis (pg) / connection.connectTimeout (mysql2)10_000The effective bound. Fails with the driver's own wording — timeout expired / connect ETIMEDOUT — which names the network.
pool.createTimeoutMillis15_000Backstop, only reached by a client that has no connect-timeout option or ignores it. Deliberately looser: the two race, and Knex wins a tie, so an equal value would mask the accurate message with Knex's misleading "the pool is probably full".

Set either explicitly and it is left untouched — do that when a datasource legitimately takes longer to connect (a distant cross-region replica). SQLite gets neither: opening a file has no handshake to time out.

Carrying a connect timeout requires connection to be an object, so a URL string is moved into the client's URL slot (connectionString for pg, uri for mysql2) before reaching Knex. This affects only what Knex receives — the config you passed is preserved as-is on the driver.

MySQL (via @objectstack/driver-sql)

pnpm add @objectstack/driver-sql mysql2
import { SqlDriver } from '@objectstack/driver-sql';

new SqlDriver({
  client: 'mysql2',
  connection: 'mysql://admin:secret@db.example.com:3306/myapp',
});

MySQL is a supported deployment target, and everything on this page's PostgreSQL section applies unchanged — the same SqlDriver, the same connect-timeout defaults, the same tenant scoping.

MySQL dialect caveats

Three behaviours genuinely differ, and each is a limit of the dialect rather than of this driver. None of them is a defect, none has an in-dialect fix planned, and two of the three have no workaround at all except running the object — or the platform — on SQLite/PostgreSQL. They are listed here rather than left to a boot log because they are worth knowing before you choose MySQL, not after.

CaveatWhat MySQL does not give youWay out
upsert cannot honour a conflict targetON DUPLICATE KEY UPDATE carries no target, so a merge can land on a UNIQUE key you never named. The driver refuses the call rather than let it.Keep one UNIQUE key per table, or run the object on SQLite/PostgreSQL.
Three runtime uniqueness indexes cannot be builtDatabase enforcement of three platform integrity guarantees. The weaker index stays in force and every boot logs an error.None in-dialect. Run the platform on SQLite/PostgreSQL for the guarantee.
A unique violation does not name the columnThe conflicting field in import errors and form-field conflict messages. The 409 UNIQUE_VIOLATION itself is unaffected.None. The message falls back to generic copy.

upsert conflict targets: the target MySQL cannot honour

On MySQL, upsert(object, data, conflictKeys) cannot promise that the merge happens on conflictKeys. Where the table carries a UNIQUE key outside the named target, the driver refuses the call rather than let it merge into a row the caller never targeted (#8755).

The same upsert call compiles differently per dialect, and only two of the three can carry a conflict target at all:

DialectCompiles toHonours the named target?
SQLite / PostgreSQLINSERT … ON CONFLICT (email) DO UPDATE …Yes. The named index is the arbiter. A collision on any other unique key raises a unique violation — a legible error.
MySQLINSERT … ON DUPLICATE KEY UPDATE …No. The statement carries no target at all, so the merge lands on whichever UNIQUE key the row collides with first.

Measured on MySQL 8.0.46, a table with email and tax_id both declared unique: true, the caller naming email:

upsert({ email: 'a@b.com',     tax_id: 'T-1', title: 'first'  }, ['email'])  -> seeded
upsert({ email: 'other@b.com', tax_id: 'T-1', title: 'second' }, ['email'])
  -> ONE row. `email` did not collide; `tax_id` did, and MySQL merged on it —
     rewriting a row whose `email` the caller never asked to touch.

The identical second call on SQLite and PostgreSQL fails with UNIQUE constraint failed: …tax_id and leaves the seeded row untouched.

So on MySQL the driver checks the table's physical keys before compiling, and refuses what it cannot honour — with code: 'VALIDATION_ERROR' and status: 400, before any row is written and before any auto-number is reserved:

Call, on MySQLResult
upsert(o, row) — no conflictKeysMerges on the primary key. Refused if the merge lands on a different row (see below).
upsert(o, row, ['id']) — the primary keyIdentical to the line above — same statement, same answer.
upsert(o, row, ['email']), the table's only UNIQUE key being on emailMerges on email. The common shape is unaffected.
upsert(o, row, ['email']), the table also carrying UNIQUE(tax_id)Refused. The message names tax_id's index and the workarounds.
upsert(o, row, ['email']), no unique index on email at allRefused on every dialect (#8621).

Two ways out, both stated in the error message:

  1. Drop or rename the extra UNIQUE key so the conflict target is the only one on the table — appropriate when the second key was incidental.
  2. Run the object on a dialect that honours the target (SQLite, PostgreSQL) — appropriate when both keys are genuine business constraints, since one of them must otherwise be given up.

The merge that lands on a row you never identified

The pre-flight above covers a caller-named non-primary target. It cannot cover the conflictKeys-less default or an explicitly named primary key, because neither call names anything a pre-flight could check — and those two compile to the same statement. On a table with several UNIQUE keys that statement can still collide on a key you did not name, and MySQL will merge there:

seed  upsert({ email: 'd@b.com', tax_id: 'T-9', title: 'first'  })  -- no conflictKeys
      -> inserted, id = iVvD35rMk4BIayYc
B     upsert({ email: 'e@b.com', tax_id: 'T-9', title: 'second' })  -- no conflictKeys
      -> the fresh id did not collide; `tax_id` did, so MySQL merged onto the
         SEEDED row — rewriting an `email` this call never asked to touch.

So the driver checks, after the statement and inside the same transaction, whether the row it landed on is the one the call supplied. If it is not, the write is rolled back and the call is refused with code: 'VALIDATION_ERROR' and status: 400. Nothing is left changed.

Call, on MySQL, table carrying a rival UNIQUE keyResult
The row is new, or matches an existing row's idMerges, exactly as before.
The row collides on a UNIQUE key you did not nameRefused, and the write is rolled back.

This check is selective, and only MySQL pays for it. A table whose only key is its primary key can never exhibit the condition, so nothing is verified and no transaction is opened there. SQLite and PostgreSQL compile ON CONFLICT (id), which honours the arbiter and raises a unique violation on any other key — they already behave this way and are unchanged.

If you meant to merge on a business key, name it (conflictKeys), which makes the intent checkable. On MySQL, a table with more than one UNIQUE key cannot have every merge honoured — keep one UNIQUE key per table, or run the object on SQLite/PostgreSQL.

Uniqueness indexes MySQL cannot build

Three platform tables get their uniqueness from an index issued as raw SQL at boot by a metadata-protocol runtime migration, because the shape each one needs cannot be expressed through the declaration surface. Two of the three are partial indexes (CREATE UNIQUE INDEX … WHERE state = '…'), and all three use functional key parts (COALESCE(…)) to fold a nullable column's NULLs into one bucket that is unique among itself.

MySQL/MariaDB has no partial indexes at any version, and rejects the functional key parts as these statements spell them. So on MySQL none of the three is built, and the guarantee each one backs is not enforced by the database:

TableThe guarantee that is not enforced on MySQL
sys_metadataADR-0005 overlay uniqueness. Package-less rows (package_id NULL) stay NULL-distinct and can duplicate, and getMetaItem then has no defined answer for which row wins.
sys_view_definitionActive-row view-name uniqueness. An archived view keeps occupying its name slot, and two same-name active shared views (owner NULL) or environment-level views (organization_id NULL) can coexist even though the platform states they cannot.
sys_settingNULL-safe row identity. user_id is NULL on every row that is not scope='user', so two tenant-scope rows for one (namespace, key) in one organization — or two platform defaults on the global layer — can coexist, and SettingsService has no defined answer for which one wins.

What happens instead is the same in all three cases, and it is deliberate. Each migration proves the new index is possible under a throwaway probe name before dropping anything, so a dialect that cannot take it is left holding exactly the index it already had — never an unconstrained table, and never a failed boot. The gap is then announced at error level on every boot, along with a query that lists any rows already violating the guarantee.

There is no in-dialect fix, and none is planned — the DDL these migrations need does not exist in MySQL/MariaDB. If you need these three guarantees enforced by the database, run the platform on SQLite or PostgreSQL. On MySQL, treat the boot's [metadata-protocol] this database cannot build … lines as expected rather than as a failure, and run the duplicate-listing query each one prints to find out whether the gap has actually been hit in your data.

The conflicting column is not named

A write that collides with a unique key comes back as 409 UNIQUE_VIOLATION on every dialect, MySQL included — that verdict is not degraded. What differs is the follow-up question: which column collided.

SQLite and PostgreSQL name the column, so the platform can read it back:

sqlite    UNIQUE constraint failed: sys_user.email
postgres  Key (email)=(acme@example.com) already exists.
mysql     Duplicate entry 'acme@example.com' for key 'idx_email_unique'

MySQL's sentence names the index, never the column. There is nothing in it to read, so on MySQL the platform answers "not determinable" and the conflicting field goes unnamed.

That is an accepted cost rather than an oversight (maintainer ruling 2026-08-08). Deriving email from idx_email_unique would be a guess — index names are free-form and a deployment's may match no column at all — and both consumers of the answer are worse off with a plausible wrong column than with none:

  • The import runner renders it into a form field: "A record with this email already exists." An index name there points the user at a field that does not exist on the object, so they cannot act on it.
  • The autonumber retry asks a yes/no question of it — is the conflicting column the autonumber field? — where a wrong name produces a wrong retry decision rather than merely a vaguer message.

So on MySQL, expect import errors and form-field conflict messages to fall back to generic copy — "A record with this value already exists" — instead of naming the field. Nothing is misreported; the message is less specific.

This is not MySQL-only in principle. SQLite and PostgreSQL also decline to name a column when the violation reports an index rather than a column (UNIQUE constraint failed: index 'idx_lower_email', violates unique constraint "sys_user_email_key"), and when the key is composite, since there is then no single offending column to name. MySQL is the dialect where going unnamed is the normal outcome rather than the exception, because its duplicate-entry message never carries a column at all.

The migration metadata-lock bound

Widening a legacy MySQL column — TIMESTAMPDATETIME(3), or a zero-precision TIMETIME(3) — runs as ALTER TABLE … MODIFY COLUMN, and MySQL will not start one without an exclusive metadata lock on the table. Any other session holding a lock blocks it: a long-running transaction, an open REPEATABLE READ snapshot, a forgotten BEGIN in a shell, a stuck report query. (Why the platform stores datetime as DATETIME(3) rather than TIMESTAMP is covered in the type system.)

MySQL's own default wait for that lock, lock_wait_timeout, is 31,536,000 seconds — one year. Inherited, a blocked widening never returns and nothing prints, which no operator can tell apart from a crash. So SqlDriver sets lock_wait_timeout = 120 on the session running the ALTER, and puts the session's previous value back afterwards so the bound never reaches unrelated runtime queries (#9354, #9542).

The bound is armed on both paths that widen. What differs is what happens when it fires:

PathWhen the bound fires
os migrate apply (the deferred-DDL flush)Refuses — exit 1, with a DATABASE_ERROR / 500 envelope naming the lock wait, the table and the remedy
Boot schema sync (every boot against a managed MySQL datasource)Warns and carries on — the widening did not happen, and the platform starts anyway

Two answers, because the paths differ in who is waiting and what they can do about it. os migrate apply is a command an operator ran, whose whole contract is to report what it did — a swallowed lock wait there prints Applied 0 change(s) and calls it success. Boot is the path nobody can retry from a prompt, and correctness never depended on the widening having run, so failing the boot would trade a silent hang for a dead platform.

os migrate apply refuses

The refusal is an ADR-0112 envelope — DATABASE_ERROR, HTTP status 500 (nothing about the operator's request is at fault; the blocker is another session). The CLI prints the message and exits 1; under --json it comes back as error:

Migration of table 'contracts' timed out after 120s waiting for a MySQL metadata
lock (lock_wait_timeout). Another session is holding a lock on the table — a
long-running transaction or an open, uncommitted session. No schema change was
made. Identify the holder with `SHOW PROCESSLIST` or by querying
`performance_schema.metadata_locks`, end it, then re-run `os migrate apply` —
the widening is idempotent, so re-running is safe.

No schema change was made, so there is nothing to undo. Find the session holding the lock, end it, and re-run: the widening re-reads information_schema and does nothing to a column that is already widened.

Boot warns and carries on

On boot the bound ends the wait but not the boot. The warning is the only signal that the widening did not happen — the platform starts, serves traffic, and looks entirely normal.

[sql-driver] could not widen MySQL datetime columns on contracts; writes stay
correct, but the 2038 ceiling and millisecond truncation remain

[sql-driver] could not widen MySQL time columns on contracts; fractional-second
writes keep rounding to whole seconds

The server's own lock-wait error travels with the entry, in its error field. Match on the messages above rather than on that one — MySQL and MariaDB word the lock-wait error differently and both translate it.

What an un-widened column costs you is exactly what the warning says and no more: a TIMESTAMP column goes on accepting and returning the same UTC instants (the driver binds the same literal either way, and the session is pinned to UTC), it merely keeps the 32-bit 2038 ceiling and truncates milliseconds, and an un-widened TIME rounds fractional seconds away. The widening is idempotent, so the first boot after the blocker clears completes it — but nothing else reports that it is outstanding, so this line is worth alerting on wherever you collect logs.

Why boot waits the same 120 seconds rather than being more patient: the number reasons about how long a legitimate metadata-lock holder can plausibly hold the lock — a property of the lock, not of who is waiting on it. Two minutes sits three orders of magnitude above the milliseconds a normal OLTP transaction holds one, so an ordinary busy table never trips it, and well below the point an operator gives up on a command that has printed nothing. Boot's difference from the flush is what happens when the bound fires, never how long it waits.

The bound is deliberately not configurable and not retried. Both wait for measured demand: a knob added now would have to be supported forever on the evidence of one stall, and os migrate apply is re-runnable anyway.

MongoDB

Configuration properties for the MongoDB driver.

PropertyTypeRequiredDescription
urlstringConnection URI (e.g., mongodb://host:27017/db)
databasestringoptionalDatabase name (overrides the database in the URI)
maxPoolSizenumberoptionalMax connection pool size (default: 10)
minPoolSizenumberoptionalMin connection pool size (default: 1)
connectTimeoutMSnumberoptionalConnection timeout in milliseconds
serverSelectionTimeoutMSnumberoptionalServer selection timeout in milliseconds
optionsMongoClientOptionsoptionalAdditional MongoClient options (e.g. ssl, replicaSet, readPreference, authSource)

Example (config):

import { MongoDBDriver } from '@objectstack/driver-mongodb';

new MongoDBDriver({
  url: 'mongodb+srv://admin:secret@cluster.mongodb.net/myapp',
  maxPoolSize: 50,
  options: { readPreference: 'nearest' },
});

Example (env-var, recommended for objectstack dev / objectstack serve):

export OS_DATABASE_URL=mongodb://localhost:27017/myapp
pnpm dev

The CLI infers the MongoDB driver from the mongodb:// (or mongodb+srv://) URL scheme automatically — no OS_DATABASE_DRIVER needed. Set OS_DATABASE_DRIVER=mongodb only if you want to be explicit.

Multi-tenancy: not supported

The MongoDB driver is single-tenant only. It implements no row-level tenant isolation — unlike SqlDriver, it ignores DriverOptions.tenantId, so reads carry no tenant predicate and writes are not stamped with a tenant column.

Rather than serve a multi-tenant deployment without isolation, the driver refuses to start in one (#3724):

SignalChecked inResult
Tenancy posture is not singleOS_TENANCY_POSTURE=group/isolated, or derived from OS_MULTI_ORG_ENABLED=truenew MongoDBDriver(), re-checked in connect()throws MongoDBMultiTenantUnsupportedError; objectstack serve exits 1
An object declares tenancy.enabled: truesyncSchema() / syncSchemasBatch()throws, naming every offending object

The error carries code === 'MONGODB_MULTI_TENANT_UNSUPPORTED'. There is no override flag — one would restore exactly the silent cross-tenant access the guard prevents. For multi-tenant deployments use @objectstack/driver-sql (PostgreSQL / MySQL / SQLite), which enforces tenant scoping at the driver level.

SQLite (via @objectstack/driver-sql)

SQLite is ideal for local-first development and embedded applications. It uses the same SqlDriver class with client: 'better-sqlite3'.

pnpm add @objectstack/driver-sql better-sqlite3
import { SqlDriver } from '@objectstack/driver-sql';

new SqlDriver({
  client: 'better-sqlite3',
  connection: { filename: './data/app.db' },
  useNullAsDefault: true,
});

Env-var path (driver inferred from file: / :memory: / .db paths):

export OS_DATABASE_URL=file:./data/app.db
# or
export OS_DATABASE_URL=:memory:

Journal mode: WAL, and cross-process access

A file-backed SQLite database is switched to WAL (write-ahead logging) the first time ObjectStack connects to it. SQLite's own default is a rollback journal (journal_mode = delete), which is the wrong trade for the way this platform is actually used — several processes on one file: a dev server, os migrate, os meta resync, a test run.

rollback journal (SQLite default)WAL (ObjectStack default)
Reader while another process writesallowed until the writer commitsalways allowed (last committed snapshot)
Writer while another process readsblocked — committing needs an exclusive lock (SQLITE_BUSY)allowed
Idle connection visible to other processesno — a lock lasts only as long as its transactionyes, which is what makes the os migrate occupancy check reliable
Files on diskapp.db (plus a transient app.db-journal)app.db, plus app.db-wal / app.db-shm while a connection is attached

Concurrent writers still serialize — SQLite allows one at a time in any mode.

Two consequences worth knowing:

  • Journal mode is stored in the file. One connection sets it and it stays set, so an existing database is converted in place on the next boot (a header change; no rows are touched) and stays in WAL even when opened by other tools.
  • app.db-wal can hold committed data. A clean shutdown checkpoints and removes it, but do not copy or restore app.db on its own while a server is attached — use sqlite3 app.db ".backup …", as Backup & restore describes.

Opting out. WAL requires shared memory beside the database and therefore does not work on network filesystems (NFS/SMB). Set the journal mode back per deployment:

export OS_DATABASE_SQLITE_JOURNAL_MODE=delete

…or per datasource, which outranks the env var:

new SqlDriver({
  client: 'better-sqlite3',
  connection: { filename: '/mnt/share/app.db' },
  useNullAsDefault: true,
  sqliteJournalMode: 'delete',
});

Either form applies delete, so it also converts a database that already adopted WAL back. The driver never assumes the switch worked: it reads the mode back and, if WAL is accepted but cannot be read through (the usual network-share symptom), reverts to delete and says so. A database that stays on a rollback journal is slower under concurrency, never broken.

:memory: databases and the WASM SQLite driver are left on their own journal mode. Neither is shared between processes: the WASM driver's live database sits in the WASM heap and what reaches disk is a byte image it exports, so there is no cross-process concurrency for WAL to buy.

Space Reclamation & Table Rotation (ADR-0057)

The SQL driver connects SQLite with auto_vacuum=INCREMENTAL and exposes reclaimSpace() (PRAGMA incremental_vacuum), which the platform LifecycleService calls after every sweep that deleted rows — so the database file actually shrinks instead of pinning at its high-water mark. Objects declaring lifecycle.storage.strategy: 'rotation' are physically time-sharded on SQLite: writes land in the current shard, reads go through a view under the table's name, and expired shards are reclaimed with a single DROP TABLE. On PostgreSQL/MySQL both are no-ops — those engines manage space with their own vacuum machinery, and rotation falls back to an equivalent age-based reap.

Memory Driver

The in-memory driver keeps records in plain in-process objects (queried via mingo). Data is lost when the process exits unless persistence is explicitly requested. It is the last-resort fallback in dev mode: objectstack dev prefers native SQLite (better-sqlite3), falls back to the pure-JS WASM SQLite driver if the native binary is unavailable, and only drops to the in-memory driver if WASM also fails to load. Set OS_DATABASE_DRIVER=memory to select it explicitly.

Both ways of building the driver are ephemeral by default (#4083, #4065):

How it is builtPersistence
A declared datasource{ driver: 'memory' } in a stack/app configEphemeral. Nothing is written to disk unless the declaration sets config.persistence — and when it does, the destination is scoped per datasource, so two memory datasources never share one file.
new InMemoryDriver() constructed directlyEphemeral. Pass persistence to opt in (see below). Note the scoping above is the factory's job: two directly-constructed 'auto' drivers in one process still share the single default path.
import { InMemoryDriver } from '@objectstack/driver-memory';

new InMemoryDriver();                         // pure memory — the default
new InMemoryDriver({ persistence: 'file' });  // opt in to durability

Persistence is opt-in

A bare new InMemoryDriver() persists nothing. Durability is requested explicitly:

new InMemoryDriver({ persistence: 'file' })   // Node.js — .objectstack/data/memory-driver.json
new InMemoryDriver({ persistence: 'local' })  // browser — localStorage
new InMemoryDriver({ persistence: 'auto' })   // pick per environment (file / localStorage / off)

'auto' chooses localStorage in a browser, a file under Node.js, and disables persistence in serverless/edge runtimes (Vercel, Lambda, Netlify, Cloud Run, Deno Deploy) where a local write would be silently discarded — supply a custom adapter there instead.

Before v17 the default was 'auto', which on Node.js meant file — so a bare new InMemoryDriver() silently wrote .objectstack/data/memory-driver.json into the working directory and reloaded it on the next boot. If you relied on that, pass persistence: 'auto' (or 'file') explicitly. See #4065.

For tests, prefer in-memory SQLiteSqlDriver with connection: { filename: ':memory:' }, or SqliteWasmDriver({ filename: ':memory:' }) when you want no native build. Both give the SQL semantics production runs on; the memory driver enforces field-level unique (with the same per-organization scoping the SQL family applies) and nothing else — no primary keys, no NOT NULL, no column types and no object-level composite indexes[] — so a green run against it is still weaker evidence than it looks. The framework's own dogfood gate boots on WASM SQLite at :memory: for this reason.

The memory driver remains fine where you want no setup at all and the assertions do not depend on storage semantics — it is ephemeral by default, so one run cannot see what an earlier run left behind.

Local Environment Runtime

Local framework commands run one active environment. The business records served at /api/v1/data/* use the driver selected by OS_DATABASE_URL and OS_DATABASE_DRIVER.

# Run the showcase with MongoDB as the local environment DB:
OS_ENVIRONMENT_ID=env_local \
OS_DATABASE_URL=mongodb://localhost:27017/objectstack \
pnpm dev

# Pin the driver explicitly when the URL is ambiguous:
OS_DATABASE_URL=file:./.objectstack/data/app.db \
OS_DATABASE_DRIVER=sqlite \
pnpm dev

Cloud control-plane databases and environment provisioning live in the Cloud distribution outside this framework repo. When this runtime is pointed at Cloud, the environment id still flows through OS_ENVIRONMENT_ID, scoped URLs, or X-Environment-Id; mutable deployment config does not live in the compiled artifact.

Multi-Datasource

ObjectStack supports multiple data sources. Objects can target specific datasources:

Datasources are declared as metadata descriptors ({ driver, config }) — the map key becomes the datasource name:

import { defineStack } from '@objectstack/spec';

export default defineStack({
  datasources: {
    analytics: {
      driver: 'postgres',
      config: { url: process.env.ANALYTICS_URL },
    },
    cache: {
      driver: 'mongodb',
      config: { url: process.env.MONGO_URL },
    },
  },
});

Then in your object definition:

export const AuditLog = ObjectSchema.create({
  name: 'audit_log',
  sharingModel: 'private',
  datasource: 'analytics', // Routes to the analytics database
  fields: { /* ... */ },
});

Read-only: grant it at the database, not in metadata

A managed datasource has no platform-level read-only gate, and this is deliberate. Read-only for a database ObjectStack owns is a database account privilegeGRANT SELECT — not a key on the datasource. There is no metadata you can write that makes a managed connection read-only.

Point the datasource's config at an account that can only read:

-- PostgreSQL: a login that can read the schema and nothing else.
CREATE USER analytics_ro PASSWORD '…';
GRANT CONNECT ON DATABASE analytics TO analytics_ro;
GRANT USAGE ON SCHEMA public TO analytics_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO analytics_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO analytics_ro;
import { defineDatasource } from '@objectstack/spec/data';

export const Analytics = defineDatasource({
  name: 'analytics',
  label: 'Analytics (read-only account)',
  driver: 'postgres',
  // The connection itself cannot write. Nothing in the app can talk past it.
  config: { url: process.env.ANALYTICS_RO_URL },
  active: true,
});

(external.credentialsRef is a federation key — the parse rejects an external block on a managed datasource — so a managed connection carries its credentials in config, from the environment as above.)

Note that a read-only account also refuses DDL, so such a datasource cannot run ObjectStack's boot-time schema sync or migrations. That is the honest consequence of a real boundary: a database you only read is a database you do not own the schema of. If you want ObjectStack to keep the schema in step, it needs a writable account — or the datasource belongs on the federation path (schemaMode: 'external'), where DDL is forbidden by design and the write gate is enforced (see below).

Why the platform does not offer the flag

The obvious-looking alternative — a readOnly boolean on the datasource — is the exact shape #4583 removed. datasource.capabilities.readOnly shipped for three releases, read as a safety property, and gated nothing: no write path consulted it, so a datasource labelled a read replica accepted inserts exactly like the primary. The shipped CRM example called one of its datasources a "Read Replica" on the strength of it.

Rebuilding it as a working ObjectQL check would not fix the underlying problem, only make it harder to see. Such a gate stops writes that go through ObjectQLEngine; it cannot stop a direct psql session, a migration, a syncSchema() DDL statement, a background job holding its own driver handle, or any other process on the same connection string. A boundary that holds in one path and not the others is not a boundary — and a flag that looks like one is worse than no flag at all, because it is trusted. The database account has no such gap: there is no code path in ObjectStack, or anywhere else, that can write through a connection the server will not let write (#4584).

The one enforced write gate is federation-only

external.allowWrites: false is enforced, by ObjectQLEngine.assertWriteAllowed before every insert/update/delete — but it answers a question about ownership, not about connections: which side may write to a database ObjectStack does not own. You cannot reach for it on a local database: the parse rejects an external block whose schemaMode is managed, and the engine check itself returns early for managed (and for a definition that declares no schemaMode) before it ever reads allowWrites. See Writes (double opt-in).

What you wantWhat actually does it
A managed datasource that cannot be writtenA database account with SELECT only. No metadata key.
A federated datasource that cannot be writtenexternal: { allowWrites: false } — the default — enforced by the engine.
A federated datasource writable for some objectsexternal.allowWrites: true on the datasource and external.writable: true on each object.

Read replicas: the platform does not route

There is no read/write splitting in ObjectStack. No query path distinguishes a read from a write, so there is nothing to route to a replica. datasource.readReplicas was removed in 17.0.0 (#4468) because it described replica connections nothing ever opened.

Put the replicas behind a single endpoint and let the database tier route: pgpool-II, ProxySQL, or an RDS / Aurora reader endpoint. Point config at that endpoint.

This is the correct answer, not a stopgap (#4479). The hard parts of read/write splitting are not the replica connections — they are deciding what counts as a read (a find inside a transaction that just issued an update must go to the primary, or the app cannot read its own writes), declaring the staleness a query will tolerate, and ejecting a replica that falls behind. A proxy is a component built to do exactly that, and it does it better than a field on a datasource could. Should the platform ever need to pick a consistency level from business semantics, the schema shape will be decided by that routing path — it will not be bolted on ahead of it.

On this page