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.

SchemaBuilder is the fluent configuration object returned by createSchemaBuilder. You chain its methods to register profiles, access-control policies, Koa-style middleware, relation metadata, logging, and telemetry before finalising the configuration with .build(). Most methods mutate and return this; .profiles() and .table() return a new SchemaBuilder with updated type parameters.

.profiles()

profiles<const P extends readonly string[]>(
  profiles: P
): SchemaBuilder<TDb, TTables, TMetadata, P>
Registers the set of valid profile names for this builder. Profiles represent the roles or personas that will be passed to repository methods at runtime (e.g. "admin", "public", "user"). Calling this method creates a new SchemaBuilder whose TProfiles type parameter is narrowed to the provided literal tuple, giving compile-time checks on .policies() profile keys and repository call sites.
profiles
readonly string[]
required
A const tuple of string literals that name each valid profile. Must include "default" if you rely on the default fallback profile.
const builder = createSchemaBuilder(db, [usersTable, postsTable] as const)
  .profiles(["default", "public", "admin", "user"] as const);
Pass the array with as const so TypeScript infers the individual string literals rather than string[]. Without it, policy profile keys lose their narrowed type.

.on()

on<K extends keyof CastorEvents>(
  type: K,
  handler: (event: CastorEvents[K]) => void
): this
Subscribes a handler to a telemetry event emitted by the Castor runtime. Returns this for chaining. All registered handlers fire asynchronously after each operation completes.
type
K extends keyof CastorEvents
required
The event name to subscribe to. Available events:
handler
(event: CastorEvents[K]) => void
required
The callback invoked with the typed event payload.
builder
  .on("execution", (ev) => {
    // ev: ExecutionEventPayload
    myMetrics.histogram("db_latency", ev.duration, { table: ev.tableName });
  })
  .on("security", (ev) => {
    // ev: SecurityEventPayload
    console.warn(`[Audit] Security event on ${ev.tableName}: ${ev.message}`);
  })
  .on("error", (ev) => {
    // ev: ErrorEventPayload
    Sentry.captureException(ev.error);
  });

.off()

off<K extends keyof CastorEvents>(
  type: K,
  handler: (event: CastorEvents[K]) => void
): this
Unsubscribes a previously registered event handler. You must pass the exact same function reference that was passed to .on().
type
K extends keyof CastorEvents
required
The event name from which to remove the handler. See .on() for the full list of available event names.
handler
(event: CastorEvents[K]) => void
required
The exact handler function reference to remove.
const executionHandler = (ev: ExecutionEventPayload) => {
  console.log(ev.traceId);
};

builder.on("execution", executionHandler);
// ... later:
builder.off("execution", executionHandler);

.policies()

policies has two overloads: a global fallback and a table-specific form.

Global policy overload

policies(
  policy: GlobalPolicyDefinition<TSchemaContext<TDb, TTables, TMetadata>, TProfiles[number]>
): this
Registers a global fallback policy that applies to every table that does not have its own table-specific policy. The function receives the current ExecutionContext, the name of the table being queried, and the active profiles array.
policy
GlobalPolicyDefinition
required
A function with the signature:
(
  ctx: ExecutionContext,
  tableName: string,
  activeProfiles: string[],
) => UnifiedPolicyConfig | Promise<UnifiedPolicyConfig>
builder.policies((ctx, tableName, profiles) => {
  if (profiles.includes("admin")) return { allowedActions: "*" };
  return { allowedActions: ["read"] }; // read-only fallback for everything else
});

Table-specific policy overload

