NestJS library for declarative Zero synced query handlers with decorator-based configuration
npm install @cbnsndwch/nest-zero-synced-queriesA lightweight NestJS library for defining Zero synced query handlers using decorators. Works seamlessly with regular NestJS controllers and providers, leveraging your existing authentication and guard infrastructure.
- Decorator-based: Use @SyncedQuery to define query handlers
- Automatic discovery: Handlers are automatically discovered in controllers and providers at startup
- Framework-agnostic auth: Use your own NestJS guards, interceptors, and parameter decorators
- Controller-friendly: Works on regular @Controller classes alongside @Get, @Post, etc.
- Parameter mapping: Use @QueryArg() decorator to explicitly map query arguments
- Type-safe: Full TypeScript support with Zod schema validation
``bash`
pnpm add @cbnsndwch/nest-zero-synced-queries
This library is part of the zero-sources monorepo, which provides utilities and integrations for @rocicorp/zero.
- @cbnsndwch/zero-contracts - Shared contracts, types, and utilities for Zero applications
- @cbnsndwch/zero-source-mongodb - MongoDB change source implementation for Zero
- @cbnsndwch/zero-nest-mongoose - NestJS/Mongoose integration with Zero schema generation
- @cbnsndwch/zero-watermark-zqlite - SQLite-based watermark storage for Zero
- @cbnsndwch/zero-watermark-nats-kv - NATS KV-based watermark storage for Zero
- ZRocket - Full-featured chat application demonstrating Zero + NestJS patterns
- MongoDB Source Server - Standalone MongoDB change source server
Get up and running in 3 simple steps:
Import and configure SyncedQueriesModule in your app module:
`typescript
import { Module } from '@nestjs/common';
import { SyncedQueriesModule } from '@cbnsndwch/nest-zero-synced-queries';
@Module({
imports: [
SyncedQueriesModule.forRoot({
path: 'api/zero/get-queries' // Optional: defaults to 'zero/get-queries'
}),
// Your feature modules...
]
})
export class AppModule {}
`
Create your Zero schema with query builder:
`typescript
// schema.ts
import { createSchema } from '@rocicorp/zero';
export const schema = createSchema({
tables: {
todo: {
columns: {
id: 'string',
title: 'string',
completed: 'boolean',
userId: 'string',
createdAt: 'number'
},
primaryKey: 'id'
}
}
});
export const builder = schema.builder;
`
Decorate your methods with @SyncedQuery:
`typescript
import { Controller, UseGuards } from '@nestjs/common';
import { SyncedQuery, QueryArg } from '@cbnsndwch/nest-zero-synced-queries';
import { AST } from '@rocicorp/zero';
import { z } from 'zod';
import { builder } from './schema.js';
import { JwtAuthGuard } from './auth/jwt-auth.guard.js';
import { CurrentUser } from './auth/current-user.decorator.js';
@Controller('todos')
@UseGuards(JwtAuthGuard)
export class TodosController {
/**
* Get all todos for the current user
*/
@SyncedQuery('myTodos', z.tuple([]))
async myTodos(@CurrentUser() user: { id: string }): Promise
return builder.todo
.where('userId', '=', user.id)
.orderBy('createdAt', 'desc').ast;
}
/**
* Get a specific todo by ID (with permission check)
*/
@SyncedQuery('todoById', z.tuple([z.string()]))
async todoById(
@CurrentUser() user: { id: string },
@QueryArg(0) todoId: string
): Promise
return builder.todo
.where('id', '=', todoId)
.where('userId', '=', user.id).ast;
}
}
`
That's it! The library automatically discovers your queries and exposes them via the configured HTTP endpoint.
Use your existing NestJS guards - the library passes the full HTTP request through:
`typescript`
@Controller('api')
@UseGuards(JwtAuthGuard, RolesGuard) // Your guards work as normal
export class ApiController {
@SyncedQuery('data', z.tuple([]))
async getData(@CurrentUser() user: User) {
return builder.data.where('userId', '=', user.id).ast;
}
}
Use @QueryArg(index) to access query parameters:
`typescript`
@SyncedQuery('postsByCategory', z.tuple([z.string(), z.number().optional()]))
async postsByCategory(
@QueryArg(0) category: string,
@QueryArg(1) limit = 10
): Promise
return builder.posts
.where('category', '=', category)
.limit(limit).ast;
}
Implement authorization by filtering results:
`typescript`
@SyncedQuery('sensitiveData', z.tuple([z.string()]))
async sensitiveData(
@CurrentUser() user: User,
@QueryArg(0) resourceId: string
): Promise
// Check permission
const hasAccess = await this.checkPermission(user.id, resourceId);
if (!hasAccess) {
// Return query that matches nothing
return builder.data.where('id', '=', '__NEVER_MATCHES__').ast;
}
return builder.data.where('id', '=', resourceId).ast;
}
Combine REST endpoints and synced queries in the same controller:
`typescript
@Controller('posts')
@UseGuards(JwtAuthGuard)
export class PostsController {
// REST endpoint for writes
@Post()
async createPost(@Body() dto: CreatePostDto) {
return this.postsService.create(dto);
}
// Synced query for reads
@SyncedQuery('allPosts', z.tuple([]))
async allPosts(): Promise
return builder.posts.orderBy('createdAt', 'desc').ast;
}
}
No parameters needed - just use the authenticated user:
`typescript
@Controller('api')
@UseGuards(JwtAuthGuard)
export class ApiController {
@SyncedQuery('myProfile', z.tuple([]))
async myProfile(@CurrentUser() user: User): Promise
return builder.users.where('id', '=', user.id).ast;
}
@SyncedQuery('publicPosts', z.tuple([]))
async publicPosts(): Promise
return builder.posts
.where('isPublic', '=', true)
.orderBy('createdAt', 'desc').ast;
}
}
`
`typescript
@Controller('posts')
@UseGuards(JwtAuthGuard)
export class PostsController {
@SyncedQuery('postById', z.tuple([z.string()]))
async postById(@QueryArg(0) postId: string): Promise
return builder.posts.where('id', '=', postId).ast;
}
@SyncedQuery('postsByUser', z.tuple([z.string()]))
async postsByUser(@QueryArg(0) userId: string): Promise
return builder.posts
.where('userId', '=', userId)
.where('isPublic', '=', true)
.orderBy('createdAt', 'desc').ast;
}
}
`
`typescript%${searchTerm}%
@SyncedQuery('searchPosts', z.tuple([z.string(), z.number().optional()]))
async searchPosts(
@QueryArg(0) searchTerm: string,
@QueryArg(1) limit = 20 // Default value for optional parameter
): Promise
return builder.posts
.where('title', 'LIKE', )`
.limit(limit).ast;
}
Use constructor injection as normal:
`typescript
@Controller('posts')
@UseGuards(JwtAuthGuard)
export class PostsController {
constructor(
private readonly permissionsService: PermissionsService
) {}
@SyncedQuery('protectedPost', z.tuple([z.string()]))
async protectedPost(
@CurrentUser() user: User,
@QueryArg(0) postId: string
): Promise
const canAccess = await this.permissionsService.canAccessPost(
user.id,
postId
);
if (!canAccess) {
return builder.posts.where('id', '=', '__NEVER_MATCHES__').ast;
}
return builder.posts.where('id', '=', postId).ast;
}
}
`
Include related data using Zero's query builder:
`typescript`
@SyncedQuery('postWithComments', z.tuple([z.string()]))
async postWithComments(@QueryArg(0) postId: string): Promise
return builder.posts
.where('id', '=', postId)
.related('comments', q =>
q.orderBy('createdAt', 'desc').limit(50)
)
.related('author').ast;
}
Want to see how this library is used in a production application? Check out ZRocket, a chat application in this monorepo that demonstrates:
- Mixed operations: REST endpoints for writes (send messages, create rooms) + synced queries for reads
- Permission filtering: Room access checks, membership validation, public vs. private content
- Complex queries: Search across accessible rooms, filter by room type, paginate results
- Service integration: Using RoomAccessService for authorization in queriesMessagesController
- Multiple controllers: and RoomsController with different query patterns
Key files to explore:
- apps/zrocket/src/features/chat/controllers/messages.controller.ts - Message queries with permission checks
- apps/zrocket/src/features/chat/controllers/rooms.controller.ts - Room queries for chats, groups, and channels
- apps/zrocket/src/features/index.ts - Module configuration
1. Co-locate operations: Put REST endpoints and synced queries in the same controller
2. Use existing guards: Leverage your authentication infrastructure with @UseGuards()Promise
3. Filter, don't throw: Return queries that match nothing instead of throwing errors for unauthorized access
4. Inject services: Use constructor injection to access business logic in queries
5. Return AST: Always return from query handlers (use .ast property)
6. Type everything: Strongly type your user objects, query parameters, and return types
7. Document queries: Add JSDoc comments describing what the query does and its parameters
- @SyncedQuery(name, schema)
- name: Unique query identifier stringschema
- : Zod schema for argument validation (e.g., z.tuple([z.string()]))Promise
- Use on controller or provider methods
- Method must return
- @QueryArg(index)
- index: Zero-based argument index@CurrentUser()
- Injects the argument at that position from the query
- Use alongside your own decorators (, etc.)
- SyncedQueriesModule.forRoot(options)
- options.path: HTTP endpoint path (default: 'zero/get-queries')DynamicModule
- Returns a for import
- SyncedQueryRegistry - Query handler registry (auto-injected)
- getHandler(name) - Get handler by namegetQueryNames()
- - List all query nameshasQuery(name)
- - Check if query existsgetHandlerCount()
- - Total handler count
- SyncedQueryTransformService - Query execution service (auto-injected)
- Used internally by the controller
- Handles query execution and AST conversion
- Ensure your controller/provider is registered in a module
- Check that SyncedQueriesModule.forRoot() is imported
- Verify the query name matches exactly
- Ensure your guard is applied: @UseGuards(YourAuthGuard)request.user
- Check that is populated by your auth strategy
- Guards receive the full HTTP request object
- Install peer dependencies: @nestjs/common, @nestjs/core, reflect-metadata, rxjs, zodemitDecoratorMetadata
- Ensure is enabled in tsconfig.json`
Found a bug or have a feature request? Please open an issue on GitHub.
Contributions are welcome! See CONTRIBUTING.md for guidelines.
MIT • Part of the zero-sources monorepo