Multi-Storage Walkthrough
If you want one concrete mental model for Farming Labs ORM, build a small platform layer that owns:
- durable control-plane data such as users, workspaces, memberships, subscriptions, and audit logs
- fast state such as sessions, rate limits, and cache entries
That is a strong fit for Farming Labs ORM because the package can keep one schema-first storage contract while letting each app choose the final runtime pairing.
In this walkthrough, the package will support both of these deployments:
- a Node app using Prisma for durable state and Redis for fast state
- a Cloudflare app using D1 for durable state and KV for fast state
The important part is that the package storage code does not fork into a Prisma version, a D1 version, a Redis version, and a KV version. The package keeps one storage contract and lets the runtime layer translate it.
1. Split the problem by storage behavior
The first design choice is not "which ORM should I support first?"
It is:
- which data needs durable relational behavior
- which data is better treated as fast key-value state
For this example:
- the control plane uses a relational runtime
- the fast state layer uses a key-value runtime
That gives the package a clean boundary instead of trying to stretch one backend across every concern.
2. Define the durable schema
Put the long-lived relational models into one shared schema package.
import {
belongsTo,
datetime,
defineSchema,
enumeration,
hasMany,
id,
json,
model,
string,
unique,
} from "@farming-labs/orm";
export const controlPlaneSchema = defineSchema({
user: model({
table: "users",
fields: {
id: id(),
email: string().unique(),
name: string(),
createdAt: datetime().defaultNow(),
},
relations: {
memberships: hasMany("workspaceMembership", { foreignKey: "userId" }),
},
}),
workspace: model({
table: "workspaces",
fields: {
id: id(),
slug: string().unique(),
name: string(),
plan: enumeration(["free", "pro", "enterprise"]),
createdAt: datetime().defaultNow(),
},
relations: {
memberships: hasMany("workspaceMembership", { foreignKey: "workspaceId" }),
subscriptions: hasMany("subscription", { foreignKey: "workspaceId" }),
auditLogs: hasMany("auditLog", { foreignKey: "workspaceId" }),
},
}),
workspaceMembership: model({
table: "workspace_memberships",
fields: {
id: id(),
userId: string().references("user.id"),
workspaceId: string().references("workspace.id"),
role: string().default("member"),
createdAt: datetime().defaultNow(),
},
unique: [unique(["userId", "workspaceId"])],
relations: {
user: belongsTo("user", { foreignKey: "userId" }),
workspace: belongsTo("workspace", { foreignKey: "workspaceId" }),
},
}),
subscription: model({
table: "subscriptions",
fields: {
id: id(),
workspaceId: string().references("workspace.id"),
providerCustomerId: string().unique(),
status: string(),
renewsAt: datetime().nullable(),
createdAt: datetime().defaultNow(),
},
relations: {
workspace: belongsTo("workspace", { foreignKey: "workspaceId" }),
},
}),
auditLog: model({
table: "audit_logs",
fields: {
id: id(),
workspaceId: string().references("workspace.id"),
action: string(),
actorId: string().nullable(),
payload: json(),
createdAt: datetime().defaultNow(),
},
relations: {
workspace: belongsTo("workspace", { foreignKey: "workspaceId" }),
},
}),
});This is the part of the package that usually wants:
- relational lookups
- compound uniques
- stronger transaction behavior
- readable generated artifacts such as Prisma or SQL output
3. Define the fast-state schema
Put short-lived and key-value-friendly state into a separate schema.
import {
datetime,
defineSchema,
id,
integer,
json,
model,
string,
unique,
} from "@farming-labs/orm";
export const fastStateSchema = defineSchema({
sessionState: model({
table: "session_state",
fields: {
id: id(),
sessionToken: string().unique(),
userId: string(),
workspaceId: string(),
state: json(),
expiresAt: datetime(),
updatedAt: datetime().defaultNow(),
},
}),
rateLimitBucket: model({
table: "rate_limit_buckets",
fields: {
id: id(),
scope: string(),
identifier: string(),
remaining: integer(),
resetAt: datetime(),
updatedAt: datetime().defaultNow(),
},
unique: [unique(["scope", "identifier"])],
}),
cacheEntry: model({
table: "cache_entries",
fields: {
id: id(),
namespace: string(),
key: string(),
value: json(),
expiresAt: datetime().nullable(),
updatedAt: datetime().defaultNow(),
},
unique: [unique(["namespace", "key"])],
}),
});This side of the package is a good fit for Redis, Upstash Redis, Cloudflare KV, or Unstorage-style runtimes. The point is not to turn those backends into a full relational database. The point is to keep one schema and one query API for state that already fits key-value behavior.
4. Generate artifacts for the durable side
The durable schema is usually the one that benefits most from generated artifacts.
import { defineConfig } from "@farming-labs/orm-cli";
import { controlPlaneSchema } from "./control-plane-schema";
export default defineConfig({
schemas: [controlPlaneSchema],
targets: {
prisma: {
out: "./generated/prisma/schema.prisma",
provider: "postgresql",
},
sql: {
out: "./generated/sql/0001_control_plane.sql",
dialect: "postgres",
},
},
});That keeps the package friendly for apps that want normal Prisma or SQL-first setup flows, while the fast-state schema can stay runtime-first.
5. Accept the raw runtime clients
The package boundary should accept the app's real clients and normalize them through the runtime helpers.
import { createOrmFromRuntime } from "@farming-labs/orm-runtime";
import { controlPlaneSchema } from "./control-plane-schema";
import { fastStateSchema } from "./fast-state-schema";
export async function createPlatformOrms(input: { durable: unknown; fast: unknown }) {
const [durableOrm, fastOrm] = await Promise.all([
createOrmFromRuntime({
schema: controlPlaneSchema,
client: input.durable,
}),
createOrmFromRuntime({
schema: fastStateSchema,
client: input.fast,
}),
]);
return {
durableOrm,
fastOrm,
};
}That one boundary is what lets the same package support:
- Prisma + Redis
- TypeORM + Upstash Redis
- direct SQL + Unstorage
- D1 + KV
without rewriting the storage layer for every stack pairing.
6. Write the storage helpers once
Now the package can compose both runtimes behind one platform-facing API.
type PlatformOrms = Awaited<ReturnType<typeof createPlatformOrms>>;
export function createPlatformStore({ durableOrm, fastOrm }: PlatformOrms) {
return {
async createWorkspaceWithOwner(input: {
slug: string;
name: string;
ownerEmail: string;
ownerName: string;
}) {
const owner = await durableOrm.user.upsert({
where: { email: input.ownerEmail },
create: {
email: input.ownerEmail,
name: input.ownerName,
},
update: {
name: input.ownerName,
},
select: {
id: true,
email: true,
name: true,
},
});
const workspace = await durableOrm.workspace.create({
data: {
slug: input.slug,
name: input.name,
plan: "free",
},
select: {
id: true,
slug: true,
name: true,
plan: true,
},
});
await durableOrm.workspaceMembership.create({
data: {
userId: owner.id,
workspaceId: workspace.id,
role: "owner",
},
});
return {
owner,
workspace,
};
},
async startSession(input: {
sessionToken: string;
userId: string;
workspaceId: string;
state: Record<string, unknown>;
expiresAt: Date;
}) {
return fastOrm.sessionState.upsert({
where: {
sessionToken: input.sessionToken,
},
create: input,
update: {
state: input.state,
expiresAt: input.expiresAt,
updatedAt: new Date(),
},
});
},
async consumeWorkspaceRateLimit(input: {
scope: string;
identifier: string;
limit: number;
resetAt: Date;
}) {
const current = await fastOrm.rateLimitBucket.findUnique({
where: {
scope_identifier: {
scope: input.scope,
identifier: input.identifier,
},
},
select: {
remaining: true,
resetAt: true,
},
});
const now = new Date();
const isActiveWindow = current ? current.resetAt > now : false;
const nextRemaining = current
? isActiveWindow
? Math.max(current.remaining - 1, 0)
: Math.max(input.limit - 1, 0)
: Math.max(input.limit - 1, 0);
const nextResetAt = isActiveWindow && current ? current.resetAt : input.resetAt;
return fastOrm.rateLimitBucket.upsert({
where: {
scope_identifier: {
scope: input.scope,
identifier: input.identifier,
},
},
create: {
scope: input.scope,
identifier: input.identifier,
remaining: nextRemaining,
resetAt: nextResetAt,
},
update: {
remaining: nextRemaining,
resetAt: nextResetAt,
updatedAt: new Date(),
},
});
},
async loadWorkspaceDashboard(slug: string) {
const workspace = await durableOrm.workspace.findUnique({
where: { slug },
select: {
id: true,
slug: true,
name: true,
plan: true,
memberships: {
select: {
role: true,
user: {
select: {
email: true,
name: true,
},
},
},
},
subscriptions: {
select: {
providerCustomerId: true,
status: true,
renewsAt: true,
},
},
},
});
if (!workspace) {
return null;
}
const cached = await fastOrm.cacheEntry.findUnique({
where: {
namespace_key: {
namespace: "workspace-dashboard",
key: workspace.id,
},
},
select: {
value: true,
expiresAt: true,
},
});
const cachedMetrics =
cached && (!cached.expiresAt || cached.expiresAt > new Date()) ? cached.value : null;
return {
workspace,
cachedMetrics,
};
},
};
}This is the most important part of the walkthrough.
The storage helpers do not know whether the durable client is Prisma, D1, TypeORM, or direct SQL. They also do not know whether the fast client is Redis, KV, or Unstorage. They only know the schema contract and the unified ORM surface.
7. Wire it into a Node app
Here is the same package running in a normal Node deployment with Prisma and Redis.
import { PrismaClient } from "@prisma/client";
import { createClient } from "redis";
import { createPlatformOrms } from "@acme/platform-storage/create-platform-orms";
import { createPlatformStore } from "@acme/platform-storage/platform-store";
const prisma = new PrismaClient();
const redis = createClient({
url: process.env.REDIS_URL,
});
await redis.connect();
const platform = createPlatformStore(
await createPlatformOrms({
durable: prisma,
fast: redis,
}),
);
await platform.createWorkspaceWithOwner({
slug: "acme",
name: "Acme",
ownerEmail: "ada@acme.dev",
ownerName: "Ada",
});In this deployment:
- Prisma owns the durable relational state
- Redis owns the fast session, cache, and rate-limit state
- the package storage helpers stay unchanged
8. Wire the same package into a Cloudflare app
Now take the same package and run it in a Cloudflare-shaped deployment.
import { createPlatformOrms } from "@acme/platform-storage/create-platform-orms";
import { createPlatformStore } from "@acme/platform-storage/platform-store";
type Env = {
DB: D1Database;
CACHE: KVNamespace;
};
export default {
async fetch(_request: Request, env: Env) {
const platform = createPlatformStore(
await createPlatformOrms({
durable: env.DB,
fast: env.CACHE,
}),
);
const dashboard = await platform.loadWorkspaceDashboard("acme");
return Response.json(dashboard);
},
};The package code does not gain a "Cloudflare adapter" branch here.
It still:
- accepts raw runtime clients
- normalizes them through the runtime layer
- reuses the same storage helpers
That is the core portability story Farming Labs ORM is trying to unlock.
9. Use one setup path in tests and local demos
If the package also owns examples, starter apps, or integration tests, use the setup helpers instead of maintaining one setup branch per backend.
import { bootstrapDatabase } from "@farming-labs/orm-runtime/setup";
import { controlPlaneSchema } from "./control-plane-schema";
import { fastStateSchema } from "./fast-state-schema";
export async function createPreparedPlatformOrms(input: { durable: unknown; fast: unknown }) {
const [durableOrm, fastOrm] = await Promise.all([
bootstrapDatabase({
schema: controlPlaneSchema,
client: input.durable,
}),
bootstrapDatabase({
schema: fastStateSchema,
client: input.fast,
}),
]);
return {
durableOrm,
fastOrm,
};
}That gives the package one entrypoint for:
- tests
- examples
- starter kits
- onboarding scripts
On SQL-style runtimes, bootstrapDatabase(...) can prepare the database. On
Redis, KV, and other runtime-first backends, it becomes the same safe no-op
setup surface instead of forcing a separate branch.
10. Design rules that keep this healthy
The pattern works best when the package stays honest about runtime boundaries.
Keep durable relational data on the durable side
Put things like these in the relational runtime:
- users
- workspaces
- memberships
- subscriptions
- audit logs
Those models usually want better relational planning, stronger consistency, and more readable generated artifacts.
Keep fast state on the fast side
Put things like these in the key-value side:
- sessions
- rate limits
- cache entries
- temporary verification or provisioning state
That lets the app choose Redis, KV, or another fast runtime without trying to make it own the whole control plane.
Do not pretend this is one cross-store transaction
This pattern gives you one storage layer, not a distributed transaction coordinator.
The package should still treat the durable and fast runtimes as separate systems:
- write durable records first
- populate fast state second
- make cache and rate-limit data replaceable
- avoid pretending both stores roll back together
11. What you get out of this
At the end of this walkthrough, the package owns:
- one durable schema
- one fast-state schema
- one runtime helper boundary
- one package-level storage API
And the consuming app still chooses the concrete pairing:
- Prisma + Redis
- D1 + KV
- TypeORM + Upstash Redis
- direct SQL + Unstorage
That is the practical value of Farming Labs ORM for shared modules and framework-owned storage.
Related guides
How is this guide?