policies<TName extends TableName<TTables[number]>>(
  tableName: TName,
  policy: PolicyDefinition<TSchemaContext<TDb, TTables, TMetadata>, TName, TProfiles[number]>
): this
Registers a policy scoped to a single named table. Takes precedence over the global policy for that table. PolicyDefinition can be either a static profile map or a single async function.
tableName
TName extends TableName<TTables[number]>
required
The literal name of the table as declared in the Drizzle schema.
policy
PolicyDefinition
required
Either a profile map (an object keyed by profile names, each value being a UnifiedPolicyConfig or an async function returning one) or a single async function (ctx, activeProfiles) => UnifiedPolicyConfig.
builder.policies("users", {
  // Static policy for "public" profile
  public: {
    allowedActions: ["read"],
    allowedFilters: ["name", "email", "settings.theme"],
    allowedProjections: ["name", "profile.bio"],
  },

  // Full access for "admin"
  admin: {
    allowedActions: "*",
    allowedSets: "*",
    allowedProjections: "*",
    allowedFilters: "*",
    allowedSorts: "*",
  },

  // Dynamic policy for "user" profile
  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: "*",
    };
  },
});
You can register both a global policy and table-specific policies in the same builder. Table-specific policies always win for their own table; the global policy handles everything else.

.use()

use(middleware: Middleware, config?: MiddlewareConfig<TTables>): this
Registers a Koa-style middleware that intercepts every repository operation before it reaches the RBAC layer and the database. Middleware runs in registration order. Each function receives an ExecutionContext and a next callback; you must call and await next() to continue the pipeline.
middleware
Middleware
required
An async function with the signature:
(ctx: ExecutionContext, next: () => Promise<any>) => Promise<any>
ctx exposes: action, tableName, profile, params, metadata, and the full translatorContext (db, tables, metadata).
config
MiddlewareConfig
default:"undefined"
Optional scoping configuration.
// Runs for every table and every action
builder.use(async (ctx, next) => {
  console.log(`[${ctx.action}] → ${ctx.tableName}`);
  const result = await next();
  console.log(`[${ctx.action}] ← ${ctx.tableName} done`);
  return result;
});

// Scoped to the "users" table, "create" and "update" actions only
builder.use(
  async (ctx, next) => {
    ctx.metadata.requestedAt = Date.now();
    return next();
  },
  { tables: "users", actions: ["create", "update"] },
);

.table()

table<
  TName extends TableName<TTables[number]>,
  const TConfig extends TSchemaMetadata<TDb, TTables>[TName],
>(
  tableName: TName,
  config: TConfig,
): SchemaBuilder<TDb, TTables, TMetadata & { [K in TName]: TConfig }, TProfiles>
Registers relation definitions and soft-delete configuration for a table. Returns a new SchemaBuilder whose TMetadata type parameter is extended with the supplied config, giving downstream methods full type information about which tables have soft-delete enabled.
tableName
TName extends TableName<TTables[number]>
required
The literal name of the table as declared in the Drizzle schema.
config
TConfig extends TSchemaMetadata[TName]
required
Table-level metadata. All fields are optional.
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 },
  },
});

.withLogger()

withLogger(config: LoggerConfig): this
Configures the internal Castor logger. By default the logger is set to "WARN". Call this method to increase verbosity during development or to reduce it in production.
config
LoggerConfig
required
Logger configuration object.
builder.withLogger({
  level: "INFO",
  format: "%d{HH:mm:ss} %p [%c] (%t) %s",
});

.withThrowError()

withThrowError(val: boolean): this
Controls what happens when the RBAC engine trims an unauthorized field from a query clause. By default (false), trimming is silent — the field is removed and execution continues. When set to true, any unauthorized field causes an error to be thrown immediately instead.
val
boolean
required
  • false (default): Silently drop unauthorized fields and continue.
  • true: Throw an error when any unauthorized field is encountered during query trimming.
// Strict field-level enforcement — throw instead of silently trim
builder.withThrowError(true);
Enabling withThrowError(true) changes the silent-trim behaviour described in the RBAC documentation. Callers will receive thrown errors for any field that does not appear in their active policy’s allow-lists, so ensure all client queries only request permitted fields before enabling this in production.

.withTraceIdGenerator()

