diff --git a/.changeset/swift-foxes-query.md b/.changeset/swift-foxes-query.md new file mode 100644 index 00000000..bc830e5d --- /dev/null +++ b/.changeset/swift-foxes-query.md @@ -0,0 +1,77 @@ +--- +'@opensaas/stack-core': minor +--- + +Add fragment-based, type-safe query utilities and integrate them into `context.db` operations + +OpenSaaS Stack now ships `defineFragment`, `ResultOf`, and `RelationSelector` — composable query helpers that give you the same benefits as Keystone's GraphQL fragments (reuse, type inference, nesting) without a GraphQL runtime. + +**Define reusable fragments:** + +```ts +import type { User, Post } from '.prisma/client' +import { defineFragment, type ResultOf } from '@opensaas/stack-core' + +const authorFragment = defineFragment()({ 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 } +``` + +**Pass fragments directly to `context.db` operations (primary API):** + +```ts +// List — typed to ResultOf[] +const posts = await context.db.post.findMany({ + query: postFragment, + where: { published: true }, + orderBy: { publishedAt: 'desc' }, + take: 10, +}) + +// Single record — typed to ResultOf | null +const post = await context.db.post.findUnique({ + where: { id: postId }, + query: postFragment, +}) +if (!post) return notFound() +``` + +**Nested relationship filtering with `RelationSelector`:** + +```ts +const commentFragment = defineFragment()({ 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 7282489b..5e6ef65a 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,91 @@ const { postsCount } = await context.graphql.run({ const count = await context.db.post.count({ where: { status: { equals: 'published' } } }) ``` -### Nested / related data +### Nested / related data (fragment passed to context.db — recommended) -GraphQL allows fetching related data in one query. OpenSaaS Stack requires separate `context.db` calls: +OpenSaaS Stack provides `defineFragment` for composable, type-safe queries that include related data in a single call — the closest equivalent to Keystone's GraphQL fragments. Pass the fragment directly to `context.db` operations using the `query` parameter. + +```typescript +// Before — one GraphQL query with nested author and tags +const { posts } = await context.graphql.run({ + query: ` + fragment AuthorFields on User { id name } + query GetPosts { + posts(where: { published: true }) { + id title author { ...AuthorFields } tags { id name } + } + } + `, +}) + +// After — define fragments once, compose and reuse them +import type { User, Post, Tag } from '.prisma/client' +import { defineFragment, type ResultOf } from '@opensaas/stack-core' + +const authorFragment = defineFragment()({ 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 → 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 }[] } + +// Primary API: pass query fragment to context.db.findMany +const posts = await context.db.post.findMany({ + query: postFragment, + where: { published: true }, + orderBy: { publishedAt: 'desc' }, +}) +// posts: PostData[] +``` + +For single-record queries: + +```typescript +const post = await context.db.post.findUnique({ + where: { id: postId }, + query: postFragment, +}) +if (!post) return notFound() +// post: PostData +``` + +For nested relationship filtering (e.g., only load approved comments): + +```typescript +const commentFragment = defineFragment()({ 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: ```typescript // Before — one query with nested author @@ -125,7 +207,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 +231,13 @@ const allPosts = await context.sudo().db.post.findMany() 1. Use Grep to find all occurrences of `context.graphql`, `context.query`, and `context.sudo().graphql` in the project (search `.ts`, `.tsx` files, exclude `node_modules`) 2. For each occurrence: a. Read the file to understand the full query/mutation - b. Identify the list name (convert to camelCase for `context.db`) - c. Identify the operation (findMany, findUnique, create, update, delete, count) - d. Rewrite using the `context.db` pattern above - e. For nested data: split into separate `context.db` calls -3. After all edits: check that any `import ... from '@keystone-6/core'` imports used only for graphql types are removed or reduced + b. Identify the operation type: + - **Read with nested data** → prefer `context.db.{list}.findMany/findUnique({ query: fragment })` with `defineFragment` (see pattern above) + - **Simple read** → `context.db.{list}.findMany()` / `findUnique()` + - **Create / update / delete** → `context.db.{list}.create()` / `update()` / `delete()` + - **Count** → `context.db.{list}.count()` + c. Identify the list name (convert to camelCase for `context.db`) + d. Rewrite using the appropriate pattern above + e. For fragment-based rewrites: create a shared `fragments.ts` file and import from it +3. After all edits: check that any `import ... from '@keystone-6/core'` imports used only for graphql types are removed or reduced; also remove any GraphQL codegen type imports (replace with `ResultOf`) 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..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.` and replace with `context.db.*` calls; invoke the `keystone-virtual-fields-context` skill for patterns +7. **Migrate context.graphql calls** — search for `context.graphql.run(`, `context.graphql.raw(`, `context.query.`; for simple reads replace with `context.db.*`; for nested/joined data use `defineFragment` + `context.db.{list}.findMany({ query: fragment })`; invoke the `migrate-context-calls` skill for detailed patterns 8. **Test** - the app structure should remain identical **DO NOT:** @@ -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,60 @@ const posts = await context.db.post.findMany({ }) ``` -List names are camelCase: `Post` → `context.db.post`, `BlogPost` → `context.db.blogPost`. Access control is enforced automatically. For detailed patterns including related data and sudo access, **invoke the `keystone-virtual-fields-context` skill**. +**Queries with nested/related data (fragments — recommended for Keystone migrations):** + +OpenSaaS Stack provides `defineFragment` for composable, fully typed queries — the closest equivalent to Keystone GraphQL fragments and codegen types. Pass the fragment directly to `context.db` operations using the `query` parameter. + +```typescript +// Keystone — GraphQL fragment + codegen types +import type { PostFragment } from './__generated__/graphql' + +const { posts } = await context.graphql.run({ + query: ` + fragment AuthorFields on User { id name } + query { posts { id title author { ...AuthorFields } } } + `, +}) + +// OpenSaaS Stack — defineFragment + context.db (no codegen, no GraphQL) +import type { User, Post } from '.prisma/client' +import { defineFragment, type ResultOf } from '@opensaas/stack-core' + +const authorFragment = defineFragment()({ 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 } + +// 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**. ## Migration Checklist @@ -455,7 +508,7 @@ List names are camelCase: `Post` → `context.db.post`, `BlogPost` → `context. - [ ] **Add Prisma adapter** to database config - [ ] **Update context creation** in API routes - [ ] **Migrate virtual fields** (if any) — replace `graphql.field()` + `resolve()` with `hooks.resolveOutput`; invoke `keystone-virtual-fields-context` skill -- [ ] **Migrate context.graphql calls** (if any) — replace with `context.db.*` calls; invoke `keystone-virtual-fields-context` skill +- [ ] **Migrate context.graphql calls** (if any) — for simple reads use `context.db.*`; for nested/related data use `defineFragment` + `context.db.{list}.findMany({ query: fragment })` from `@opensaas/stack-core`; invoke `migrate-context-calls` skill for detailed patterns - [ ] Analyze and adapt access control patterns - [ ] Run `opensaas generate` - [ ] Run `prisma generate` diff --git a/docs/content/guides/migration.md b/docs/content/guides/migration.md index 5da0d01d..fb0fa631 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,147 @@ 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 | `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 + +```typescript +// Before (Keystone) +const { posts } = await context.graphql.run({ + query: `query { posts(where: { published: true }) { id title } }`, +}) + +// After (OpenSaaS Stack) — pass the fragment directly to context.db +import type { Post } from '.prisma/client' +import { defineFragment, 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 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 +if (!post) return notFound() +``` + +### 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 { postFragment } from './fragments' + +// List with pagination +const posts = await context.db.post.findMany({ + query: postFragment, + where: { published: true }, + orderBy: { publishedAt: 'desc' }, + take: 10, +}) + +// Single record +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 ### Start Small 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..f2fa89a5 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 }) => { + 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) 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 d4464c93..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 @@ -89,3 +92,14 @@ 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, + 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 new file mode 100644 index 00000000..d009aaf3 --- /dev/null +++ b/packages/core/src/query/index.test.ts @@ -0,0 +1,830 @@ +import { describe, it, expect, vi } from 'vitest' +import { + defineFragment, + runQuery, + runQueryOne, + buildInclude, + pickFields, + isFragment, +} 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 () => null), + } + 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< + 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, + 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 new file mode 100644 index 00000000..ad86f57d --- /dev/null +++ b/packages/core/src/query/index.ts @@ -0,0 +1,505 @@ +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 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 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 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: authorFragment, + * comments: { query: commentFragment, where: { approved: true } }, + * } + * ``` + */ +export type FieldSelection = { + readonly [K in keyof T]?: UnwrapItem extends Record + ? RelationSelector> | true + : true +} + +/** + * A reusable, composable field-selection descriptor for model type `TItem`. + * + * Create with {@link defineFragment}. Compose by referencing another Fragment + * (or a {@link RelationSelector}) 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 +} + +// ───────────────────────────────────────────────────────────── +// Internal type helpers +// ───────────────────────────────────────────────────────────── + +/** + * @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). + */ +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]: [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] + ? ResultOf> | null + : undefined extends TItem[K] + ? ResultOf> | undefined + : ResultOf> +} + +// ───────────────────────────────────────────────────────────── +// 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}. + */ +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 Basic usage + * ```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) + * ``` + * + * @example Compose fragments + * ```ts + * import type { Post } from '.prisma/client' + * + * export const postFragment = defineFragment()({ + * id: true, + * title: true, + * 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 { _type: 'fragment', _fields: fields } + } +} + +// ───────────────────────────────────────────────────────────── +// 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/selectors. + * + * Scalar fields (`true`) do not require an include entry — Prisma returns all + * 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 + */ +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 || value === true || typeof value !== 'object') continue + + const val = value as Record + + // ── Shorthand: Fragment directly ────────────────────────── + if (isFragment(val)) { + hasIncludes = true + 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 + } + } + + 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`. + * + * Exported for use in `context/index.ts`. + * @internal + */ +export 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 + 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, val._fields as FieldSelection), + ) + } else if (fieldValue === null || fieldValue === undefined) { + result[key] = fieldValue + } else { + 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 + } + } + + return result as SelectedFields +} + +// ───────────────────────────────────────────────────────────── +// Standalone 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. + * + * **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}. + * @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< + TItem, + TFields + >[] +} + +/** + * 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. + * + * **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}. + * @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..b98c06b4 --- /dev/null +++ b/specs/keystone-migration.md @@ -0,0 +1,721 @@ +# 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 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 })` | `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' +``` + +--- + +### 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, 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 } + +// Primary API: fragment passed directly to context.db operations +const posts = await context.db.post.findMany({ query: 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 context.db.post.findMany({ + query: postFragment, + where: { status: 'published' }, + orderBy: { publishedAt: 'desc' }, + take: 10, + skip: 0, +}) +// posts: PostData[] +``` + +--- + +### 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 context.db.post.findUnique({ + where: { id: postId }, + query: postFragment, +}) +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 context.db.post.findMany({ + query: 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 context.db.comment.findMany({ query: 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) +``` + +--- + +### 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: + +```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 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). + +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` → context.db with fragments + +```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, 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 } + +// 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 }) +```