@farming-labs/orm

Adapter Ecosystem

Some libraries do not really want “an ORM.” What they actually need is a clean way to stop maintaining an adapter ecosystem.

That is the problem this layer is aimed at.

Libraries like NextAuth.js and Better Auth are good examples of packages that end up carrying a lot of repeated storage work:

Farming Labs ORM gives you a way to collapse much of that into:

The point is not to add one more adapter. The point is to reduce how many adapter-shaped things the library has to own at all.

What the replacement looks like

The replacement pattern is simple:

  1. define the storage contract once
  2. generate backend-specific artifacts when the consumer wants generated output
  3. create a unified ORM from the consumer's raw runtime client when the consumer wants live execution
  4. write the package storage logic once against that unified ORM
  5. use the same setup helpers in tests, demos, and framework-owned install flows

That gives the package one storage-facing core instead of many backend-specific tracks.

Step 1: define the storage contract once

The package should own one schema definition instead of several ORM-specific versions of the same model.

library-schema.ts
import { belongsTo, datetime, defineSchema, hasMany, id, model, string } from "@farming-labs/orm";

export const librarySchema = defineSchema({
  user: model({
    table: "users",
    fields: {
      id: id(),
      email: string().unique(),
      name: string(),
      createdAt: datetime().defaultNow(),
    },
    relations: {
      sessions: hasMany("session", { foreignKey: "userId" }),
      accounts: hasMany("account", { foreignKey: "userId" }),
    },
  }),

  session: model({
    table: "sessions",
    fields: {
      id: id(),
      userId: string().references("user.id"),
      token: string().unique(),
      expiresAt: datetime(),
    },
    relations: {
      user: belongsTo("user", { foreignKey: "userId" }),
    },
  }),

  account: model({
    table: "accounts",
    fields: {
      id: id(),
      userId: string().references("user.id"),
      provider: string(),
      accountId: string(),
    },
    constraints: {
      unique: [["provider", "accountId"]],
    },
    relations: {
      user: belongsTo("user", { foreignKey: "userId" }),
    },
  }),
});

This pattern is useful for auth libraries, billing modules, platform packages, and framework-owned storage modules. The example happens to look auth-shaped because that is one of the clearest cases, but the same replacement pattern works anywhere a package owns a shared storage contract.

Step 2: generate backend-specific output from the same schema

If the consuming app wants generated Prisma, Drizzle, or SQL artifacts, the package does not need a separate storage definition.

import { renderDrizzleSchema, renderPrismaSchema, renderSafeSql } from "@farming-labs/orm";
import { librarySchema } from "./library-schema";

const prismaSchema = renderPrismaSchema(librarySchema, {
  provider: "postgresql",
});

const drizzleSchema = renderDrizzleSchema(librarySchema, {
  dialect: "pg",
});

const sqlSchema = renderSafeSql(librarySchema, {
  dialect: "postgres",
});

This is the generation-first path.

Use it when the package wants to:

Step 3: accept a raw runtime client when the app already has one

If the consumer already owns a real database client, the package can normalize that client into one ORM layer instead of forcing the user to manually wire a different adapter package.

create-library-runtime.ts
import { createOrmFromRuntime } from "@farming-labs/orm-runtime";
import { librarySchema } from "./library-schema";

export async function createLibraryOrm(database: unknown) {
  return createOrmFromRuntime({
    schema: librarySchema,
    client: database,
  });
}

If the package wants to explain integration failures more clearly before it constructs the ORM, it can inspect the runtime first:

import { inspectDatabaseRuntime } from "@farming-labs/orm";

const inspection = inspectDatabaseRuntime(database);

if (!inspection.runtime) {
  throw new Error(inspection.summary);
}

This is the runtime-first path.

It is the path that makes “bring your own client” integration possible.

That is why libraries like NextAuth.js and Better Auth tend to need this kind of solution: they want the app to keep control of the final database client while still letting the package own one shared storage layer.

Step 4: write the package storage logic once

Once the package has a unified ORM, the storage helpers only need to be written one time.

library-store.ts
import type { OrmClient } from "@farming-labs/orm";
import { librarySchema } from "./library-schema";

export function createLibraryStore(orm: OrmClient<typeof librarySchema>) {
  return {
    findUserByEmail(email: string) {
      return orm.user.findUnique({
        where: { email },
        select: {
          id: true,
          email: true,
          sessions: {
            select: {
              token: true,
              expiresAt: true,
            },
          },
          accounts: {
            select: {
              provider: true,
              accountId: true,
            },
          },
        },
      });
    },

    findLinkedAccount(provider: string, accountId: string) {
      return orm.account.findUnique({
        where: {
          provider,
          accountId,
        },
        select: {
          userId: true,
          provider: true,
          accountId: true,
        },
      });
    },

    createSession(input: { userId: string; token: string; expiresAt: Date }) {
      return orm.session.create({
        data: {
          userId: input.userId,
          token: input.token,
          expiresAt: input.expiresAt,
        },
        select: {
          id: true,
          token: true,
          expiresAt: true,
        },
      });
    },
  };
}

This is the part that actually replaces the adapter ecosystem.

The package no longer needs:

It keeps one storage implementation and lets the app choose how that implementation gets translated underneath.

Step 5: use one setup path in tests, demos, and install flows

The same package can also stop owning a separate setup story per backend.

If the package just needs to prepare the database:

import { pushSchema } from "@farming-labs/orm-runtime/setup";
import { librarySchema } from "./library-schema";

await pushSchema({
  schema: librarySchema,
  client: database,
});

If the package wants setup plus the ORM back:

import { bootstrapDatabase } from "@farming-labs/orm-runtime/setup";
import { librarySchema } from "./library-schema";

const orm = await bootstrapDatabase({
  schema: librarySchema,
  client: database,
});

This matters a lot in:

The shape of the replacement

Without this pattern, a library ecosystem usually grows sideways:

With this pattern, the library grows inward around a stable core:

That is the actual replacement story.

The library still may need custom work for some platform-specific backends, but the shared contract stops fragmenting across every mainstream storage stack.

Potential improvements this unlocks

Once a package stops centering many backend-specific adapters, a few important improvements become much easier to ship.

That is usually where the biggest win shows up. The value is not only fewer adapters. It is also a cleaner place to improve correctness, docs, testing, and integration UX over time.

Performance and platform benefits

This replacement pattern can also improve the operational side of the package, not just the maintenance story.

The important nuance is that this does not magically make every database query faster on its own.

Prisma is still Prisma. MongoDB is still MongoDB. Direct SQL is still limited by the underlying network and database cost.

The more realistic wins usually come from: