@farming-labs/orm

Full-Stack Frameworks

Full-stack frameworks usually end up owning more storage-facing behavior than they planned.

Even when the framework does not call itself an ORM or a database layer, it often still needs a durable place for:

The hard part is usually not query syntax. The hard part is having to explain, support, and maintain the same storage story across several different runtime stacks.

Why frameworks drift so quickly

Frameworks often end up owning a surprising number of storage surfaces at once:

If each surface assumes a different backend shape, the framework slowly drifts into:

Farming Labs ORM helps by letting the framework define and consume one storage-facing contract while still letting the application choose the final runtime.

Where Farming Labs ORM fits inside a framework

For framework authors, the most useful pattern is:

  1. define the framework-owned schema contract once
  2. accept a raw runtime client from the app
  3. create the ORM from that runtime
  4. expose framework modules that talk to the unified runtime
  5. use runtime-aware setup helpers in tests, templates, and examples

That keeps the framework focused on product behavior instead of backend plumbing.

A practical framework architecture

1. Keep the schema in a shared package

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

export const frameworkSchema = defineSchema({
  user: model({
    table: "users",
    fields: {
      id: id(),
      email: string().unique(),
      name: string(),
      createdAt: datetime().defaultNow(),
    },
    relations: {
      projects: hasMany("projectMembership", { foreignKey: "userId" }),
    },
  }),

  project: model({
    table: "projects",
    fields: {
      id: id(),
      slug: string().unique(),
      name: string(),
      environment: enumeration(["development", "preview", "production"]),
      createdAt: datetime().defaultNow(),
    },
    relations: {
      memberships: hasMany("projectMembership", { foreignKey: "projectId" }),
      routeCaches: hasMany("routeCache", { foreignKey: "projectId" }),
      webhooks: hasMany("webhookDelivery", { foreignKey: "projectId" }),
      jobs: hasMany("jobRun", { foreignKey: "projectId" }),
    },
  }),

  projectMembership: model({
    table: "project_memberships",
    fields: {
      id: id(),
      userId: string().references("user.id"),
      projectId: string().references("project.id"),
      role: string().default("member"),
      createdAt: datetime().defaultNow(),
    },
    relations: {
      user: belongsTo("user", { foreignKey: "userId" }),
      project: belongsTo("project", { foreignKey: "projectId" }),
    },
  }),

  routeCache: model({
    table: "route_cache",
    fields: {
      id: id(),
      projectId: string().references("project.id"),
      key: string().unique(),
      payload: string(),
      createdAt: datetime().defaultNow(),
    },
    relations: {
      project: belongsTo("project", { foreignKey: "projectId" }),
    },
  }),

  webhookDelivery: model({
    table: "webhook_deliveries",
    fields: {
      id: id(),
      projectId: string().references("project.id"),
      endpoint: string(),
      eventType: string(),
      status: string(),
      createdAt: datetime().defaultNow(),
    },
    relations: {
      project: belongsTo("project", { foreignKey: "projectId" }),
    },
  }),

  jobRun: model({
    table: "job_runs",
    fields: {
      id: id(),
      projectId: string().references("project.id"),
      jobType: string(),
      status: string(),
      createdAt: datetime().defaultNow(),
    },
    relations: {
      project: belongsTo("project", { foreignKey: "projectId" }),
    },
  }),
});

This does not mean the framework must own every model in one giant schema file. In practice, many frameworks will compose several schema packages:

The important part is that the framework defines those contracts once instead of rewriting them per backend.

If the framework also wants to render artifacts directly, it can call the generators from the same schema package:

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

const prisma = renderPrismaSchema(frameworkSchema, {
  provider: "postgresql",
});

const drizzle = renderDrizzleSchema(frameworkSchema, {
  dialect: "pg",
});

const sql = renderSafeSql(frameworkSchema, {
  dialect: "postgres",
});

That is especially useful when the framework owns:

2. Accept the raw client at the framework boundary

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

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

This is one of the most valuable integration points because the framework does not need to force the app to manually build the ORM first.

The framework can simply say:

  1. pass your raw Prisma, Drizzle, Kysely, MikroORM, SQL, Mongo, or Mongoose client
  2. let the framework normalize it through Farming Labs ORM
  3. use the framework modules on top of that normalized runtime

If the framework wants to explain integration errors more clearly, inspect the runtime before constructing the ORM:

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

const inspection = inspectDatabaseRuntime(database);

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

3. Write framework storage helpers once

