ObjectStackObjectStack

Database Drivers

Configuration reference for supported database drivers

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

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.

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; mingo does not enforce primary keys, uniqueness, NOT NULL or column types, so a green run against the memory driver is 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',
  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