From opensaas-migration
Detailed migration patterns for Keystone virtual fields and context.graphql → context.db when migrating to OpenSaaS Stack. Invoke this skill when virtual fields or context.graphql usage is detected in a project.
npx claudepluginhub opensaasau/stack --plugin opensaas-migrationThis skill uses the workspace's default tool permissions.
Detailed patterns for the two areas that differ most between Keystone and OpenSaaS Stack.
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Generates original PNG/PDF visual art via design philosophy manifestos for posters, graphics, and static designs on user request.
Detailed patterns for the two areas that differ most between Keystone and OpenSaaS Stack.
Keystone virtual fields use GraphQL's type system. OpenSaaS Stack has no GraphQL — virtual fields use hooks.resolveOutput instead.
import { virtual } from '@keystone-6/core/fields'
import { graphql } from '@keystone-6/core'
fields: {
// Simple computed string
fullName: virtual({
field: graphql.field({
type: graphql.String,
resolve: (item) => `${item.firstName} ${item.lastName}`,
}),
}),
// Computed with context (e.g. related count)
postCount: virtual({
field: graphql.field({
type: graphql.Int,
resolve: async (item, _, context) => {
return context.query.Post.count({
where: { author: { id: { equals: item.id } } },
})
},
}),
}),
// With arguments
excerpt: virtual({
field: graphql.field({
type: graphql.String,
args: { length: graphql.arg({ type: graphql.nonNull(graphql.Int) }) },
resolve: (item, { length }) => item.content?.slice(0, length),
}),
}),
}
import { virtual } from '@opensaas/stack-core/fields'
fields: {
// Simple computed string
fullName: virtual({
type: 'string',
hooks: {
resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`,
},
}),
// Computed with context — use context.db not context.query
postCount: virtual({
type: 'number',
hooks: {
resolveOutput: async ({ item, context }) => {
return context.db.post.count({
where: { authorId: { equals: item.id } },
})
},
},
}),
// Arguments are NOT supported — use a fixed value or split into separate fields
excerpt: virtual({
type: 'string',
hooks: {
resolveOutput: ({ item }) => item.content?.slice(0, 200),
},
}),
}
| Keystone | OpenSaaS Stack |
|---|---|
graphql.field({ type: graphql.String, resolve }) | { type: 'string', hooks: { resolveOutput } } |
resolve(item, args, context) | resolveOutput({ item, context }) |
context.query.Post.count(...) | context.db.post.count(...) |
| Field arguments supported | Field arguments NOT supported |
graphql.Int, graphql.Boolean etc. | 'number', 'boolean' etc. |
Custom types via graphql.object() | Custom types via type descriptor { value: MyClass, from: 'my-pkg' } |
| Keystone GraphQL type | OpenSaaS type value |
|---|---|
graphql.String | 'string' |
graphql.Int / graphql.Float | 'number' |
graphql.Boolean | 'boolean' |
| Custom object type | { value: MyClass, from: 'package-name' } |
graphql.list(graphql.String) | 'string[]' |
import Decimal from 'decimal.js'
fields: {
totalPrice: virtual({
// Type descriptor: OpenSaaS generates the correct import
type: { value: Decimal, from: 'decimal.js' },
hooks: {
resolveOutput: ({ item }) =>
new Decimal(item.price).times(item.quantity),
},
}),
}
graphql.field(), graphql.String, graphql.Int etc. references from virtual fieldsresolve(item, args, context) with hooks: { resolveOutput: ({ item, context }) => ... }context.query.* calls inside resolveOutput with context.db.*args (not supported) — bake in defaults or split into multiple fieldstype to a string literal or type descriptor objectKeystone provides context.graphql.run() and context.graphql.raw() for type-safe data access from API routes, server actions, and hooks. OpenSaaS Stack uses context.db.{listName}.{method}() directly — the same access control rules apply automatically.
List names are camelCase in context.db: Post → context.db.post, BlogPost → context.db.blogPost.
// Keystone
const { posts } = await context.graphql.run({
query: `query {
posts(where: { status: { equals: published } }) {
id title publishedAt
}
}`,
})
// OpenSaaS Stack
const posts = await context.db.post.findMany({
where: { status: { equals: 'published' } },
})
// Keystone
const { posts } = await context.graphql.run({
query: `query GetAuthorPosts($authorId: ID!) {
posts(
where: { author: { id: { equals: $authorId } } }
orderBy: [{ createdAt: desc }]
take: 10
) { id title createdAt }
}`,
variables: { authorId: userId },
})
// OpenSaaS Stack
const posts = await context.db.post.findMany({
where: { authorId: { equals: userId } },
orderBy: { createdAt: 'desc' },
take: 10,
})
// Keystone
const { post } = await context.graphql.run({
query: `query GetPost($id: ID!) {
post(where: { id: $id }) { id title content }
}`,
variables: { id: postId },
})
// OpenSaaS Stack
const post = await context.db.post.findUnique({
where: { id: postId },
})
// Keystone
const { createPost } = await context.graphql.run({
query: `mutation CreatePost($data: PostCreateInput!) {
createPost(data: $data) { id title }
}`,
variables: { data: { title: 'Hello', content: '...' } },
})
// OpenSaaS Stack
const post = await context.db.post.create({
data: { title: 'Hello', content: '...' },
})
// Keystone
const { updatePost } = await context.graphql.run({
query: `mutation UpdatePost($id: ID!, $data: PostUpdateInput!) {
updatePost(where: { id: $id }, data: $data) { id title }
}`,
variables: { id: postId, data: { title: 'Updated' } },
})
// OpenSaaS Stack
const post = await context.db.post.update({
where: { id: postId },
data: { title: 'Updated' },
})
// Returns null if access denied or not found
if (!post) return { error: 'Access denied' }
// Keystone
await context.graphql.run({
query: `mutation DeletePost($id: ID!) {
deletePost(where: { id: $id }) { id }
}`,
variables: { id: postId },
})
// OpenSaaS Stack
const deleted = await context.db.post.delete({
where: { id: postId },
})
// Keystone
const { postsCount } = await context.graphql.run({
query: `query { postsCount(where: { status: { equals: published } }) }`,
})
// OpenSaaS Stack
const count = await context.db.post.count({
where: { status: { equals: 'published' } },
})
Keystone returns nested GraphQL results in a single query. OpenSaaS Stack uses separate context.db calls per list:
// Keystone — nested in one query
const { post } = await context.graphql.run({
query: `query GetPost($id: ID!) {
post(where: { id: $id }) {
id title
author { id name email }
tags { id name }
}
}`,
variables: { id: postId },
})
// OpenSaaS Stack — separate calls
const post = await context.db.post.findUnique({ where: { id: postId } })
const author = post?.authorId
? await context.db.user.findUnique({ where: { id: post.authorId } })
: null
// Keystone
const sudoContext = context.sudo()
const { posts } = await sudoContext.graphql.run({ query: '...' })
// OpenSaaS Stack
const posts = await context.sudo().db.post.findMany()
| Keystone | OpenSaaS Stack |
|---|---|
context.graphql.run({ query, variables }) | context.db.{list}.{method}(args) |
context.graphql.raw({ query, variables }) | context.db.{list}.{method}(args) |
| Nested related data in one query | Separate context.db calls per list |
Returns { data: { listName: [...] } } | Returns result directly (or null on access denial) |
| GraphQL string queries | Prisma-style filter objects |
context.query.* (also Keystone) | context.db.* |
context.sudo().graphql.* | context.sudo().db.* |
context.graphql.run( and context.graphql.raw( callscontext.query. calls (another Keystone API)context.db.{camelCaseListName}.{method}()context.db callsnull returns (OpenSaaS returns null on access denial, not errors)context.sudo().graphql.* with context.sudo().db.*