Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/fajarnugraha37/drizzle-castor/llms.txt

Use this file to discover all available pages before exploring further.

The schema builder is the entry point for everything in Drizzle Castor. You call createSchemaBuilder once, chain configuration methods to register tables, profiles, policies, middleware, and logging, then call .build() to produce a CastorInstance — the object that exposes repoFactory and subscribeToTelemetry. Because each method returns a new or mutated builder, the entire configuration reads as a single fluent expression.

createSchemaBuilder

createSchemaBuilder accepts three parameters and returns a SchemaBuilder instance.
db
AnyDatabase
required
A Drizzle ORM database instance. Supported drivers: BunSQLiteDatabase, BetterSQLite3Database, LibSQLDatabase, PostgresJsDatabase, NodePgDatabase, MySql2Database, and AnyD1Database.
tables
readonly AnyTable[]
required
An array of Drizzle table objects passed as const. These are the tables Castor will manage. Any table referenced in relations or repoFactory calls must appear here.
mode
"strict" | "lenient"
default:"\"lenient\""
Controls access-control behaviour for tables that have no policy registered. See the section below for details.
import { drizzle } from "drizzle-orm/bun-sqlite";
import { createSchemaBuilder } from "@fajarnugraha37/drizzle-castor";
import { usersTable, postsTable, profilesTable } from "./schema";

const db = drizzle("sqlite.db");

const builder = createSchemaBuilder(db, [
  usersTable,
  postsTable,
  profilesTable,
] as const, "lenient");

Strict vs lenient mode

When mode is "lenient", any table that has no policy defined allows all actions for all profiles. This is safe for local development and internal tools, but you should switch to "strict" before exposing endpoints to untrusted callers.
const builder = createSchemaBuilder(db, tables, "lenient");
// Tables without a .policies() call allow every action.
Running in lenient mode emits a warning log at startup: [Drizzle-Castor] Warning: Running in lenient mode. Unprotected tables will allow all actions by default. Treat this as a reminder to harden your policies before production.

Builder methods

Every method below is available on the SchemaBuilder instance returned by createSchemaBuilder. Methods that require type-level changes (.profiles(), .table()) return a new builder carrying updated generics; all other methods mutate and return this.

.profiles(profiles)

Registers the valid profile strings that callers may pass to repository methods. TypeScript uses this tuple to narrow the profile parameter across the entire instance.
const builder = createSchemaBuilder(db, tables, "lenient")
  .profiles(["default", "public", "admin", "user"] as const);
Always declare profiles before calling .policies() so that TypeScript can enforce that policy keys match a declared profile name.

.withLogger(config)

Configures the internal Pino-based logger. Accepts a LoggerConfig object with a level and an optional Quarkus-style format string.
config.level
"TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR"
default:"\"WARN\""
Minimum log level to emit.
config.format
string
Format string. Supports %d{HH:mm:ss} (datetime), %p (level), %c (category), %t (traceId), %s (message), and %{key.path} (ExecutionContext injection).
const builder = createSchemaBuilder(db, tables)
  .withLogger({
    level: "INFO",
    format: "%d{HH:mm:ss} %p [%c] (%t) %s",
  });

.table(tableName, config)

Registers relation and soft-delete metadata for a named table. This is the primary way to teach Castor about your data model without touching the physical Drizzle schema. Each call returns a new SchemaBuilder with the table’s config merged into the type-level metadata map, so TypeScript can validate relation paths used in queries.
builder.table("users", {
  oneToOne: [
    {
      relationName: "profile",
      relatedTable: "profiles",
      localKey: "users.id",
      foreignKey: "profiles.userId",
    },
  ],
  oneToMany: [
    {
      relationName: "posts",
      relatedTable: "posts",
      localKey: "users.id",
      foreignKey: "posts.userId",
    },
  ],
  softDelete: {
    deleteValue: { deletedFlag: 1 },
    restoreValue: { deletedFlag: 0 },
  },
});
See Defining Table Relations for the full set of relation types and the M:N joinTable shape.

.policies(tableName, policy) and .policies(globalPolicy)

Registers an access-control policy. Two overloads are available.
Pass a table name and a policy map where each key is a profile name. Values can be static config objects or async functions.
builder.policies("users", {
  public: {
    allowedActions: ["read"],
    allowedProjections: ["name", "profile.bio"],
    allowedFilters: ["name", "email", "settings.theme"],
  },
  admin: {
    allowedActions: "*",
    allowedSets: "*",
    allowedProjections: "*",
    allowedFilters: "*",
    allowedSorts: "*",
  },
  user: async (ctx) => {
    const isOwner = ctx.params.id === ctx.state.userId;
    return {
      allowedActions: isOwner ? ["read", "update"] : ["read"],
      allowedSets: ["settings.theme", "persona.skills"],
      allowedProjections: ["*"],
      allowedFilters: "*",
      allowedSorts: "*",
    };
  },
});

