Aphex

Database

Configure PostgreSQL, run migrations, and customize multi-tenancy. Use the DatabaseAdapter interface only if you're building a custom backend.

Aphex ships with a PostgreSQL adapter built on Drizzle ORM. Most projects need to do nothing more than set DATABASE_URL and run pnpm db:push. The interface details at the bottom of this page are only relevant if you're writing a custom adapter for a non-Postgres backend.

Quick setup

The base template wires this up for you:

Set the connection string

.env
DATABASE_URL=postgres://root:mysecretpassword@localhost:5432/local

You can also split it into discrete vars (PG_HOST, PG_PORT, PG_USER, PG_PASSWORD, PG_DATABASE) — pgConnectionUrl(env) reads either form.

Boot Postgres

pnpm db:start    # docker compose up -d (Postgres + Mailpit)

The docker-compose.yml in your project root spins up a Postgres 18 container with the env-driven credentials.

Push the schema

pnpm db:push     # dev — direct schema sync
# or, for prod:
pnpm db:generate # produces a SQL migration file
pnpm db:migrate  # applies it

Drizzle Kit generates migrations into ./drizzle/ with a journal at drizzle/meta/_journal.json.

On first request after migrations, the CMS hook calls initializeRLS() to set up Row-Level Security policies on cms_documents and cms_assets. You don't need to run anything manually.

What the template wires up

src/lib/server/db/index.ts is the only file you usually touch — it builds a singleton DatabaseAdapter from the Postgres provider and Drizzle schema:

src/lib/server/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { createPostgreSQLProvider, pgConnectionUrl } from '@aphexcms/postgresql-adapter';
import * as cmsSchema from './cms-schema';
import * as authSchema from './auth-schema';
import type { DatabaseAdapter } from '@aphexcms/cms-core/server';
import { env } from '$env/dynamic/private';

const schema = { ...cmsSchema, ...authSchema };

export const client = postgres(pgConnectionUrl(env), { max: 10 });
export const drizzleDb = drizzle(client, { schema });

const provider = createPostgreSQLProvider({
	client,
	multiTenancy: {
		enableRLS: true, // database-level isolation (default: true)
		enableHierarchy: true // parent → child org reads (default: true)
	}
});

export const db = provider.createAdapter() as DatabaseAdapter;

The Drizzle instance merges CMS tables (documents, assets, schemas, organizations) with auth tables (Better Auth users, sessions, accounts). Both schemas live next to it:

index.ts
cms-schema.ts
auth-schema.ts

Connection pooling

The default pool is 10 connections. Tune it for your deployment:

export const client = postgres(pgConnectionUrl(env), {
	max: 10, // serverless: keep small (1–3); long-lived: 10–50
	idle_timeout: 20
});

Multi-tenancy

Aphex stores every document and asset against an organizationId. Two layers enforce isolation:

Row-Level Security (RLS)

Before each query the adapter runs:

SET LOCAL app.organization_id = '<uuid>';

The RLS policy then filters rows automatically:

OperationBehaviour
SELECTreturns rows from the current org and any child organizations
INSERT / UPDATE / DELETEonly allowed against the current org (parents can't write to children)

That asymmetry — read down the tree, write only your own — is what enables the parent / child publishing workflows.

System operations

Background jobs, migrations, and seed scripts use systemContext(), which sets app.override_access = true and bypasses RLS:

import { systemContext } from '@aphexcms/cms-core/server';

const docs = await localAPI.collections.post.find(
	systemContext('org-id'), // overrideAccess: true
	{ perspective: 'published' }
);

Single-tenant deployments

If you don't need multi-tenancy, disable both flags. RLS still adds about 0.5–1 ms per query — measurable at scale.

const provider = createPostgreSQLProvider({
	client,
	multiTenancy: {
		enableRLS: false,
		enableHierarchy: false
	}
});

Document lifecycle

Documents follow a draft / published model with hash-based change detection:

Create — always starts as status: 'draft' with draftData only.

Auto-save — the admin UI saves draft changes every 2 seconds via updateDocDraft().

Publish — copies draftData to publishedData, generates a publishedHash, sets publishedAt. A new entry is written to cms_document_versions.

Unpublish — clears publishedData and publishedHash, reverts to status: 'draft'.

Delete — permanently removes the document and its version history.

publishedHash is a 20-character base36 hash with sorted object keys, so the admin UI can reliably show "you have unpublished changes" without diffing JSON.

Migrations workflow

drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
	schema: './src/lib/server/db/schema.ts',
	dialect: 'postgresql',
	dbCredentials: { url: databaseUrl },
	verbose: true,
	strict: true
});
CommandWhen to use
pnpm db:generateAfter editing your Drizzle schema. Produces a SQL migration file.
pnpm db:pushDev only. Pushes schema directly without writing a migration.
pnpm db:migrateProduction. Applies pending migrations.
pnpm db:studioOpens Drizzle Studio at localhost:4983 for inspecting tables.

