Each plugin in Checkstack has its own isolated database schema (e.g., plugin_catalog, plugin_auth). This ensures plugins don’t conflict with each other and allows for clean separation of concerns.
Plugins define tables using Drizzle’s pgTable() function. At runtime, the plugin loader sets search_path on each plugin’s database connection to route queries to the correct schema.
// plugins/my-feature-backend/src/schema.ts
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
// Define tables using pgTable (no schema prefix needed)
export const items = pgTable("items", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
description: text("description"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
When you run bun run generate, Drizzle produces schema-agnostic migrations:
CREATE TABLE "items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
At runtime, the plugin’s search_path ensures tables are created in the correct schema (e.g., plugin_my_feature).
[!IMPORTANT] Each plugin’s migrations are tracked in its own
plugin_{id}.__drizzle_migrationstable. This is configured automatically by the plugin loader using themigrationsSchemaoption.
This per-plugin tracking ensures:
Plugins can specify previousPluginIds in their metadata to safely rename:
export const pluginMetadata = definePluginMetadata({
pluginId: "new-feature-name",
previousPluginIds: ["old-feature-name"], // Old schema renamed automatically
});
When the plugin loads, the database factory automatically renames the old schema (plugin_old_feature_name) to the new one (plugin_new_feature_name) before migrations run.
Backend plugins with database schemas need:
{
"dependencies": {
"@checkstack/common": "workspace:*",
"drizzle-orm": "^0.45.1"
},
"devDependencies": {
"drizzle-kit": "^0.31.8"
}
}
Schema names follow the pattern plugin_{pluginId}:
pluginId: "catalog" → schema plugin_catalogpluginId: "auth" → schema plugin_authpluginId: "my-feature" → schema plugin_my_featureNote: Hyphens in plugin IDs are converted to underscores for valid PostgreSQL schema names.
Plugin database interactions should use SafeDatabase<S> instead of NodePgDatabase<S>:
import type { SafeDatabase } from "@checkstack/backend-api";
import type * as schema from "./schema";
type Db = SafeDatabase<typeof schema>;
Why SafeDatabase?
Drizzle’s NodePgDatabase includes a query property for the Relational Query API. However, this API bypasses PostgreSQL’s search_path mechanism and can access tables in other schemas, which breaks plugin isolation.
The platform’s scoped database proxy blocks relational queries at runtime and throws an error. SafeDatabase prevents this at compile-time by omitting the query property:
// SafeDatabase is just NodePgDatabase without 'query'
type SafeDatabase<S> = Omit<NodePgDatabase<S>, "query">;
Blocked API:
// ❌ This will fail at runtime (blocked by scoped database proxy)
const config = await db.query.items.findFirst({
where: eq(items.id, id),
});
Recommended Pattern:
// ✅ Use standard select queries
const [config] = await db
.select()
.from(items)
.where(eq(items.id, id))
.limit(1);