.use(middleware, config?)

Registers a Koa-style middleware that wraps every repository action. Middlewares execute in registration order before the RBAC layer.
middleware
Middleware
required
An async function (ctx, next) => Promise<any>. Call next() to pass control downstream.
config.tables
string | string[]
Restrict this middleware to specific tables. If omitted, it applies to all tables.
config.actions
DbAction | DbAction[]
Restrict this middleware to specific actions ("create", "read", "update", "softDelete", "restore", "hardDelete").
builder.use(async (ctx, next) => {
  console.log(`Executing ${ctx.action} on table ${ctx.tableName}`);
  const result = await next();
  console.log("Finished execution!");
  return result;
});

.on(type, handler) and .off(type, handler)

Subscribe or unsubscribe from telemetry events emitted via the internal mitt event bus. Events are emitted asynchronously and never block query execution. Available event types: "execution", "security", "error", "soft-deleted", "restored", "hard-deleted".
builder.on("execution", (ev) => {
  // ev: { action, tableName, duration, status, traceId }
  myMetrics.histogram("db_latency", ev.duration, { table: ev.tableName });
});

builder.on("security", (ev) => {
  console.warn(`[Audit] Security event on ${ev.tableName}: ${ev.message}`);
});

.build()

Finalises configuration and returns a CastorInstance. Call this exactly once after all builder methods have been chained.
db
AnyDatabase
The original Drizzle database instance.
tables
readonly AnyTable[]
The table array passed to createSchemaBuilder.
metadata
TMetadata
The accumulated table configs registered via .table() calls.
repoFactory
(tableName: TName) => Repository
Factory function that creates a fully typed repository for a named table. See Working with Repositories.
subscribeToTelemetry
(subscriber) => () => void
Programmatic alternative to .on(). Returns an unsubscribe function.

Full example

The following example chains all builder methods in the order recommended for production use.
import { drizzle } from "drizzle-orm/bun-sqlite";
import { createSchemaBuilder } from "@fajarnugraha37/drizzle-castor";
import {
  usersTable,
  postsTable,
  commentsTable,
  profilesTable,
  companiesTable,
  groupsTable,
  userGroups,
} from "./schema";

const db = drizzle("sqlite.db");

export const schemaMetadataBuilder = createSchemaBuilder(
  db,
  [
    usersTable,
    postsTable,
    commentsTable,
    profilesTable,
    companiesTable,
    groupsTable,
    userGroups,
  ] as const,
  "lenient",
)
  // 1. Declare valid profiles
  .profiles(["default", "public", "admin", "user"] as const)

  // 2. Configure structured logging
  .withLogger({
    level: "INFO",
    format: "%d{HH:mm:ss} %p [%c] (%t) %s",
  })

  // 3. Register table relations and soft-delete config
  .table("users", {
    oneToOne: [
      {
        relationName: "profile",
        relatedTable: "profiles",
        localKey: "users.id",
        foreignKey: "profiles.userId",
      },
    ],
    oneToMany: [
      {
        relationName: "posts",
        relatedTable: "posts",
        localKey: "users.id",
        foreignKey: "posts.userId",
      },
    ],
    softDelete: {
      deleteValue: { deletedFlag: 1 },
      restoreValue: { deletedFlag: 0 },
    },
  })

  // 4. Register access-control policies
  .policies("users", {
    public: {
      allowedActions: ["read"],
      allowedProjections: ["name", "profile.bio"],
      allowedFilters: ["name", "email", "settings.theme"],
    },
    admin: {
      allowedActions: "*",
      allowedSets: "*",
      allowedProjections: "*",
      allowedFilters: "*",
      allowedSorts: "*",
    },
  })

  // 5. Register custom middleware
  .use(async (ctx, next) => {
    console.log(`[Audit] ${ctx.action} on ${ctx.tableName}`);
    return next();
  })

  // 6. Subscribe to telemetry events
  .on("execution", (ev) => {
    myMetrics.histogram("db_latency", ev.duration, { table: ev.tableName });
  });

// Finalise and export the CastorInstance
export const schemaMetadata = schemaMetadataBuilder.build();

// Obtain a typed repository
const userRepo = schemaMetadata.repoFactory("users");
Store the result of .build() in a module-level export so that repositories are created once and reused across your application, rather than rebuilt on every request.

Working with Repositories

Explore every repository method, factory helpers, and how TypeScript infers return types from your projection.

Access control policies

Deep-dive into action-level and field-level RBAC configuration.

Middleware pipeline

Learn how the Koa-style middleware stack wraps every database action.

API: createSchemaBuilder

Full API reference for the builder factory function and all its parameters.

Build docs developers (and LLMs) love