Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .changeset/swift-foxes-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
'@opensaas/stack-core': minor
---

Add fragment-based, type-safe query utilities and integrate them into `context.db` operations

OpenSaaS Stack now ships `defineFragment`, `ResultOf`, and `RelationSelector` — composable query helpers that give you the same benefits as Keystone's GraphQL fragments (reuse, type inference, nesting) without a GraphQL runtime.

**Define reusable fragments:**

```ts
import type { User, Post } from '.prisma/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, // nested relationship
} as const)

// Types are inferred — no codegen step required
type PostData = ResultOf<typeof postFragment>
// → { id: string; title: string; author: { id: string; name: string } | null }
```

**Pass fragments directly to `context.db` operations (primary API):**

```ts
// List — typed to ResultOf<typeof postFragment>[]
const posts = await context.db.post.findMany({
query: postFragment,
where: { published: true },
orderBy: { publishedAt: 'desc' },
take: 10,
})

// Single record — typed to ResultOf<typeof postFragment> | null
const post = await context.db.post.findUnique({
where: { id: postId },
query: postFragment,
})
if (!post) return notFound()
```

**Nested relationship filtering with `RelationSelector`:**

```ts
const commentFragment = defineFragment<Comment>()({ id: true, body: true } as const)

const postWithComments = defineFragment<Post>()({
id: true,
title: true,
comments: {
query: commentFragment,
where: { approved: true },
orderBy: { createdAt: 'desc' },
take: 5,
},
} as const)

const posts = await context.db.post.findMany({ query: postWithComments })
```

**Standalone helpers also available** for use in hooks and utilities:

```ts
import { runQuery, runQueryOne } from '@opensaas/stack-core'

const posts = await runQuery(context, 'Post', postFragment, { where: { published: true } })
const post = await runQueryOne(context, 'Post', postFragment, { id: postId })
```

Fragments compose freely and can be nested to any depth. Access control is always enforced — the `query` parameter only controls the include structure and field shape, not security. `orderBy` is now also supported in `context.db.<list>.findMany()`.