withTraceIdGenerator(gen: TraceIdGenerator): this
Replaces the built-in random trace-ID generator with a custom function. The generator is called once per repository operation and its return value is attached to ExecutionEventPayload.traceId and MutationEventPayload.traceId.
gen
TraceIdGenerator
required
A synchronous or asynchronous function that returns a string used as the trace ID for each operation:
type TraceIdGenerator = () => string | Promise<string>;
import { randomUUID } from "crypto";

// Use Node's built-in UUID generator as the trace ID source
builder.withTraceIdGenerator(() => randomUUID());

// Or propagate an incoming HTTP request's trace ID via AsyncLocalStorage
builder.withTraceIdGenerator(() => requestContext.get("traceId") ?? randomUUID());

.build()

build(): CastorInstance<TDb, TTables, TMetadata>
Finalises all registered configuration and returns a CastorInstance. Call .build() once, after all chained configuration is complete, and export the result for use across your application. Returns a CastorInstance with the following shape:
db
TDb
The original Drizzle database instance passed to createSchemaBuilder.
tables
TTables
The original table tuple passed to createSchemaBuilder.
metadata
TMetadata
The merged metadata object built up through .table() calls.
repoFactory
(tableName: TName) => Repository
Factory function that creates a fully typed, middleware-and-policy-wrapped Repository for the named table. Only tables registered via .table() are accepted by the type system.
subscribeToTelemetry
(subscriber) => () => void
Alternative low-level telemetry subscription API. Returns an unsubscribe function. Prefer .on() / .off() on the builder for most use cases.
// Finalise the builder
export const schemaMetadata = schemaMetadataBuilder.build();

// Create a repository for the "users" table
const userRepo = schemaMetadata.repoFactory("users");

// Use the repository
const user = await userRepo.searchOne(
  {
    projection: ["name", "profile.bio", "posts.title"],
    filter: { name: { $like: "%Jane%" } },
  },
  "admin",
);

Full chained example

The example below shows a complete builder configuration drawn from the quickstart, using every major method in a single chain.
import { drizzle } from "drizzle-orm/bun-sqlite";
import { createSchemaBuilder } from "@fajarnugraha37/drizzle-castor";
import { usersTable, postsTable, profilesTable } from "./schema";
import { randomUUID } from "crypto";

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

export const schemaMetadata = createSchemaBuilder(
  db,
  [usersTable, postsTable, profilesTable] as const,
  "strict",
)
  // 1. Register valid profiles
  .profiles(["default", "public", "admin", "user"] as const)

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

  // 3. Custom trace ID
  .withTraceIdGenerator(() => randomUUID())

  // 4. Throw on unauthorized field access instead of silently trimming
  .withThrowError(false)

  // 5. Telemetry
  .on("execution", (ev) => {
    console.log(`${ev.traceId} | ${ev.action} on ${ev.tableName} | ${ev.duration}ms`);
  })
  .on("security", (ev) => {
    console.warn(`[Security] ${ev.type} on ${ev.tableName}: ${ev.message}`);
  })

  // 6. Global fallback policy
  .policies((ctx, tableName, profiles) => {
    if (profiles.includes("admin")) return { allowedActions: "*" };
    return { allowedActions: ["read"] };
  })

  // 7. Table-specific policy
  .policies("users", {
    public: { allowedActions: ["read"], allowedProjections: ["name", "email"] },
    admin: { allowedActions: "*", allowedSets: "*", allowedProjections: "*", allowedFilters: "*", allowedSorts: "*" },
  })

  // 8. Middleware
  .use(async (ctx, next) => {
    const start = Date.now();
    const result = await next();
    console.log(`Middleware: ${ctx.action} on ${ctx.tableName} took ${Date.now() - start}ms`);
    return result;
  })

  // 9. Table relation and soft-delete metadata
  .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 },
    },
  })

  // 10. Finalise
  .build();

Build docs developers (and LLMs) love