| name | keystone-virtual-fields-context |
| description | 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. |
Keystone: Virtual Fields & context.graphql Migration
Detailed patterns for the two areas that differ most between Keystone and OpenSaaS Stack.
Virtual Fields
Keystone virtual fields use GraphQL's type system. OpenSaaS Stack has no GraphQL — virtual fields use hooks.resolveOutput instead.
Before (Keystone)
import { virtual } from '@keystone-6/core/fields'
import { graphql } from '@keystone-6/core'
fields: {
fullName: virtual({
field: graphql.field({
type: graphql.String,
resolve: (item) => `${item.firstName} ${item.lastName}`,
}),
}),
postCount: virtual({
field: graphql.field({
type: graphql.Int,
resolve: async (item, _, context) => {
return context.query.Post.count({
where: { author: { id: { equals: item.id } } },
})
},
}),
}),
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),
}),
}),
}
After (OpenSaaS Stack)
import { virtual } from '@opensaas/stack-core/fields'
fields: {
fullName: virtual({
type: 'string',
hooks: {
resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`,
},
}),
postCount: virtual({
type: 'number',
hooks: {
resolveOutput: async ({ item, context }) => {
return context.db.post.count({
where: { authorId: { equals: item.id } },
})
},
},
}),
excerpt: virtual({
type: 'string',
hooks: {
resolveOutput: ({ item }) => item.content?.slice(0, 200),
},
}),
}
Key Differences
| 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' } |
Type Mappings
| 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[]' |
Custom Types (e.g. Decimal for financial fields)
import Decimal from 'decimal.js'
fields: {
totalPrice: virtual({
type: { value: Decimal, from: 'decimal.js' },
hooks: {
resolveOutput: ({ item }) =>
new Decimal(item.price).times(item.quantity),
},
}),
}
Checklist
context.graphql → context.db
Keystone 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.
Query (findMany)
const { posts } = await context.graphql.run({
query: `query {
posts(where: { status: { equals: published } }) {
id title publishedAt
}
}`,
})
const posts = await context.db.post.findMany({
where: { status: { equals: 'published' } },
})
Query with filters and ordering
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 },
})
const posts = await context.db.post.findMany({
where: { authorId: { equals: userId } },
orderBy: { createdAt: 'desc' },
take: 10,
})
Query single item
const { post } = await context.graphql.run({
query: `query GetPost($id: ID!) {
post(where: { id: $id }) { id title content }
}`,
variables: { id: postId },
})
const post = await context.db.post.findUnique({
where: { id: postId },
})
Create
const { createPost } = await context.graphql.run({
query: `mutation CreatePost($data: PostCreateInput!) {
createPost(data: $data) { id title }
}`,
variables: { data: { title: 'Hello', content: '...' } },
})
const post = await context.db.post.create({
data: { title: 'Hello', content: '...' },
})
Update
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' } },
})
const post = await context.db.post.update({
where: { id: postId },
data: { title: 'Updated' },
})
if (!post) return { error: 'Access denied' }
Delete
await context.graphql.run({
query: `mutation DeletePost($id: ID!) {
deletePost(where: { id: $id }) { id }
}`,
variables: { id: postId },
})
const deleted = await context.db.post.delete({
where: { id: postId },
})
Count
const { postsCount } = await context.graphql.run({
query: `query { postsCount(where: { status: { equals: published } }) }`,
})
const count = await context.db.post.count({
where: { status: { equals: 'published' } },
})
Related data
Keystone returns nested GraphQL results in a single query. OpenSaaS Stack uses separate context.db calls per list:
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 },
})
const post = await context.db.post.findUnique({ where: { id: postId } })
const author = post?.authorId
? await context.db.user.findUnique({ where: { id: post.authorId } })
: null
Bypassing access control (sudo)
const sudoContext = context.sudo()
const { posts } = await sudoContext.graphql.run({ query: '...' })
const posts = await context.sudo().db.post.findMany()
Key Differences
| 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.* |
Checklist