Context API Reference
Complete API reference for the Stack context system. The context provides access-controlled database operations with automatic security, hooks, and validation.
Overview
The context is the runtime interface for all database operations in Stack. It wraps your Prisma client with:
- Access control - Automatic enforcement of access rules
- Hooks execution - Data transformation and side effects
- Field validation - Automatic validation of field rules
- Type safety - Full TypeScript inference from Prisma types
- Silent failures - Returns
null/[]on access denial (prevents information leakage)
Core Function
getContext()
Creates an access-controlled context for database operations.
import { getContext } from '@opensaas/stack-core/context'
const context = await getContext(config, prisma, session, storage)
Type Signature:
function getContext<TConfig extends OpenSaasConfig, TPrisma extends PrismaClientLike>(
config: TConfig,
prisma: TPrisma,
session: Session,
storage?: StorageUtils,
_isSudo?: boolean,
): {
db: AccessControlledDB<TPrisma>
session: Session
prisma: TPrisma
storage: StorageUtils
serverAction: (props: ServerActionProps) => Promise<unknown>
sudo: () => Context
withSession: (session: Session | null) => Context
_isSudo: boolean
}
Parameters
config (required)
Your OpenSaaS configuration object.
Type: OpenSaasConfig
Example:
import config from './opensaas.config'
const context = await getContext(config, prisma, session)
prisma (required)
Your Prisma client instance. Pass as generic for type safety.
Type: TPrisma extends PrismaClientLike
Example:
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const context = await getContext(config, prisma, session)
session (required)
Current user session or null for anonymous access.
Type: Session | null
Session Type:
type Session = {
userId?: string
[key: string]: unknown
} | null
Example:
// With authentication
const session = { userId: 'user-123', role: 'admin' }
const context = await getContext(config, prisma, session)
// Anonymous (no authentication)
const context = await getContext(config, prisma, null)
storage (optional)
Storage utilities for file/image uploads.
Type: StorageUtils
Default: Throws error when storage operations are attempted
Example:
import { createStorageUtils } from '@opensaas/stack-storage'
const storage = createStorageUtils(config.storage)
const context = await getContext(config, prisma, session, storage)
_isSudo (optional, internal)
Internal flag for sudo mode. Do not set manually - use context.sudo() instead.
Type: boolean Default: false
Return Value
Returns a context object with the following properties:
db
Access-controlled database interface with full Prisma type inference.
Type: AccessControlledDB<TPrisma>
Available Operations:
findUnique(args)- Find single record by unique fieldfindMany(args)- Find multiple records with filteringcreate(args)- Create new recordupdate(args)- Update existing recorddelete(args)- Delete recordcount(args)- Count records
Example:
// Query posts (access control enforced)
const posts = await context.db.post.findMany({
where: { status: 'published' },
})
// Create post (access control + hooks)
const post = await context.db.post.create({
data: {
title: 'My Post',
content: 'Post content...',
},
})
session
Current user session (same as input parameter).
Type: Session | null
Example:
if (context.session?.userId) {
console.log('User is authenticated:', context.session.userId)
}
prisma
Raw Prisma client (bypasses access control - use with caution).
Type: TPrisma
Warning: Using context.prisma directly bypasses all access control. Only use when necessary and ensure proper authorization.
Example:
// Direct Prisma access (bypasses access control)
const count = await context.prisma.post.count()
storage
Storage utilities for file/image operations.
Type: StorageUtils
Methods:
uploadFile(provider, file, buffer, options)- Upload fileuploadImage(provider, file, buffer, options)- Upload image with transformationsdeleteFile(provider, filename)- Delete filedeleteImage(metadata)- Delete image and transformations
Example:
const metadata = await context.storage.uploadImage('avatars', file, buffer, {
transformations: { thumbnail: { width: 150, height: 150 } },
})
serverAction()
Generic server action handler for Next.js Server Actions.
Type: (props: ServerActionProps) => Promise<unknown>
Props:
type ServerActionProps =
| { listKey: string; action: 'create'; data: Record<string, unknown> }
| { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
| { listKey: string; action: 'delete'; id: string }
Example:
'use server'
async function handleAction(formData: FormData) {
const context = await getContext(config, prisma, session)
return await context.serverAction({
listKey: 'Post',
action: 'create',
data: {
title: formData.get('title'),
content: formData.get('content'),
},
})
}
sudo()
Creates a new context with access control bypassed.
Type: () => Context
Important: Sudo mode bypasses access control but still executes hooks and validation.
Example:
const adminContext = context.sudo()
// Can access all records regardless of access rules
const allPosts = await adminContext.db.post.findMany()
withSession()
Creates a new context carrying a different session, reusing this context's config and client (including a transaction client — a call inside context.transaction() stays in that transaction) and storage. Access control and hooks run normally against the new session.
Type: (session: Session | null) => Context
Important: This is not an authorization — it substitutes who hooks and access control see, it does not change what they decide. The derived context can do exactly what any context built with that session directly could do. It's orthogonal to sudo(): context.withSession(s).sudo() and context.sudo().withSession(s) are equivalent, since withSession() preserves the receiver's sudo state.
Example:
// An unattended job runner that is legitimately authorised but arrives
// without the session a list's validate hook expects to see.
const asOwner = context.withSession(job.ownerSession)
await asOwner.db.task.update({ where: { id: job.taskId }, data: { status: 'done' } })
// Drop to anonymous
const anonymous = context.withSession(null)
_isSudo
Flag indicating if context is in sudo mode.
Type: boolean
Example:
if (context._isSudo) {
console.log('Running in sudo mode - access control bypassed')
}
Generated Context Factory
The stack automatically generates a context factory at .opensaas/context.ts that simplifies context creation in your application.
Generated getContext()
import { getContext } from '@/.opensaas/context'
// Anonymous access
const context = await getContext()
// With session
const context = await getContext({ userId: 'user-123' })
Generated Implementation:
import { getContext as coreGetContext } from '@opensaas/stack-core/context'
import { PrismaClient } from '@prisma/client'
import config from '../opensaas.config'
// Singleton Prisma client
const prisma = globalThis.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalThis.prisma = prisma
export async function getContext(session: Session = null) {
return coreGetContext(config, prisma, session)
}
Database Operations
All database operations are access-controlled and execute hooks in the correct order.
A read with no include (and no fragment query) returns the row's own columns plus its virtual fields — never relations, matching Prisma's own semantics for the same call. Name a relation explicitly via include (or a fragment query, see Queries & Fragments) to fetch it. Foreign-key columns (e.g. authorId) are always returned, so a relation stays reachable by id without an include.
findUnique()
Find a single record by unique field (typically ID).
Signature:
db[listKey].findUnique(args: {
where: { id: string }
include?: Record<string, unknown>
}): Promise<Item | null>
Parameters:
where- Unique field filter (e.g.,{ id: '...' })include- Optional relationships to include
Returns: Record or null if not found or access denied
Access Control:
- Checks
operation.queryaccess - Applies field-level read access
- Returns
nullon access denial (silent failure)
Hooks Executed:
- Field-level
resolveOutput(transforms output values) - Field-level
afterOperation(side effects)
Example:
const post = await context.db.post.findUnique({
where: { id: 'post-123' },
})
if (!post) {
// Either doesn't exist OR user doesn't have access
return { error: 'Post not found' }
}
With Relationships:
const post = await context.db.post.findUnique({
where: { id: 'post-123' },
include: { author: true },
})
findMany()
Find multiple records with optional filtering, pagination, and relationships.
Signature:
db[listKey].findMany(args?: {
where?: Record<string, unknown>
take?: number
skip?: number
include?: Record<string, unknown>
}): Promise<Item[]>
Parameters:
where- Filter conditions (merged with access filters)take- Maximum number of records to returnskip- Number of records to skip (for pagination)include- Relationships to include
Returns: Array of records (empty array [] if none found or access denied)
Access Control:
- Checks
operation.queryaccess - Merges access filters with user's
whereclause - Applies field-level read access
- Returns
[]on access denial (silent failure)
Hooks Executed:
- Field-level
resolveOutputfor each record - Field-level
afterOperationfor each record
Example:
// All published posts
const posts = await context.db.post.findMany({
where: { status: 'published' },
})
// With pagination
const posts = await context.db.post.findMany({
where: { status: 'published' },
take: 10,
skip: 20,
})
// With relationships
const posts = await context.db.post.findMany({
where: { status: 'published' },
include: { author: true, comments: true },
})
create()
Create a new record with full validation, access control, and hooks.
Signature:
db[listKey].create(args: {
data: Record<string, unknown>
}): Promise<Item | null>
Parameters:
data- Field values for the new record
Returns: Created record or null if access denied
Access Control:
- Checks
operation.createaccess - Applies field-level create access
- Returns
nullon access denial (silent failure)
Hooks Executed (in order):
- List-level
resolveInput- Transform input data - Field-level
resolveInput- Transform field values (e.g., hash passwords) - List-level
validateInput- Custom validation - Field validation - Built-in rules (isRequired, length, min/max)
- Field-level create access - Filter writable fields
- Field-level
beforeOperation- Side effects before write - List-level
beforeOperation- Side effects before write - Database create operation
- List-level
afterOperation- Side effects after write - Field-level
afterOperation- Side effects after write - Field-level read access - Filter readable fields
- Field-level
resolveOutput- Transform output values
Example:
const post = await context.db.post.create({
data: {
title: 'My First Post',
content: 'This is the content...',
status: 'draft',
},
})
if (!post) {
return { error: 'Access denied' }
}
With Validation Errors:
try {
const post = await context.db.post.create({
data: { title: '' }, // Empty title (required field)
})
} catch (error) {
if (error instanceof ValidationError) {
console.log(error.errors) // [{ field: 'title', message: 'Title is required' }]
}
}
update()
Update an existing record with full validation, access control, and hooks.
Signature:
db[listKey].update(args: {
where: { id: string }
data: Record<string, unknown>
}): Promise<Item | null>
Parameters:
where- Unique field to identify recorddata- Fields to update (partial update)
Returns: Updated record or null if not found or access denied
Access Control:
- Fetches existing record first
- Checks
operation.updateaccess (with access to existing item) - Applies field-level update access
- Returns
nullon access denial (silent failure)
Hooks Executed (in order):
- List-level
resolveInput- Transform input data - Field-level
resolveInput- Transform field values - List-level
validateInput- Custom validation - Field validation - Built-in rules
- Field-level update access - Filter writable fields
- Field-level
beforeOperation- Side effects before write - List-level
beforeOperation- Side effects before write - Database update operation
- List-level
afterOperation- Side effects after write - Field-level
afterOperation- Side effects after write - Field-level read access - Filter readable fields
- Field-level
resolveOutput- Transform output values
Example:
const post = await context.db.post.update({
where: { id: 'post-123' },
data: {
status: 'published',
publishedAt: new Date(),
},
})
if (!post) {
// Either doesn't exist OR user doesn't have access
return { error: 'Access denied or not found' }
}
Partial Updates:
// Only update title (other fields unchanged)
const post = await context.db.post.update({
where: { id: 'post-123' },
data: { title: 'Updated Title' },
})
delete()
Delete an existing record with access control and hooks.
Signature:
db[listKey].delete(args: {
where: { id: string }
}): Promise<Item | null>
Parameters:
where- Unique field to identify record
Returns: Deleted record or null if not found or access denied
Access Control:
- Fetches existing record first
- Checks
operation.deleteaccess (with access to existing item) - Returns
nullon access denial (silent failure)
Hooks Executed (in order):
- Field-level
beforeOperation- Side effects before delete - List-level
beforeOperation- Side effects before delete - Database delete operation
- List-level
afterOperation- Side effects after delete - Field-level
afterOperation- Side effects after delete (e.g., cleanup files)
Example:
const post = await context.db.post.delete({
where: { id: 'post-123' },
})
if (!post) {
return { error: 'Access denied or not found' }
}
Use Case - Cleanup:
// Field hook automatically cleans up files
thumbnail: text({
hooks: {
afterOperation: async ({ operation, value }) => {
if (operation === 'delete' && value) {
await deleteFromStorage(value)
}
},
},
})
count()
Count records with optional filtering and access control.
Signature:
db[listKey].count(args?: {
where?: Record<string, unknown>
}): Promise<number>
Parameters:
where- Optional filter conditions (merged with access filters)
Returns: Number of matching records (returns 0 if access denied)
Access Control:
- Checks
operation.queryaccess - Merges access filters with user's
whereclause - Returns
0on access denial (silent failure)
Example:
// Count all published posts
const count = await context.db.post.count({
where: { status: 'published' },
})
// Count all posts (respects access control)
const totalCount = await context.db.post.count()
Sudo Mode
Sudo mode creates a context that bypasses access control while still executing hooks and validation.
When to Use Sudo Mode
- Admin operations - System-level operations that need unrestricted access
- Background jobs - Scheduled tasks that process data regardless of user permissions
- Migrations - Data migrations that need to access all records
- Internal operations - Server-side operations that shouldn't be restricted by user permissions
Creating Sudo Context
const adminContext = context.sudo()
What Sudo Mode Does
Bypasses:
- Operation-level access control (query, create, update, delete)
- Field-level access control (read, create, update)
Still Executes:
- All hooks (resolveInput, validateInput, beforeOperation, afterOperation)
- Field validation (isRequired, length, min, max)
- Field transformations (password hashing, etc.)
Example Usage
// Regular context - restricted by access control
const userPosts = await context.db.post.findMany()
// Returns only posts the user can access
// Sudo context - unrestricted access
const sudoContext = context.sudo()
const allPosts = await sudoContext.db.post.findMany()
// Returns ALL posts regardless of access rules
// Still validates and executes hooks
const post = await sudoContext.db.post.create({
data: {
title: '', // ValidationError - still validates
password: 'plain', // Still hashes password
},
})
Security Warning
⚠️ Important: Sudo mode should only be used in trusted server-side code. Never expose sudo operations to client-facing APIs without proper authorization checks.
// ❌ BAD - Never do this
export async function deleteAnyPost(id: string) {
const context = await getContext()
return await context.sudo().db.post.delete({ where: { id } })
}
// ✅ GOOD - Check permissions first
export async function deletePostAsAdmin(id: string) {
const context = await getContext()
if (context.session?.role !== 'admin') {
throw new Error('Admin access required')
}
// Safe to use sudo after verifying admin role
return await context.sudo().db.post.delete({ where: { id } })
}
Silent Failures
Stack uses silent failures to prevent information leakage about the existence of records.
Why Silent Failures?
When access is denied, returning explicit errors can reveal:
- Whether a record exists
- What fields it has
- Information about the data structure
Silent failures prevent this by returning the same result whether:
- Record doesn't exist
- User doesn't have access
- Access rule filtered out the record
Behavior by Operation
| Operation | Access Denied Returns |
|---|---|
findUnique() | null |
findMany() | [] (empty array) |
create() | null |
update() | null |
delete() | null |
count() | 0 |
Handling Silent Failures
Always check for null or empty results:
const post = await context.db.post.update({
where: { id },
data: { title: 'New Title' },
})
if (!post) {
// Could be: doesn't exist, access denied, or filtered by access rule
return { error: 'Unable to update post' }
}
When to Use Explicit Errors
If you need to distinguish between "not found" and "access denied", use sudo mode to check existence:
const post = await context.db.post.findUnique({ where: { id } })
if (!post) {
// Check if it exists at all
const exists = await context.sudo().db.post.findUnique({ where: { id } })
if (!exists) {
return { error: 'Post not found' }
} else {
return { error: 'Access denied' }
}
}
Type Safety
The context provides full TypeScript type inference from your Prisma schema.
Inferred Types
// Type: Post | null
const post = await context.db.post.findUnique({
where: { id: 'post-123' },
})
// Type: Post[]
const posts = await context.db.post.findMany()
// TypeScript knows available fields
if (post) {
console.log(post.title) // ✅ Type: string
console.log(post.invalidField) // ❌ TypeScript error
}
Generic Context
Pass Prisma client as generic for full type safety:
import { PrismaClient } from '@prisma/client'
import { getContext } from '@opensaas/stack-core/context'
const prisma = new PrismaClient()
// Full type inference for all operations
const context = getContext<typeof config, typeof prisma>(config, prisma, session)
Best Practices
1. Always Use Context (Not Raw Prisma)
// ✅ Good: Uses context (access control enforced)
const posts = await context.db.post.findMany()
// ❌ Bad: Bypasses access control
const posts = await context.prisma.post.findMany()
2. Check for Null/Empty Results
// ✅ Good: Handles silent failures
const post = await context.db.post.update({ where: { id }, data })
if (!post) {
return { error: 'Unable to update post' }
}
// ❌ Bad: Assumes success
const post = await context.db.post.update({ where: { id }, data })
console.log(post.title) // Potential runtime error if null
3. Use Sudo Mode Sparingly
// ✅ Good: Sudo only when necessary
async function adminCleanup() {
if (session?.role !== 'admin') {
throw new Error('Admin only')
}
const context = await getContext()
return await context.sudo().db.post.deleteMany()
}
// ❌ Bad: Unnecessary sudo usage
async function getUserPosts(userId: string) {
const context = await getContext()
return await context.sudo().db.post.findMany() // Should use regular context
}
4. Validate Input Before Operations
// ✅ Good: Validate input
async function createPost(data: unknown) {
const validated = postSchema.parse(data)
return await context.db.post.create({ data: validated })
}
// ❌ Bad: No validation
async function createPost(data: any) {
return await context.db.post.create({ data })
}
5. Use Generated Context Factory
// ✅ Good: Use generated factory
import { getContext } from '@/.opensaas/context'
const context = await getContext(session)
// ❌ Bad: Manually create context each time
import { getContext as coreGetContext } from '@opensaas/stack-core/context'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const context = coreGetContext(config, prisma, session)
Error Handling
Validation Errors
import { ValidationError } from '@opensaas/stack-core'
try {
const post = await context.db.post.create({
data: { title: '' },
})
} catch (error) {
if (error instanceof ValidationError) {
console.log('Validation failed:', error.errors)
// [{ field: 'title', message: 'Title is required' }]
console.log('Field errors:', error.fieldErrors)
// { title: ['Title is required'] }
}
}
Access Denial (Silent)
const post = await context.db.post.update({
where: { id },
data: { title: 'New Title' },
})
if (!post) {
// Silent failure - could be access denied or not found
return { error: 'Unable to update post' }
}
Database Errors
try {
const post = await context.db.post.create({
data: {/* ... */},
})
} catch (error) {
// Prisma errors (unique constraint, foreign key, etc.)
console.error('Database error:', error)
}
Next Steps
- Config API - Configuration options
- Field Types API - Field configuration
- Access Control - Security patterns
- Hooks - Data transformation
- Generators - Code generation