Farm.js

Database and ORM Clients

Use a database or ORM when data has named fields, relationships, unique constraints, joins, transactions, or queryable filters. This is separate from Farm's getStorage() key/value API.

Farm supports three database ownership patterns. Choose the owner before choosing the client:

Data ownerRecommended path
Your applicationCreate an app-owned ORM client and query it through db.model
A Farm integration that declares schemaPass a client through Farm config and query it through ctx.args.db.model
Better Auth, Prisma, Drizzle, or another libraryConfigure that library's native database adapter and use the library's own API

KV storage and database clients may use the same infrastructure, but they do not expose the same interface. postgresStorage(...) stores key/value records in a PostgreSQL-backed KV table. A raw pg.Pool or PostgreSQL ORM client provides relational access.

Application-owned ORM

Use @farming-labs/orm directly when models belong to your application rather than to a Farm integration. Install the packages you import directly instead of relying on Farm's transitive dependencies:

pnpm add @farming-labs/orm @farming-labs/orm-sql pg
src/lib/database.ts
import { Pool } from "pg";

export const database = new Pool({
  connectionString: process.env.DATABASE_URL,
});
src/lib/db.ts
import { createOrm, defineSchema, id, model, string } from "@farming-labs/orm";
import { createPgPoolDriver } from "@farming-labs/orm-sql";
import { database } from "./database";

export const appSchema = defineSchema({
  project: model({
    table: "project",
    fields: {
      id: id(),
      name: string(),
      ownerId: string().map("owner_id"),
    },
  }),
});

export const db = createOrm({
  schema: appSchema,
  driver: createPgPoolDriver(database),
});

Application server code can now import db and query its own tables. Farm config is not required for this direct pattern.

Schema-backed Farm integrations

Use Farm's integration ORM when a reusable integration owns models and should work across supported database clients. The integration declares the schema; the application supplies the runtime client.

Pass a PostgreSQL client

farm.config.ts
import { defineConfig } from "@farm.js/core";
import { billing } from "./src/integrations/billing";
import { database } from "./src/lib/database";

export default defineConfig({
  storage: {
    client: database,
    mounts: {
      cache: {
        driver: "redis",
        url: process.env.REDIS_URL!,
      },
    },
  },
  integrations: {
    billing,
  },
});

storage.client is the current beta config name for the integration runtime client. When its value is a raw pg.Pool, Farm detects PostgreSQL and builds the integration ORM from it. The cache mount remains a separate KV store and is read with getStorage("cache").

The client can be a ready object or a lazy factory:

storage: {
  client: () => database,
}

Declare a schema

Schemas are plain data: models, fields, and relationships. The same declaration works for an integration or for application-owned models, so it is not part of the integration API.

src/integrations/billing-schema.ts
import { defineSchema } from "@farm.js/core";

export const billingSchema = defineSchema({
  models: {
    billingAccount: {
      name: "billing_account",
      fields: {
        id: {
          type: "id",
          primaryKey: true,
        },
        ownerId: {
          type: "string",
          name: "owner_id",
          required: true,
          index: true,
        },
        status: {
          type: "enum",
          required: true,
          values: ["free", "active"],
          default: "free",
        },
        seatQuantity: {
          type: "integer",
          name: "seat_quantity",
          nullable: true,
        },
        createdAt: {
          type: "datetime",
          name: "created_at",
          required: true,
          default: "now",
        },
      },
      constraints: [
        {
          type: "unique",
          fields: ["ownerId"],
          name: "billing_account_owner_unique",
        },
      ],
    },
  },
});

defineIntegrationSchema remains available as a deprecated exact alias of defineSchema, along with the FarmIntegrationSchema* type names. Existing code keeps working without changes; new code should use defineSchema and the FarmSchema* types.

Farm resolves extend and override before it generates tables or creates the integration ORM. Models and fields added through extend appear on ctx.args.db, while overridden table names, column names, field properties, and constraints are used by both the generated artifacts and runtime queries. The inferred ORM type follows that same resolved shape.

List fields and storage targets

list: true is preserved as an array by generated Sync declarations and Prisma schema output. MongoDB documents can also store arrays without a separate column shape. Farm's portable runtime ORM, SQL generators, and Drizzle generators do not have one list representation that works across their supported clients and dialects, so they reject a list field instead of silently creating a scalar column. Use a json field for an array value or model the values in a related table when the schema must work through those targets.

Query through ctx.args.db

