Migration Guide
This guide covers how to migrate your existing Prisma, Next.js, or KeystoneJS projects to Stack using AI-powered tools.
Migrating specifically from KeystoneJS? Start with the canonical Migrating from KeystoneJS guide, then return here for the full step-by-step walkthrough. For the context.graphql.run replacement, see Queries & projections.
Introduction
Stack provides an intelligent migration system that helps you transition existing projects with minimal manual work. The migration assistant:
- Analyzes your current schema and project structure
- Guides you through an interactive wizard
- Generates a working
opensaas.config.ts - Integrates with Claude Code for AI assistance
- Validates the generated configuration
The entire migration process typically takes 10-15 minutes with AI assistance, or 30-60 minutes manually.
Quick Start (AI-Assisted Migration)
The fastest way to migrate is with AI assistance through Claude Code:
# Navigate to your project
cd my-existing-project
# Run the migration command with AI support
npx @opensaas/stack-cli migrate --with-ai
This command will:
- Detect your project type (Prisma, KeystoneJS, or Next.js)
- Analyze your schema and count models
- Set up Claude Code integration with migration tools
- Provide instructions for next steps
What You'll See
๐ Stack Migration
โ Detected: prisma, nextjs
โ Found 8 models
โโ User (6 fields)
โโ Post (10 fields)
โโ Comment (5 fields)
โโ Tag (3 fields)
โโ Category (4 fields)
โโ Media (7 fields)
โโ Setting (4 fields)
โโ Session (5 fields)
โ Claude Code ready
โโ Created .claude directory
โโ Added opensaas-stack-marketplace
โโ Enabled opensaas-migration plugin (with MCP server)
โโ Wrote .claude/settings.json and .claude/opensaas-project.json
โ
Analysis complete!
๐ค Next Steps:
1. Open this project in Claude Code
2. Ask: "Help me migrate to Stack"
3. Follow the interactive wizard
๐ Documentation: https://stack.opensaas.au/docs/how-to/migrate-from-keystone
Using Claude Code
After running the migration command, open your project in Claude Code:
Start the conversation:
typescriptHelp me migrate to StackAnswer the wizard questions:
- Whether to preserve your existing database
- Authentication requirements
- Access control patterns
- Admin UI preferences
Review and apply:
- Claude will generate
opensaas.config.ts - Install suggested dependencies
- Run the generator
- Validate the migration
- Claude will generate
Manual Migration
If you prefer manual migration or don't use Claude Code:
1. Analyze Your Project
npx @opensaas/stack-cli migrate
This gives you a summary of your project without setting up AI tools.
2. Install Dependencies
pnpm add @opensaas/stack-core @opensaas/stack-cli
pnpm add -D prisma typescript tsx
Add authentication if needed:
pnpm add @opensaas/stack-auth better-auth
Already running better-auth? If your project has live better-auth tables (typically AuthUser/AuthSession/AuthAccount/AuthVerification in a separate auth schema, with an app User that is a distinct model), don't recreate them. Adopt the existing tables with the adoptBetterAuthTables() recipe so the generated Auth lists diff clean against your live database โ no destructive auth migration โ and keep your domain User separate from the auth identity. See Adopting an Existing better-auth Installation.
3. Create Configuration
Create opensaas.config.ts in your project root. Use your existing Prisma schema as reference.
Example: Basic Migration
If you have this Prisma schema:
model Post {
id String @id @default(cuid())
title String
content String
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(cuid())
email String @unique
name String?
posts Post[]
}
Convert it to OpenSaaS config. Post's query rule returns a filter rather than a boolean, which scopes the read: an anonymous visitor sees published posts, a signed-in author additionally sees their own drafts. Note the guard is on session?.userId, not on session โ a filter value of undefined is refused as a validation error rather than quietly dropped, so a rule must never build one.
// opensaas.config.ts
import { config, list } from '@opensaas/stack-core'
import { text, checkbox, relationship } from '@opensaas/stack-core/fields'
export default config({
db: { provider: 'postgresql' },
lists: {
User: list({
fields: {
email: text({ validation: { isRequired: true } }),
name: text(),
posts: relationship({ ref: 'Post.author', many: true }),
},
access: {
operation: {
query: () => true,
create: ({ session }) => !!session,
update: ({ session, item }) => session?.userId === item?.id,
delete: ({ session, item }) => session?.userId === item?.id,
},
},
}),
Post: list({
fields: {
title: text({ validation: { isRequired: true } }),
content: text({ validation: { isRequired: true } }),
published: checkbox({ defaultValue: false }),
author: relationship({ ref: 'User.posts' }),
},
access: {
operation: {
query: ({ session }) =>
session?.userId
? { OR: [{ published: { equals: true } }, { authorId: { equals: session.userId } }] }
: { published: { equals: true } },
create: ({ session }) => !!session,
update: ({ session, item }) => session?.userId === item?.authorId,
delete: ({ session, item }) => session?.userId === item?.authorId,
},
},
}),
},
})
The db block carries no connection string and no client constructor. The connection is resolved from the environment at runtime โ DIRECT_DATABASE_URL, then DATABASE_URL, then the local Dev database โ so pointing the new config at your existing database is a matter of setting DATABASE_URL, not of editing the config. Postgres is the only provider. See Config API for the rest of the db keys.
4. Generate and reconcile
opensaas dev is the whole local loop: it starts a Dev database, runs generate, reconciles the schema, and spawns your app. Point DATABASE_URL at your existing database first if you are migrating live data โ with the variable set, no Dev database is started and the loop reconciles against yours.
pnpm opensaas dev
Editing opensaas.config.ts while the loop is running regenerates and reconciles again. A non-destructive change applies on its own. A destructive one โ a dropped column, a narrowed type โ stops: the loop leaves both the generated bundle and the database at the previous schema, prints the plan, and keeps serving. Apply it deliberately from a second terminal:
pnpm db:update --plan <id>
The plan id is the one the loop printed with the plan; if the plan has changed since, the loop prints the new plan and id instead of applying.
pnpm db:update needs the loop
pnpm db:update opens no connection of its own โ it hands the request to the running opensaas dev loop, which owns the Dev database's data directory and the staged generation. With no loop listening it exits non-zero with a message saying so, rather than reaching the database a second way. And because a client cached inside the running app would go on querying a column that no longer exists, a destructive mid-session change restarts the app once the update is applied. Expect the app process to come back, and expect in-flight local state to be lost with it.
Production is a different command: deploys run prisma db migrate from the committed migrations/ directory. There is no opensaas db migrate โ opensaas db carries only update, and opensaas migrate is the project-analysis assistant this page is about, not a schema command.
5. Update Application Code
Replace direct Prisma calls with context. A read is composed on context.db.<List> โ the list's own PascalCase key, not a camelCase one โ and ends in a terminal that resolves access:
Before:
<!-- doc-check: excuses="TS2307 './lib/prisma'" reason="the reader's own pre-migration file" -->
import { prisma } from './lib/prisma'
const posts = await prisma.post.findMany({
where: { published: true },
})
After:
<!-- doc-check: excuses="session, TS2339 'Post'" reason="session is a bare name the prose supplies, and Post is not one of the three lists the prelude models" -->
import { getContext } from '@/.opensaas/context'
const context = await getContext({ userId: session.userId })
const posts = await context.db.Post.where({ published: { equals: true } }).all()
There is no findMany, findUnique, findFirst or count(). The terminals are all(), first(), aggregate() and nearest(); where and orderBy accumulate across calls, while select, limit, offset and cursor replace. A denied read is silent โ all() gives [], first() gives null, aggregate() zeroes every key โ so every first() result is a null check.
Supported Project Types
Prisma Projects
Detection: Looks for prisma/schema.prisma
What's Migrated:
- All models โ Lists
- Fields โ Field types
- Relations โ Relationship fields
- Enums โ Select fields
- Database provider โ DB config
Example:
npx @opensaas/stack-cli migrate --type prisma
KeystoneJS Projects
Detection: Looks for keystone.config.ts or keystone.ts
What's Migrated:
- Lists โ Lists
- Field types โ OpenSaaS field types
- Access control โ Access control patterns
- Hooks โ Hooks
- Authentication โ Auth plugin
context.graphql.runqueries โ composedcontext.dbreads (see Migrating context.graphql.run below)
Example:
npx @opensaas/stack-cli migrate --type keystone
Next.js Projects
Detection: Looks for next in package.json
What's Migrated:
- Existing Prisma models (if present)
- API routes โ Server actions (manual)
- Authentication patterns โ Auth plugin
Example:
npx @opensaas/stack-cli migrate --type nextjs
Migration Wizard Questions
When using AI assistance, the wizard asks these questions:
1. Database Configuration
Question: "Do you want to preserve your existing database?"
- Yes โ Uses your existing
DATABASE_URL, preserves data - No โ Leaves the variable unset, so
opensaas devstarts a local Dev database
There is no provider question. Postgres is the only provider, so a migration from a MySQL or SQLite source is also a database port: move the data across first, then point DATABASE_URL at the Postgres instance.
2. Authentication
Question: "Do you need authentication?"
- Yes โ Adds auth plugin, User/Session lists
- No โ No auth, anonymous access patterns
Question: "Which auth providers?" (if yes above)
Options:
- Email/Password
- GitHub OAuth
- Google OAuth
- Discord OAuth
- Twitter OAuth
3. Access Control
Question: "What default access control pattern?"
Options:
Public read, authenticated write - Most common for blogs, content sites
<!-- doc-check: whole="a bare
access:object-literal property, not a statement" -->typescriptaccess: { operation: { query: () => true, create: ({ session }) => !!session, update: ({ session }) => !!session, delete: ({ session }) => !!session, } }Private (owner-only) - For user-specific data.
queryreturns a filter, so users only ever see their own records; the guard is onsession?.userIdbecause a filter value ofundefinedis refused, not ignored.<!-- doc-check: whole="a bare
access:object-literal property, not a statement" -->typescriptaccess: { operation: { query: ({ session }) => session?.userId ? { userId: { equals: session.userId } } : false, create: ({ session }) => !!session, update: ({ session, item }) => session?.userId === item?.userId, delete: ({ session, item }) => session?.userId === item?.userId, } }Admin only - For protected resources
<!-- doc-check: whole="a bare
access:object-literal property, not a statement" -->typescriptaccess: { operation: { query: ({ session }) => session?.role === 'admin', create: ({ session }) => session?.role === 'admin', update: ({ session }) => session?.role === 'admin', delete: ({ session }) => session?.role === 'admin', } }Public - For truly public data <!-- doc-check: whole="a bare
access:object-literal property, not a statement" -->typescriptaccess: { operation: { query: () => true, create: () => true, update: () => true, delete: () => true, } }
4. Admin UI
Question: "Where should the admin UI be mounted?"
Common options:
/admin(default)/dashboard/manage- Custom path
Field Type Mapping
The migration system automatically maps field types:
Prisma โ OpenSaaS
| Prisma Type | OpenSaaS Field | Import |
|---|---|---|
String | text() | @opensaas/stack-core/fields |
Int | integer() | @opensaas/stack-core/fields |
Boolean | checkbox() | @opensaas/stack-core/fields |
DateTime | timestamp() | @opensaas/stack-core/fields |
Enum | select() | @opensaas/stack-core/fields |
| Relations | relationship() | @opensaas/stack-core/fields |
KeystoneJS โ OpenSaaS
Keystone field builders map onto same-named OpenSaaS builders (text() โ text(), select() โ select(), and so on). For the complete mapping โ including float() / decimal(), image() / file(), document(), and the relationship() ref-format differences โ see the field-type table in the canonical Migrating from KeystoneJS guide.
Claude Code Integration
MCP Tools Available
The migration system provides these MCP tools to Claude:
Schema Analysis
opensaas_introspect_prisma- Detailed Prisma schema analysisopensaas_introspect_keystone- KeystoneJS config analysis
Migration Wizard
opensaas_start_migration- Begin interactive migrationopensaas_answer_migration- Answer wizard questions
Documentation
opensaas_search_migration_docs- Search migration docsopensaas_get_example- Get code examples for common patterns
Available Slash Commands
After running migrate --with-ai, you get these commands:
/analyze-schema- Detailed schema breakdown/generate-config- Generate the config file/validate-migration- Validate configuration
Migration Assistant Agent
The opensaas-migration plugin ships a specialized agent (migration-assistant) that:
- Understands your project context
- Guides you through the wizard
- Explains technical concepts simply
- Validates your choices
- Generates working code
- Provides next steps
Common Migration Scenarios
Blog with Authentication
Original Prisma:
model User {
id String @id @default(cuid())
email String @unique
password String
name String
posts Post[]
}
model Post {
id String @id @default(cuid())
title String
content String
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id])
}
Generated Config:
Post's query rule returns true for a signed-in visitor and a filter for an anonymous one, so anonymous visitors see published posts and nothing else:
import { config, list } from '@opensaas/stack-core'
import { text, checkbox, relationship } from '@opensaas/stack-core/fields'
import { authPlugin } from '@opensaas/stack-auth'
export default config({
plugins: [
authPlugin({
emailAndPassword: { enabled: true },
sessionFields: ['userId', 'email', 'name'],
}),
],
db: { provider: 'postgresql' },
lists: {
Post: list({
fields: {
title: text({ validation: { isRequired: true } }),
content: text({ validation: { isRequired: true } }),
published: checkbox({ defaultValue: false }),
author: relationship({ ref: 'User.posts' }),
},
access: {
operation: {
query: ({ session }) => (session ? true : { published: { equals: true } }),
create: ({ session }) => !!session,
update: ({ session, item }) => session?.userId === item?.authorId,
delete: ({ session, item }) => session?.userId === item?.authorId,
},
},
}),
},
})
Note: User list is auto-generated by authPlugin.
E-commerce Platform
Key Considerations:
- Product catalog (public read)
- Orders (owner-only access)
- Admin management (role-based)
- Inventory tracking
Migration Steps:
- Identify public vs. private models
- Set up role-based access (admin, customer)
- Configure relationships (Order โ Product)
- Add hooks for inventory updates
Access Control Pattern: the product catalogue is publicly readable and admin-writable; Order.query returns true for an admin and an owner filter for everybody else. The filter names the relationship's foreign-key column, customerId, not the relationship field.
<!-- doc-check: whole="bare Product:/Order: object-literal properties โ entries of a lists: object โ not statements" -->
Product: list({
fields: {
name: text({ validation: { isRequired: true } }),
price: decimal(),
},
access: {
operation: {
query: () => true,
create: ({ session }) => session?.role === 'admin',
update: ({ session }) => session?.role === 'admin',
delete: ({ session }) => session?.role === 'admin',
},
},
}),
Order: list({
fields: {
customer: relationship({ ref: 'User.orders' }),
},
access: {
operation: {
query: ({ session }) =>
session?.role === 'admin'
? true
: session?.userId
? { customerId: { equals: session.userId } }
: false,
create: ({ session }) => !!session,
update: ({ session }) => session?.role === 'admin',
delete: ({ session }) => session?.role === 'admin',
},
},
}),
SaaS Application with Teams
Key Considerations:
- Multi-tenant data isolation
- Team-based access
- Role hierarchies (owner, admin, member)
- Shared resources
Migration Steps:
- Add Team model if not present
- Link resources to teams
- Implement team-scoped access
- Add role checks
Access Control Pattern: an access rule may run its own read on context.db to resolve membership. Both rules below return false rather than a filter when there is no membership โ building { teamId: undefined } would be refused as a validation error, and returning it would be a security bug in any case.
<!-- doc-check: whole="a bare Project: object-literal property โ an entry of a lists: object โ not a statement" -->
Project: list({
fields: {
name: text(),
team: relationship({ ref: 'Team.projects' }),
},
access: {
operation: {
query: async ({ session, context }) => {
if (!session?.userId) return false
const membership = await context.db.TeamMember.where({
userId: { equals: session.userId },
}).first()
if (!membership) return false
return { teamId: { equals: membership.teamId } }
},
create: ({ session }) => !!session,
update: async ({ session, item, context }) => {
if (!session?.userId) return false
const membership = await context.db.TeamMember.where({
userId: { equals: session.userId },
teamId: { equals: item.teamId },
role: { in: ['owner', 'admin'] },
}).first()
return !!membership
},
},
},
}),
Data Preservation
Existing Database
To preserve your existing database:
Keep the same DATABASE_URL:
envDATABASE_URL=postgresql://user:pass@localhost:5432/mydbReconcile through the dev loop, and read the plan before consenting:
bashpnpm opensaas devWith
DATABASE_URLset, no Dev database starts and the loop reconciles against yours. A destructive plan stops and prints itself rather than applying โ that printout is your review step, and nothing is dropped until you runpnpm db:update --plan <id>.OpenSaaS generates schema compatible with existing data:
- Same table names (PascalCase models)
- Same field names
- Same relationships
- Additional access control metadata (runtime only)
Migration Safety
The migration system is non-destructive:
- โ Existing data is preserved
- โ Table structure remains the same
- โ Foreign keys maintained
- โ Indexes preserved
- โ ๏ธ No automatic backups (back up manually first!)
Best Practice:
# Backup before migration
pg_dump mydb > backup.sql
# Then migrate
npx @opensaas/stack-cli migrate --with-ai
Troubleshooting
Project Not Detected
Error:
No recognizable project found
Solutions:
- Ensure you're in the project root
- Check for
prisma/schema.prisma(Prisma) - Check for
keystone.config.ts(Keystone) - Check for
nextin package.json (Next.js) - Use
--typeflag to force detection:bashnpx @opensaas/stack-cli migrate --type prisma
Schema Analysis Failed
Error:
Failed to analyze schema
Solutions:
- Verify Prisma schema syntax
- Ensure schema file is readable
- Check for TypeScript errors in Keystone config
- Run
npx prisma formatto fix formatting
Claude Code Not Working
Error:
MCP server not responding
Solutions:
- Check
.claude/settings.jsonwas created and enables the plugin:json{ "extraKnownMarketplaces": { "opensaas-stack-marketplace": { "source": { "source": "github", "repo": "OpenSaasAU/stack" } } }, "enabledPlugins": { "opensaas-migration@opensaas-stack-marketplace": true } } - Restart Claude Code
- The MCP server (named
opensaas-stack) is declared by the plugin's own manifest โ nomcpServersentry is written to settings.json - Check for errors in Claude Code console
Generated Config Errors
Error:
TypeScript errors in opensaas.config.ts
Solutions:
- Check imports are correct
- Verify field types are valid
- Ensure database config is complete
- Run
npx tsx opensaas.config.tsto test - Ask Claude to fix specific errors
Database Connection Issues
Error:
Can't reach database server
Solutions:
- Check DATABASE_URL in
.env - Verify database is running
- Test connection with Prisma:bash
npx prisma db pull - Check firewall/network settings
Next Steps After Migration
1. Verify Generated Files
# Should exist:
opensaas.config.ts # Your config
.opensaas/context.ts # Generated context factory
.opensaas/types.ts # Generated types
.opensaas/lists.ts # Generated list metadata
prisma/contract.ts # Generated contract module (+ contract.json, contract.d.ts)
prisma.config.ts # Prisma CLI config
2. Install Dependencies
pnpm install
3. Run the dev loop
pnpm opensaas dev
One command generates, reconciles the schema and starts the app. Mid-session config edits regenerate and reconcile again; a destructive one waits for pnpm db:update --plan <id>.
4. Update Application Code
Replace Prisma calls with context. context.db is keyed by the list's PascalCase name, and a read is composed rather than passed a single args object:
<!-- doc-check: whole="a before/after pair sharing one fence: both snippets bind posts, and prisma/session are bare names the prose supplies" -->
// Before
const posts = await prisma.post.findMany()
// After
import { getContext } from '@/.opensaas/context'
const context = await getContext({ userId: session.userId })
const posts = await context.db.Post.all()
5. Add Admin UI
The admin surface takes the generated getContext() result and the generated config, plus a server action you own โ there is no separate admin-context helper.
// app/admin/[[...admin]]/page.tsx
import { AdminUI } from '@opensaas/stack-ui'
import type { ServerActionInput } from '@opensaas/stack-ui/server'
import { getContext, config } from '@/.opensaas/context'
async function serverAction(props: ServerActionInput) {
'use server'
const context = await getContext()
return await context.serverAction(props)
}
interface AdminPageProps {
params: Promise<{ admin?: string[] }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}
export default async function AdminPage({ params, searchParams }: AdminPageProps) {
const resolvedParams = await params
return (
<AdminUI
context={await getContext()}
config={await config}
params={resolvedParams.admin}
searchParams={await searchParams}
basePath="/admin"
serverAction={serverAction}
/>
)
}
6. Test Access Control
A denied read is silent, so verify by comparing what two sessions see rather than by expecting a throw:
<!-- doc-check: excuses="getContext" reason="a bare name the prose supplies" -->
const anonContext = await getContext()
const publicPosts = await anonContext.db.Post.all()
const authContext = await getContext({ userId: 'user-123' })
const myPosts = await authContext.db.Post.all()
The anonymous read should come back with published posts only; the authenticated one should additionally carry that user's drafts. A single record is checked the same way โ first() answers null for both "no such row" and "not yours", which is deliberate: the two are indistinguishable to a caller.
7. Update API Routes
Convert to server actions:
<!-- doc-check: excuses="prisma, TS2307 '@/lib/auth', TS2339 'Post'" reason="prisma is a bare name the prose supplies, @/lib/auth is the reader's own file, and Post is not one of the three lists the prelude models" -->
// Before (API route)
// app/api/posts/route.ts
export async function GET() {
const posts = await prisma.post.findMany()
return Response.json(posts)
}
// After (Server action)
// app/actions/posts.ts
;('use server')
import { getContext } from '@/.opensaas/context'
import { getSession } from '@/lib/auth'
export async function getPosts() {
const session = await getSession()
const context = session ? await getContext(session) : await getContext()
return await context.db.Post.where({ published: { equals: true } }).all()
}
Migrating context.graphql.run
If you're migrating from KeystoneJS, your project likely uses context.graphql.run() or context.graphql.raw() for type-safe database access. Stack has no GraphQL layer โ a read is composed on context.db.<List> and narrowed with .select(), which the engine honours exactly. There is no fragment to declare and no codegen step: the generated types give each list's surface its own shape.
The full set of context.graphql.run โ context.db.* recipes โ including the harder cases (relation-filter where-shape translation, Keystone's connect / disconnect / set nested writes, and gql.tada typed documents) โ lives in the canonical Migrating from KeystoneJS guide and the Queries & projections reference. This section is a short summary that points there rather than duplicating them.
Quick reference
| Keystone | Stack |
|---|---|
| GraphQL fragment string | .select('id', 'title') on the read itself |
ResultOf<typeof query> (codegen) | inferred from the generated list types โ no codegen step |
context.graphql.run({ query, variables }) โ list | context.db.Post.where(...).select(...).all() |
context.graphql.run({ query, variables }) โ single | context.db.Post.where({ id: { equals: id } }).select(...).first() |
| Nested relationship filtering | .include('comments', (comments) => comments.where(...).select(...)) |
Simple list query
<!-- doc-check: whole="a before/after Keystone pair sharing one fence, both binding posts, followed by a single-record example whose return sits outside a function" -->
// Before (Keystone)
const { posts } = await context.graphql.run({
query: `query { posts(where: { published: true }) { id title } }`,
})
// After (Stack)
const posts = await context.db.Post.where({ published: { equals: true } })
.select('title')
.all()
// Single record
const post = await context.db.Post.where({ id: { equals: postId } })
.select('title')
.first()
if (!post) return notFound()
.select() replaces any previous call rather than accumulating, and the result matches it exactly โ the engine widens the query for what a computed field declared and strips the difference back out.
Nested / related data
A relation is reached with .include(), whose refinement is a read of the related list โ it takes its own where, orderBy, limit, offset and .select():
<!-- doc-check: excuses="TS2339 'Post', TS7006 'author', TS7006 'comments'" reason="Post is not one of the three lists the prelude models, and the .include() callbacks are typed by the relation they are attached to" -->
const posts = await context.db.Post.where({ published: { equals: true } })
.orderBy({ publishedAt: 'desc' })
.select('title', 'publishedAt')
.include('author', (author) => author.select('name', 'email'))
.include('comments', (comments) =>
comments
.where({ approved: { equals: true } })
.orderBy({ createdAt: 'desc' })
.limit(5)
.select('body'),
)
.all()
Arity decides nullability, not the foreign key's: a to-one include lands as Row | null and a to-many as Row[], so post.author?.name stays a null-check even where the column is NOT NULL. The full statement, including why, is at Cost: every to-one read off an included row is a null-check.
Composability comes from the query value itself: it is immutable, so a partially composed read can be shared and narrowed at each call site.
<!-- doc-check: excuses="TS2339 'Post'" reason="Post is not one of the three lists the prelude models" -->
const publishedPosts = context.db.Post.where({ published: { equals: true } })
const recent = await publishedPosts.orderBy({ publishedAt: 'desc' }).limit(10).all()
const titles = await publishedPosts.select('title').all()
Every operation runs through the secured terminals, so access control is enforced automatically and a denied read comes back as [] or null rather than throwing. For the dedicated reference see Queries & projections; for the complete Keystone-specific recipes, see the canonical Migrating from KeystoneJS guide.
Best Practices
Start Small
- Migrate one model at a time
- Test access control for each list
- Add complexity gradually
Use Version Control
# Create migration branch
git checkout -b migrate-to-opensaas
# Commit frequently
git add opensaas.config.ts
git commit -m "Add initial OpenSaaS config"
Test Thoroughly
- Test anonymous access
- Test authenticated access
- Test different roles
- Test edge cases (null values, empty lists)
Name your access rules
A filter-returning rule inlined into access.operation is the hardest part of a migrated config to read six months later. Lift it into a named function instead โ the name carries the policy, and the same rule can then be reused and unit-tested on its own:
import { config, list } from '@opensaas/stack-core'
import type { Session } from '@opensaas/stack-core'
import { checkbox, relationship, text } from '@opensaas/stack-core/fields'
const publishedOrOwnDrafts = ({ session }: { session: Session | null }) =>
session?.userId
? { OR: [{ published: { equals: true } }, { authorId: { equals: session.userId } }] }
: { published: { equals: true } }
export default config({
db: { provider: 'postgresql' },
lists: {
Post: list({
fields: {
title: text({ validation: { isRequired: true } }),
published: checkbox({ defaultValue: false }),
author: relationship({ ref: 'User.posts' }),
},
access: { operation: { query: publishedOrOwnDrafts } },
}),
User: list({
fields: { posts: relationship({ ref: 'Post.author', many: true }) },
}),
},
})
Plan for Rollback
Keep your old code:
# Tag before migration
git tag pre-opensaas-migration
# Easy rollback if needed
git reset --hard pre-opensaas-migration
Getting Help
Documentation
Community
AI Assistance
Use Claude Code for help:
- "Explain this access control pattern"
- "How do I migrate this Prisma relation?"
- "What's the OpenSaaS equivalent of this Keystone field?"
- "Debug this configuration error"
Advanced Topics
Custom Field Types
If you have custom Prisma types, create custom fields:
// lib/fields/slug.ts
import { z } from 'zod'
import type {
BaseFieldConfig,
ContractFieldDescriptor,
FieldKeys,
TypeInfo,
} from '@opensaas/stack-core/extend'
export type SlugField<
TTypeInfo extends TypeInfo = TypeInfo,
TKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
> = BaseFieldConfig<TTypeInfo, TKey> & {
type: 'slug'
from?: string
}
export function slug<
TTypeInfo extends TypeInfo = TypeInfo,
TKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
>(options?: Omit<SlugField<TTypeInfo, TKey>, 'type'>): SlugField<TTypeInfo, TKey> {
return {
type: 'slug',
...options,
getContractField: (fieldName): ContractFieldDescriptor => ({
kind: 'column',
name: fieldName,
type: { pack: 'pg', type: 'text' },
nullable: false,
}),
getZodSchema: () => z.string(),
}
}
Complex Relationships
Implicit many-to-many is refused: a config with many: true on both sides of a relationship fails opensaas generate with an error naming both ends. A Prisma schema like this:
model Post {
tags Tag[]
}
model Tag {
posts Post[]
}
becomes a junction you author yourself โ a list with a to-one relationship to each side, its own surrogate id, and a unique db.indexes entry over the two fields so the pair cannot be inserted twice. Both outer lists then point at the junction with many: true:
<!-- doc-check: whole="bare Post:/Tag:/PostTag: object-literal properties โ entries of a lists: object โ not statements" -->
Post: list({
fields: {
tags: relationship({ ref: 'PostTag.post', many: true }),
},
}),
Tag: list({
fields: {
posts: relationship({ ref: 'PostTag.tag', many: true }),
},
}),
PostTag: list({
fields: {
post: relationship({ ref: 'Post.tags' }),
tag: relationship({ ref: 'Tag.posts' }),
},
db: {
indexes: [{ fields: ['post', 'tag'], unique: true }],
},
}),
The join table Prisma used to manage invisibly becomes a model you name, own and can add columns to โ an addedAt, an ordering, a role. For an existing database this is also the point at which you choose the junction's physical table name with db.map, so the new model lands on the rows you already have.
Migrating Hooks
KeystoneJS hooks map to OpenSaaS hooks:
<!-- doc-check: whole="a KeystoneJS/OpenSaaS hooks: pair sharing one fence, both bare object-literal properties" -->
// KeystoneJS
hooks: {
resolveInput: async ({ resolvedData }) => {
if (resolvedData.status === 'published') {
return { ...resolvedData, publishedAt: new Date() }
}
return resolvedData
},
}
// OpenSaaS (same!)
hooks: {
resolveInput: async ({ resolvedData }) => {
if (resolvedData.status === 'published') {
return { ...resolvedData, publishedAt: new Date() }
}
return resolvedData
},
}
Deployment Configuration
The config does not change between environments โ the connection does. Set DATABASE_URL (and DIRECT_DATABASE_URL where a pooler sits in front, so schema commands reach a connection that can run DDL), and deploy by running prisma db migrate from the committed migrations/ directory.
Two db keys matter beyond the provider on a real deployment: extensions, which declares the extension packs the generator emits contract spaces for, and client, which carries pool options and a pg factory for serverless Postgres.
export default config({
db: {
provider: 'postgresql',
extensions: [{ name: 'pgvector', from: '@prisma/orm-extension-pgvector' }],
},
lists: {/* ... */},
})
See Config API for db.client and the rest.
Summary
The Stack migration system provides:
โ Automated detection of Prisma, KeystoneJS, Next.js projects โ AI-guided wizard through Claude Code integration โ Schema introspection and analysis โ Config generation with sensible defaults โ Data preservation - non-destructive migration โ Access control patterns for common scenarios โ Validation tools to verify migration โ Documentation and examples throughout
Time to migrate: 10-15 minutes with AI, 30-60 minutes manually
Next: Authentication Guide or Access Control