For production, always go through db:generate → review the SQL → db:migrate. db:push will happily drop a column without warning.

Advanced filtering

The adapter translates the where syntax in the Local API directly into JSONB operators:

const posts = await localAPI.collections.post.find(context, {
	where: {
		title: { contains: 'tutorial' },
		status: { equals: 'published' }
	},
	sort: ['-publishedAt'],
	limit: 10,
	depth: 1 // resolve one level of references
});
FilterSQL it produces
equals=
containsILIKE '%value%'
in= ANY(...)
greater_than> (numeric / date)
'a.b': { ... }data->'a'->>'b'

References are resolved recursively up to a configurable depth (0–5). At depth: 0, references come back as { _ref: 'doc-id' }. At higher depths the referenced document is inlined. Circular references are detected and skipped.

Table reference

You'll only need this when writing custom adapters or doing direct SQL.

cms_documents

ColumnTypeDescription
iduuidPrimary key
organizationIduuidForeign key to cms_organizations
typevarchar(100)Schema name (post, page, …)
statusenum'draft' or 'published'
draftDatajsonbCurrent working version
publishedDatajsonbLive version (null until published)
publishedHashvarchar(20)Content hash for change detection
createdBytextUser ID
updatedBytextUser ID
publishedAttimestampWhen last published
createdAttimestampCreation time
updatedAttimestampLast modification

cms_assets

ColumnTypeDescription
iduuidPrimary key
organizationIduuidFK to organizations
assetTypevarchar(20)'image' or 'file'
filenamevarchar(255)Generated filename
originalFilenamevarchar(255)Original upload name
mimeTypevarchar(100)MIME type
sizeintegerBytes
urltextPublic URL
pathtextInternal storage path
storageAdaptervarchar(50)Adapter that stored the file
width, heightintegerImage dimensions (null for files)
metadatajsonbImage metadata (format, color, …)
title, description, alt, creditLinetextEditor-supplied metadata

Other tables

  • cms_document_versions — version history (one row per draft save / publish). See Version History.
  • cms_schema_types — registered document and object type definitions (fields stored as JSONB).
  • cms_organizations — organizations with parentOrganizationId for hierarchy.
  • cms_organization_members — user-to-organization membership with role.
  • cms_organization_roles — built-in and custom roles per org with capability arrays.
  • cms_invitations — pending org invitations with token and expiry.
  • cms_user_sessions — tracks each user's active organization.
  • cms_user_profiles — CMS-specific user data (instance role, preferences).
  • cms_instance_settings — single-row instance configuration.

Custom adapters

Implement DatabaseAdapter (a composite of specialized interfaces) and pass the result to createCMSConfig({ database }):

interface DatabaseAdapter
	extends
		DocumentAdapter,
		AssetAdapter,
		UserProfileAdapter,
		SchemaAdapter,
		OrganizationAdapter,
		InstanceAdapter {
	connect?(): Promise<void>;
	disconnect?(): Promise<void>;
	isHealthy(): Promise<boolean>;

	// Multi-tenancy (optional)
	initializeRLS?(): Promise<void>;
	hierarchyEnabled: boolean;
	withOrgContext?<T>(organizationId: string, fn: () => Promise<T>): Promise<T>;
	getChildOrganizations(parentOrganizationId: string): Promise<string[]>;

	// First-user detection
	hasAnyUserProfiles?(): Promise<boolean>;
}

Each sub-interface is a focused contract:

InterfaceResponsibility
DocumentAdapterCRUD for documents, publishing, advanced queries with filtering / sorting / pagination
AssetAdapterCRUD for assets, advanced filtering, reference counting
UserProfileAdapterCMS user profiles (instance role, preferences)
SchemaAdapterSchema type registration and retrieval
OrganizationAdapterOrganizations, members, invitations, user sessions
InstanceAdapterInstance-level settings

The @aphexcms/postgresql-adapter source is the reference implementation — clone it as a starting point.

See also

Edit on GitHub

Last updated on