src/integrations/billing.ts
import { defineIntegration, integrationRoute } from "@farm.js/core";
import { billingSchema } from "./billing-schema";

export const billing = defineIntegration({
  category: "payment",
  type: "custom-billing",
  instance: {},
  schema: billingSchema,
  async setup(ctx) {
    const db = await ctx.args.getDb();
    await db.billingAccount.findMany();
  },
  routes: [
    integrationRoute.get("/api/billing/account", {
      async handler(_request, ctx) {
        const account = await ctx.args.db.billingAccount.findFirst({
          where: {
            ownerId: String(ctx.data.ownerId),
          },
          select: {
            status: true,
            seatQuantity: true,
            createdAt: true,
          },
        });

        return Response.json({ account });
      },
    }),
  ],
});

ctx.args.db is lazy in request handlers. Use await ctx.args.getDb() when setup or lifecycle code should resolve the client explicitly.

The integration schema controls the type of ctx.args.db:

  • Model names become properties such as ctx.args.db.billingAccount.
  • Field names become typed input and output fields.
  • Enum values become string unions.
  • Nullable fields include null.
  • Datetime fields read as Date.
  • Unique constraints can be used in unique lookups when the detected driver supports them.

SQLite runtime client

The same integration can use a raw SQLite database instead of PostgreSQL:

import { defineConfig } from "@farm.js/core";
import { DatabaseSync } from "node:sqlite";

const database = new DatabaseSync("farm.sqlite");

export default defineConfig({
  storage: {
    client: database,
  },
});

Create physical tables that match the integration schema before querying them. Switching clients does not migrate or reshape existing data automatically.

Better Auth and other adapter-owned data

Some integrations own their database configuration. Better Auth is one example:

import { betterAuth } from "better-auth";
import { database } from "./database";

export const auth = betterAuth({
  database,
});

Here, Better Auth uses the PostgreSQL pool through its own database adapter and owns the user, session, account, and verification tables. Farm's Better Auth integration mounts the HTTP handler; it does not automatically route Better Auth queries through ctx.args.db.

The application may reuse the same pool for its own Farming Labs ORM models. Prefer Better Auth's APIs and database hooks for auth-owned writes. Making Better Auth itself query through @farming-labs/orm requires a dedicated Better Auth database adapter.

The same ownership rule applies to Prisma, Drizzle, and provider SDKs: configure their native client or adapter directly unless a Farm integration declares a schema for the records.

Raw runtime client

Use the raw client only for operations that the integration ORM does not represent:

async setup(ctx) {
  const client = await ctx.args.storage.getClient();

  if (!client) {
    ctx.log.warn("No integration database client configured.");
    return;
  }

  // Use a provider-specific operation here.
}

ctx.args.storage.getClient() keeps its current name for compatibility. It returns the raw object from storage.client; it does not return a KV mount.

Schema generation and migrations

Farm can generate artifacts for integration schemas, then run the application's migration command:

farm generate
farm migrate
export default defineConfig({
  storage: {
    client: database,
  },
  migrations: {
    commands: [
      {
        name: "apply database schema",
        command: "pnpm drizzle-kit migrate",
      },
    ],
  },
});

farm migrate orchestrates the configured command; it does not replace Prisma, Drizzle, SQL, Better Auth, or provider-specific migration tools. Run the migration process owned by each schema owner.

Prisma and Drizzle artifacts use schema model and field keys as generated code identifiers. Keep those keys valid for the selected target. Farm rejects empty, punctuated, leading-digit, reserved, or colliding generated identifiers before writing an artifact. Use a model or field's name property when its physical table or column name needs characters that are not valid in generated code.

Failure modes

  • If an integration does not define schema, ctx.args.db throws. Use the integration's native API or raw runtime client instead.
  • If storage.client is missing, Farm cannot construct a runtime-backed integration ORM.
  • If the physical tables do not match the declared schema, the database fails at query time.
  • Passing a raw database client does not configure the KV store used by getStorage().
  • A database-backed KV helper such as postgresStorage(...) is not a relational ORM client.

Production checklist

  • Share connection pools instead of creating one pool per request.
  • Keep database credentials in server-only environment variables.
  • Use provider-recommended connection and TLS settings.
  • Run migrations before code that depends on the new schema.
  • Keep each table's ownership clear and avoid bypassing auth or billing invariants with direct writes.
  • Close clients during shutdown when the underlying driver requires it.