Skip to content

Storage adapters

Trokky stores documents and files through two separate adapters. storage.data holds documents, users, API tokens, webhooks, settings and audit logs. storage.media holds uploaded files and their generated variants. Both are required — there is no adapter that does both jobs.

Three adapters ship in @trokky/trokky:

AdapterKindWhat it uses
filesystem-datadataJSON files in directories you name
filesystem-mediamediaReal files in a directory you name
postgres-datadataA PostgreSQL database

There is no Postgres or S3 media adapter. If you move data to Postgres, media still goes through filesystem-media onto a disk.

An adapter registers itself in a global registry as a side effect of being imported. The configuration then names a registered adapter by string. Both halves are needed:

server.ts
import { TrokkyExpress } from '@trokky/trokky/express'
import '@trokky/trokky/adapters/postgres-data'
import '@trokky/trokky/adapters/filesystem-media'
const trokky = await TrokkyExpress.create({
schemas,
storage: {
data: {
adapter: 'postgres-data',
options: { connection: process.env.DATABASE_URL },
},
media: {
adapter: 'filesystem-media',
options: { mediaDir: '/data/media' },
},
},
})

Drop the import and the config still names postgres-data, but nothing registered it. Startup fails with Failed to create data adapter "postgres-data". A bundler that strips side-effect imports produces the same failure from a file that looks correct — see Traps for that one.

StorageConfig names adapters that do not exist

Section titled “StorageConfig names adapters that do not exist”

The adapter field is typed as a union that includes cloudflare-d1, dynamodb, cloudflare-r2 and s3. None of them are implemented in the package. TypeScript accepts them, and startup then fails because nothing registered under that name.

Treat the three in the table above as the list.

Documents become JSON files on disk, one file per document, under directories you name. You can read them, diff them, and commit them.

Choose it while you are learning, for single-editor sites, and for content you want reviewable in git. Its costs are real: there is no transaction across files, concurrent writers on the same document are not coordinated, and listing a collection means reading the directory. It also cannot outlive an ephemeral filesystem — see Deployment shapes.

OptionDefaultWhat it does
contentDir'./content'Documents, one directory per collection
usersDir'./users'User records, including password hashes
tokensDir'./tokens'API tokens and refresh tokens
webhooksDir'./webhooks'Webhook registrations
settingsDir'./settings'Instance settings
auditLogsDir'./audit-logs'Audit log entries
createDirstrueCreate the directories above if missing
prettyJsontrueIndent written JSON
jsonSpaces2Indentation width when prettyJson is on
silentfalseSuppress the adapter’s warning logs

Those six directories are independent. Naming only contentDir leaves users, tokens and audit logs writing to ./users, ./tokens and ./audit-logs relative to the process working directory, which is rarely what you meant. Set all six, or set none:

data: {
adapter: 'filesystem-data',
options: {
contentDir: path.join(process.cwd(), 'data/content'),
usersDir: path.join(process.cwd(), 'data/system/users'),
tokensDir: path.join(process.cwd(), 'data/system/tokens'),
webhooksDir: path.join(process.cwd(), 'data/system/webhooks'),
settingsDir: path.join(process.cwd(), 'data/system/settings'),
auditLogsDir: path.join(process.cwd(), 'data/system/audit-logs'),
},
}

The adapter’s own type also accepts syncWrites (default false), fileMode (default 0o644) and dirMode (default 0o755). These are not declared on the storage.data.options type, so passing them through TrokkyExpress.create() is a TypeScript error even though the values would reach the adapter at runtime.

Documents live in a data JSONB column, one row per document. Users, tokens, webhooks, settings and audit logs get their own tables, all prefixed.

Choose it when more than one person edits at once, when the content set is large enough that reading a directory per list is slow, or when your host has no persistent disk for data. The costs: you now operate a database, your content is no longer readable as files, and pg is an optional dependency of @trokky/trokky — if your installer skips optional dependencies, the adapter throws on import.

OptionDefaultWhat it does
connectionprocess.env.DATABASE_URL, then 'postgresql://localhost:5432/trokky'Connection string, or a pg PoolConfig object
schema'public'PostgreSQL schema holding the tables
tablePrefix'trokky_'Prefix on every table Trokky creates
pool.max20Maximum pooled connections
pool.idleTimeoutMillis30000Idle connection timeout
pool.connectionTimeoutMillis2000Pool acquisition timeout
autoMigratetrueCreate tables and indexes on construction
sslfalsePassed to pg as the ssl option
enableQueryLoggingfalseLog every query
connectionTimeout5000Connection timeout in milliseconds

schema and tablePrefix are validated against ^[a-zA-Z_][a-zA-Z0-9_]*$ and ^[a-zA-Z0-9_]*$ respectively, and the constructor throws on anything else. They are interpolated into SQL, which is why.

Two mismatches to know about. The storage.data.options type declares useMigrations and dropExisting; the adapter reads neither, so setting them does nothing. Conversely autoMigrate, ssl, enableQueryLogging and connectionTimeout are read by the adapter but not declared on that type, so TypeScript rejects them in a TrokkyExpress.create() call even though they would take effect. The options the type and the adapter agree on are connection, schema, tablePrefix and pool.

With autoMigrate on, migration starts during construction rather than on first query. A database that is unreachable at boot surfaces as a startup error, not as a failing request later.

Uploads are written as real files under mediaDir, alongside JSON metadata. Generated variants are written next to them.

There is no alternative media adapter, so the only decision is where mediaDir points and whether that path survives a redeploy.

OptionDefaultWhat it does
mediaDir'./media'Directory holding files, metadata and variants
mediaBaseUrl'/media'Prefix for generated media URLs
createDirstrueCreate mediaDir if missing
prettyJsontrueIndent written metadata JSON
jsonSpaces2Indentation width when prettyJson is on
silentfalseSuppress the adapter’s warning logs

mediaBaseUrl deserves a note. The adapter’s own default is null, which makes it emit file:// URLs. The Express integration overrides that with '/media' when you leave it unset, so through TrokkyExpress.create() you get relative URLs, not file:// ones. Set it to an absolute origin when the frontend fetching media is not on the same origin as the CMS.

As with filesystem-data, syncWrites, fileMode and dirMode exist on the adapter but not on the storage.media.options type.

The two adapters are chosen independently, so the useful combinations are:

  • filesystem-data + filesystem-media — everything on one disk. The default, and the right one locally.
  • postgres-data + filesystem-media — content in a database, uploads on a volume. This is the production shape, and it is the one where people forget that the volume is still needed.

There is no combination that removes the disk requirement, because media has nowhere else to go.

Schemas, queries, the Studio and the HTTP API are identical across adapters. Backups are too: trokky backup describes content rather than storage, so a filesystem backup restores into Postgres and back. Backup and restore covers what restore does and does not preserve.