OpenSEO Database Schema and Migration Strategy: A Complete Technical Guide
OpenSEO implements a provider-agnostic dual-dialect architecture using Drizzle ORM that simultaneously supports Cloudflare D1 (SQLite) and PostgreSQL Hyperdrive, enabling seamless runtime switching via environment variables while maintaining type safety through canonical schema barrels.
The open-source SEO platform every-app/open-seo manages all persistent data through a sophisticated database abstraction layer. Unlike traditional single-database architectures, OpenSEO maintains parallel SQLite and PostgreSQL schema definitions that share identical type shapes, allowing developers to run locally on D1 while deploying to production PostgreSQL instances without code changes.
Dual-Dialect Schema Architecture
OpenSEO’s database layer revolves around a provider-aware schema barrel that dynamically selects the appropriate dialect at runtime. This design eliminates vendor lock-in while ensuring consistent data structures across environments.
Provider Detection and Runtime Selection
The system detects the active database provider through getDatabaseProvider() in src/db/provider.ts, which reads the DATABASE_PROVIDER environment variable. When set to "postgres", the application loads PostgreSQL table definitions; otherwise, it defaults to SQLite modules.
// src/db/schema.ts (excerpt)
import { getDatabaseProvider } from "./provider";
import * as sqliteApp from "./app.schema";
import * as pgApp from "./pg/app.schema";
type AppSchema = typeof sqliteApp & typeof sqliteAudit;
const runtimeSchema =
getDatabaseProvider() === "postgres"
? { ...pgApp, ...pgAudit }
: { ...sqliteApp, ...sqliteAudit };
export const { projects, savedKeywords, audits, user } =
runtimeSchema as unknown as AppSchema;
Canonical Schema Barrel Structure
The central src/db/schema.ts acts as the single source of truth for the entire application. It imports raw SQLite definitions from src/db/*.schema.ts files and PostgreSQL equivalents from src/db/pg/*.schema.ts, then re-exports a unified interface. This allows business logic to import tables without concern for the underlying dialect.
Type Safety and Schema Parity
To prevent drift between dialects, OpenSEO includes src/db/schema-parity.test.ts, a dedicated test suite that enforces structural parity between SQLite and PostgreSQL table definitions. A type assertion (schema as unknown as AppSchema) guarantees that both dialects expose identical shapes, catching column mismatches or type inconsistencies during development rather than at runtime.
Table Definitions and Structure
Each logical entity exists as parallel implementations optimized for its respective database engine while maintaining API compatibility.
Core Application Tables
The SQLite canonical definitions reside in src/db/app.schema.ts, defining core entities including projects, saved keywords, rank-tracking data, and audit results using Drizzle’s sqliteTable helper:
// src/db/app.schema.ts (conceptual)
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const projects = sqliteTable("projects", {
id: text("id").primaryKey(),
name: text("name").notNull(),
deleted: integer("deleted", { mode: "timestamp" }),
});
Authentication Schema
User authentication tables (user, session, account) are defined in src/db/better-auth-schema.ts following the Better Auth specification, ensuring compatibility with the authentication library while maintaining the dual-dialect pattern.
PostgreSQL Equivalents
PostgreSQL variants live in src/db/pg/app.schema.ts and utilize the pgTable helper. These mirror the SQLite structures but leverage PostgreSQL-specific features where appropriate, such as native UUID support or array types, while maintaining interface consistency.
Migration Handling with Drizzle-Kit
OpenSEO maintains separate migration pipelines for each dialect, using Drizzle-Kit to generate SQL migration files while providing npm scripts for common operations.
SQLite/D1 Migration Pipeline
Configuration for Cloudflare D1 resides in drizzle.config.ts at the repository root:
import { defineConfig } from "drizzle-kit";
import { getLocalD1Url } from "@every-app/sdk/cloudflare/server";
export default defineConfig({
dialect: "sqlite",
schema: "./src/db/d1/schema.ts",
out: "./drizzle",
dbCredentials: { url: getLocalD1Url() || "" },
});
Apply SQLite migrations using Wrangler commands:
# Local development
wrangler d1 migrations apply DB --local
# Production
wrangler d1 migrations apply DB --remote
Shortcut via package.json scripts:
pnpm db:migrate:local # Apply to local D1
pnpm db:migrate:prod # Apply to production D1
PostgreSQL Migration Pipeline
PostgreSQL configuration lives in drizzle-pg.config.ts, reading credentials from environment variables:
import { defineConfig } from "drizzle-kit";
import { loadLocalEnv } from "./scripts/cli-utils";
loadLocalEnv(); // Loads POSTGRES_DATABASE_URL from .env.local
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/pg/schema.ts",
out: "./drizzle-pg",
dbCredentials: { url: process.env.POSTGRES_DATABASE_URL! },
});
Execute PostgreSQL migrations via:
pnpm db:migrate:pg # Runs drizzle-kit migrate
Schema Generation Workflow
When modifying table structures, developers must update both dialects to maintain parity:
- Edit
src/db/app.schema.ts(SQLite) andsrc/db/pg/app.schema.ts(PostgreSQL) simultaneously - Run
pnpm db:generateto regenerate TypeScript types and create SQLite migration files indrizzle/ - Run
pnpm db:generate:pgfor PostgreSQL equivalents indrizzle-pg/ - Apply migrations to both providers using the respective commands
Generated migrations are plain SQL files (e.g., drizzle/0001_round_unus.sql) stored in dialect-specific directories and applied automatically by their respective tooling.
Cross-Provider Data Migration
For production transitions from D1 to PostgreSQL, OpenSEO provides scripts/migrate-d1-to-postgres.ts, a utility script that performs paginated batch transfers while preserving IDs and handling conflicts. This one-time migration tool reads from SQLite in chunks and inserts into PostgreSQL tables, with detailed runbooks available in runbooks/d1-to-postgres-simple.md and runbooks/d1-to-postgres-detailed.md.
Provider-Agnostic Query Patterns
Application code remains database-agnostic by importing from the canonical barrel:
import { db } from "@/db"; // Provider-aware Drizzle client
import { projects } from "@/db/schema";
async function listActiveProjects() {
return await db
.select()
.from(projects)
.where(projects.deleted.isNull())
.all();
}
The db client initialization automatically configures the correct driver based on DATABASE_PROVIDER, ensuring queries execute identically regardless of the underlying engine.
Summary
- Dual-dialect architecture: OpenSEO supports both Cloudflare D1 (SQLite) and PostgreSQL through parallel schema definitions in
src/db/andsrc/db/pg/. - Runtime provider selection: The
getDatabaseProvider()function switches implementations based on theDATABASE_PROVIDERenvironment variable. - Type safety:
src/db/schema-parity.test.tsenforces structural parity between dialects, whilesrc/db/schema.tsprovides a unified export interface. - Separate migration pipelines: SQLite uses
drizzle.config.tsand Wrangler commands; PostgreSQL usesdrizzle-pg.config.tsand Drizzle-Kit. - Workflow automation: npm scripts (
pnpm db:generate,pnpm db:migrate:pg) streamline schema updates across both providers. - Data portability: Built-in migration scripts facilitate one-time transfers from D1 to PostgreSQL without data loss.
Frequently Asked Questions
How does OpenSEO switch between SQLite and PostgreSQL at runtime?
OpenSEO reads the DATABASE_PROVIDER environment variable through getDatabaseProvider() in src/db/provider.ts. When set to "postgres", the runtime schema barrel exports PostgreSQL table definitions; otherwise, it exports SQLite equivalents. Both implementations share identical TypeScript interfaces through a type assertion, ensuring compile-time safety regardless of the active provider.
Where are the database migrations stored in the OpenSEO repository?
Migration files reside in dialect-specific directories: drizzle/ contains SQLite migration SQL files for D1, while drizzle-pg/ stores PostgreSQL migrations. These are generated by Drizzle-Kit based on the respective config files (drizzle.config.ts and drizzle-pg.config.ts) and should be committed to version control to ensure consistency across environments.
What happens if the SQLite and PostgreSQL schemas diverge?
The repository includes src/db/schema-parity.test.ts, a test suite that validates structural equivalence between the two dialects. Additionally, the type system enforces parity through the AppSchema type in src/db/schema.ts, which requires both SQLite and PostgreSQL module types to align. Any divergence will trigger TypeScript compilation errors or test failures during the build process.
Can I migrate existing data from Cloudflare D1 to PostgreSQL without downtime?
OpenSEO provides scripts/migrate-d1-to-postgres.ts specifically for this scenario. The script executes paginated reads from SQLite and batch inserts into PostgreSQL while preserving primary keys and handling unique constraint conflicts. While the migration runs, you should pause writes to the D1 database, run the script, then update DATABASE_PROVIDER to "postgres" and redeploy.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →