From 3aa04e0462418e9390b3c5be10358ad07f9e4456 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 08:40:23 +0000 Subject: [PATCH 1/5] feat(core): add fragment-based query utilities for Keystone migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds defineFragment, runQuery, and runQueryOne to @opensaas/stack-core. These utilities provide composable, type-safe query helpers that replace context.graphql.run() for teams migrating from KeystoneJS: - defineFragment()({ field: true, rel: nestedFragment }) — creates a reusable field-selection descriptor with full TypeScript inference - ResultOf — infers the exact result shape, analogous to gql.tada's ResultOf (no GraphQL codegen step required) - runQuery(context, listKey, fragment, args?) — executes findMany through context.db, respecting all access control rules - runQueryOne(context, listKey, fragment, where) — executes findFirst Also adds: - specs/keystone-migration.md — comprehensive migration guide for both humans and AI agents covering all context.graphql patterns - docs/content/guides/migration.md — new "Migrating context.graphql.run" section with before/after examples - claude-plugins migration skills updated to recommend fragments for nested data patterns https://claude.ai/code/session_012ismTCY2S84rCjRWL4jnwU --- .changeset/swift-foxes-query.md | 49 ++ .../skills/migrate-context-calls/SKILL.md | 69 +- .../skills/opensaas-migration/SKILL.md | 43 +- docs/content/guides/migration.md | 82 +++ packages/core/src/index.ts | 10 + packages/core/src/query/index.test.ts | 510 +++++++++++++++ packages/core/src/query/index.ts | 350 ++++++++++ specs/keystone-migration.md | 615 ++++++++++++++++++ 8 files changed, 1715 insertions(+), 13 deletions(-) create mode 100644 .changeset/swift-foxes-query.md create mode 100644 packages/core/src/query/index.test.ts create mode 100644 packages/core/src/query/index.ts create mode 100644 specs/keystone-migration.md diff --git a/.changeset/swift-foxes-query.md b/.changeset/swift-foxes-query.md new file mode 100644 index 00000000..0e049009 --- /dev/null +++ b/.changeset/swift-foxes-query.md @@ -0,0 +1,49 @@ +--- +'@opensaas/stack-core': minor +--- + +Add fragment-based, type-safe query utilities for migrating from `context.graphql.run` + +OpenSaaS Stack now ships `defineFragment`, `runQuery`, and `runQueryOne` — 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()({ id: true, name: true } as const) + +const postFragment = defineFragment()({ + id: true, + title: true, + author: authorFragment, // nested relationship +} as const) + +// Types are inferred — no codegen step required +type PostData = ResultOf +// → { id: string; title: string; author: { id: string; name: string } | null } +``` + +**Run queries (respects access control):** + +```ts +import { runQuery, runQueryOne } from '@opensaas/stack-core' + +// List — replaces context.graphql.run with a query { posts { ... } } +const posts = await runQuery(context, 'Post', postFragment, { + where: { published: true }, + orderBy: { publishedAt: 'desc' }, + take: 10, +}) +// posts: PostData[] + +// Single record — replaces context.graphql.run with a query { post(where: ...) { ... } } +const post = await runQueryOne(context, 'Post', postFragment, { id: postId }) +if (!post) return notFound() +// post: PostData +``` + +Fragments compose freely and can be nested to any depth. The same fragment instance can be reused in multiple parent fragments. + +See `specs/keystone-migration.md` for a full migration guide from Keystone's `context.graphql.run`. diff --git a/claude-plugins/opensaas-migration/skills/migrate-context-calls/SKILL.md b/claude-plugins/opensaas-migration/skills/migrate-context-calls/SKILL.md index 7282489b..340a3fa9 100644 --- a/claude-plugins/opensaas-migration/skills/migrate-context-calls/SKILL.md +++ b/claude-plugins/opensaas-migration/skills/migrate-context-calls/SKILL.md @@ -115,9 +115,59 @@ const { postsCount } = await context.graphql.run({ const count = await context.db.post.count({ where: { status: { equals: 'published' } } }) ``` -### Nested / related data +### Nested / related data (with runQuery — recommended) -GraphQL allows fetching related data in one query. OpenSaaS Stack requires separate `context.db` calls: +OpenSaaS Stack provides `defineFragment` and `runQuery` / `runQueryOne` for composable, type-safe queries that include related data in a single call — the closest equivalent to Keystone's GraphQL fragments. + +```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, runQuery, type ResultOf } from '@opensaas/stack-core' + +const authorFragment = defineFragment()({ id: true, name: true } as const) +const tagFragment = defineFragment()({ id: true, name: true } as const) +const postFragment = defineFragment()({ + id: true, + title: true, + author: authorFragment, // nested fragment → loads with include + tags: tagFragment, // many relationship +} as const) + +// Type-inferred — no codegen needed +type PostData = ResultOf +// → { id: string; title: string; author: { id: string; name: string } | null; tags: { id: string; name: string }[] } + +const posts = await runQuery(context, 'Post', postFragment, { + where: { published: true }, +}) +// posts: PostData[] +``` + +For single-record queries use `runQueryOne`: + +```typescript +import { runQueryOne } from '@opensaas/stack-core' + +const post = await runQueryOne(context, 'Post', postFragment, { id: postId }) +if (!post) return notFound() +// post: PostData +``` + +### 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 @@ -125,7 +175,6 @@ 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 } }) @@ -150,9 +199,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 `runQuery` / `runQueryOne` 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`) 4. Report: list every file changed and summarise what was replaced diff --git a/claude-plugins/opensaas-migration/skills/opensaas-migration/SKILL.md b/claude-plugins/opensaas-migration/skills/opensaas-migration/SKILL.md index 8dd1b818..0c8ed604 100644 --- a/claude-plugins/opensaas-migration/skills/opensaas-migration/SKILL.md +++ b/claude-plugins/opensaas-migration/skills/opensaas-migration/SKILL.md @@ -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` + `runQuery`/`runQueryOne`; invoke the `migrate-context-calls` skill for detailed patterns 8. **Test** - the app structure should remain identical **DO NOT:** @@ -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 @@ -419,7 +419,40 @@ 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` + `runQuery` / `runQueryOne` for composable, fully typed queries — the closest equivalent to Keystone GraphQL fragments and codegen types. + +```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 + ResultOf (no codegen) +import type { User, Post } from '.prisma/client' +import { defineFragment, runQuery, type ResultOf } from '@opensaas/stack-core' + +const authorFragment = defineFragment()({ id: true, name: true } as const) +const postFragment = defineFragment()({ + id: true, + title: true, + author: authorFragment, +} as const) + +type PostData = ResultOf +// → { id: string; title: string; author: { id: string; name: string } | null } + +const posts = await runQuery(context, 'Post', postFragment) +// posts: PostData[] +``` + +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 @@ -455,7 +488,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` + `runQuery`/`runQueryOne` from `@opensaas/stack-core`; invoke `migrate-context-calls` skill for detailed patterns - [ ] Analyze and adapt access control patterns - [ ] Run `opensaas generate` - [ ] Run `prisma generate` diff --git a/docs/content/guides/migration.md b/docs/content/guides/migration.md index 5da0d01d..d76793b2 100644 --- a/docs/content/guides/migration.md +++ b/docs/content/guides/migration.md @@ -262,6 +262,7 @@ npx @opensaas/stack-cli migrate --type prisma - Access control → Access control patterns - Hooks → Hooks - Authentication → Auth plugin +- `context.graphql.run` queries → fragment-based query utilities (see [Migrating context.graphql.run](#migrating-contextgraphqlrun) below) **Example:** @@ -861,6 +862,87 @@ export async function getPosts() { } ``` +## 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. OpenSaaS Stack has no GraphQL layer — instead it provides **fragment-based query utilities** that give you the same benefits (composability, type inference, fragment reuse) without GraphQL. + +### Quick reference + +| Keystone | OpenSaaS Stack | +|---|---| +| GraphQL fragment string | `defineFragment()(fields)` | +| `ResultOf` (codegen) | `ResultOf` (built-in) | +| `context.graphql.run({ query, variables })` — list | `runQuery(context, 'List', fragment, args)` | +| `context.graphql.run({ query, variables })` — single | `runQueryOne(context, 'List', fragment, where)` | + +### Simple list query + +```typescript +// Before (Keystone) +const { posts } = await context.graphql.run({ + query: `query { posts(where: { published: true }) { id title } }`, +}) + +// After (OpenSaaS Stack) +import type { Post } from '.prisma/client' +import { defineFragment, runQuery, type ResultOf } from '@opensaas/stack-core' + +const postFragment = defineFragment()({ id: true, title: true } as const) +type PostData = ResultOf // { id: string; title: string } + +const posts = await runQuery(context, 'Post', postFragment, { where: { published: true } }) +``` + +### Nested / related data with reusable fragments + +The biggest win from fragments is composability — define a fragment once and use it in many queries. + +```typescript +// fragments.ts — define once, import everywhere +import type { User, Post, Tag } from '.prisma/client' +import { defineFragment, type ResultOf } from '@opensaas/stack-core' + +export const authorFragment = defineFragment()({ + id: true, name: true, email: true, +} as const) + +export const tagFragment = defineFragment()({ id: true, name: true } as const) + +export const postFragment = defineFragment()({ + id: true, + title: true, + publishedAt: true, + author: authorFragment, // nested — access-controlled include + tags: tagFragment, // many relationship +} as const) + +// Inferred types — no GraphQL codegen required +export type AuthorData = ResultOf +export type PostData = ResultOf +// PostData → { id: string; title: string; publishedAt: Date | null; +// author: { id: string; name: string; email: string } | null; +// tags: { id: string; name: string }[] } +``` + +```typescript +// Usage in a server action or route +import { runQuery, runQueryOne } from '@opensaas/stack-core' +import { postFragment } from './fragments' + +// List +const posts = await runQuery(context, 'Post', postFragment, { + where: { published: true }, + orderBy: { publishedAt: 'desc' }, + take: 10, +}) + +// Single record +const post = await runQueryOne(context, 'Post', postFragment, { id: postId }) +if (!post) return notFound() +``` + +All operations go through `context.db` under the hood, so access control is enforced automatically. See the full [Keystone migration spec](../../../specs/keystone-migration.md) for more patterns. + ## Best Practices ### Start Small diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d4464c93..42da43ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -89,3 +89,13 @@ export { isHashedPassword, HashedPassword, } from './utils/password.js' + +// Query utilities — fragment-based, type-safe query helpers +export { defineFragment, runQuery, runQueryOne } from './query/index.js' +export type { + Fragment, + FieldSelection, + ResultOf, + QueryArgs, + QueryRunnerContext, +} from './query/index.js' diff --git a/packages/core/src/query/index.test.ts b/packages/core/src/query/index.test.ts new file mode 100644 index 00000000..b2f00ea8 --- /dev/null +++ b/packages/core/src/query/index.test.ts @@ -0,0 +1,510 @@ +import { describe, it, expect, vi } from 'vitest' +import { defineFragment, runQuery, runQueryOne } from './index.js' +import type { ResultOf, Fragment, FieldSelection, QueryRunnerContext } from './index.js' + +// ───────────────────────────────────────────────────────────── +// Test model types (stand-ins for Prisma-generated types) +// ───────────────────────────────────────────────────────────── + +type Tag = { + id: string + name: string + slug: string +} + +type User = { + id: string + name: string + email: string + role: 'admin' | 'user' + bio: string | null +} + +type Post = { + id: string + title: string + content: string | null + published: boolean + createdAt: Date + authorId: string | null + author: User | null + tags: Tag[] +} + +type Comment = { + id: string + body: string + post: Post | null + author: User | null +} + +// ───────────────────────────────────────────────────────────── +// Helpers for building a fake context.db delegate +// ───────────────────────────────────────────────────────────── + +function makeDelegate(rows: unknown[], findFirstRow?: unknown) { + return { + findMany: vi.fn(async (_args?: unknown) => rows), + findFirst: vi.fn(async (_args?: unknown) => findFirstRow ?? rows[0] ?? null), + } +} + +function makeContext(delegates: Record>): QueryRunnerContext { + return { db: delegates } +} + +// ───────────────────────────────────────────────────────────── +// defineFragment — construction +// ───────────────────────────────────────────────────────────── + +describe('defineFragment', () => { + it('returns an object with _type: "fragment" and the field selection', () => { + const frag = defineFragment()({ id: true, name: true } as const) + + expect(frag._type).toBe('fragment') + expect(frag._fields).toEqual({ id: true, name: true }) + }) + + it('accepts true for all field types', () => { + const frag = defineFragment()({ + id: true, + title: true, + content: true, + published: true, + createdAt: true, + authorId: true, + } as const) + + expect(Object.keys(frag._fields)).toEqual([ + 'id', 'title', 'content', 'published', 'createdAt', 'authorId', + ]) + }) + + it('accepts a nested fragment for a relationship field', () => { + const userFrag = defineFragment()({ id: true, name: true } as const) + const postFrag = defineFragment()({ id: true, author: userFrag } as const) + + expect(postFrag._fields.author).toBe(userFrag) + }) + + it('accepts a nested fragment for a many relationship', () => { + const tagFrag = defineFragment()({ id: true, name: true } as const) + const postFrag = defineFragment()({ id: true, tags: tagFrag } as const) + + expect(postFrag._fields.tags).toBe(tagFrag) + }) +}) + +// ───────────────────────────────────────────────────────────── +// ResultOf — static type tests (compile-time checks) +// ───────────────────────────────────────────────────────────── + +describe('ResultOf (type-level checks)', () => { + it('scalar fragment produces a plain picked type', () => { + const frag = defineFragment()({ id: true, name: true, email: true } as const) + // TypeScript compile check: ResultOf must have id, name, email + type Result = ResultOf + const r: Result = { id: '1', name: 'Alice', email: 'a@test.com' } + expect(r).toBeTruthy() + expect(frag._type).toBe('fragment') + }) + + it('nullable scalar fields are preserved', () => { + const frag = defineFragment()({ id: true, content: true } as const) + type Result = ResultOf + // content should be string | null + const r: Result = { id: '1', content: null } + expect(r.content).toBeNull() + expect(frag._type).toBe('fragment') + }) + + it('nested fragment on a nullable relationship preserves null', () => { + const userFrag = defineFragment()({ id: true, name: true } as const) + const postFrag = defineFragment()({ id: true, author: userFrag } as const) + type PostResult = ResultOf + // author should be { id: string; name: string } | null + const r: PostResult = { id: '1', author: null } + expect(r.author).toBeNull() + expect(postFrag._type).toBe('fragment') + }) + + it('nested fragment on a many relationship produces an array', () => { + const tagFrag = defineFragment()({ id: true, name: true } as const) + const postFrag = defineFragment()({ id: true, tags: tagFrag } as const) + type PostResult = ResultOf + const r: PostResult = { id: '1', tags: [{ id: 't1', name: 'ts' }] } + expect(r.tags).toHaveLength(1) + expect(postFrag._type).toBe('fragment') + }) + + it('deep nesting (comment → post → author) resolves correctly', () => { + const userFrag = defineFragment()({ id: true, name: true } as const) + const postFrag = defineFragment()({ id: true, title: true, author: userFrag } as const) + const commentFrag = defineFragment()({ + id: true, + body: true, + post: postFrag, + } as const) + type CommentResult = ResultOf + const r: CommentResult = { + id: 'c1', + body: 'hi', + post: { + id: 'p1', + title: 'Hello', + author: { id: 'u1', name: 'Alice' }, + }, + } + expect(r.post?.author?.name).toBe('Alice') + expect(commentFrag._type).toBe('fragment') + }) +}) + +// ───────────────────────────────────────────────────────────── +// runQuery — runtime behaviour +// ───────────────────────────────────────────────────────────── + +describe('runQuery', () => { + const userFrag = defineFragment()({ id: true, name: true, email: true } as const) + + const rawUsers: User[] = [ + { id: 'u1', name: 'Alice', email: 'alice@test.com', role: 'admin', bio: null }, + { id: 'u2', name: 'Bob', email: 'bob@test.com', role: 'user', bio: 'Hey there' }, + ] + + it('calls context.db[dbKey].findMany with no args when none supplied', async () => { + const delegate = makeDelegate(rawUsers) + const ctx = makeContext({ user: delegate }) + + await runQuery(ctx, 'User', userFrag) + + expect(delegate.findMany).toHaveBeenCalledWith(undefined) + }) + + it('returns only the fields specified in the fragment', async () => { + const ctx = makeContext({ user: makeDelegate(rawUsers) }) + const results = await runQuery(ctx, 'User', userFrag) + + expect(results).toHaveLength(2) + // Only id, name, email — not role or bio + expect(results[0]).toEqual({ id: 'u1', name: 'Alice', email: 'alice@test.com' }) + expect(results[1]).toEqual({ id: 'u2', name: 'Bob', email: 'bob@test.com' }) + expect(results[0]).not.toHaveProperty('role') + expect(results[0]).not.toHaveProperty('bio') + }) + + it('passes where, orderBy, take, skip to findMany', async () => { + const delegate = makeDelegate(rawUsers) + const ctx = makeContext({ user: delegate }) + + await runQuery(ctx, 'User', userFrag, { + where: { role: 'admin' }, + orderBy: { name: 'asc' }, + take: 5, + skip: 2, + }) + + expect(delegate.findMany).toHaveBeenCalledWith({ + where: { role: 'admin' }, + orderBy: { name: 'asc' }, + take: 5, + skip: 2, + }) + }) + + it('converts PascalCase listKey to camelCase for db access', async () => { + const delegate = makeDelegate([]) + const ctx = makeContext({ blogPost: delegate }) + + await runQuery(ctx, 'BlogPost', defineFragment()({ id: true } as const)) + + expect(delegate.findMany).toHaveBeenCalled() + }) + + it('returns an empty array when findMany returns nothing', async () => { + const ctx = makeContext({ user: makeDelegate([]) }) + const results = await runQuery(ctx, 'User', userFrag) + expect(results).toEqual([]) + }) + + describe('with nested fragment (relationship)', () => { + const tagFrag = defineFragment()({ id: true, name: true } as const) + const postFrag = defineFragment()({ + id: true, + title: true, + author: userFrag, + tags: tagFrag, + } as const) + + const rawPosts = [ + { + id: 'p1', + title: 'Hello World', + content: 'body', + published: true, + createdAt: new Date('2024-01-01'), + authorId: 'u1', + author: { + id: 'u1', + name: 'Alice', + email: 'alice@test.com', + role: 'admin', + bio: null, + }, + tags: [ + { id: 't1', name: 'TypeScript', slug: 'typescript' }, + { id: 't2', name: 'Node', slug: 'node' }, + ], + }, + ] + + it('passes include for relationship fields to findMany', async () => { + const delegate = makeDelegate(rawPosts) + const ctx = makeContext({ post: delegate }) + + await runQuery(ctx, 'Post', postFrag) + + expect(delegate.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: { author: true, tags: true }, + }), + ) + }) + + it('picks only the selected nested fields', async () => { + const ctx = makeContext({ post: makeDelegate(rawPosts) }) + const results = await runQuery(ctx, 'Post', postFrag) + + expect(results[0].author).toEqual({ id: 'u1', name: 'Alice', email: 'alice@test.com' }) + // role and bio are NOT selected in userFrag + expect(results[0].author).not.toHaveProperty('role') + expect(results[0].author).not.toHaveProperty('bio') + }) + + it('maps tag arrays and strips unselected fields', async () => { + const ctx = makeContext({ post: makeDelegate(rawPosts) }) + const results = await runQuery(ctx, 'Post', postFrag) + + // slug is not in tagFrag, so it must be absent + expect(results[0].tags[0]).not.toHaveProperty('slug') + expect(results[0].tags[0]).toEqual({ id: 't1', name: 'TypeScript' }) + expect(results[0].tags[1]).toEqual({ id: 't2', name: 'Node' }) + }) + }) + + describe('with null relationship', () => { + it('preserves null for an unset relationship', async () => { + const authorFrag = defineFragment()({ id: true, name: true } as const) + const postFrag = defineFragment()({ id: true, author: authorFrag } as const) + + const rawPost = { + id: 'p2', + title: 'Draft', + content: null, + published: false, + createdAt: new Date(), + authorId: null, + author: null, + tags: [], + } + + const ctx = makeContext({ post: makeDelegate([rawPost]) }) + const results = await runQuery(ctx, 'Post', postFrag) + + expect(results[0].author).toBeNull() + }) + }) + + describe('deeply nested (three levels)', () => { + it('recursively builds include and picks fields', async () => { + const authorFrag = defineFragment()({ id: true, name: true } as const) + const postFrag = defineFragment()({ + id: true, + title: true, + author: authorFrag, + } as const) + const commentFrag = defineFragment()({ + id: true, + body: true, + post: postFrag, + } as const) + + const raw = [ + { + id: 'c1', + body: 'Nice post!', + post: { + id: 'p1', + title: 'Hello', + content: 'body', + published: true, + createdAt: new Date(), + authorId: 'u1', + author: { id: 'u1', name: 'Alice', email: 'a@t.com', role: 'user', bio: null }, + tags: [], + }, + author: null, + }, + ] + + const delegate = makeDelegate(raw) + const ctx = makeContext({ comment: delegate }) + + const results = await runQuery(ctx, 'Comment', commentFrag) + + // include passed to findMany + expect(delegate.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: { + post: { include: { author: true } }, + }, + }), + ) + + // result shape + expect(results[0]).toEqual({ + id: 'c1', + body: 'Nice post!', + post: { + id: 'p1', + title: 'Hello', + author: { id: 'u1', name: 'Alice' }, + }, + }) + }) + }) +}) + +// ───────────────────────────────────────────────────────────── +// runQueryOne — runtime behaviour +// ───────────────────────────────────────────────────────────── + +describe('runQueryOne', () => { + const userFrag = defineFragment()({ id: true, name: true, email: true } as const) + + const rawUser: User = { + id: 'u1', + name: 'Alice', + email: 'alice@test.com', + role: 'admin', + bio: null, + } + + it('calls context.db[dbKey].findFirst with the where clause', async () => { + const delegate = makeDelegate([rawUser], rawUser) + const ctx = makeContext({ user: delegate }) + + await runQueryOne(ctx, 'User', userFrag, { id: 'u1' }) + + expect(delegate.findFirst).toHaveBeenCalledWith({ where: { id: 'u1' } }) + }) + + it('returns only the fields specified in the fragment', async () => { + const ctx = makeContext({ user: makeDelegate([rawUser], rawUser) }) + const result = await runQueryOne(ctx, 'User', userFrag, { id: 'u1' }) + + expect(result).toEqual({ id: 'u1', name: 'Alice', email: 'alice@test.com' }) + expect(result).not.toHaveProperty('role') + expect(result).not.toHaveProperty('bio') + }) + + it('returns null when findFirst returns null', async () => { + const delegate = makeDelegate([], null) + const ctx = makeContext({ user: delegate }) + + const result = await runQueryOne(ctx, 'User', userFrag, { id: 'nope' }) + + expect(result).toBeNull() + }) + + it('returns null when findFirst returns undefined', async () => { + const delegate = { + findMany: vi.fn(async () => []), + findFirst: vi.fn(async () => undefined), + } + const ctx = makeContext({ user: delegate }) + + const result = await runQueryOne(ctx, 'User', userFrag, { id: 'nope' }) + + expect(result).toBeNull() + }) + + it('passes include for relationship fields to findFirst', async () => { + const authorFrag = defineFragment()({ id: true, name: true } as const) + const postFrag = defineFragment()({ id: true, title: true, author: authorFrag } as const) + + const rawPost = { + id: 'p1', + title: 'Hello', + content: null, + published: true, + createdAt: new Date(), + authorId: 'u1', + author: { id: 'u1', name: 'Alice', email: 'a@t.com', role: 'user', bio: null }, + tags: [], + } + + const delegate = makeDelegate([rawPost], rawPost) + const ctx = makeContext({ post: delegate }) + + const result = await runQueryOne(ctx, 'Post', postFrag, { id: 'p1' }) + + expect(delegate.findFirst).toHaveBeenCalledWith({ + where: { id: 'p1' }, + include: { author: true }, + }) + expect(result).toEqual({ + id: 'p1', + title: 'Hello', + author: { id: 'u1', name: 'Alice' }, + }) + }) + + it('converts PascalCase listKey to camelCase', async () => { + const delegate = makeDelegate([], null) + const ctx = makeContext({ blogPost: delegate }) + + await runQueryOne(ctx, 'BlogPost', defineFragment()({ id: true } as const), { + id: 'p1', + }) + + expect(delegate.findFirst).toHaveBeenCalled() + }) +}) + +// ───────────────────────────────────────────────────────────── +// Fragment composition — reusability +// ───────────────────────────────────────────────────────────── + +describe('fragment composition', () => { + it('the same fragment can be reused in multiple parent fragments', async () => { + const userFrag = defineFragment()({ id: true, name: true } as const) + const postFrag = defineFragment()({ id: true, author: userFrag } as const) + const commentFrag = defineFragment()({ id: true, author: userFrag } as const) + + // Both refer to the exact same userFrag instance + expect(postFrag._fields.author).toBe(userFrag) + expect(commentFrag._fields.author).toBe(userFrag) + }) + + it('composes three levels deep without mutation', async () => { + const userFrag = defineFragment()({ id: true, name: true } as const) + const postFrag = defineFragment()({ + id: true, + title: true, + author: userFrag, + } as const) + const commentFrag = defineFragment()({ + id: true, + body: true, + post: postFrag, + } as const) + + // Verify structure without executing any query + expect(commentFrag._type).toBe('fragment') + const postFieldInComment = commentFrag._fields.post as Fragment> + expect(postFieldInComment._type).toBe('fragment') + const authorFieldInPost = postFieldInComment._fields.author as Fragment> + expect(authorFieldInPost._fields).toEqual({ id: true, name: true }) + }) +}) diff --git a/packages/core/src/query/index.ts b/packages/core/src/query/index.ts new file mode 100644 index 00000000..9f977737 --- /dev/null +++ b/packages/core/src/query/index.ts @@ -0,0 +1,350 @@ +import { getDbKey } from '../lib/case-utils.js' + +// ───────────────────────────────────────────────────────────── +// Internal helpers +// ───────────────────────────────────────────────────────────── + +/** + * Unwrap the item type from a field type, stripping null, undefined, and + * Array wrappers so we can constrain nested fragment shapes. + * + * Examples: + * User | null → User + * User[] → User + * (User | null)[] → User + */ +type UnwrapItem = NonNullable extends Array ? NonNullable : NonNullable + +// ───────────────────────────────────────────────────────────── +// Core types +// ───────────────────────────────────────────────────────────── + +/** + * A field selection for model type `TItem`. + * + * Each key maps to: + * - `true` — include the scalar/primitive field as-is + * - A `Fragment` — include a relationship field and recurse into its fields + * + * Only keys present in `TItem` are accepted. For relationship (object) fields + * you may pass either a typed Fragment or `true` (which returns the raw Prisma + * value and loses narrowing). + * + * @example + * ```ts + * const sel: FieldSelection = { + * id: true, + * title: true, + * author: userFragment, // Fragment + * } + * ``` + */ +export type FieldSelection = { + readonly [K in keyof T]?: UnwrapItem extends Record + ? Fragment, FieldSelection>> | true + : true +} + +/** + * A reusable, composable field-selection descriptor for model type `TItem`. + * + * Create with {@link defineFragment}. Compose by referencing another Fragment + * as the value for a relationship key. + * + * @example + * ```ts + * const userFragment = defineFragment()({ id: true, name: true } as const) + * const postFragment = defineFragment()({ + * id: true, + * title: true, + * author: userFragment, + * } as const) + * ``` + */ +export type Fragment = FieldSelection> = { + readonly _type: 'fragment' + readonly _fields: TFields +} + +/** + * Infer the TypeScript result type from a Fragment. + * + * Analogous to `gql.tada`'s `ResultOf` helper — given a fragment definition, + * `ResultOf` tells you exactly what shape you will receive at runtime. + * + * - Scalar fields selected with `true` retain their original Prisma type. + * - Relationship fields selected with a nested Fragment are recursively narrowed. + * - Nullability and array wrappers from the original model type are preserved. + * + * @example + * ```ts + * type UserData = ResultOf + * // → { id: string; name: string } + * + * type PostData = ResultOf + * // → { id: string; title: string; author: { id: string; name: string } | null } + * ``` + */ +export type ResultOf = F extends Fragment + ? SelectedFields + : never + +/** + * @internal + * Map a FieldSelection over a model type, computing the picked output type. + */ +type SelectedFields> = { + [K in keyof TFields & keyof TItem]: TFields[K] extends Fragment< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + > + ? // Relationship field — preserve array/null/undefined wrappers from the model + TItem[K] extends Array + ? ResultOf[] + : null extends TItem[K] + ? ResultOf | null + : undefined extends TItem[K] + ? ResultOf | undefined + : ResultOf + : // Scalar field (value is `true`) + TItem[K] +} + +/** + * Arguments accepted by {@link runQuery}. + */ +export type QueryArgs = { + /** Prisma where filter. The access control layer will additionally scope results. */ + where?: Record + /** Prisma orderBy clause. Pass a single object or an array for multi-column ordering. */ + orderBy?: Record | Array> + /** Maximum number of records to return. */ + take?: number + /** Number of records to skip (for pagination). */ + skip?: number +} + +/** + * Minimal context shape required by the query runners. + * Compatible with the full `AccessContext` produced by `getContext()`. + */ +export interface QueryRunnerContext { + db: { + [key: string]: { + findMany: (args?: unknown) => Promise + findFirst: (args?: unknown) => Promise + } + } +} + +// ───────────────────────────────────────────────────────────── +// Fragment factory +// ───────────────────────────────────────────────────────────── + +/** + * Create a type-safe, reusable fragment for a given model type. + * + * The function is curried so that TypeScript can infer both the model type + * (from the explicit type parameter) and the field selection (from the + * argument), without requiring you to repeat yourself. + * + * @example + * ```ts + * import type { User } from '.prisma/client' + * import { defineFragment } from '@opensaas/stack-core' + * + * export const userFragment = defineFragment()({ + * id: true, + * name: true, + * email: true, + * } as const) + * ``` + * + * Fragments are composable — reference another fragment as the value for any + * relationship key: + * + * ```ts + * import type { Post } from '.prisma/client' + * + * export const postFragment = defineFragment()({ + * id: true, + * title: true, + * author: userFragment, // nested fragment + * tags: tagFragment, // nested many fragment + * } as const) + * ``` + */ +export function defineFragment() { + return function >(fields: TFields): Fragment { + return { _type: 'fragment', _fields: fields } + } +} + +// ───────────────────────────────────────────────────────────── +// Runtime helpers (internal) +// ───────────────────────────────────────────────────────────── + +/** + * Walk a field selection and build the Prisma `include` map needed to eagerly + * load all nested relationship fragments. + * + * Scalar fields (`true`) do not require an include entry — Prisma returns all + * scalar columns by default. Only relationship fields backed by a Fragment + * generate include entries (recursively). + */ +function buildInclude( + fields: FieldSelection, +): Record | undefined { + const include: Record = {} + let hasIncludes = false + + for (const [key, value] of Object.entries(fields as Record)) { + if ( + value !== null && + typeof value === 'object' && + '_type' in value && + (value as Fragment)._type === 'fragment' + ) { + hasIncludes = true + const nestedFields = (value as Fragment>)._fields + const nestedInclude = buildInclude(nestedFields) + // If the nested fragment itself has no relationship includes, we only + // need `true` (Prisma will load all scalars). If it does, we need to + // nest `{ include: ... }`. + include[key] = nestedInclude ? { include: nestedInclude } : true + } + } + + return hasIncludes ? include : undefined +} + +/** + * Recursively pick only the fields requested by a fragment from a raw Prisma + * result object. This ensures the runtime shape exactly matches the type + * produced by `ResultOf`. + */ +function pickFields>( + item: TItem, + fields: TFields, +): SelectedFields { + const result: Record = {} + + for (const [key, value] of Object.entries(fields as Record)) { + const fieldValue = (item as Record)[key] + + if (value === true) { + result[key] = fieldValue + } else if ( + value !== null && + typeof value === 'object' && + '_type' in value && + (value as Fragment)._type === 'fragment' + ) { + const nestedFrag = value as Fragment> + + if (Array.isArray(fieldValue)) { + result[key] = fieldValue.map((elem) => + pickFields(elem as unknown, nestedFrag._fields), + ) + } else if (fieldValue === null || fieldValue === undefined) { + result[key] = fieldValue + } else { + result[key] = pickFields(fieldValue as unknown, nestedFrag._fields) + } + } + } + + return result as SelectedFields +} + +// ───────────────────────────────────────────────────────────── +// Query runners +// ───────────────────────────────────────────────────────────── + +/** + * Execute a fragment-based query against a list, returning all matching + * records shaped to the fragment's field selection. + * + * Under the hood this calls `context.db[listKey].findMany()`, so all access + * control rules defined in your config are still enforced. + * + * @param context - An `AccessContext` (or any object with a compatible `db`). + * @param listKey - The PascalCase list name (e.g. `'Post'`, `'BlogPost'`). + * @param fragment - A fragment created with {@link defineFragment}. + * @param args - Optional query arguments (where, orderBy, take, skip). + * @returns An array typed to exactly the fragment's field selection. + * + * @example + * ```ts + * const posts = await runQuery(context, 'Post', postFragment, { + * where: { published: true }, + * orderBy: { createdAt: 'desc' }, + * take: 10, + * }) + * // posts: Array> + * ``` + */ +export async function runQuery>( + context: QueryRunnerContext, + listKey: string, + fragment: Fragment, + args?: QueryArgs, +): Promise[]> { + const dbKey = getDbKey(listKey) + const include = buildInclude(fragment._fields as FieldSelection) + + const findManyArgs: Record = {} + if (args?.where !== undefined) findManyArgs.where = args.where + if (args?.orderBy !== undefined) findManyArgs.orderBy = args.orderBy + if (args?.take !== undefined) findManyArgs.take = args.take + if (args?.skip !== undefined) findManyArgs.skip = args.skip + if (include) findManyArgs.include = include + + const results = await context.db[dbKey].findMany( + Object.keys(findManyArgs).length > 0 ? findManyArgs : undefined, + ) + + return results.map((item) => + pickFields(item as TItem, fragment._fields), + ) as SelectedFields[] +} + +/** + * Execute a fragment-based query that returns a single record (or `null`). + * + * Under the hood this calls `context.db[listKey].findFirst()`, so all access + * control rules are still enforced. + * + * @param context - An `AccessContext` (or any object with a compatible `db`). + * @param listKey - The PascalCase list name (e.g. `'Post'`). + * @param fragment - A fragment created with {@link defineFragment}. + * @param where - A Prisma where clause to identify the record. + * @returns The matched record shaped to the fragment, or `null`. + * + * @example + * ```ts + * const post = await runQueryOne(context, 'Post', postFragment, { id: postId }) + * if (!post) return notFound() + * // post: ResultOf + * ``` + */ +export async function runQueryOne>( + context: QueryRunnerContext, + listKey: string, + fragment: Fragment, + where: Record, +): Promise | null> { + const dbKey = getDbKey(listKey) + const include = buildInclude(fragment._fields as FieldSelection) + + const findFirstArgs: Record = { where } + if (include) findFirstArgs.include = include + + const item = await context.db[dbKey].findFirst(findFirstArgs) + + if (item === null || item === undefined) return null + + return pickFields(item as TItem, fragment._fields) as SelectedFields +} diff --git a/specs/keystone-migration.md b/specs/keystone-migration.md new file mode 100644 index 00000000..92cd715a --- /dev/null +++ b/specs/keystone-migration.md @@ -0,0 +1,615 @@ +# Migrating from KeystoneJS to OpenSaas Stack + +This guide is intended for both human developers and AI agents tasked with migrating a Keystone 6 project to the OpenSaas Stack. + +--- + +## Overview of differences + +| Concern | Keystone 6 | OpenSaas Stack | +|---|---|---| +| Schema definition | `list()` in `schema.ts` | `list()` in `opensaas.config.ts` | +| Database | Prisma (managed by Keystone) | Prisma 7 with driver adapters | +| Access control | Functions on `access` key | Same pattern (compatible API) | +| Hooks | `resolveInput`, `validateInput`, etc. | Same names + `resolveOutput` | +| GraphQL API | Built-in, always on | **Not provided** | +| `context.graphql.run()` | Run raw GraphQL queries | `runQuery` / `runQueryOne` (see below) | +| Type generation | GraphQL codegen | Built-in TypeScript inference via `ResultOf` | +| Auth | `@keystone-6/auth` | `@opensaas/stack-auth` | +| Admin UI | Auto-generated from schema | Auto-generated from config | + +--- + +## 1. Config migration + +### Keystone (`schema.ts` + `keystone.ts`) + +```typescript +// schema.ts +import { list } from '@keystone-6/core' +import { text, relationship, timestamp } from '@keystone-6/core/fields' + +export const lists = { + Post: list({ + fields: { + title: text({ validation: { isRequired: true } }), + author: relationship({ ref: 'User.posts' }), + publishedAt: timestamp(), + }, + access: { + operation: { + query: () => true, + create: ({ session }) => !!session, + update: ({ session }) => !!session, + delete: ({ session }) => !!session, + }, + }, + }), + User: list({ + fields: { + name: text(), + email: text({ isIndexed: 'unique' }), + posts: relationship({ ref: 'Post.author', many: true }), + }, + }), +} +``` + +### OpenSaas Stack (`opensaas.config.ts`) + +```typescript +import { config, list } from '@opensaas/stack-core' +import { text, relationship, timestamp } from '@opensaas/stack-core/fields' + +export default config({ + db: { + provider: 'sqlite', + url: process.env.DATABASE_URL ?? 'file:./dev.db', + prismaClientConstructor: (PrismaClient) => { + // Prisma 7 requires a driver adapter + const { PrismaBetterSQLite3 } = require('@prisma/adapter-better-sqlite3') + const Database = require('better-sqlite3') + const db = new Database(process.env.DATABASE_URL ?? './dev.db') + return new PrismaClient({ adapter: new PrismaBetterSQLite3(db) }) + }, + }, + lists: { + Post: list({ + fields: { + title: text({ validation: { isRequired: true } }), + author: relationship({ ref: 'User.posts' }), + publishedAt: timestamp(), + }, + access: { + operation: { + query: () => true, + create: ({ session }) => !!session, + update: ({ session }) => !!session, + delete: ({ session }) => !!session, + }, + }, + }), + User: list({ + fields: { + name: text(), + email: text(), + posts: relationship({ ref: 'Post.author', many: true }), + }, + }), + }, +}) +``` + +**Key differences:** +- `config()` wraps all lists in a single default export. +- Database config (`db`) is required and must include `prismaClientConstructor` for Prisma 7. +- Field imports come from `@opensaas/stack-core/fields` (not `@keystone-6/core/fields`). + +--- + +## 2. Replacing `context.graphql.run` with `runQuery` / `runQueryOne` + +This is the most significant API change. OpenSaas Stack does **not** include a GraphQL layer. Instead it provides first-class TypeScript utilities that give you the same benefits — fragment reuse, type inference, composability — without GraphQL at runtime. + +### Concept mapping + +| GraphQL / Keystone concept | OpenSaas Stack equivalent | +|---|---| +| GraphQL fragment | `defineFragment()(fields)` | +| `ResultOf` | `ResultOf` | +| `VariablesOf` | `QueryArgs` (or your own Prisma where type) | +| `context.graphql.run({ query, variables })` | `runQuery(context, listKey, fragment, args)` | +| Single-record query | `runQueryOne(context, listKey, fragment, where)` | + +### Import + +```typescript +import { + defineFragment, + runQuery, + runQueryOne, + type ResultOf, + type QueryArgs, +} from '@opensaas/stack-core' +``` + +--- + +### Pattern A — simple list query + +**Before (Keystone):** + +```typescript +const { data } = await context.graphql.run({ + query: ` + query { + posts { + id + title + publishedAt + } + } + `, +}) +const posts = data.posts // typed as any, or via codegen +``` + +**After (OpenSaas Stack):** + +```typescript +import type { Post } from '.prisma/client' +import { defineFragment, runQuery, type ResultOf } from '@opensaas/stack-core' + +const postFragment = defineFragment()({ + id: true, + title: true, + publishedAt: true, +} as const) + +// Type inferred automatically — no codegen needed +type PostData = ResultOf +// → { id: string; title: string; publishedAt: Date | null } + +const posts = await runQuery(context, 'Post', postFragment) +// posts: PostData[] +``` + +--- + +### Pattern B — query with where / orderBy / pagination + +**Before (Keystone):** + +```typescript +const { data } = await context.graphql.run({ + query: ` + query GetPosts($where: PostWhereInput, $take: Int, $skip: Int) { + posts(where: $where, take: $take, skip: $skip) { + id + title + } + } + `, + variables: { + where: { status: { equals: 'published' } }, + take: 10, + skip: 0, + }, +}) +``` + +**After (OpenSaas Stack):** + +```typescript +const posts = await runQuery(context, 'Post', postFragment, { + where: { status: 'published' }, + orderBy: { publishedAt: 'desc' }, + take: 10, + skip: 0, +}) +``` + +--- + +### Pattern C — single record + +**Before (Keystone):** + +```typescript +const { data } = await context.graphql.run({ + query: ` + query GetPost($id: ID!) { + post(where: { id: $id }) { + id + title + content + } + } + `, + variables: { id: postId }, +}) +const post = data.post +``` + +**After (OpenSaas Stack):** + +```typescript +const post = await runQueryOne(context, 'Post', postFragment, { id: postId }) +if (!post) return notFound() // null means not found or access denied +``` + +--- + +### Pattern D — reusable fragments with nested relationships + +One of Keystone's killer features was composable GraphQL fragments. OpenSaas Stack keeps this pattern with `defineFragment`. + +**Before (Keystone):** + +```graphql +# fragments.graphql +fragment AuthorFields on User { + id + name + email +} + +fragment PostSummary on Post { + id + title + publishedAt + author { + ...AuthorFields + } +} +``` + +```typescript +import { POST_SUMMARY } from './fragments.graphql' + +const { data } = await context.graphql.run({ + query: ` + query { posts { ...PostSummary } } + ${POST_SUMMARY} + ${AUTHOR_FIELDS} + `, +}) +``` + +**After (OpenSaas Stack):** + +```typescript +// fragments.ts — a single source of truth, fully typed +import type { User, Post } from '.prisma/client' +import { defineFragment, type ResultOf } from '@opensaas/stack-core' + +export const authorFragment = defineFragment()({ + id: true, + name: true, + email: true, +} as const) + +export const postSummaryFragment = defineFragment()({ + id: true, + title: true, + publishedAt: true, + author: authorFragment, // ← compose fragments +} as const) + +// Infer types — no GraphQL codegen step +export type AuthorData = ResultOf +// → { id: string; name: string; email: string } + +export type PostSummaryData = ResultOf +// → { id: string; title: string; publishedAt: Date | null; author: AuthorData | null } +``` + +```typescript +// Usage in a server action or route handler +import { postSummaryFragment } from './fragments' + +const posts = await runQuery(context, 'Post', postSummaryFragment, { + where: { published: true }, + orderBy: { publishedAt: 'desc' }, +}) +// posts is PostSummaryData[] +``` + +--- + +### Pattern E — many-to-many relationships + +**Before (Keystone):** + +```graphql +fragment PostWithTags on Post { + id + title + tags { + id + name + } +} +``` + +**After (OpenSaas Stack):** + +```typescript +import type { Post, Tag } from '.prisma/client' + +const tagFragment = defineFragment()({ id: true, name: true } as const) + +const postWithTagsFragment = defineFragment()({ + id: true, + title: true, + tags: tagFragment, // many relationship → array in ResultOf +} as const) + +type PostWithTags = ResultOf +// → { id: string; title: string; tags: { id: string; name: string }[] } +``` + +--- + +### Pattern F — deeply nested (three levels) + +```typescript +const userFragment = defineFragment()({ id: true, name: true } as const) +const postFragment = defineFragment()({ id: true, title: true, author: userFragment } as const) +const commentFragment = defineFragment()({ + id: true, + body: true, + post: postFragment, + author: userFragment, +} as const) + +type CommentData = ResultOf +// → { +// id: string +// body: string +// post: { id: string; title: string; author: { id: string; name: string } | null } | null +// author: { id: string; name: string } | null +// } + +const comments = await runQuery(context, 'Comment', commentFragment) +``` + +--- + +### Pattern G — reusing the same fragment instance across multiple parent fragments + +Fragments are plain objects and can be referenced freely: + +```typescript +const userFragment = defineFragment()({ id: true, name: true } as const) + +// Reuse in multiple parents +const postFragment = defineFragment()({ id: true, author: userFragment } as const) +const commentFragment = defineFragment()({ id: true, author: userFragment } as const) +``` + +--- + +## 3. Access control — no changes needed + +Access control functions in Keystone and OpenSaas Stack share the same shape: + +```typescript +// Keystone +access: { + operation: { + query: ({ session }) => !!session, + }, + filter: { + query: ({ session }) => ({ author: { id: { equals: session?.itemId } } }), + }, +} + +// OpenSaas Stack — identical shape for operation-level access +access: { + operation: { + query: ({ session }) => !!session, + update: ({ session, item }) => session?.userId === item.authorId, + }, +} +``` + +Filter-based access (returning a Prisma `where` object) is also compatible. + +--- + +## 4. Hook migration + +Most hooks map directly. The only new one is `resolveOutput` (for transforming read values). + +| Keystone hook | OpenSaas Stack equivalent | +|---|---| +| `resolveInput` | `resolveInput` ✓ | +| `validateInput` | `validate` (or `validateInput` for backwards compat) | +| `beforeOperation` | `beforeOperation` ✓ | +| `afterOperation` | `afterOperation` ✓ | +| _(none)_ | `resolveOutput` (new — transforms read values) | + +### `validateInput` → `validate` + +```typescript +// Keystone +hooks: { + validateInput: ({ resolvedData, addValidationError }) => { + if (!resolvedData.title) addValidationError('Title is required') + }, +} + +// OpenSaas Stack — preferred name +hooks: { + validate: ({ resolvedData, addValidationError }) => { + if (!resolvedData.title) addValidationError('Title is required') + }, +} +// (validateInput still works for backwards compatibility) +``` + +--- + +## 5. Field type mapping + +| Keystone field | OpenSaas Stack field | +|---|---| +| `text()` | `text()` | +| `integer()` | `integer()` | +| `float()` | `decimal()` | +| `decimal()` | `decimal()` | +| `checkbox()` | `checkbox()` | +| `timestamp()` | `timestamp()` | +| `calendarDay()` | `calendarDay()` | +| `password()` | `password()` | +| `select()` | `select()` | +| `relationship()` | `relationship()` | +| `json()` | `json()` | +| `virtual()` | `virtual()` | +| `image()` | _(use storage config)_ | +| `file()` | _(use storage config)_ | + +--- + +## 6. Many-to-many join table naming (important for data preservation) + +Keystone and Prisma use different implicit join-table naming conventions for M2M relationships. Without adjustment, running `prisma db push` on a migrated schema will **create new empty join tables** while your data remains in the old ones. + +**Keystone convention:** `__` (e.g. `_Post_tags`) +**Prisma default:** alphabetically sorted `_` (e.g. `_PostToTag`) + +Fix this in your config with `joinTableNaming`: + +```typescript +export default config({ + db: { + provider: 'postgresql', + joinTableNaming: 'keystone', // ← preserve Keystone table names + prismaClientConstructor: ..., + }, + // ... +}) +``` + +Or per-relationship with `db.relationName`: + +```typescript +tags: relationship({ + ref: 'Tag.posts', + many: true, + db: { relationName: 'Post_tags' }, // ← exact join table name +}), +``` + +--- + +## 7. Authentication + +Replace `@keystone-6/auth` with `@opensaas/stack-auth`. The config shape changes but the concepts are the same. + +```typescript +// Keystone +import { createAuth } from '@keystone-6/auth' +const { withAuth } = createAuth({ + listKey: 'User', + identityField: 'email', + secretField: 'password', +}) + +// OpenSaas Stack +import { authPlugin } from '@opensaas/stack-auth' +export default config({ + plugins: [ + authPlugin({ + emailAndPassword: { enabled: true }, + }), + ], + // ... +}) +``` + +See [`packages/auth/CLAUDE.md`](../packages/auth/CLAUDE.md) and [`examples/auth-demo`](../examples/auth-demo/) for full setup. + +--- + +## 8. Checklist for migration agents + +When automating a Keystone → OpenSaas Stack migration, work through this checklist in order: + +1. **[ ] Install packages** — replace `@keystone-6/core` with `@opensaas/stack-core`, `@opensaas/stack-core/fields`, and (if auth) `@opensaas/stack-auth`. + +2. **[ ] Convert `schema.ts` + `keystone.ts`** into a single `opensaas.config.ts` using the config structure above. + +3. **[ ] Add `prismaClientConstructor`** to the `db` config block (Prisma 7 requirement). + +4. **[ ] Run `pnpm generate`** to produce `prisma/schema.prisma`, `.opensaas/types.ts`, and `.opensaas/context.ts`. + +5. **[ ] Check M2M join tables** — set `joinTableNaming: 'keystone'` or per-field `db.relationName` if the database has existing data. + +6. **[ ] Run `pnpm db:push`** (or `prisma migrate dev`) and verify the schema diff shows no unintended new tables. + +7. **[ ] Replace all `context.graphql.run` calls:** + a. Identify each unique GraphQL query/fragment. + b. Create a `defineFragment()({...})` for each shape. + c. Replace the call site with `runQuery` / `runQueryOne`. + d. Replace `data.posts` with the direct return value. + e. Replace any codegen-generated types with `ResultOf`. + +8. **[ ] Migrate hooks** — rename `validateInput` → `validate` if desired (backwards-compat alias exists). + +9. **[ ] Migrate auth** — replace `@keystone-6/auth` with `authPlugin` (see §7). + +10. **[ ] Run `pnpm lint && pnpm format`** to fix code style. + +11. **[ ] Run `pnpm test`** to verify correctness. + +--- + +## 9. Quick reference — `context.graphql.run` → query utilities + +```typescript +// ── BEFORE (Keystone) ─────────────────────────────────────────────── + +// Define a type (usually via GraphQL codegen) +type PostData = { id: string; title: string; author: { id: string; name: string } | null } + +// Run a query +const { data, errors } = await context.graphql.run<{ posts: PostData[] }>({ + query: ` + query GetPosts($where: PostWhereInput) { + posts(where: $where) { + id + title + author { id name } + } + } + `, + variables: { where: { published: { equals: true } } }, +}) +if (errors?.length) throw new Error(errors[0].message) +const posts = data.posts + +// ── AFTER (OpenSaas Stack) ────────────────────────────────────────── + +import type { User, Post } from '.prisma/client' +import { defineFragment, runQuery, type ResultOf } from '@opensaas/stack-core' + +// Declare fragments once, reuse everywhere +const authorFragment = defineFragment()({ id: true, name: true } as const) +const postFragment = defineFragment()({ + id: true, + title: true, + author: authorFragment, +} as const) + +// Types are inferred — no codegen step +type PostData = ResultOf +// → { id: string; title: string; author: { id: string; name: string } | null } + +// Run the query — access control is still enforced +const posts = await runQuery(context, 'Post', postFragment, { + where: { published: true }, +}) +// posts: PostData[] +``` From e65446acc52509689ab7ab956646d4026b609362 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 09:06:55 +0000 Subject: [PATCH 2/5] feat(core): integrate fragment queries into context.db operations - Add `context.db.post.findMany({ query: fragment, where, orderBy, take, skip })` and `context.db.post.findUnique({ where, query: fragment })` as the primary API for type-safe, fragment-scoped queries - Add `RelationSelector` type: union of Fragment shorthand or `{ query, where?, orderBy?, take?, skip? }` for nested relationship filtering - Export `buildInclude`, `pickFields`, `isFragment` helpers from query module - Add `AugmentedFindMany`, `AugmentedFindUnique`, `FindManyQueryArgs` types to access/types.ts and re-export from public API - Add `orderBy` support to `createFindMany` (previously silently ignored) - 42 tests covering all new paths: RelationSelector buildInclude, pickFields, runQuery integration, factory function (variables) pattern, isFragment guard https://claude.ai/code/session_012ismTCY2S84rCjRWL4jnwU --- packages/core/src/access/index.ts | 3 + packages/core/src/access/types.ts | 85 ++++++- packages/core/src/context/index.ts | 81 +++++-- packages/core/src/index.ts | 4 + packages/core/src/query/index.test.ts | 329 +++++++++++++++++++++++++- packages/core/src/query/index.ts | 302 +++++++++++++++++------ 6 files changed, 703 insertions(+), 101 deletions(-) diff --git a/packages/core/src/access/index.ts b/packages/core/src/access/index.ts index 9f92a491..3f718e72 100644 --- a/packages/core/src/access/index.ts +++ b/packages/core/src/access/index.ts @@ -7,6 +7,9 @@ export type { AccessControlledDB, PrismaClientLike, StorageUtils, + AugmentedFindMany, + AugmentedFindUnique, + FindManyQueryArgs, } from './types.js' export { checkAccess, diff --git a/packages/core/src/access/types.ts b/packages/core/src/access/types.ts index 8ba93f60..650dc2db 100644 --- a/packages/core/src/access/types.ts +++ b/packages/core/src/access/types.ts @@ -1,3 +1,5 @@ +import type { Fragment, FieldSelection, ResultOf } from '../query/index.js' + /** * Session interface - can be augmented by developers to add custom fields * @@ -57,6 +59,85 @@ export type PrismaModelDelegate = { // eslint-disable-next-line @typescript-eslint/no-explicit-any export type PrismaClientLike = any +// ───────────────────────────────────────────────────────────── +// Augmented find operation types — add `query` overload to findMany / findUnique +// ───────────────────────────────────────────────────────────── + +/** + * Extra query arguments accepted when a `query` Fragment is provided alongside + * `context.db..findMany({ query: myFragment, ... })`. + */ +export type FindManyQueryArgs = { + where?: Record + orderBy?: Record | Array> + take?: number + skip?: number +} + +/** + * Overloaded `findMany` that accepts an optional `query` Fragment. + * + * - **With `query`**: builds the Prisma `include` from the fragment, executes the + * query, applies access control, and returns records shaped to `ResultOf[]`. + * - **Without `query`**: behaves exactly like the original Prisma `findMany`. + * + * TypeScript resolves the return type from the presence (or absence) of `query` + * in the argument object — no explicit type annotation is needed. + * + * @example + * ```ts + * // Narrowed return type from fragment + * const posts = await context.db.post.findMany({ + * query: postFragment, + * where: { published: true }, + * orderBy: { createdAt: 'desc' }, + * take: 10, + * }) + * // posts: ResultOf[] + * + * // Original Prisma behaviour (no fragment) + * const posts = await context.db.post.findMany({ where: { published: true } }) + * // posts: Post[] + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface AugmentedFindMany any> { + // Overload 1: with query fragment — return type narrows to ResultOf[] + >( + args: FindManyQueryArgs & { query: Fragment }, + ): Promise>[]> + // Overload 2: original Prisma behaviour + (...args: Parameters): ReturnType +} + +/** + * Overloaded `findUnique` that accepts an optional `query` Fragment. + * + * - **With `query`**: builds the Prisma `include` from the fragment, executes the + * query, applies access control, and returns a record shaped to `ResultOf` + * or `null`. + * - **Without `query`**: behaves exactly like the original Prisma `findUnique`. + * + * @example + * ```ts + * const post = await context.db.post.findUnique({ + * where: { id: postId }, + * query: postFragment, + * }) + * // post: ResultOf | null + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface AugmentedFindUnique any> { + // Overload 1: with query fragment — return type narrows to ResultOf | null + >(args: { + where: Record + query: Fragment + }): Promise> | null> + // Overload 2: original Prisma behaviour + (...args: Parameters): ReturnType +} + /** * Map Prisma client to access-controlled database context * Preserves Prisma's type information for each model @@ -79,8 +160,8 @@ export type AccessControlledDB = { count: any } ? { - findUnique: TPrisma[K]['findUnique'] - findMany: TPrisma[K]['findMany'] + findUnique: AugmentedFindUnique + findMany: AugmentedFindMany create: TPrisma[K]['create'] update: TPrisma[K]['update'] delete: TPrisma[K]['delete'] diff --git a/packages/core/src/context/index.ts b/packages/core/src/context/index.ts index 27405295..aec048ae 100644 --- a/packages/core/src/context/index.ts +++ b/packages/core/src/context/index.ts @@ -20,6 +20,7 @@ import { processNestedOperations } from './nested-operations.js' import { getDbKey } from '../lib/case-utils.js' import type { PrismaClientLike } from '../access/types.js' import type { FieldConfig } from '../config/types.js' +import { buildInclude, pickFields, isFragment } from '../query/index.js' /** * Execute field-level resolveInput hooks @@ -610,7 +611,12 @@ function createFindUnique( context: AccessContext, config: OpenSaasConfig, ) { - return async (args: { where: { id: string }; include?: Record }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return async (args: { + where: { id: string } + include?: Record + query?: any + }) => { // Check query access (skip if sudo mode) let where: Record = args.where if (!context._isSudo) { @@ -632,18 +638,26 @@ function createFindUnique( where = mergedWhere } - // Build include with access control filters - const accessControlledInclude = await buildIncludeWithAccessControl( - listConfig.fields, - { - session: context.session, - context, - }, - config, - ) + // When a query fragment is provided, build the include from the fragment + // instead of the access-controlled include. Access control still runs via + // filterReadableFields; the fragment then narrows to only the requested fields. + const fragment = isFragment(args.query) ? args.query : null + let include: Record | undefined - // Merge user-provided include with access-controlled include - const include = args.include || accessControlledInclude + if (fragment) { + include = buildInclude(fragment._fields) ?? undefined + } else { + // Build include with access control filters + const accessControlledInclude = await buildIncludeWithAccessControl( + listConfig.fields, + { + session: context.session, + context, + }, + config, + ) + include = args.include || accessControlledInclude + } // Execute query with optimized includes // Access Prisma model dynamically - required because model names are generated at runtime @@ -672,6 +686,11 @@ function createFindUnique( listName, ) + // When a fragment is provided, pick only the requested fields from the result + if (fragment) { + return pickFields(filtered, fragment._fields) + } + return filtered } } @@ -689,9 +708,12 @@ function createFindMany( ) { return async (args?: { where?: Record + orderBy?: Record | Array> take?: number skip?: number include?: Record + // eslint-disable-next-line @typescript-eslint/no-explicit-any + query?: any }) => { // Check singleton constraint (throw error instead of silently returning empty) if (isSingletonList(listConfig)) { @@ -722,18 +744,23 @@ function createFindMany( where = mergedWhere } - // Build include with access control filters - const accessControlledInclude = await buildIncludeWithAccessControl( - listConfig.fields, - { - session: context.session, - context, - }, - config, - ) - - // Merge user-provided include with access-controlled include - const include = args?.include || accessControlledInclude + // When a query fragment is provided, build include from fragment fields + const fragment = isFragment(args?.query) ? args.query : null + let include: Record | undefined + if (fragment) { + include = buildInclude(fragment._fields) ?? undefined + } else { + // Build include with access control filters + const accessControlledInclude = await buildIncludeWithAccessControl( + listConfig.fields, + { + session: context.session, + context, + }, + config, + ) + include = args?.include || accessControlledInclude + } // Execute query with optimized includes // Access Prisma model dynamically - required because model names are generated at runtime @@ -741,6 +768,7 @@ function createFindMany( const model = (prisma as any)[getDbKey(listName)] const items = await model.findMany({ where, + orderBy: args?.orderBy, take: args?.take, skip: args?.skip, include, @@ -764,6 +792,11 @@ function createFindMany( ), ) + // When a fragment is provided, pick only the requested fields from each result + if (fragment) { + return filtered.map((item: Record) => pickFields(item, fragment._fields)) + } + return filtered } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 42da43ca..1cae0800 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -60,6 +60,9 @@ export type { PrismaFilter, AccessControlledDB, StorageUtils, + AugmentedFindMany, + AugmentedFindUnique, + FindManyQueryArgs, } from './access/index.js' // Context @@ -96,6 +99,7 @@ export type { Fragment, FieldSelection, ResultOf, + RelationSelector, QueryArgs, QueryRunnerContext, } from './query/index.js' diff --git a/packages/core/src/query/index.test.ts b/packages/core/src/query/index.test.ts index b2f00ea8..18df627a 100644 --- a/packages/core/src/query/index.test.ts +++ b/packages/core/src/query/index.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi } from 'vitest' -import { defineFragment, runQuery, runQueryOne } from './index.js' +import { + defineFragment, + runQuery, + runQueryOne, + buildInclude, + pickFields, + isFragment, +} from './index.js' import type { ResultOf, Fragment, FieldSelection, QueryRunnerContext } from './index.js' // ───────────────────────────────────────────────────────────── @@ -49,7 +56,9 @@ function makeDelegate(rows: unknown[], findFirstRow?: unknown) { } } -function makeContext(delegates: Record>): QueryRunnerContext { +function makeContext( + delegates: Record>, +): QueryRunnerContext { return { db: delegates } } @@ -76,7 +85,12 @@ describe('defineFragment', () => { } as const) expect(Object.keys(frag._fields)).toEqual([ - 'id', 'title', 'content', 'published', 'createdAt', 'authorId', + 'id', + 'title', + 'content', + 'published', + 'createdAt', + 'authorId', ]) }) @@ -504,7 +518,314 @@ describe('fragment composition', () => { expect(commentFrag._type).toBe('fragment') const postFieldInComment = commentFrag._fields.post as Fragment> expect(postFieldInComment._type).toBe('fragment') - const authorFieldInPost = postFieldInComment._fields.author as Fragment> + const authorFieldInPost = postFieldInComment._fields.author as Fragment< + User, + FieldSelection + > expect(authorFieldInPost._fields).toEqual({ id: true, name: true }) }) }) + +// ───────────────────────────────────────────────────────────── +// isFragment — runtime guard +// ───────────────────────────────────────────────────────────── + +describe('isFragment', () => { + it('returns true for a fragment created by defineFragment', () => { + const frag = defineFragment()({ id: true } as const) + expect(isFragment(frag)).toBe(true) + }) + + it('returns false for a RelationSelector object', () => { + const frag = defineFragment()({ id: true } as const) + const selector = { query: frag, where: { active: true } } + expect(isFragment(selector)).toBe(false) + }) + + it('returns false for primitives and null', () => { + expect(isFragment(true)).toBe(false) + expect(isFragment(null)).toBe(false) + expect(isFragment(undefined)).toBe(false) + expect(isFragment('fragment')).toBe(false) + expect(isFragment(42)).toBe(false) + }) + + it('returns false for a plain object without _type', () => { + expect(isFragment({ id: true })).toBe(false) + }) +}) + +// ───────────────────────────────────────────────────────────── +// buildInclude — RelationSelector with filter args +// ───────────────────────────────────────────────────────────── + +type Comment2 = { + id: string + body: string + approved: boolean + post: Post | null +} + +type PostWithComments = { + id: string + title: string + comments: Comment2[] +} + +describe('buildInclude with RelationSelector', () => { + it('generates a simple include for a shorthand fragment', () => { + const userFrag = defineFragment()({ id: true, name: true } as const) + const postFrag = defineFragment()({ id: true, author: userFrag } as const) + const result = buildInclude(postFrag._fields as FieldSelection) + expect(result).toEqual({ author: true }) + }) + + it('generates a where-filtered include for a RelationSelector', () => { + const commentFrag = defineFragment()({ id: true, body: true } as const) + const postFrag = defineFragment()({ + id: true, + comments: { + query: commentFrag, + where: { approved: true }, + }, + } as const) + + const result = buildInclude(postFrag._fields as FieldSelection) + expect(result).toEqual({ + comments: { where: { approved: true } }, + }) + }) + + it('includes orderBy, take and skip in the nested include', () => { + const commentFrag = defineFragment()({ id: true } as const) + const postFrag = defineFragment()({ + id: true, + comments: { + query: commentFrag, + where: { approved: true }, + orderBy: { id: 'asc' as const }, + take: 5, + skip: 10, + }, + } as const) + + const result = buildInclude(postFrag._fields as FieldSelection) + expect(result).toEqual({ + comments: { + where: { approved: true }, + orderBy: { id: 'asc' }, + take: 5, + skip: 10, + }, + }) + }) + + it('combines RelationSelector args with a nested include from the inner fragment', () => { + const userFrag = defineFragment()({ id: true, name: true } as const) + const commentWithAuthorFrag = defineFragment()({ + id: true, + body: true, + author: userFrag, + } as const) + const postFrag = defineFragment()({ + id: true, + // Using RelationSelector with nested include (Comment has author) + author: { + query: defineFragment()({ id: true } as const), + where: { role: 'admin' }, + }, + } as const) + + const result = buildInclude(postFrag._fields as FieldSelection) + // author has where clause (no nested include needed since User scalar fields) + expect(result).toEqual({ + author: { where: { role: 'admin' } }, + }) + + // Separate test: RelationSelector where inner fragment has nested relationships + const commentSelector = defineFragment()({ + id: true, + post: commentWithAuthorFrag._fields.post, + author: userFrag, + } as const) + const result2 = buildInclude(commentWithAuthorFrag._fields as FieldSelection) + expect(result2).toEqual({ author: true }) + // Suppress unused variable warning + expect(commentSelector._type).toBe('fragment') + }) + + it('returns undefined when there are no relationship fields', () => { + const frag = defineFragment()({ id: true, name: true, email: true } as const) + const result = buildInclude(frag._fields as FieldSelection) + expect(result).toBeUndefined() + }) +}) + +// ───────────────────────────────────────────────────────────── +// pickFields — RelationSelector branch +// ───────────────────────────────────────────────────────────── + +describe('pickFields with RelationSelector', () => { + it('picks fields from a nested array using RelationSelector', () => { + const commentFrag = defineFragment()({ id: true, body: true } as const) + const postFrag = defineFragment()({ + id: true, + comments: { query: commentFrag, where: { approved: true } }, + } as const) + + const raw = { + id: 'p1', + title: 'Hello', + comments: [ + { id: 'c1', body: 'Great!', approved: true }, + { id: 'c2', body: 'Nice', approved: false }, + ], + } + + const result = pickFields(raw, postFrag._fields) + expect(result).toEqual({ + id: 'p1', + comments: [ + { id: 'c1', body: 'Great!' }, + { id: 'c2', body: 'Nice' }, + ], + }) + // title is not selected + expect(result).not.toHaveProperty('title') + // approved is not in commentFrag + expect((result.comments as unknown[])[0]).not.toHaveProperty('approved') + }) + + it('handles null relationship in RelationSelector', () => { + const userFrag = defineFragment()({ id: true, name: true } as const) + const postFrag = defineFragment()({ + id: true, + author: { query: userFrag, where: { role: 'admin' } }, + } as const) + + const raw = { id: 'p1', author: null } + const result = pickFields(raw, postFrag._fields) + expect(result).toEqual({ id: 'p1', author: null }) + }) +}) + +// ───────────────────────────────────────────────────────────── +// runQuery — RelationSelector with filter args +// ───────────────────────────────────────────────────────────── + +describe('runQuery with RelationSelector', () => { + it('passes nested where/orderBy/take/skip to include entry', async () => { + const commentFrag = defineFragment()({ id: true, body: true } as const) + const postFrag = defineFragment()({ + id: true, + title: true, + comments: { + query: commentFrag, + where: { approved: true }, + orderBy: { id: 'asc' as const }, + take: 3, + }, + } as const) + + const rawPosts = [ + { + id: 'p1', + title: 'Hello', + comments: [{ id: 'c1', body: 'First!', approved: true }], + }, + ] + + const delegate = makeDelegate(rawPosts) + const ctx = makeContext({ postWithComments: delegate }) + + await runQuery(ctx, 'PostWithComments', postFrag) + + expect(delegate.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + include: { + comments: { where: { approved: true }, orderBy: { id: 'asc' }, take: 3 }, + }, + }), + ) + }) + + it('picks only fragment fields from nested items in RelationSelector results', async () => { + const commentFrag = defineFragment()({ id: true, body: true } as const) + const postFrag = defineFragment()({ + id: true, + comments: { query: commentFrag, where: { approved: true } }, + } as const) + + const rawPosts = [ + { + id: 'p1', + title: 'Hello', + comments: [ + { id: 'c1', body: 'Yes!', approved: true }, + { id: 'c2', body: 'No', approved: false }, + ], + }, + ] + + const ctx = makeContext({ postWithComments: makeDelegate(rawPosts) }) + const results = await runQuery(ctx, 'PostWithComments', postFrag) + + expect(results[0]).toEqual({ + id: 'p1', + comments: [ + { id: 'c1', body: 'Yes!' }, + { id: 'c2', body: 'No' }, + ], + }) + expect(results[0]).not.toHaveProperty('title') + }) +}) + +// ───────────────────────────────────────────────────────────── +// Variables pattern — factory function +// ───────────────────────────────────────────────────────────── + +describe('factory function (variables) pattern', () => { + it('creates different fragments with different runtime values', () => { + const commentFrag = defineFragment()({ id: true, body: true } as const) + + function makePostFrag(approvedOnly: boolean) { + return defineFragment()({ + id: true, + comments: { + query: commentFrag, + where: { approved: approvedOnly }, + }, + } as const) + } + + const approvedFrag = makePostFrag(true) + const allFrag = makePostFrag(false) + + const approvedInclude = buildInclude(approvedFrag._fields as FieldSelection) + const allInclude = buildInclude(allFrag._fields as FieldSelection) + + expect(approvedInclude).toEqual({ comments: { where: { approved: true } } }) + expect(allInclude).toEqual({ comments: { where: { approved: false } } }) + }) + + it('ResultOf is the same shape regardless of runtime where values', () => { + const commentFrag = defineFragment()({ id: true, body: true } as const) + + const makePostFrag = (status: boolean) => + defineFragment()({ + id: true, + comments: { query: commentFrag, where: { approved: status } }, + } as const) + + // Runtime usage (ensures the function is used, not just as a type) + const frag = makePostFrag(true) + expect(frag._type).toBe('fragment') + + type PostData = ResultOf> + // Compile-time check: PostData should have id and comments + const r: PostData = { id: 'p1', comments: [{ id: 'c1', body: 'hi' }] } + expect(r.id).toBe('p1') + expect(r.comments).toHaveLength(1) + }) +}) diff --git a/packages/core/src/query/index.ts b/packages/core/src/query/index.ts index 9f977737..e60260d2 100644 --- a/packages/core/src/query/index.ts +++ b/packages/core/src/query/index.ts @@ -19,29 +19,81 @@ type UnwrapItem = NonNullable extends Array ? NonNullable : No // Core types // ───────────────────────────────────────────────────────────── +/** + * A selector for a relationship field. + * + * Two forms are accepted: + * 1. A `Fragment` directly (shorthand — no extra Prisma args on the nested query). + * 2. An object `{ query, where?, orderBy?, take?, skip? }` to combine a fragment + * with Prisma filter/ordering/pagination applied to the nested relationship. + * + * @example Shorthand (most common) + * ```ts + * const postFrag = defineFragment()({ + * id: true, + * author: authorFragment, // shorthand + * } as const) + * ``` + * + * @example With nested filtering + * ```ts + * const postFrag = defineFragment()({ + * id: true, + * comments: { + * query: commentFragment, + * where: { approved: true }, + * orderBy: { createdAt: 'desc' }, + * take: 5, + * }, + * } as const) + * ``` + * + * @example Variables via factory function + * ```ts + * function makePostFragment(status: string) { + * return defineFragment()({ + * id: true, + * comments: { query: commentFragment, where: { status } }, + * } as const) + * } + * type PostData = ResultOf> + * ``` + */ +export type RelationSelector> = + | Fragment> + | { + readonly query: Fragment> + readonly where?: Record + readonly orderBy?: Record | Array> + readonly take?: number + readonly skip?: number + } + /** * A field selection for model type `TItem`. * * Each key maps to: - * - `true` — include the scalar/primitive field as-is - * - A `Fragment` — include a relationship field and recurse into its fields + * - `true` — include the scalar/primitive field as-is + * - A `Fragment` — include a relationship and recurse (shorthand) + * - A `RelationSelector` — include a relationship with optional Prisma filter/ordering * * Only keys present in `TItem` are accepted. For relationship (object) fields - * you may pass either a typed Fragment or `true` (which returns the raw Prisma - * value and loses narrowing). + * you may pass a Fragment, a RelationSelector, or `true` (returns the raw Prisma + * value and loses type narrowing). * * @example * ```ts * const sel: FieldSelection = { * id: true, * title: true, - * author: userFragment, // Fragment + * author: authorFragment, + * comments: { query: commentFragment, where: { approved: true } }, * } * ``` */ export type FieldSelection = { readonly [K in keyof T]?: UnwrapItem extends Record - ? Fragment, FieldSelection>> | true + ? RelationSelector> | true : true } @@ -49,7 +101,7 @@ export type FieldSelection = { * A reusable, composable field-selection descriptor for model type `TItem`. * * Create with {@link defineFragment}. Compose by referencing another Fragment - * as the value for a relationship key. + * (or a {@link RelationSelector}) as the value for a relationship key. * * @example * ```ts @@ -66,35 +118,28 @@ export type Fragment = FieldSelecti readonly _fields: TFields } +// ───────────────────────────────────────────────────────────── +// Internal type helpers +// ───────────────────────────────────────────────────────────── + /** - * Infer the TypeScript result type from a Fragment. - * - * Analogous to `gql.tada`'s `ResultOf` helper — given a fragment definition, - * `ResultOf` tells you exactly what shape you will receive at runtime. - * - * - Scalar fields selected with `true` retain their original Prisma type. - * - Relationship fields selected with a nested Fragment are recursively narrowed. - * - Nullability and array wrappers from the original model type are preserved. - * - * @example - * ```ts - * type UserData = ResultOf - * // → { id: string; name: string } - * - * type PostData = ResultOf - * // → { id: string; title: string; author: { id: string; name: string } | null } - * ``` + * @internal + * Extract the Fragment from either a Fragment directly or a RelationSelector object. + * Returns `never` for scalar `true` selections (so they fall to the scalar branch). */ -export type ResultOf = F extends Fragment - ? SelectedFields - : never +type ExtractFragment = + TSelector extends Fragment + ? Fragment + : TSelector extends { readonly query: Fragment } + ? Fragment + : never /** * @internal * Map a FieldSelection over a model type, computing the picked output type. */ type SelectedFields> = { - [K in keyof TFields & keyof TItem]: TFields[K] extends Fragment< + [K in keyof TFields & keyof TItem]: ExtractFragment extends Fragment< // eslint-disable-next-line @typescript-eslint/no-explicit-any any, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -102,16 +147,43 @@ type SelectedFields> = { > ? // Relationship field — preserve array/null/undefined wrappers from the model TItem[K] extends Array - ? ResultOf[] + ? ResultOf>[] : null extends TItem[K] - ? ResultOf | null + ? ResultOf> | null : undefined extends TItem[K] - ? ResultOf | undefined - : ResultOf + ? ResultOf> | undefined + : ResultOf> : // Scalar field (value is `true`) TItem[K] } +// ───────────────────────────────────────────────────────────── +// Public type utilities +// ───────────────────────────────────────────────────────────── + +/** + * Infer the TypeScript result type from a Fragment. + * + * Analogous to `gql.tada`'s `ResultOf` helper — given a fragment definition, + * `ResultOf` tells you exactly what shape you will receive at runtime. + * + * - Scalar fields selected with `true` retain their original Prisma type. + * - Relationship fields selected with a nested Fragment/RelationSelector are + * recursively narrowed. + * - Nullability and array wrappers from the original model type are preserved. + * + * @example + * ```ts + * type UserData = ResultOf + * // → { id: string; name: string } + * + * type PostData = ResultOf + * // → { id: string; title: string; author: { id: string; name: string } | null } + * ``` + */ +export type ResultOf = + F extends Fragment ? SelectedFields : never + /** * Arguments accepted by {@link runQuery}. */ @@ -150,7 +222,7 @@ export interface QueryRunnerContext { * (from the explicit type parameter) and the field selection (from the * argument), without requiring you to repeat yourself. * - * @example + * @example Basic usage * ```ts * import type { User } from '.prisma/client' * import { defineFragment } from '@opensaas/stack-core' @@ -162,58 +234,116 @@ export interface QueryRunnerContext { * } as const) * ``` * - * Fragments are composable — reference another fragment as the value for any - * relationship key: - * + * @example Compose fragments * ```ts * import type { Post } from '.prisma/client' * * export const postFragment = defineFragment()({ * id: true, * title: true, - * author: userFragment, // nested fragment - * tags: tagFragment, // nested many fragment + * author: userFragment, + * } as const) + * ``` + * + * @example Nested filtering with RelationSelector + * ```ts + * export const postWithApprovedComments = defineFragment()({ + * id: true, + * title: true, + * comments: { + * query: commentFragment, + * where: { approved: true }, + * orderBy: { createdAt: 'desc' }, + * take: 5, + * }, * } as const) * ``` + * + * @example Variables via factory function + * ```ts + * function makePostFragment(status: string) { + * return defineFragment()({ + * id: true, + * comments: { query: commentFragment, where: { status } }, + * } as const) + * } + * type PostData = ResultOf> + * + * const posts = await context.db.post.findMany({ + * query: makePostFragment('approved'), + * where: { published: true }, + * }) + * ``` */ export function defineFragment() { - return function >(fields: TFields): Fragment { + return function >( + fields: TFields, + ): Fragment { return { _type: 'fragment', _fields: fields } } } // ───────────────────────────────────────────────────────────── -// Runtime helpers (internal) +// Runtime helpers — exported for use in context/index.ts // ───────────────────────────────────────────────────────────── +/** @internal */ +export function isFragment(value: unknown): value is Fragment> { + return ( + value !== null && + typeof value === 'object' && + '_type' in value && + (value as { _type: unknown })._type === 'fragment' + ) +} + /** * Walk a field selection and build the Prisma `include` map needed to eagerly - * load all nested relationship fragments. + * load all nested relationship fragments/selectors. * * Scalar fields (`true`) do not require an include entry — Prisma returns all - * scalar columns by default. Only relationship fields backed by a Fragment - * generate include entries (recursively). + * scalar columns by default. Only relationship fields backed by a Fragment or + * RelationSelector generate include entries (recursively). + * + * Exported for use in `context/index.ts` when the `query` parameter is present. + * @internal */ -function buildInclude( - fields: FieldSelection, -): Record | undefined { +export function buildInclude(fields: FieldSelection): Record | undefined { const include: Record = {} let hasIncludes = false for (const [key, value] of Object.entries(fields as Record)) { - if ( - value !== null && - typeof value === 'object' && - '_type' in value && - (value as Fragment)._type === 'fragment' - ) { + if (value === null || value === true || typeof value !== 'object') continue + + const val = value as Record + + // ── Shorthand: Fragment directly ────────────────────────── + if (isFragment(val)) { hasIncludes = true - const nestedFields = (value as Fragment>)._fields - const nestedInclude = buildInclude(nestedFields) - // If the nested fragment itself has no relationship includes, we only - // need `true` (Prisma will load all scalars). If it does, we need to - // nest `{ include: ... }`. + const nestedInclude = buildInclude(val._fields as FieldSelection) include[key] = nestedInclude ? { include: nestedInclude } : true + continue + } + + // ── RelationSelector: { query, where?, orderBy?, take?, skip? } ── + if ('query' in val && isFragment(val.query)) { + hasIncludes = true + const selector = val as { + query: Fragment> + where?: Record + orderBy?: unknown + take?: number + skip?: number + } + const nestedInclude = buildInclude(selector.query._fields as FieldSelection) + const includeEntry: Record = {} + if (selector.where !== undefined) includeEntry.where = selector.where + if (selector.orderBy !== undefined) includeEntry.orderBy = selector.orderBy + if (selector.take !== undefined) includeEntry.take = selector.take + if (selector.skip !== undefined) includeEntry.skip = selector.skip + if (nestedInclude) includeEntry.include = nestedInclude + include[key] = Object.keys(includeEntry).length > 0 ? includeEntry : true + continue } } @@ -224,8 +354,11 @@ function buildInclude( * Recursively pick only the fields requested by a fragment from a raw Prisma * result object. This ensures the runtime shape exactly matches the type * produced by `ResultOf`. + * + * Exported for use in `context/index.ts`. + * @internal */ -function pickFields>( +export function pickFields>( item: TItem, fields: TFields, ): SelectedFields { @@ -236,23 +369,43 @@ function pickFields>( if (value === true) { result[key] = fieldValue - } else if ( - value !== null && - typeof value === 'object' && - '_type' in value && - (value as Fragment)._type === 'fragment' - ) { - const nestedFrag = value as Fragment> + continue + } + + if (value === null || typeof value !== 'object') continue + + const val = value as Record + // ── Shorthand: Fragment directly ────────────────────────── + if (isFragment(val)) { if (Array.isArray(fieldValue)) { result[key] = fieldValue.map((elem) => - pickFields(elem as unknown, nestedFrag._fields), + pickFields(elem as unknown, val._fields as FieldSelection), ) } else if (fieldValue === null || fieldValue === undefined) { result[key] = fieldValue } else { - result[key] = pickFields(fieldValue as unknown, nestedFrag._fields) + result[key] = pickFields(fieldValue as unknown, val._fields as FieldSelection) } + continue + } + + // ── RelationSelector: { query, where?, ... } ────────────── + if ('query' in val && isFragment(val.query)) { + const nestedFrag = val.query as Fragment> + if (Array.isArray(fieldValue)) { + result[key] = fieldValue.map((elem) => + pickFields(elem as unknown, nestedFrag._fields as FieldSelection), + ) + } else if (fieldValue === null || fieldValue === undefined) { + result[key] = fieldValue + } else { + result[key] = pickFields( + fieldValue as unknown, + nestedFrag._fields as FieldSelection, + ) + } + continue } } @@ -260,7 +413,7 @@ function pickFields>( } // ───────────────────────────────────────────────────────────── -// Query runners +// Standalone query runners // ───────────────────────────────────────────────────────────── /** @@ -270,6 +423,9 @@ function pickFields>( * Under the hood this calls `context.db[listKey].findMany()`, so all access * control rules defined in your config are still enforced. * + * **Tip:** You can also call `context.db.post.findMany({ query: fragment, ... })` + * directly — both forms produce the same result. + * * @param context - An `AccessContext` (or any object with a compatible `db`). * @param listKey - The PascalCase list name (e.g. `'Post'`, `'BlogPost'`). * @param fragment - A fragment created with {@link defineFragment}. @@ -306,9 +462,10 @@ export async function runQuery>( Object.keys(findManyArgs).length > 0 ? findManyArgs : undefined, ) - return results.map((item) => - pickFields(item as TItem, fragment._fields), - ) as SelectedFields[] + return results.map((item) => pickFields(item as TItem, fragment._fields)) as SelectedFields< + TItem, + TFields + >[] } /** @@ -317,6 +474,9 @@ export async function runQuery>( * Under the hood this calls `context.db[listKey].findFirst()`, so all access * control rules are still enforced. * + * **Tip:** You can also call `context.db.post.findUnique({ where: { id }, query: fragment })` + * directly. + * * @param context - An `AccessContext` (or any object with a compatible `db`). * @param listKey - The PascalCase list name (e.g. `'Post'`). * @param fragment - A fragment created with {@link defineFragment}. From 33398bd3fc2c032a64e5c65e143b4d00c93f90d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 09:11:42 +0000 Subject: [PATCH 3/5] docs: update migration guide and skills to reflect new context.db fragment API - Primary API is now context.db.post.findMany({ query: fragment, where, orderBy, ... }) and context.db.post.findUnique({ where, query: fragment }) - Document RelationSelector pattern: { query, where?, orderBy?, take?, skip? } - Document factory function (variables) pattern - Update changeset to describe full feature set including new primary API - Update migrate-context-calls and opensaas-migration skills - Update specs/keystone-migration.md with patterns H (RelationSelector) and I (factory fn) https://claude.ai/code/session_012ismTCY2S84rCjRWL4jnwU --- .changeset/swift-foxes-query.md | 62 ++-- .../skills/migrate-context-calls/SKILL.md | 74 +++-- .../skills/opensaas-migration/SKILL.md | 38 ++- docs/content/guides/migration.md | 104 +++++-- specs/keystone-migration.md | 280 ++++++++++++------ 5 files changed, 404 insertions(+), 154 deletions(-) diff --git a/.changeset/swift-foxes-query.md b/.changeset/swift-foxes-query.md index 0e049009..bc830e5d 100644 --- a/.changeset/swift-foxes-query.md +++ b/.changeset/swift-foxes-query.md @@ -2,9 +2,9 @@ '@opensaas/stack-core': minor --- -Add fragment-based, type-safe query utilities for migrating from `context.graphql.run` +Add fragment-based, type-safe query utilities and integrate them into `context.db` operations -OpenSaaS Stack now ships `defineFragment`, `runQuery`, and `runQueryOne` — composable query helpers that give you the same benefits as Keystone's GraphQL fragments (reuse, type inference, nesting) without a GraphQL runtime. +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:** @@ -15,9 +15,9 @@ import { defineFragment, type ResultOf } from '@opensaas/stack-core' const authorFragment = defineFragment()({ id: true, name: true } as const) const postFragment = defineFragment()({ - id: true, - title: true, - author: authorFragment, // nested relationship + id: true, + title: true, + author: authorFragment, // nested relationship } as const) // Types are inferred — no codegen step required @@ -25,25 +25,53 @@ type PostData = ResultOf // → { id: string; title: string; author: { id: string; name: string } | null } ``` -**Run queries (respects access control):** +**Pass fragments directly to `context.db` operations (primary API):** ```ts -import { runQuery, runQueryOne } from '@opensaas/stack-core' - -// List — replaces context.graphql.run with a query { posts { ... } } -const posts = await runQuery(context, 'Post', postFragment, { - where: { published: true }, +// List — typed to ResultOf[] +const posts = await context.db.post.findMany({ + query: postFragment, + where: { published: true }, orderBy: { publishedAt: 'desc' }, - take: 10, + take: 10, }) -// posts: PostData[] -// Single record — replaces context.graphql.run with a query { post(where: ...) { ... } } -const post = await runQueryOne(context, 'Post', postFragment, { id: postId }) +// Single record — typed to ResultOf | null +const post = await context.db.post.findUnique({ + where: { id: postId }, + query: postFragment, +}) if (!post) return notFound() -// post: PostData ``` -Fragments compose freely and can be nested to any depth. The same fragment instance can be reused in multiple parent fragments. +**Nested relationship filtering with `RelationSelector`:** + +```ts +const commentFragment = defineFragment()({ id: true, body: true } as const) + +const postWithComments = defineFragment()({ + 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..findMany()`. See `specs/keystone-migration.md` for a full migration guide from Keystone's `context.graphql.run`. diff --git a/claude-plugins/opensaas-migration/skills/migrate-context-calls/SKILL.md b/claude-plugins/opensaas-migration/skills/migrate-context-calls/SKILL.md index 340a3fa9..f8d20498 100644 --- a/claude-plugins/opensaas-migration/skills/migrate-context-calls/SKILL.md +++ b/claude-plugins/opensaas-migration/skills/migrate-context-calls/SKILL.md @@ -115,9 +115,9 @@ const { postsCount } = await context.graphql.run({ const count = await context.db.post.count({ where: { status: { equals: 'published' } } }) ``` -### Nested / related data (with runQuery — recommended) +### Nested / related data (fragment passed to context.db — recommended) -OpenSaaS Stack provides `defineFragment` and `runQuery` / `runQueryOne` for composable, type-safe queries that include related data in a single call — the closest equivalent to Keystone's GraphQL fragments. +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 @@ -134,37 +134,69 @@ const { posts } = await context.graphql.run({ // After — define fragments once, compose and reuse them import type { User, Post, Tag } from '.prisma/client' -import { defineFragment, runQuery, type ResultOf } from '@opensaas/stack-core' +import { defineFragment, type ResultOf } from '@opensaas/stack-core' const authorFragment = defineFragment()({ id: true, name: true } as const) -const tagFragment = defineFragment()({ id: true, name: true } as const) -const postFragment = defineFragment()({ - id: true, - title: true, - author: authorFragment, // nested fragment → loads with include - tags: tagFragment, // many relationship +const tagFragment = defineFragment()({ id: true, name: true } as const) +const postFragment = defineFragment()({ + 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 // → { id: string; title: string; author: { id: string; name: string } | null; tags: { id: string; name: string }[] } -const posts = await runQuery(context, 'Post', postFragment, { +// 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 use `runQueryOne`: +For single-record queries: ```typescript -import { runQueryOne } from '@opensaas/stack-core' - -const post = await runQueryOne(context, 'Post', postFragment, { id: postId }) +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()({ id: true, body: true } as const) + +const postWithComments = defineFragment()({ + 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: @@ -200,12 +232,12 @@ const allPosts = await context.sudo().db.post.findMany() 2. For each occurrence: a. Read the file to understand the full query/mutation b. Identify the operation type: - - **Read with nested data** → prefer `runQuery` / `runQueryOne` 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 + - **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`) 4. Report: list every file changed and summarise what was replaced diff --git a/claude-plugins/opensaas-migration/skills/opensaas-migration/SKILL.md b/claude-plugins/opensaas-migration/skills/opensaas-migration/SKILL.md index 0c8ed604..1dd4cc65 100644 --- a/claude-plugins/opensaas-migration/skills/opensaas-migration/SKILL.md +++ b/claude-plugins/opensaas-migration/skills/opensaas-migration/SKILL.md @@ -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.`; for simple reads replace with `context.db.*`; for nested/joined data use `defineFragment` + `runQuery`/`runQueryOne`; invoke the `migrate-context-calls` skill for detailed 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:** @@ -421,7 +421,7 @@ const posts = await context.db.post.findMany({ **Queries with nested/related data (fragments — recommended for Keystone migrations):** -OpenSaaS Stack provides `defineFragment` + `runQuery` / `runQueryOne` for composable, fully typed queries — the closest equivalent to Keystone GraphQL fragments and codegen types. +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 @@ -434,22 +434,42 @@ const { posts } = await context.graphql.run({ `, }) -// OpenSaaS Stack — defineFragment + ResultOf (no codegen) +// OpenSaaS Stack — defineFragment + context.db (no codegen, no GraphQL) import type { User, Post } from '.prisma/client' -import { defineFragment, runQuery, type ResultOf } from '@opensaas/stack-core' +import { defineFragment, type ResultOf } from '@opensaas/stack-core' const authorFragment = defineFragment()({ id: true, name: true } as const) -const postFragment = defineFragment()({ - id: true, - title: true, +const postFragment = defineFragment()({ + id: true, + title: true, author: authorFragment, } as const) type PostData = ResultOf // → { id: string; title: string; author: { id: string; name: string } | null } -const posts = await runQuery(context, 'Post', postFragment) +// 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()({ id: true, body: true } as const) +const postWithComments = defineFragment()({ + 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**. @@ -488,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) — for simple reads use `context.db.*`; for nested/related data use `defineFragment` + `runQuery`/`runQueryOne` from `@opensaas/stack-core`; invoke `migrate-context-calls` skill for detailed patterns +- [ ] **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` diff --git a/docs/content/guides/migration.md b/docs/content/guides/migration.md index d76793b2..54f16c40 100644 --- a/docs/content/guides/migration.md +++ b/docs/content/guides/migration.md @@ -868,12 +868,13 @@ If you're migrating from KeystoneJS, your project likely uses `context.graphql.r ### Quick reference -| Keystone | OpenSaaS Stack | -|---|---| -| GraphQL fragment string | `defineFragment()(fields)` | -| `ResultOf` (codegen) | `ResultOf` (built-in) | -| `context.graphql.run({ query, variables })` — list | `runQuery(context, 'List', fragment, args)` | -| `context.graphql.run({ query, variables })` — single | `runQueryOne(context, 'List', fragment, where)` | +| Keystone | OpenSaaS Stack | +| ---------------------------------------------------- | ------------------------------------------------------------------ | +| GraphQL fragment string | `defineFragment()(fields)` | +| `ResultOf` (codegen) | `ResultOf` (built-in) | +| `context.graphql.run({ query, variables })` — list | `context.db.post.findMany({ query: fragment, where?, ... })` | +| `context.graphql.run({ query, variables })` — single | `context.db.post.findUnique({ where: { id }, query: fragment })` | +| Nested relationship filtering | `RelationSelector`: `{ query: fragment, where?, orderBy?, take? }` | ### Simple list query @@ -883,14 +884,26 @@ const { posts } = await context.graphql.run({ query: `query { posts(where: { published: true }) { id title } }`, }) -// After (OpenSaaS Stack) +// After (OpenSaaS Stack) — pass the fragment directly to context.db import type { Post } from '.prisma/client' -import { defineFragment, runQuery, type ResultOf } from '@opensaas/stack-core' +import { defineFragment, type ResultOf } from '@opensaas/stack-core' const postFragment = defineFragment()({ id: true, title: true } as const) -type PostData = ResultOf // { id: string; title: string } +type PostData = ResultOf // { id: string; title: string } + +const posts = await context.db.post.findMany({ + query: postFragment, + where: { published: true }, +}) +// posts: PostData[] -const posts = await runQuery(context, 'Post', postFragment, { where: { published: true } }) +// Single record +const post = await context.db.post.findUnique({ + where: { id: postId }, + query: postFragment, +}) +// post: PostData | null +if (!post) return notFound() ``` ### Nested / related data with reusable fragments @@ -903,22 +916,24 @@ import type { User, Post, Tag } from '.prisma/client' import { defineFragment, type ResultOf } from '@opensaas/stack-core' export const authorFragment = defineFragment()({ - id: true, name: true, email: true, + id: true, + name: true, + email: true, } as const) export const tagFragment = defineFragment()({ id: true, name: true } as const) export const postFragment = defineFragment()({ - id: true, - title: true, + id: true, + title: true, publishedAt: true, - author: authorFragment, // nested — access-controlled include - tags: tagFragment, // many relationship + author: authorFragment, // nested — access-controlled include + tags: tagFragment, // many relationship } as const) // Inferred types — no GraphQL codegen required export type AuthorData = ResultOf -export type PostData = ResultOf +export type PostData = ResultOf // PostData → { id: string; title: string; publishedAt: Date | null; // author: { id: string; name: string; email: string } | null; // tags: { id: string; name: string }[] } @@ -926,21 +941,66 @@ export type PostData = ResultOf ```typescript // Usage in a server action or route -import { runQuery, runQueryOne } from '@opensaas/stack-core' import { postFragment } from './fragments' -// List -const posts = await runQuery(context, 'Post', postFragment, { - where: { published: true }, +// List with pagination +const posts = await context.db.post.findMany({ + query: postFragment, + where: { published: true }, orderBy: { publishedAt: 'desc' }, - take: 10, + take: 10, }) // Single record -const post = await runQueryOne(context, 'Post', postFragment, { id: postId }) +const post = await context.db.post.findUnique({ where: { id: postId }, query: postFragment }) if (!post) return notFound() ``` +### Nested relationship filtering with `RelationSelector` + +When you need to filter, sort, or paginate a nested relationship within the same query, use a `RelationSelector` object instead of a plain fragment: + +```typescript +import type { Post, Comment } from '.prisma/client' +import { defineFragment, type ResultOf } from '@opensaas/stack-core' + +const commentFragment = defineFragment()({ id: true, body: true } as const) + +const postWithApprovedComments = defineFragment()({ + id: true, + title: true, + comments: { + query: commentFragment, // nested fragment + where: { approved: true }, // Prisma filter on the relationship + orderBy: { createdAt: 'desc' }, + take: 5, + }, +} as const) + +type PostWithComments = ResultOf +// → { id: string; title: string; comments: { id: string; body: string }[] } + +const posts = await context.db.post.findMany({ query: postWithApprovedComments }) +``` + +For dynamic filter values, use a factory function: + +```typescript +function makePostFrag(status: string) { + return defineFragment()({ + id: true, + comments: { query: commentFragment, where: { status } }, + } as const) +} + +type PostData = ResultOf> + +const posts = await context.db.post.findMany({ + query: makePostFrag('approved'), + where: { published: true }, +}) +``` + All operations go through `context.db` under the hood, so access control is enforced automatically. See the full [Keystone migration spec](../../../specs/keystone-migration.md) for more patterns. ## Best Practices diff --git a/specs/keystone-migration.md b/specs/keystone-migration.md index 92cd715a..ca807f5c 100644 --- a/specs/keystone-migration.md +++ b/specs/keystone-migration.md @@ -6,17 +6,17 @@ This guide is intended for both human developers and AI agents tasked with migra ## Overview of differences -| Concern | Keystone 6 | OpenSaas Stack | -|---|---|---| -| Schema definition | `list()` in `schema.ts` | `list()` in `opensaas.config.ts` | -| Database | Prisma (managed by Keystone) | Prisma 7 with driver adapters | -| Access control | Functions on `access` key | Same pattern (compatible API) | -| Hooks | `resolveInput`, `validateInput`, etc. | Same names + `resolveOutput` | -| GraphQL API | Built-in, always on | **Not provided** | -| `context.graphql.run()` | Run raw GraphQL queries | `runQuery` / `runQueryOne` (see below) | -| Type generation | GraphQL codegen | Built-in TypeScript inference via `ResultOf` | -| Auth | `@keystone-6/auth` | `@opensaas/stack-auth` | -| Admin UI | Auto-generated from schema | Auto-generated from config | +| Concern | Keystone 6 | OpenSaas Stack | +| ----------------------- | ------------------------------------- | -------------------------------------------- | +| Schema definition | `list()` in `schema.ts` | `list()` in `opensaas.config.ts` | +| Database | Prisma (managed by Keystone) | Prisma 7 with driver adapters | +| Access control | Functions on `access` key | Same pattern (compatible API) | +| Hooks | `resolveInput`, `validateInput`, etc. | Same names + `resolveOutput` | +| GraphQL API | Built-in, always on | **Not provided** | +| `context.graphql.run()` | Run raw GraphQL queries | `runQuery` / `runQueryOne` (see below) | +| Type generation | GraphQL codegen | Built-in TypeScript inference via `ResultOf` | +| Auth | `@keystone-6/auth` | `@opensaas/stack-auth` | +| Admin UI | Auto-generated from schema | Auto-generated from config | --- @@ -38,7 +38,7 @@ export const lists = { }, access: { operation: { - query: () => true, + query: () => true, create: ({ session }) => !!session, update: ({ session }) => !!session, delete: ({ session }) => !!session, @@ -82,7 +82,7 @@ export default config({ }, access: { operation: { - query: () => true, + query: () => true, create: ({ session }) => !!session, update: ({ session }) => !!session, delete: ({ session }) => !!session, @@ -101,35 +101,35 @@ export default config({ ``` **Key differences:** + - `config()` wraps all lists in a single default export. - Database config (`db`) is required and must include `prismaClientConstructor` for Prisma 7. - Field imports come from `@opensaas/stack-core/fields` (not `@keystone-6/core/fields`). --- -## 2. Replacing `context.graphql.run` with `runQuery` / `runQueryOne` +## 2. Replacing `context.graphql.run` with fragment-based queries This is the most significant API change. OpenSaas Stack does **not** include a GraphQL layer. Instead it provides first-class TypeScript utilities that give you the same benefits — fragment reuse, type inference, composability — without GraphQL at runtime. ### Concept mapping -| GraphQL / Keystone concept | OpenSaas Stack equivalent | -|---|---| -| GraphQL fragment | `defineFragment()(fields)` | -| `ResultOf` | `ResultOf` | -| `VariablesOf` | `QueryArgs` (or your own Prisma where type) | -| `context.graphql.run({ query, variables })` | `runQuery(context, listKey, fragment, args)` | -| Single-record query | `runQueryOne(context, listKey, fragment, where)` | +| GraphQL / Keystone concept | OpenSaas Stack equivalent | +| ------------------------------------------- | ------------------------------------------------------------------- | +| GraphQL fragment | `defineFragment()(fields)` | +| `ResultOf` | `ResultOf` | +| `VariablesOf` | `QueryArgs` (or your own Prisma where type) | +| `context.graphql.run({ query, variables })` | `context.db.post.findMany({ query: fragment, where, ... })` | +| Single-record query | `context.db.post.findUnique({ where: { id }, query: fragment })` | +| Standalone query helpers | `runQuery(context, listKey, fragment, args)` / `runQueryOne(...)` | ### Import ```typescript import { defineFragment, - runQuery, - runQueryOne, type ResultOf, - type QueryArgs, + type RelationSelector, } from '@opensaas/stack-core' ``` @@ -151,18 +151,18 @@ const { data } = await context.graphql.run({ } `, }) -const posts = data.posts // typed as any, or via codegen +const posts = data.posts // typed as any, or via codegen ``` **After (OpenSaas Stack):** ```typescript import type { Post } from '.prisma/client' -import { defineFragment, runQuery, type ResultOf } from '@opensaas/stack-core' +import { defineFragment, type ResultOf } from '@opensaas/stack-core' const postFragment = defineFragment()({ - id: true, - title: true, + id: true, + title: true, publishedAt: true, } as const) @@ -170,7 +170,8 @@ const postFragment = defineFragment()({ type PostData = ResultOf // → { id: string; title: string; publishedAt: Date | null } -const posts = await runQuery(context, 'Post', postFragment) +// Primary API: fragment passed directly to context.db operations +const posts = await context.db.post.findMany({ query: postFragment }) // posts: PostData[] ``` @@ -201,12 +202,14 @@ const { data } = await context.graphql.run({ **After (OpenSaas Stack):** ```typescript -const posts = await runQuery(context, 'Post', postFragment, { - where: { status: 'published' }, +const posts = await context.db.post.findMany({ + query: postFragment, + where: { status: 'published' }, orderBy: { publishedAt: 'desc' }, - take: 10, - skip: 0, + take: 10, + skip: 0, }) +// posts: PostData[] ``` --- @@ -234,8 +237,11 @@ const post = data.post **After (OpenSaas Stack):** ```typescript -const post = await runQueryOne(context, 'Post', postFragment, { id: postId }) -if (!post) return notFound() // null means not found or access denied +const post = await context.db.post.findUnique({ + where: { id: postId }, + query: postFragment, +}) +if (!post) return notFound() // null means not found or access denied ``` --- @@ -284,20 +290,20 @@ import type { User, Post } from '.prisma/client' import { defineFragment, type ResultOf } from '@opensaas/stack-core' export const authorFragment = defineFragment()({ - id: true, - name: true, + id: true, + name: true, email: true, } as const) export const postSummaryFragment = defineFragment()({ - id: true, - title: true, + id: true, + title: true, publishedAt: true, - author: authorFragment, // ← compose fragments + author: authorFragment, // ← compose fragments } as const) // Infer types — no GraphQL codegen step -export type AuthorData = ResultOf +export type AuthorData = ResultOf // → { id: string; name: string; email: string } export type PostSummaryData = ResultOf @@ -308,8 +314,9 @@ export type PostSummaryData = ResultOf // Usage in a server action or route handler import { postSummaryFragment } from './fragments' -const posts = await runQuery(context, 'Post', postSummaryFragment, { - where: { published: true }, +const posts = await context.db.post.findMany({ + query: postSummaryFragment, + where: { published: true }, orderBy: { publishedAt: 'desc' }, }) // posts is PostSummaryData[] @@ -340,9 +347,9 @@ import type { Post, Tag } from '.prisma/client' const tagFragment = defineFragment()({ id: true, name: true } as const) const postWithTagsFragment = defineFragment()({ - id: true, + id: true, title: true, - tags: tagFragment, // many relationship → array in ResultOf + tags: tagFragment, // many relationship → array in ResultOf } as const) type PostWithTags = ResultOf @@ -354,12 +361,16 @@ type PostWithTags = ResultOf ### Pattern F — deeply nested (three levels) ```typescript -const userFragment = defineFragment()({ id: true, name: true } as const) -const postFragment = defineFragment()({ id: true, title: true, author: userFragment } as const) +const userFragment = defineFragment()({ id: true, name: true } as const) +const postFragment = defineFragment()({ + id: true, + title: true, + author: userFragment, +} as const) const commentFragment = defineFragment()({ - id: true, - body: true, - post: postFragment, + id: true, + body: true, + post: postFragment, author: userFragment, } as const) @@ -371,7 +382,7 @@ type CommentData = ResultOf // author: { id: string; name: string } | null // } -const comments = await runQuery(context, 'Comment', commentFragment) +const comments = await context.db.comment.findMany({ query: commentFragment }) ``` --- @@ -384,12 +395,87 @@ Fragments are plain objects and can be referenced freely: const userFragment = defineFragment()({ id: true, name: true } as const) // Reuse in multiple parents -const postFragment = defineFragment()({ id: true, author: userFragment } as const) +const postFragment = defineFragment()({ id: true, author: userFragment } as const) const commentFragment = defineFragment()({ id: true, author: userFragment } as const) ``` --- +### Pattern H — nested filtering with `RelationSelector` + +Use `RelationSelector` to apply Prisma filter/ordering/pagination to a nested relationship within the same fragment: + +```typescript +import type { Post, Comment } from '.prisma/client' +import { defineFragment, type ResultOf } from '@opensaas/stack-core' + +const commentFragment = defineFragment()({ id: true, body: true } as const) + +const postWithRecentComments = defineFragment()({ + id: true, + title: true, + // RelationSelector: fragment + Prisma args for the nested relationship + comments: { + query: commentFragment, + where: { approved: true }, + orderBy: { createdAt: 'desc' }, + take: 5, + }, +} as const) + +type PostWithComments = ResultOf +// → { id: string; title: string; comments: { id: string; body: string }[] } + +const posts = await context.db.post.findMany({ query: postWithRecentComments }) +``` + +### Pattern I — variables via factory function + +When the nested filter needs a runtime value, use a factory function: + +```typescript +function makePostFragment(status: string) { + return defineFragment()({ + id: true, + title: true, + comments: { + query: commentFragment, + where: { status }, + }, + } as const) +} + +// ResultOf works with the return type +type PostData = ResultOf> + +// Build the fragment at call-site with the runtime value +const posts = await context.db.post.findMany({ + query: makePostFragment('approved'), + where: { published: true }, +}) +``` + +--- + +### Standalone helpers: `runQuery` / `runQueryOne` + +For convenience, standalone functions are also available when you don't have direct access to `context.db` (e.g., in hook implementations or utility functions): + +```typescript +import { runQuery, runQueryOne } from '@opensaas/stack-core' + +// Equivalent to context.db.post.findMany({ query: postFragment, where, ... }) +const posts = await runQuery(context, 'Post', postFragment, { + where: { published: true }, + orderBy: { publishedAt: 'desc' }, +}) + +// Equivalent to context.db.post.findUnique({ where: { id }, query: postFragment }) +const post = await runQueryOne(context, 'Post', postFragment, { id: postId }) +``` + +--- + ## 3. Access control — no changes needed Access control functions in Keystone and OpenSaas Stack share the same shape: @@ -422,13 +508,13 @@ Filter-based access (returning a Prisma `where` object) is also compatible. Most hooks map directly. The only new one is `resolveOutput` (for transforming read values). -| Keystone hook | OpenSaas Stack equivalent | -|---|---| -| `resolveInput` | `resolveInput` ✓ | -| `validateInput` | `validate` (or `validateInput` for backwards compat) | -| `beforeOperation` | `beforeOperation` ✓ | -| `afterOperation` | `afterOperation` ✓ | -| _(none)_ | `resolveOutput` (new — transforms read values) | +| Keystone hook | OpenSaas Stack equivalent | +| ----------------- | ---------------------------------------------------- | +| `resolveInput` | `resolveInput` ✓ | +| `validateInput` | `validate` (or `validateInput` for backwards compat) | +| `beforeOperation` | `beforeOperation` ✓ | +| `afterOperation` | `afterOperation` ✓ | +| _(none)_ | `resolveOutput` (new — transforms read values) | ### `validateInput` → `validate` @@ -453,22 +539,22 @@ hooks: { ## 5. Field type mapping -| Keystone field | OpenSaas Stack field | -|---|---| -| `text()` | `text()` | -| `integer()` | `integer()` | -| `float()` | `decimal()` | -| `decimal()` | `decimal()` | -| `checkbox()` | `checkbox()` | -| `timestamp()` | `timestamp()` | -| `calendarDay()` | `calendarDay()` | -| `password()` | `password()` | -| `select()` | `select()` | -| `relationship()` | `relationship()` | -| `json()` | `json()` | -| `virtual()` | `virtual()` | -| `image()` | _(use storage config)_ | -| `file()` | _(use storage config)_ | +| Keystone field | OpenSaas Stack field | +| ---------------- | ---------------------- | +| `text()` | `text()` | +| `integer()` | `integer()` | +| `float()` | `decimal()` | +| `decimal()` | `decimal()` | +| `checkbox()` | `checkbox()` | +| `timestamp()` | `timestamp()` | +| `calendarDay()` | `calendarDay()` | +| `password()` | `password()` | +| `select()` | `select()` | +| `relationship()` | `relationship()` | +| `json()` | `json()` | +| `virtual()` | `virtual()` | +| `image()` | _(use storage config)_ | +| `file()` | _(use storage config)_ | --- @@ -552,9 +638,11 @@ When automating a Keystone → OpenSaas Stack migration, work through this check 7. **[ ] Replace all `context.graphql.run` calls:** a. Identify each unique GraphQL query/fragment. b. Create a `defineFragment()({...})` for each shape. - c. Replace the call site with `runQuery` / `runQueryOne`. - d. Replace `data.posts` with the direct return value. - e. Replace any codegen-generated types with `ResultOf`. + c. Replace list queries with `context.db..findMany({ query: fragment, where?, orderBy?, take?, skip? })`. + d. Replace single-record queries with `context.db..findUnique({ where: { id }, query: fragment })`. + e. Replace `data.posts` (or similar) with the direct return value. + f. Replace any codegen-generated types with `ResultOf`. + g. For nested relationship filtering, use `RelationSelector` (`{ query, where, orderBy, take, skip }`) instead of separate queries. 8. **[ ] Migrate hooks** — rename `validateInput` → `validate` if desired (backwards-compat alias exists). @@ -566,7 +654,7 @@ When automating a Keystone → OpenSaas Stack migration, work through this check --- -## 9. Quick reference — `context.graphql.run` → query utilities +## 9. Quick reference — `context.graphql.run` → context.db with fragments ```typescript // ── BEFORE (Keystone) ─────────────────────────────────────────────── @@ -593,13 +681,13 @@ const posts = data.posts // ── AFTER (OpenSaas Stack) ────────────────────────────────────────── import type { User, Post } from '.prisma/client' -import { defineFragment, runQuery, type ResultOf } from '@opensaas/stack-core' +import { defineFragment, type ResultOf } from '@opensaas/stack-core' // Declare fragments once, reuse everywhere const authorFragment = defineFragment()({ id: true, name: true } as const) -const postFragment = defineFragment()({ - id: true, - title: true, +const postFragment = defineFragment()({ + id: true, + title: true, author: authorFragment, } as const) @@ -607,9 +695,31 @@ const postFragment = defineFragment()({ type PostData = ResultOf // → { id: string; title: string; author: { id: string; name: string } | null } -// Run the query — access control is still enforced -const posts = await runQuery(context, 'Post', postFragment, { +// List query — access control is still enforced +const posts = await context.db.post.findMany({ + query: postFragment, where: { published: true }, }) // posts: PostData[] + +// Single record +const post = await context.db.post.findUnique({ + where: { id: postId }, + query: postFragment, +}) +// post: PostData | null + +// Nested relationship filtering (RelationSelector) +const commentFragment = defineFragment()({ id: true, body: true } as const) +const postWithComments = defineFragment()({ + id: true, + title: true, + comments: { + query: commentFragment, + where: { approved: true }, + orderBy: { createdAt: 'desc' }, + take: 5, + }, +} as const) +const postsWithTopComments = await context.db.post.findMany({ query: postWithComments }) ``` From 5a0e9acf1bd8df87890af9b609cb178aaba017db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 09:21:35 +0000 Subject: [PATCH 4/5] fix(core): move eslint-disable comment to correct line in createFindUnique The eslint-disable directive was two lines above the `any` annotation, making it unused (triggering a lint error). Moved it to the line directly above `query?: any`. https://claude.ai/code/session_012ismTCY2S84rCjRWL4jnwU --- packages/core/src/context/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/context/index.ts b/packages/core/src/context/index.ts index aec048ae..f2fa89a5 100644 --- a/packages/core/src/context/index.ts +++ b/packages/core/src/context/index.ts @@ -611,10 +611,10 @@ function createFindUnique( context: AccessContext, config: OpenSaasConfig, ) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any return async (args: { where: { id: string } include?: Record + // eslint-disable-next-line @typescript-eslint/no-explicit-any query?: any }) => { // Check query access (skip if sudo mode) From c6f12d8681b3d805b89eeafe2443881c99d70eb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 09:28:49 +0000 Subject: [PATCH 5/5] Fix TypeScript tsc errors in fragment query type system - Fix `SelectedFields` mapped type: use `[ExtractFragment] extends [never]` tuple-wrapping to correctly identify scalar fields; previously `never extends Fragment` was vacuously true, causing scalar fields to resolve to `never` under strict tsc (vitest skips type-checking so this was hidden) - Fix test: use `null` instead of `undefined` in findFirst mock (undefined is not assignable to `{} | null`) - Fix test: remove invalid `commentWithAuthorFrag._fields.post` access (the fragment was defined without a `post` field) https://claude.ai/code/session_012ismTCY2S84rCjRWL4jnwU --- .../skills/migrate-context-calls/SKILL.md | 4 ++-- docs/content/guides/migration.md | 6 ++--- packages/core/src/query/index.test.ts | 3 +-- packages/core/src/query/index.ts | 13 ++++------- specs/keystone-migration.md | 22 ++++++++----------- 5 files changed, 19 insertions(+), 29 deletions(-) diff --git a/claude-plugins/opensaas-migration/skills/migrate-context-calls/SKILL.md b/claude-plugins/opensaas-migration/skills/migrate-context-calls/SKILL.md index f8d20498..5e6ef65a 100644 --- a/claude-plugins/opensaas-migration/skills/migrate-context-calls/SKILL.md +++ b/claude-plugins/opensaas-migration/skills/migrate-context-calls/SKILL.md @@ -142,7 +142,7 @@ const postFragment = defineFragment()({ id: true, title: true, author: authorFragment, // nested fragment → loaded via Prisma include - tags: tagFragment, // many relationship + tags: tagFragment, // many relationship } as const) // Type-inferred — no codegen needed @@ -179,7 +179,7 @@ const postWithComments = defineFragment()({ title: true, comments: { query: commentFragment, - where: { approved: true }, // filter nested relationship + where: { approved: true }, // filter nested relationship orderBy: { createdAt: 'desc' }, take: 5, }, diff --git a/docs/content/guides/migration.md b/docs/content/guides/migration.md index 54f16c40..fb0fa631 100644 --- a/docs/content/guides/migration.md +++ b/docs/content/guides/migration.md @@ -928,7 +928,7 @@ export const postFragment = defineFragment()({ title: true, publishedAt: true, author: authorFragment, // nested — access-controlled include - tags: tagFragment, // many relationship + tags: tagFragment, // many relationship } as const) // Inferred types — no GraphQL codegen required @@ -970,8 +970,8 @@ const postWithApprovedComments = defineFragment()({ id: true, title: true, comments: { - query: commentFragment, // nested fragment - where: { approved: true }, // Prisma filter on the relationship + query: commentFragment, // nested fragment + where: { approved: true }, // Prisma filter on the relationship orderBy: { createdAt: 'desc' }, take: 5, }, diff --git a/packages/core/src/query/index.test.ts b/packages/core/src/query/index.test.ts index 18df627a..d009aaf3 100644 --- a/packages/core/src/query/index.test.ts +++ b/packages/core/src/query/index.test.ts @@ -434,7 +434,7 @@ describe('runQueryOne', () => { it('returns null when findFirst returns undefined', async () => { const delegate = { findMany: vi.fn(async () => []), - findFirst: vi.fn(async () => undefined), + findFirst: vi.fn(async () => null), } const ctx = makeContext({ user: delegate }) @@ -645,7 +645,6 @@ describe('buildInclude with RelationSelector', () => { // Separate test: RelationSelector where inner fragment has nested relationships const commentSelector = defineFragment()({ id: true, - post: commentWithAuthorFrag._fields.post, author: userFrag, } as const) const result2 = buildInclude(commentWithAuthorFrag._fields as FieldSelection) diff --git a/packages/core/src/query/index.ts b/packages/core/src/query/index.ts index e60260d2..ad86f57d 100644 --- a/packages/core/src/query/index.ts +++ b/packages/core/src/query/index.ts @@ -139,13 +139,10 @@ type ExtractFragment = * Map a FieldSelection over a model type, computing the picked output type. */ type SelectedFields> = { - [K in keyof TFields & keyof TItem]: ExtractFragment extends Fragment< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - any - > - ? // Relationship field — preserve array/null/undefined wrappers from the model + [K in keyof TFields & keyof TItem]: [ExtractFragment] extends [never] + ? // Scalar field (value is `true`) — tuple wrapping avoids the vacuous `never extends T` pitfall + TItem[K] + : // Relationship field — preserve array/null/undefined wrappers from the model TItem[K] extends Array ? ResultOf>[] : null extends TItem[K] @@ -153,8 +150,6 @@ type SelectedFields> = { : undefined extends TItem[K] ? ResultOf> | undefined : ResultOf> - : // Scalar field (value is `true`) - TItem[K] } // ───────────────────────────────────────────────────────────── diff --git a/specs/keystone-migration.md b/specs/keystone-migration.md index ca807f5c..b98c06b4 100644 --- a/specs/keystone-migration.md +++ b/specs/keystone-migration.md @@ -114,23 +114,19 @@ This is the most significant API change. OpenSaas Stack does **not** include a G ### Concept mapping -| GraphQL / Keystone concept | OpenSaas Stack equivalent | -| ------------------------------------------- | ------------------------------------------------------------------- | -| GraphQL fragment | `defineFragment()(fields)` | -| `ResultOf` | `ResultOf` | -| `VariablesOf` | `QueryArgs` (or your own Prisma where type) | -| `context.graphql.run({ query, variables })` | `context.db.post.findMany({ query: fragment, where, ... })` | -| Single-record query | `context.db.post.findUnique({ where: { id }, query: fragment })` | -| Standalone query helpers | `runQuery(context, listKey, fragment, args)` / `runQueryOne(...)` | +| GraphQL / Keystone concept | OpenSaas Stack equivalent | +| ------------------------------------------- | ----------------------------------------------------------------- | +| GraphQL fragment | `defineFragment()(fields)` | +| `ResultOf` | `ResultOf` | +| `VariablesOf` | `QueryArgs` (or your own Prisma where type) | +| `context.graphql.run({ query, variables })` | `context.db.post.findMany({ query: fragment, where, ... })` | +| Single-record query | `context.db.post.findUnique({ where: { id }, query: fragment })` | +| Standalone query helpers | `runQuery(context, listKey, fragment, args)` / `runQueryOne(...)` | ### Import ```typescript -import { - defineFragment, - type ResultOf, - type RelationSelector, -} from '@opensaas/stack-core' +import { defineFragment, type ResultOf, type RelationSelector } from '@opensaas/stack-core' ``` ---