OpenSaaS Stack Migration
Expert guidance for migrating existing projects to OpenSaaS Stack.
Migration Process
1. Install Required Packages
IMPORTANT: Always install packages before starting migration
Detect the user's package manager (check for package-lock.json, pnpm-lock.yaml, yarn.lock, or bun.lockb) and use their preferred package manager.
Required packages:
# Using npm
npm install --save-dev @opensaas/stack-cli
npm install @opensaas/stack-core
# Using pnpm
pnpm add -D @opensaas/stack-cli
pnpm add @opensaas/stack-core
# Using yarn
yarn add -D @opensaas/stack-cli
yarn add @opensaas/stack-core
# Using bun
bun add -D @opensaas/stack-cli
bun add @opensaas/stack-core
Optional packages (based on user needs):
@opensaas/stack-auth - If the project needs authentication
@opensaas/stack-ui - If the project needs the admin UI
@opensaas/stack-tiptap - If the project needs rich text editing
@opensaas/stack-storage - If the project needs file storage
@opensaas/stack-rag - If the project needs semantic search/RAG
Database adapters (required for Prisma 7):
SQLite:
npm install better-sqlite3 @prisma/adapter-better-sqlite3
PostgreSQL:
npm install pg @prisma/adapter-pg
Neon (serverless PostgreSQL):
npm install @neondatabase/serverless @prisma/adapter-neon ws
2. Uninstall Old Packages (KeystoneJS Only)
IMPORTANT: For KeystoneJS projects, uninstall KeystoneJS packages before installing OpenSaaS
KeystoneJS migrations should preserve the existing file structure and just swap packages. Do NOT create a new project structure.
# Detect package manager and uninstall KeystoneJS packages
npm uninstall @keystone-6/core @keystone-6/auth @keystone-6/fields-document
# Or with pnpm
pnpm remove @keystone-6/core @keystone-6/auth @keystone-6/fields-document
Remove all @keystone-6/* packages from package.json.
3. Schema Analysis
Prisma Projects:
- Analyze existing
schema.prisma
- Identify models, fields, and relationships
- Note any Prisma-specific features used
KeystoneJS Projects:
- Review list definitions in
keystone.config.ts or keystone.ts
- Map KeystoneJS fields to OpenSaaS fields
- Identify access control patterns
- Note the existing file structure - preserve it during migration
4. Access Control Design
Common Patterns:
// Public read, authenticated write
operation: {
query: () => true,
create: ({ session }) => !!session?.userId,
update: ({ session }) => !!session?.userId,
delete: ({ session }) => !!session?.userId,
}
// Author-only access
operation: {
query: () => true,
update: ({ session, item }) => item.authorId === session?.userId,
delete: ({ session, item }) => item.authorId === session?.userId,
}
// Admin-only
operation: {
query: ({ session }) => session?.role === 'admin',
create: ({ session }) => session?.role === 'admin',
update: ({ session }) => session?.role === 'admin',
delete: ({ session }) => session?.role === 'admin',
}
// Filter-based access — return the Prisma filter DIRECTLY (no `where` wrapper);
// the engine merges it into the query's where clause
operation: {
query: ({ session }) =>
session ? { authorId: { equals: session.userId } } : false,
}
5. Field Mapping
Prisma to OpenSaaS:
| Prisma Type |
OpenSaaS Field |
String |
text() |
Int |
integer() |
Boolean |
checkbox() |
DateTime |
timestamp() |
Decimal |
decimal() |
Json |
json() |
Enum |
select({ options: [...] }) |
Relation |
relationship({ ref: '...' }) |
KeystoneJS to OpenSaaS:
| KeystoneJS Field |
OpenSaaS Field |
text |
text() |
integer |
integer() |
checkbox |
checkbox() |
timestamp |
timestamp() |
select |
select() |
relationship |
relationship() |
password |
password() |
decimal |
decimal() |
calendarDay |
calendarDay() |
json |
json() |
virtual |
virtual() — requires changes (no GraphQL, use hooks.resolveOutput) |
6. Database Configuration
SQLite (Development):
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
export default config({
db: {
provider: 'sqlite',
url: process.env.DATABASE_URL || 'file:./dev.db',
prismaClientConstructor: (PrismaClient) => {
const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL || 'file:./dev.db' })
return new PrismaClient({ adapter })
},
},
})
PostgreSQL (Production):
import { PrismaPg } from '@prisma/adapter-pg'
import pg from 'pg'
export default config({
db: {
provider: 'postgresql',
url: process.env.DATABASE_URL,
prismaClientConstructor: (PrismaClient) => {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
return new PrismaClient({ adapter })
},
},
})
KeystoneJS Migration Strategy
CRITICAL: KeystoneJS projects should be migrated IN PLACE
Do NOT create a new project structure. Instead:
File Structure Preservation
Keep existing files and update them:
- Rename config file:
- keystone.config.ts → opensaas.config.ts - OR keystone.ts → opensaas.config.ts
- Update imports in ALL files:
```typescript // Before (KeystoneJS) import { config, list } from '@keystone-6/core' import { text, relationship, timestamp } from '@keystone-6/core/fields'
// After (OpenSaaS) import { config, list } from '@opensaas/stack-core' import { text, relationship, timestamp } from '@opensaas/stack-core/fields' ```
- Rename KeystoneJS concepts to OpenSaaS:
- keystone.config.ts → opensaas.config.ts - Keystone references → OpenSaaS or remove entirely - Keep all other file names and structure as-is
- Update schema/list definitions:
- Keep existing list definitions - Update field imports from @keystone-6/core/fields to @opensaas/stack-core/fields - Adapt access control syntax (KeystoneJS and OpenSaaS are similar) - Keep existing GraphQL API file structure
- Preserve API routes and pages:
- Keep existing Next.js pages - Update any KeystoneJS context calls to use OpenSaaS context - Maintain existing route structure
Import Mapping
| KeystoneJS Import |
OpenSaaS Import |
@keystone-6/core |
@opensaas/stack-core |
@keystone-6/core/fields |
@opensaas/stack-core/fields |
@keystone-6/auth |
@opensaas/stack-auth |
@keystone-6/fields-document |
@opensaas/stack-tiptap |
Example: KeystoneJS to OpenSaaS Config
Before (keystone.config.ts):
import { config, list } from '@keystone-6/core'
import { text, relationship, timestamp } from '@keystone-6/core/fields'
export default config({
db: {
provider: 'postgresql',
url: process.env.DATABASE_URL,
},
lists: {
Post: list({
fields: {
title: text({ validation: { isRequired: true } }),
content: text({ ui: { displayMode: 'textarea' } }),
author: relationship({ ref: 'User.posts' }),
publishedAt: timestamp(),
},
}),
},
})
After (opensaas.config.ts):
import { config, list } from '@opensaas/stack-core'
import { text, relationship, timestamp } from '@opensaas/stack-core/fields'
import { PrismaPg } from '@prisma/adapter-pg'
import pg from 'pg'
export default config({
db: {
provider: 'postgresql',
url: process.env.DATABASE_URL,
prismaClientConstructor: (PrismaClient) => {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
return new PrismaClient({ adapter })
},
},
lists: {
Post: list({
fields: {
title: text({ validation: { isRequired: true } }),
content: text({ ui: { displayMode: 'textarea' } }), // ui.displayMode carries over from Keystone
author: relationship({ ref: 'User.posts' }),
publishedAt: timestamp(),
},
}),
},
})
Steps for KeystoneJS Migration
- Uninstall KeystoneJS packages (see step 2 above)
- Install OpenSaaS packages (see step 1 above)
- Rename
keystone.config.ts to opensaas.config.ts
- Find and replace in ALL project files:
- @keystone-6/core → @opensaas/stack-core - @keystone-6/core/fields → @opensaas/stack-core/fields - @keystone-6/auth → @opensaas/stack-auth
- Add Prisma adapter to database config (required for Prisma 7)
- Migrate virtual fields — if any
virtual() fields exist, invoke the keystone-virtual-fields-context skill
- Migrate context.graphql calls — search for
context.graphql.run(, context.graphql.raw(, context.query.; for simple reads replace with context.db.*; for nested/joined data use defineFragment + context.db.{list}.findMany({ query: fragment }); invoke the migrate-context-calls skill for detailed patterns
- Test - the app structure should remain identical
DO NOT:
- Create new folders or reorganize the project
- Move files to different locations
- Create a new "OpenSaaS structure"
- Change API endpoints or routes
DO:
- Keep existing file structure
- Update imports only
- Adapt config to OpenSaaS syntax
- Preserve existing API routes and pages
Common Migration Challenges
Challenge: Preserving Existing Data
Solution:
- Use
opensaas generate to create Prisma schema
- Use
prisma db push instead of migrations for existing databases
- Never use
prisma migrate dev with existing data
Challenge: Complex Access Control
Solution:
- Start with simple boolean access control
- Iterate to filter-based access as needed
- Use field-level access for sensitive data
Challenge: Custom Field Types
Solution:
- Create custom field builders extending
BaseFieldConfig
- Implement
getZodSchema, getPrismaType, getTypeScriptType
- Register UI components for admin interface
Challenge: KeystoneJS Document Field
Solution:
- Replace with
@opensaas/stack-tiptap rich text field
- Or create custom field type for document structure
- May require data migration for existing documents
Challenge: Virtual Fields
Keystone virtual fields use graphql.field({ type: graphql.String, resolve }). OpenSaaS Stack has no GraphQL — virtual fields use hooks.resolveOutput with a type property instead.
Quick example:
// Keystone
fullName: virtual({
field: graphql.field({
type: graphql.String,
resolve: (item) => `${item.firstName} ${item.lastName}`,
}),
})
// OpenSaaS Stack
fullName: virtual({
type: 'string',
hooks: {
resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`,
},
})
Field arguments are not supported in OpenSaaS Stack. For detailed patterns including context queries and custom types, invoke the keystone-virtual-fields-context skill.
Challenge: context.graphql Calls
Keystone apps often use context.graphql.run() for type-safe data access from routes, server actions, and hooks. OpenSaaS Stack has no GraphQL — use context.db.{listName}.{method}() directly, or the new fragment-based query utilities for nested/joined data.
Simple queries (no nesting):
// Keystone
const { posts } = await context.graphql.run({
query: `query { posts(where: { status: { equals: published } }) { id title } }`,
})
// OpenSaaS Stack
const posts = await context.db.post.findMany({
where: { status: { equals: 'published' } },
})
Queries with nested/related data (fragments — recommended for Keystone migrations):
OpenSaaS Stack provides defineFragment for composable, fully typed queries — the closest equivalent to Keystone GraphQL fragments and codegen types. Pass the fragment directly to context.db operations using the query parameter.
// Keystone — GraphQL fragment + codegen types
import type { PostFragment } from './__generated__/graphql'
const { posts } = await context.graphql.run({
query: `
fragment AuthorFields on User { id name }
query { posts { id title author { ...AuthorFields } } }
`,
})
// OpenSaaS Stack — defineFragment + context.db (no codegen, no GraphQL)
import type { User, Post } from '@/.opensaas/prisma-client/client'
import { defineFragment, type ResultOf } from '@opensaas/stack-core'
const authorFragment = defineFragment<User>()({ id: true, name: true } as const)
const postFragment = defineFragment<Post>()({
id: true,
title: true,
author: authorFragment,
} as const)
type PostData = ResultOf<typeof postFragment>
// → { id: string; title: string; author: { id: string; name: string } | null }
// Primary API: pass query to context.db operations
const posts = await context.db.post.findMany({ query: postFragment })
// posts: PostData[]
// With filter, orderBy, pagination
const filtered = await context.db.post.findMany({
query: postFragment,
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 10,
})
// Single record
const post = await context.db.post.findUnique({ where: { id }, query: postFragment })
// Nested relationship filtering with RelationSelector
const commentFrag = defineFragment<Comment>()({ id: true, body: true } as const)
const postWithComments = defineFragment<Post>()({
id: true,
comments: { query: commentFrag, where: { approved: true }, take: 5 },
} as const)
const postsWithComments = await context.db.post.findMany({ query: postWithComments })
List names are camelCase: Post → context.db.post, BlogPost → context.db.blogPost. Access control is enforced automatically. For detailed patterns including sudo access, invoke the migrate-context-calls skill.
Migration Checklist
For Prisma Projects:
For KeystoneJS Projects:
Best Practices
- Start Simple: Begin with basic access control, refine later
- Test Access Control: Verify permissions work as expected
- Use Context Everywhere: Replace direct Prisma calls with
context.db
- Leverage Plugins: Use
@opensaas/stack-auth for authentication
- Version Control: Commit
opensaas.config.ts to git
- Document Decisions: Comment complex access control logic
Reporting Issues
When you encounter bugs or missing features in OpenSaaS Stack:
If during migration you discover:
- Bugs in OpenSaaS Stack packages
- Missing features that would improve the migration experience
- Documentation gaps or errors
- API inconsistencies or unexpected behavior
Use the github-issue-creator agent to create a GitHub issue on the OpenSaasAU/stack repository:
Invoke the github-issue-creator agent with:
- Clear description of the bug or missing feature
- Steps to reproduce (if applicable)
- Expected vs actual behavior
- Affected files and line numbers
- Your suggested solution (if you have one)
This ensures bugs and feature requests are properly tracked and addressed by the OpenSaaS Stack team, improving the experience for future users.
Example:
If you notice that the migration command doesn't properly handle Prisma enums, invoke the github-issue-creator agent:
"Found a bug: The migration generator doesn't convert Prisma enums to OpenSaaS select fields. Enums are being ignored during schema analysis in packages/cli/src/migration/introspectors/prisma-introspector.ts"
The agent will create a detailed GitHub issue with reproduction steps and proposed solution.
Resources