See `specs/keystone-migration.md` for a full migration guide from Keystone's `context.graphql.run`.
Original file line number Diff line number Diff line change
Expand Up @@ -115,17 +115,98 @@ const { postsCount } = await context.graphql.run({
const count = await context.db.post.count({ where: { status: { equals: 'published' } } })
```

### Nested / related data
### Nested / related data (fragment passed to context.db — recommended)

GraphQL allows fetching related data in one query. OpenSaaS Stack requires separate `context.db` calls:
OpenSaaS Stack provides `defineFragment` for composable, type-safe queries that include related data in a single call — the closest equivalent to Keystone's GraphQL fragments. Pass the fragment directly to `context.db` operations using the `query` parameter.

```typescript
// Before — one GraphQL query with nested author and tags
const { posts } = await context.graphql.run({
query: `
fragment AuthorFields on User { id name }
query GetPosts {
posts(where: { published: true }) {
id title author { ...AuthorFields } tags { id name }
}
}
`,
})

// After — define fragments once, compose and reuse them
import type { User, Post, Tag } from '.prisma/client'
import { defineFragment, type ResultOf } from '@opensaas/stack-core'

const authorFragment = defineFragment<User>()({ id: true, name: true } as const)
const tagFragment = defineFragment<Tag>()({ id: true, name: true } as const)
const postFragment = defineFragment<Post>()({
id: true,
title: true,
author: authorFragment, // nested fragment → loaded via Prisma include
tags: tagFragment, // many relationship
} as const)

// Type-inferred — no codegen needed
type PostData = ResultOf<typeof postFragment>
// → { id: string; title: string; author: { id: string; name: string } | null; tags: { id: string; name: string }[] }

// Primary API: pass query fragment to context.db.findMany
const posts = await context.db.post.findMany({
query: postFragment,
where: { published: true },
orderBy: { publishedAt: 'desc' },
})
// posts: PostData[]
```

For single-record queries:

```typescript
const post = await context.db.post.findUnique({
where: { id: postId },
query: postFragment,
})
if (!post) return notFound()
// post: PostData
```

For nested relationship filtering (e.g., only load approved comments):

```typescript
const commentFragment = defineFragment<Comment>()({ id: true, body: true } as const)

const postWithComments = defineFragment<Post>()({
id: true,
title: true,
comments: {
query: commentFragment,
where: { approved: true }, // filter nested relationship
orderBy: { createdAt: 'desc' },
take: 5,
},
} as const)

const posts = await context.db.post.findMany({ query: postWithComments })
```

Standalone `runQuery` / `runQueryOne` helpers are also available for use in hooks or utilities where `context.db` is available but direct method call is inconvenient:

```typescript
import { runQuery, runQueryOne } from '@opensaas/stack-core'

const posts = await runQuery(context, 'Post', postFragment, { where: { published: true } })
const post = await runQueryOne(context, 'Post', postFragment, { id: postId })
```

### Nested / related data (separate context.db calls — simpler alternative)

If you only need one level of nesting without fragment reuse, separate calls are fine:

```typescript
// Before — one query with nested author
const { post } = await context.graphql.run({
query: `query { post(where: { id: $id }) { id title author { id name } } }`,
variables: { id: postId },
})
const authorName = post.author.name

// After — separate calls
const post = await context.db.post.findUnique({ where: { id: postId } })
Expand All @@ -150,9 +231,13 @@ const allPosts = await context.sudo().db.post.findMany()
1. Use Grep to find all occurrences of `context.graphql`, `context.query`, and `context.sudo().graphql` in the project (search `.ts`, `.tsx` files, exclude `node_modules`)
2. For each occurrence:
a. Read the file to understand the full query/mutation
b. Identify the list name (convert to camelCase for `context.db`)
c. Identify the operation (findMany, findUnique, create, update, delete, count)
d. Rewrite using the `context.db` pattern above
e. For nested data: split into separate `context.db` calls
3. After all edits: check that any `import ... from '@keystone-6/core'` imports used only for graphql types are removed or reduced
b. Identify the operation type:
- **Read with nested data** → prefer `context.db.{list}.findMany/findUnique({ query: fragment })` with `defineFragment` (see pattern above)
- **Simple read** → `context.db.{list}.findMany()` / `findUnique()`
- **Create / update / delete** → `context.db.{list}.create()` / `update()` / `delete()`
- **Count** → `context.db.{list}.count()`
c. Identify the list name (convert to camelCase for `context.db`)
d. Rewrite using the appropriate pattern above
e. For fragment-based rewrites: create a shared `fragments.ts` file and import from it
3. After all edits: check that any `import ... from '@keystone-6/core'` imports used only for graphql types are removed or reduced; also remove any GraphQL codegen type imports (replace with `ResultOf<typeof fragment>`)
4. Report: list every file changed and summarise what was replaced
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ export default config({
- `@keystone-6/auth` → `@opensaas/stack-auth`
5. **Add Prisma adapter** to database config (required for Prisma 7)
6. **Migrate virtual fields** — if any `virtual()` fields exist, invoke the `keystone-virtual-fields-context` skill
7. **Migrate context.graphql calls** — search for `context.graphql.run(`, `context.graphql.raw(`, `context.query.` and replace with `context.db.*` calls; invoke the `keystone-virtual-fields-context` skill for patterns
7. **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
8. **Test** - the app structure should remain identical

**DO NOT:**
Expand Down Expand Up @@ -403,9 +403,9 @@ Field arguments are not supported in OpenSaaS Stack. For detailed patterns inclu

### 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 instead.
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.

**Quick example:**
**Simple queries (no nesting):**

```typescript
// Keystone
Expand All @@ -419,7 +419,60 @@ const posts = await context.db.post.findMany({
})
```

List names are camelCase: `Post` → `context.db.post`, `BlogPost` → `context.db.blogPost`. Access control is enforced automatically. For detailed patterns including related data and sudo access, **invoke the `keystone-virtual-fields-context` skill**.
**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.

```typescript
// 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 '.prisma/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

Expand Down Expand Up @@ -455,7 +508,7 @@ List names are camelCase: `Post` → `context.db.post`, `BlogPost` → `context.
- [ ] **Add Prisma adapter** to database config
- [ ] **Update context creation** in API routes
- [ ] **Migrate virtual fields** (if any) — replace `graphql.field()` + `resolve()` with `hooks.resolveOutput`; invoke `keystone-virtual-fields-context` skill
- [ ] **Migrate context.graphql calls** (if any) — replace with `context.db.*` calls; invoke `keystone-virtual-fields-context` skill
- [ ] **Migrate context.graphql calls** (if any) — for simple reads use `context.db.*`; for nested/related data use `defineFragment` + `context.db.{list}.findMany({ query: fragment })` from `@opensaas/stack-core`; invoke `migrate-context-calls` skill for detailed patterns
- [ ] Analyze and adapt access control patterns
- [ ] Run `opensaas generate`
- [ ] Run `prisma generate`
Expand Down
Loading
Loading