framework-store.ts
export function createFrameworkStore(orm: Awaited<ReturnType<typeof createFrameworkOrm>>) {
  return {
    findProjectBySlug(slug: string) {
      return orm.project.findUnique({
        where: { slug },
        select: {
          id: true,
          slug: true,
          name: true,
          environment: true,
        },
      });
    },

    findUserByEmail(email: string) {
      return orm.user.findUnique({
        where: { email },
      });
    },

    putCachedRoute(input: { projectId: string; key: string; payload: string }) {
      return orm.routeCache.upsert({
        where: { key: input.key },
        create: input,
        update: {
          payload: input.payload,
        },
      });
    },

    recordWebhookAttempt(input: {
      projectId: string;
      endpoint: string;
      eventType: string;
      status: string;
    }) {
      return orm.webhookDelivery.create({
        data: input,
      });
    },

    createJobRun(input: { projectId: string; jobType: string; status: string }) {
      return orm.jobRun.create({
        data: input,
      });
    },
  };
}

That keeps the framework code focused on framework behavior instead of backend plumbing.

Concrete places a full-stack framework can use the ORM

This is where the ORM tends to fit naturally.

Framework auth package

A framework can own:

while still accepting a raw Prisma, SQL, Mongo, or Drizzle runtime from the application.

Framework billing package

A framework can ship:

as one shared billing contract across starter apps and self-hosted deployments.

Framework teams and permissions module

Many frameworks eventually need:

Those are classic shared-contract models that benefit from one schema source of truth.

Framework route cache or render metadata module

A framework that owns route output caching, prerender metadata, or invalidation state can keep one portable record shape instead of documenting it differently for every backend.

Framework CMS or admin module

If the framework wants to ship content, admin, or internal tooling modules, it can define content entities once while still letting each app choose where those records live.

Framework jobs and workflows module

If the framework ships background jobs or workflow state, a unified runtime surface is useful for:

Framework webhooks and integration logs

Frameworks that coordinate external services usually need durable storage for:

Those records are a good fit for one shared framework contract.

Framework feature flags and environments module

If a framework owns environment variables, rollout flags, or preview-deployment state, those are exactly the kinds of cross-cutting records that drift when each backend gets its own interpretation.

Framework starter kits and example apps

Even if the framework does not expose all of its internal models to users, starter kits still benefit from one shared schema contract. That keeps:

closer to the real product surface.

How full-stack frameworks should structure the integration

The cleanest approach is usually to split responsibilities like this:

Framework package responsibilities

Application responsibilities

That separation keeps the framework portable without taking runtime choice away from the app.

Runtime-first, generation-first, and hybrid integration

There are three common patterns.

Runtime-first

Use this when the framework mostly wants a runtime contract and wants the app to provide a raw client directly.

const orm = await createOrmFromRuntime({
  schema,
  client,
});

This is a great fit when:

Generation-first

Use this when the framework also wants to ship generated artifacts or starter projects.

farm-orm.config.ts
export default defineConfig({
  schemas: [frameworkSchema],
  targets: {
    prisma: {
      out: "./generated/prisma/schema.prisma",
      provider: "postgresql",
    },
  },
});

This is a great fit when:

Hybrid

Many frameworks will want both:

That is often the best long-term story for a full-stack framework because it supports:

Setup flows in dev, tests, and templates

Frameworks often need to bootstrap a real database for:

Use the runtime-aware setup helpers for that:

import { bootstrapDatabase } from "@farming-labs/orm-runtime/setup";

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

That gives the framework one runtime-aware setup story instead of one setup path per backend family.

If the framework wants setup without immediately returning the ORM client, it can split the flow:

import { pushSchema } from "@farming-labs/orm-runtime/setup";

await pushSchema({
  schema: frameworkSchema,
  client,
});

That is useful for:

Helper map for framework authors

Capabilities matter for framework authors

Frameworks usually need to branch on behavior more carefully than app code.

const caps = orm.$driver.capabilities;

caps.numericIds;
caps.supportsSchemaNamespaces;
caps.supportsTransactions;
caps.upsert;
caps.returningMode.update;
caps.nativeRelations.filtered;
caps.textMatching;

That helps a framework choose:

Error handling at the framework boundary

Frameworks should usually convert ORM failures into framework-facing errors at the integration boundary.

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

try {
  await orm.user.create({
    data: {
      email: "ada@farminglabs.dev",
      name: "Ada",
    },
  });
} catch (error) {
  if (isOrmError(error)) {
    switch (error.code) {
      case "UNIQUE_CONSTRAINT_VIOLATION":
        // return framework-level validation error
        break;
      case "MISSING_TABLE":
        // return setup/configuration error
        break;
      case "TRANSACTION_CONFLICT":
        // optional retry or queue handoff
        break;
    }
  }

  throw error;
}

That keeps the framework from learning every backend's native error format and lets it expose stable framework-facing error semantics instead.

When this is especially valuable

Farming Labs ORM is especially useful for frameworks that:

When this is less useful

Farming Labs ORM is less compelling when a framework:

Practical rule of thumb

If your framework pain sounds like:

then Farming Labs ORM is a strong fit.