From 371635d6e25b4c3578b03ec490456e4c5877bad2 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 30 Nov 2022 21:05:16 +0800 Subject: [PATCH 1/4] feat: add server CRUD api for SSR --- package.json | 2 +- packages/runtime/package.json | 2 +- packages/runtime/src/client.ts | 3 - packages/runtime/src/handler/data/crud.ts | 455 ++++++++++++++++++ packages/runtime/src/handler/data/handler.ts | 385 +-------------- packages/runtime/src/types.ts | 9 + packages/schema/package.json | 6 +- .../schema/src/generator/service/index.ts | 44 +- samples/todo/components/Spaces.tsx | 11 +- samples/todo/package-lock.json | 52 +- samples/todo/package.json | 6 +- samples/todo/pages/index.tsx | 29 +- tests/integration/tests/utils.ts | 2 +- 13 files changed, 591 insertions(+), 415 deletions(-) delete mode 100644 packages/runtime/src/client.ts create mode 100644 packages/runtime/src/handler/data/crud.ts diff --git a/package.json b/package.json index a86a32fd4..42094d9f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "0.3.19", + "version": "0.3.20", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b492230d0..7274c68d4 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "0.3.19", + "version": "0.3.20", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/client.ts b/packages/runtime/src/client.ts deleted file mode 100644 index a27d2b4dc..000000000 --- a/packages/runtime/src/client.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { ServerErrorCode, RequestOptions } from './types'; -export * as request from './request'; -export * from './validation'; diff --git a/packages/runtime/src/handler/data/crud.ts b/packages/runtime/src/handler/data/crud.ts new file mode 100644 index 000000000..872577a97 --- /dev/null +++ b/packages/runtime/src/handler/data/crud.ts @@ -0,0 +1,455 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import cuid from 'cuid'; +import { TRANSACTION_FIELD_NAME } from '../../constants'; +import { + DbClientContract, + DbOperations, + getServerErrorMessage, + QueryContext, + ServerErrorCode, + Service, +} from '../../types'; +import { ValidationError } from '../../validation'; +import { RequestHandlerError } from '../types'; +import { + and, + checkPolicyForIds, + injectTransactionId, + preprocessWritePayload, + preUpdateCheck, + queryIds, + readWithCheck, +} from './policy-utils'; + +const PRISMA_ERROR_MAPPING: Record = { + P2002: ServerErrorCode.UNIQUE_CONSTRAINT_VIOLATION, + P2003: ServerErrorCode.REFERENCE_CONSTRAINT_VIOLATION, + P2025: ServerErrorCode.REFERENCE_CONSTRAINT_VIOLATION, +}; + +/** + * Request handler for /data endpoint which processes data CRUD requests. + */ +export class CRUD { + constructor(private readonly service: Service) {} + + private get db() { + return this.service.db as DbClientContract; + } + + async get( + model: string, + id: string, + args: any, + context: QueryContext + ): Promise { + args = args ?? {}; + args.where = and(args.where, { id }); + + let entities: unknown[]; + try { + entities = await readWithCheck( + model, + args, + this.service, + context, + this.db + ); + } catch (err) { + throw this.processError(err, 'get', model); + } + + if (entities.length === 0) { + throw new RequestHandlerError(ServerErrorCode.ENTITY_NOT_FOUND); + } + return entities[0]; + } + + async find( + model: string, + args: any, + context: QueryContext + ): Promise { + try { + return await readWithCheck( + model, + args ?? {}, + this.service, + context, + this.db + ); + } catch (err) { + throw this.processError(err, 'find', model); + } + } + + async create( + model: string, + args: any, + context: QueryContext + ): Promise { + if (!args) { + throw new RequestHandlerError( + ServerErrorCode.INVALID_REQUEST_PARAMS, + 'body is required' + ); + } + if (!args.data) { + throw new RequestHandlerError( + ServerErrorCode.INVALID_REQUEST_PARAMS, + 'data field is required' + ); + } + + let createResult: { id: string }; + + try { + await this.service.validateModelPayload(model, 'create', args.data); + + // preprocess payload to modify fields as required by attribute like @password + await preprocessWritePayload(model, args, this.service); + + const transactionId = cuid(); + + // start an interactive transaction + createResult = await this.db.$transaction( + async (tx: Record) => { + // inject transaction id into update/create payload (direct and nested) + const { createdModels } = await injectTransactionId( + model, + args, + 'create', + transactionId, + this.service + ); + + // conduct the create + this.service.verbose( + `Conducting create: ${model}:\n${JSON.stringify(args)}` + ); + const createResult = (await tx[model].create(args)) as { + id: string; + }; + + // verify that the created entity pass policy check + await checkPolicyForIds( + model, + [createResult.id], + 'create', + this.service, + context, + tx + ); + + // verify that nested creates pass policy check + await Promise.all( + createdModels.map(async (model) => { + const createdIds = await queryIds(model, tx, { + [TRANSACTION_FIELD_NAME]: `${transactionId}:create`, + }); + this.service.verbose( + `Validating nestedly created entities: ${model}#[${createdIds.join( + ', ' + )}]` + ); + await checkPolicyForIds( + model, + createdIds, + 'create', + this.service, + context, + tx + ); + }) + ); + + return createResult; + } + ); + } catch (err) { + throw this.processError(err, 'create', model); + } + + // verify that return data requested by query args pass policy check + const readArgs = { ...args, where: { id: createResult.id } }; + delete readArgs.data; + + try { + const result = await readWithCheck( + model, + readArgs, + this.service, + context, + this.db + ); + if (result.length === 0) { + throw new RequestHandlerError( + ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED + ); + } + return result[0]; + } catch (err) { + if ( + err instanceof RequestHandlerError && + err.code === ServerErrorCode.DENIED_BY_POLICY + ) { + throw new RequestHandlerError( + ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED + ); + } else { + throw err; + } + } + } + + async update( + model: string, + id: string, + args: any, + context: QueryContext + ): Promise { + if (!args) { + throw new RequestHandlerError( + ServerErrorCode.INVALID_REQUEST_PARAMS, + 'body is required' + ); + } + + try { + await this.service.validateModelPayload(model, 'update', args.data); + + // preprocess payload to modify fields as required by attribute like @password + await preprocessWritePayload(model, args, this.service); + + args.where = { ...args.where, id }; + + const transactionId = cuid(); + + await this.db.$transaction( + async (tx: Record) => { + // make sure the entity (including ones involved in nested write) pass policy check + await preUpdateCheck( + model, + id, + args, + this.service, + context, + tx + ); + + // inject transaction id into update/create payload (direct and nested) + const { createdModels } = await injectTransactionId( + model, + args, + 'update', + transactionId, + this.service + ); + + // conduct the update + this.service.verbose( + `Conducting update: ${model}:\n${JSON.stringify(args)}` + ); + await tx[model].update(args); + + // verify that nested creates pass policy check + await Promise.all( + createdModels.map(async (model) => { + const createdIds = await queryIds(model, tx, { + [TRANSACTION_FIELD_NAME]: `${transactionId}:create`, + }); + this.service.verbose( + `Validating nestedly created entities: ${model}#[${createdIds.join( + ', ' + )}]` + ); + await checkPolicyForIds( + model, + createdIds, + 'create', + this.service, + context, + tx + ); + }) + ); + } + ); + } catch (err) { + throw this.processError(err, 'update', model); + } + + // verify that return data requested by query args pass policy check + const readArgs = { ...args }; + delete readArgs.data; + + try { + const result = await readWithCheck( + model, + readArgs, + this.service, + context, + this.db + ); + if (result.length === 0) { + throw new RequestHandlerError( + ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED + ); + } + return result[0]; + } catch (err) { + if ( + err instanceof RequestHandlerError && + err.code === ServerErrorCode.DENIED_BY_POLICY + ) { + throw new RequestHandlerError( + ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED + ); + } else { + throw err; + } + } + } + + async del( + model: string, + id: string, + args: any, + context: QueryContext + ): Promise { + let result: unknown; + + try { + // ensures the item under deletion passes policy check + await checkPolicyForIds( + model, + [id], + 'delete', + this.service, + context, + this.db + ); + + args = args ?? {}; + args.where = { ...args.where, id }; + + result = await this.db.$transaction( + async (tx: Record) => { + // first fetch the data that needs to be returned after deletion + let readResult: any; + try { + const items = await readWithCheck( + model, + args, + this.service, + context, + tx + ); + readResult = items[0]; + } catch (err) { + if ( + err instanceof RequestHandlerError && + err.code === ServerErrorCode.DENIED_BY_POLICY + ) { + // can't read back, just return undefined, outer logic handles it + } else { + throw err; + } + } + + // conduct the deletion + this.service.verbose( + `Conducting delete ${model}:\n${JSON.stringify(args)}` + ); + await tx[model].delete(args); + + return readResult; + } + ); + } catch (err) { + throw this.processError(err, 'del', model); + } + + if (result) { + return result; + } else { + throw new RequestHandlerError( + ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED + ); + } + } + + private isPrismaClientKnownRequestError( + err: any + ): err is { code: string; message: string } { + return ( + err.__proto__.constructor.name === 'PrismaClientKnownRequestError' + ); + } + + private isPrismaClientValidationError( + err: any + ): err is { message: string } { + return err.__proto__.constructor.name === 'PrismaClientValidationError'; + } + + private processError( + err: unknown, + operation: 'get' | 'find' | 'create' | 'update' | 'del', + model: string + ) { + if (err instanceof RequestHandlerError) { + return err; + } + + if (this.isPrismaClientKnownRequestError(err)) { + this.service.warn( + `Prisma request error: ${operation} ${model}: ${err}` + ); + + // errors thrown by Prisma, try mapping to a known error + if (PRISMA_ERROR_MAPPING[err.code]) { + return new RequestHandlerError( + PRISMA_ERROR_MAPPING[err.code], + getServerErrorMessage(PRISMA_ERROR_MAPPING[err.code]) + ); + } else { + return new RequestHandlerError( + ServerErrorCode.UNKNOWN, + 'an unhandled Prisma error occurred: ' + err.code + ); + } + } else if (this.isPrismaClientValidationError(err)) { + this.service.warn( + `Prisma validation error: ${operation} ${model}: ${err}` + ); + + // prisma validation error + return new RequestHandlerError( + ServerErrorCode.INVALID_REQUEST_PARAMS, + getServerErrorMessage(ServerErrorCode.INVALID_REQUEST_PARAMS) + ); + } else if (err instanceof ValidationError) { + this.service.warn( + `Field constraint validation error: ${operation} ${model}: ${err.message}` + ); + + return new RequestHandlerError( + ServerErrorCode.INVALID_REQUEST_PARAMS, + err.message + ); + } else { + // generic errors + this.service.error( + `An unknown error occurred: ${JSON.stringify(err)}` + ); + if (err instanceof Error && err.stack) { + this.service.error(err.stack); + } + return new RequestHandlerError( + ServerErrorCode.UNKNOWN, + getServerErrorMessage(ServerErrorCode.UNKNOWN) + ); + } + } +} diff --git a/packages/runtime/src/handler/data/handler.ts b/packages/runtime/src/handler/data/handler.ts index fcf42dd03..8d6745177 100644 --- a/packages/runtime/src/handler/data/handler.ts +++ b/packages/runtime/src/handler/data/handler.ts @@ -1,33 +1,13 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import cuid from 'cuid'; import { NextApiRequest, NextApiResponse } from 'next'; -import { TRANSACTION_FIELD_NAME } from '../../constants'; import { RequestHandlerOptions } from '../../request-handler'; import { DbClientContract, - DbOperations, - getServerErrorMessage, QueryContext, ServerErrorCode, Service, } from '../../types'; -import { ValidationError } from '../../validation'; import { RequestHandler, RequestHandlerError } from '../types'; -import { - and, - checkPolicyForIds, - injectTransactionId, - preprocessWritePayload, - preUpdateCheck, - queryIds, - readWithCheck, -} from './policy-utils'; - -const PRISMA_ERROR_MAPPING: Record = { - P2002: ServerErrorCode.UNIQUE_CONSTRAINT_VIOLATION, - P2003: ServerErrorCode.REFERENCE_CONSTRAINT_VIOLATION, - P2025: ServerErrorCode.REFERENCE_CONSTRAINT_VIOLATION, -}; +import { CRUD } from './crud'; /** * Request handler for /data endpoint which processes data CRUD requests. @@ -35,10 +15,14 @@ const PRISMA_ERROR_MAPPING: Record = { export default class DataHandler implements RequestHandler { + private readonly crud: CRUD; + constructor( private readonly service: Service, private readonly options: RequestHandlerOptions - ) {} + ) { + this.crud = new CRUD(service); + } async handle( req: NextApiRequest, @@ -99,59 +83,19 @@ export default class DataHandler }); break; + case ServerErrorCode.UNKNOWN: + res.status(500).send({ + code: err.code, + message: err.message, + }); + break; + default: res.status(400).send({ code: err.code, message: err.message, }); } - } else if (this.isPrismaClientKnownRequestError(err)) { - this.service.warn(`${method} ${model}: ${err}`); - - // errors thrown by Prisma, try mapping to a known error - if (PRISMA_ERROR_MAPPING[err.code]) { - res.status(400).send({ - code: PRISMA_ERROR_MAPPING[err.code], - message: getServerErrorMessage( - PRISMA_ERROR_MAPPING[err.code] - ), - }); - } else { - res.status(400).send({ - code: 'PRISMA:' + err.code, - message: 'an unhandled Prisma error occurred', - }); - } - } else if (this.isPrismaClientValidationError(err)) { - this.service.warn(`${method} ${model}: ${err}`); - - // prisma validation error - res.status(400).send({ - code: ServerErrorCode.INVALID_REQUEST_PARAMS, - message: getServerErrorMessage( - ServerErrorCode.INVALID_REQUEST_PARAMS - ), - }); - } else if (err instanceof ValidationError) { - this.service.warn( - `Field constraint validation error for model "${model}": ${err.message}` - ); - res.status(400).send({ - code: ServerErrorCode.INVALID_REQUEST_PARAMS, - message: err.message, - }); - } else { - // generic errors - this.service.error( - `An unknown error occurred: ${JSON.stringify(err)}` - ); - if (err instanceof Error && err.stack) { - this.service.error(err.stack); - } - res.status(500).send({ - code: ServerErrorCode.UNKNOWN, - message: getServerErrorMessage(ServerErrorCode.UNKNOWN), - }); } } } @@ -168,29 +112,11 @@ export default class DataHandler if (id) { // GET /:id, make sure "id" is injected - args.where = and(args.where, { id }); - - const result = await readWithCheck( - model, - args, - this.service, - context, - this.service.db - ); - - if (result.length === 0) { - throw new RequestHandlerError(ServerErrorCode.ENTITY_NOT_FOUND); - } - res.status(200).send(result[0]); + const result = await this.crud.get(model, id, args, context); + res.status(200).send(result); } else { // GET /, get list - const result = await readWithCheck( - model, - args, - this.service, - context, - this.service.db - ); + const result = await this.crud.find(model, args, context); res.status(200).send(result); } } @@ -201,114 +127,8 @@ export default class DataHandler model: string, context: QueryContext ) { - // validate args - const args = req.body; - if (!args) { - throw new RequestHandlerError( - ServerErrorCode.INVALID_REQUEST_PARAMS, - 'body is required' - ); - } - if (!args.data) { - throw new RequestHandlerError( - ServerErrorCode.INVALID_REQUEST_PARAMS, - 'data field is required' - ); - } - - await this.service.validateModelPayload(model, 'create', args.data); - - // preprocess payload to modify fields as required by attribute like @password - await preprocessWritePayload(model, args, this.service); - - const transactionId = cuid(); - - // start an interactive transaction - const r = await this.service.db.$transaction( - async (tx: Record) => { - // inject transaction id into update/create payload (direct and nested) - const { createdModels } = await injectTransactionId( - model, - args, - 'create', - transactionId, - this.service - ); - - // conduct the create - this.service.verbose( - `Conducting create: ${model}:\n${JSON.stringify(args)}` - ); - const createResult = (await tx[model].create(args)) as { - id: string; - }; - - // verify that the created entity pass policy check - await checkPolicyForIds( - model, - [createResult.id], - 'create', - this.service, - context, - tx - ); - - // verify that nested creates pass policy check - await Promise.all( - createdModels.map(async (model) => { - const createdIds = await queryIds(model, tx, { - [TRANSACTION_FIELD_NAME]: `${transactionId}:create`, - }); - this.service.verbose( - `Validating nestedly created entities: ${model}#[${createdIds.join( - ', ' - )}]` - ); - await checkPolicyForIds( - model, - createdIds, - 'create', - this.service, - context, - tx - ); - }) - ); - - return createResult; - } - ); - - // verify that return data requested by query args pass policy check - const readArgs = { ...args, where: { id: r.id } }; - delete readArgs.data; - - try { - const result = await readWithCheck( - model, - readArgs, - this.service, - context, - this.service.db - ); - if (result.length === 0) { - throw new RequestHandlerError( - ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED - ); - } - res.status(201).send(result[0]); - } catch (err) { - if ( - err instanceof RequestHandlerError && - err.code === ServerErrorCode.DENIED_BY_POLICY - ) { - throw new RequestHandlerError( - ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED - ); - } else { - throw err; - } - } + const result = await this.crud.create(model, req.body, context); + res.status(201).send(result); } private async put( @@ -325,104 +145,8 @@ export default class DataHandler ); } - const args = req.body; - if (!args) { - throw new RequestHandlerError( - ServerErrorCode.INVALID_REQUEST_PARAMS, - 'body is required' - ); - } - - await this.service.validateModelPayload(model, 'update', args.data); - - // preprocess payload to modify fields as required by attribute like @password - await preprocessWritePayload(model, args, this.service); - - args.where = { ...args.where, id }; - - const transactionId = cuid(); - - await this.service.db.$transaction( - async (tx: Record) => { - // make sure the entity (including ones involved in nested write) pass policy check - await preUpdateCheck( - model, - id, - args, - this.service, - context, - tx - ); - - // inject transaction id into update/create payload (direct and nested) - const { createdModels } = await injectTransactionId( - model, - args, - 'update', - transactionId, - this.service - ); - - // conduct the update - this.service.verbose( - `Conducting update: ${model}:\n${JSON.stringify(args)}` - ); - await tx[model].update(args); - - // verify that nested creates pass policy check - await Promise.all( - createdModels.map(async (model) => { - const createdIds = await queryIds(model, tx, { - [TRANSACTION_FIELD_NAME]: `${transactionId}:create`, - }); - this.service.verbose( - `Validating nestedly created entities: ${model}#[${createdIds.join( - ', ' - )}]` - ); - await checkPolicyForIds( - model, - createdIds, - 'create', - this.service, - context, - tx - ); - }) - ); - } - ); - - // verify that return data requested by query args pass policy check - const readArgs = { ...args }; - delete readArgs.data; - - try { - const result = await readWithCheck( - model, - readArgs, - this.service, - context, - this.service.db - ); - if (result.length === 0) { - throw new RequestHandlerError( - ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED - ); - } - res.status(200).send(result[0]); - } catch (err) { - if ( - err instanceof RequestHandlerError && - err.code === ServerErrorCode.DENIED_BY_POLICY - ) { - throw new RequestHandlerError( - ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED - ); - } else { - throw err; - } - } + const result = await this.crud.update(model, id, req.body, context); + res.status(200).send(result); } private async del( @@ -439,73 +163,8 @@ export default class DataHandler ); } - // ensures the item under deletion passes policy check - await checkPolicyForIds( - model, - [id], - 'delete', - this.service, - context, - this.service.db - ); - const args = req.query.q ? JSON.parse(req.query.q as string) : {}; - args.where = { ...args.where, id }; - - const r = await this.service.db.$transaction( - async (tx: Record) => { - // first fetch the data that needs to be returned after deletion - let readResult: any; - try { - const items = await readWithCheck( - model, - args, - this.service, - context, - tx - ); - readResult = items[0]; - } catch (err) { - if ( - err instanceof RequestHandlerError && - err.code === ServerErrorCode.DENIED_BY_POLICY - ) { - // can't read back, just return undefined, outer logic handles it - } else { - throw err; - } - } - - // conduct the deletion - this.service.verbose( - `Conducting delete ${model}:\n${JSON.stringify(args)}` - ); - await tx[model].delete(args); - - return readResult; - } - ); - - if (r) { - res.status(200).send(r); - } else { - throw new RequestHandlerError( - ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED - ); - } - } - - private isPrismaClientKnownRequestError( - err: any - ): err is { code: string; message: string } { - return ( - err.__proto__.constructor.name === 'PrismaClientKnownRequestError' - ); - } - - private isPrismaClientValidationError( - err: any - ): err is { message: string } { - return err.__proto__.constructor.name === 'PrismaClientValidationError'; + const result = await this.crud.del(model, id, args, context); + res.status(200).send(result); } } diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index e3e7ec6e0..82ff49be8 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -99,6 +99,15 @@ export interface Service { context: QueryContext ): Promise; + /** + * Validates the given write payload for the given model according to field constraints in model. + * + * @param model Model name + * @param mode Write mode + * @param payload Write payload + * + * @throws @see ValidationError + */ validateModelPayload( model: string, mode: 'create' | 'update', diff --git a/packages/schema/package.json b/packages/schema/package.json index 22cfd0de3..6d3664557 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for modeling data and access policies in full-stack development with Next.js and Typescript", - "version": "0.3.19", + "version": "0.3.20", "author": { "name": "ZenStack Team" }, @@ -94,7 +94,7 @@ "mixpanel": "^0.17.0", "node-machine-id": "^1.1.12", "pluralize": "^8.0.0", - "prisma": "^4.5.0", + "prisma": "~4.5.0", "promisify": "^0.0.3", "sleep-promise": "^9.1.0", "ts-morph": "^16.0.0", @@ -106,7 +106,7 @@ "vscode-uri": "^3.0.6" }, "devDependencies": { - "@prisma/internals": "^4.5.0", + "@prisma/internals": "~4.5.0", "@types/async-exit-hook": "^2.0.0", "@types/jest": "^29.2.0", "@types/node": "^14.18.32", diff --git a/packages/schema/src/generator/service/index.ts b/packages/schema/src/generator/service/index.ts index 8ba3f685a..4d5766d4c 100644 --- a/packages/schema/src/generator/service/index.ts +++ b/packages/schema/src/generator/service/index.ts @@ -1,8 +1,10 @@ -import { Context, Generator } from '../types'; -import { Project } from 'ts-morph'; -import * as path from 'path'; +import { DataModel, isDataModel } from '@lang/generated/ast'; +import { camelCase } from 'change-case'; import colors from 'colors'; +import * as path from 'path'; +import { Project } from 'ts-morph'; import { RUNTIME_PACKAGE } from '../constants'; +import { Context, Generator } from '../types'; /** * Generates ZenStack service code @@ -20,9 +22,18 @@ export default class ServiceGenerator implements Generator { { overwrite: true } ); + const models = context.schema.declarations.filter((d): d is DataModel => + isDataModel(d) + ); + sf.addStatements([ - `import { PrismaClient } from "../.prisma";`, + `import { Prisma as P, PrismaClient } from "../.prisma";`, `import { DefaultService } from "${RUNTIME_PACKAGE}/lib/service";`, + `import { CRUD } from "${RUNTIME_PACKAGE}/lib/handler/data/crud";`, + `import type { QueryContext } from "${RUNTIME_PACKAGE}/lib/types";`, + `import type { ${models + .map((m) => m.name) + .join(', ')} } from "../.prisma";`, ]); const cls = sf.addClass({ @@ -31,6 +42,11 @@ export default class ServiceGenerator implements Generator { extends: 'DefaultService', }); + cls.addProperty({ + name: 'private crud', + initializer: `new CRUD(this)`, + }); + cls.addMethod({ name: 'initializePrisma', }).setBodyText(` @@ -53,6 +69,26 @@ export default class ServiceGenerator implements Generator { return import('./field-constraint'); `); + // server-side CRUD operations per model + for (const model of models) { + cls.addGetAccessor({ + name: camelCase(model.name), + }).setBodyText(` + return { + get: (context: QueryContext, id: string, args?: P.SelectSubset>) => + this.crud.get('${model.name}', id, args, context) as Promise>>, + find: (context: QueryContext, args?: P.SelectSubset) => + this.crud.find('${model.name}', args, context) as Promise, Array>>>, + create: (context: QueryContext, args: P.${model.name}CreateArgs) => + this.crud.create('${model.name}', args, context) as Promise>>, + update: >(context: QueryContext, id: string, args: Omit) => + this.crud.update('${model.name}', id, args, context) as Promise>>, + del: >(context: QueryContext, id: string, args?: Omit) => + this.crud.del('${model.name}', id, args, context) as Promise>>, + } + `); + } + // Recommended by Prisma for Next.js // https://www.prisma.io/docs/guides/database/troubleshooting-orm/help-articles/nextjs-prisma-client-dev-practices#problem sf.addStatements([ diff --git a/samples/todo/components/Spaces.tsx b/samples/todo/components/Spaces.tsx index aaf52862d..eaac0f578 100644 --- a/samples/todo/components/Spaces.tsx +++ b/samples/todo/components/Spaces.tsx @@ -1,13 +1,14 @@ -import { useSpace } from '@zenstackhq/runtime/client'; +import { Space } from '@zenstackhq/runtime/types'; import Link from 'next/link'; -export default function Spaces() { - const { find } = useSpace(); - const spaces = find(); +type Props = { + spaces: Space[]; +}; +export default function Spaces({ spaces }: Props) { return (
    - {spaces.data?.map((space) => ( + {spaces?.map((space) => (
  • { +type Props = { + spaces: Space[]; +}; + +const Home: NextPage = ({ spaces }) => { const user = useCurrentUser(); return ( @@ -22,7 +30,7 @@ const Home: NextPage = () => { - + )} @@ -30,4 +38,15 @@ const Home: NextPage = () => { ); }; +export const getServerSideProps: GetServerSideProps = async ({ + req, + res, +}) => { + const session = await unstable_getServerSession(req, res, authOptions); + const spaces = await service.space.find({ user: session?.user }); + return { + props: { spaces }, + }; +}; + export default Home; diff --git a/tests/integration/tests/utils.ts b/tests/integration/tests/utils.ts index 4cdbf4d96..18f9857d2 100644 --- a/tests/integration/tests/utils.ts +++ b/tests/integration/tests/utils.ts @@ -36,7 +36,7 @@ export async function setup(schemaFile: string) { 'typescript', 'swr', 'react', - 'prisma', + 'prisma@~4.5.0', 'zod', '../../../../packages/schema', '../../../../packages/runtime/dist', From 4276795bc8465a7a5a43a7ed4a63eab8e4b270a0 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 30 Nov 2022 21:11:28 +0800 Subject: [PATCH 2/4] update lock file --- pnpm-lock.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c90bcdb2..6759fbe7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,7 +50,7 @@ importers: packages/schema: specifiers: - '@prisma/internals': ^4.5.0 + '@prisma/internals': ~4.5.0 '@types/async-exit-hook': ^2.0.0 '@types/jest': ^29.2.0 '@types/node': ^14.18.32 @@ -77,7 +77,7 @@ importers: mixpanel: ^0.17.0 node-machine-id: ^1.1.12 pluralize: ^8.0.0 - prisma: ^4.5.0 + prisma: ~4.5.0 promisify: ^0.0.3 rimraf: ^3.0.2 sleep-promise: ^9.1.0 From 318f179607fffd2e44990fe81ddcf4901fba465f Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 30 Nov 2022 22:57:11 +0800 Subject: [PATCH 3/4] ssr: hooks accepts initial data (usually from ssr) --- docs/runtime-api.md | 9 +-- package.json | 2 +- packages/runtime/package.json | 2 +- packages/runtime/src/request.ts | 6 +- packages/runtime/src/types.ts | 5 +- packages/schema/package.json | 2 +- packages/schema/src/cli/cli-util.ts | 2 + .../schema/src/generator/react-hooks/index.ts | 4 +- samples/todo/components/BreadCrumb.tsx | 19 +++--- samples/todo/lib/query-utils.ts | 11 ++++ samples/todo/package-lock.json | 39 ++++++----- samples/todo/package.json | 9 ++- .../pages/space/[slug]/[listId]/index.tsx | 65 ++++++++++++++----- samples/todo/pages/space/[slug]/index.tsx | 48 +++++++++++++- 14 files changed, 158 insertions(+), 65 deletions(-) create mode 100644 samples/todo/lib/query-utils.ts diff --git a/docs/runtime-api.md b/docs/runtime-api.md index 8b81ef77a..b6b805782 100644 --- a/docs/runtime-api.md +++ b/docs/runtime-api.md @@ -49,9 +49,10 @@ const { get, find, create, update, del } = useUser(); Options controlling hooks' fetch behavior. ```ts -type RequestOptions = { +type RequestOptions = { // indicates if fetch should be disabled - disabled: boolean; + disabled?: boolean; + initialData?: T; }; ``` @@ -121,7 +122,7 @@ function del(id: string): Promise; This module contains API for server-side programming. The following declarations are exported: -### `default` +### **default** The default export of this module is a `service` object which encapsulates most of the server-side APIs. @@ -144,7 +145,7 @@ await service.db.user.update({ The server-side database access API uses the [same set of typing](#zenstackhqruntimetypes) as the client side. The `service.db` object is a Prisma Client, and you can find all API documentations [here](https://www.prisma.io/docs/reference/api-reference/prisma-client-reference ':target=blank'). -### `requestHandler` +### **requestHandler** Function for handling API endpoint requests. Used for installing the generated CRUD services onto an API route: diff --git a/package.json b/package.json index 42094d9f7..ee484064d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "0.3.20", + "version": "0.3.21", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 7274c68d4..85b467094 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "0.3.20", + "version": "0.3.21", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/request.ts b/packages/runtime/src/request.ts index 0d234b3ed..b5984beb4 100644 --- a/packages/runtime/src/request.ts +++ b/packages/runtime/src/request.ts @@ -103,10 +103,12 @@ function makeUrl(url: string, args: unknown) { export function get( url: string | null, args?: unknown, - options?: RequestOptions + options?: RequestOptions ): SWRResponse { const reqUrl = options?.disabled ? null : url ? makeUrl(url, args) : null; - return useSWR(reqUrl, fetcher); + return useSWR(reqUrl, fetcher, { + fallbackData: options?.initialData, + }); } export async function post( diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 82ff49be8..4b8f85508 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -226,9 +226,10 @@ export type LogEvent = { /** * Client request options */ -export type RequestOptions = { +export type RequestOptions = { // disable data fetching - disabled: boolean; + disabled?: boolean; + initialData?: T; }; /** diff --git a/packages/schema/package.json b/packages/schema/package.json index 6d3664557..8cdc49ea7 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for modeling data and access policies in full-stack development with Next.js and Typescript", - "version": "0.3.20", + "version": "0.3.21", "author": { "name": "ZenStack Team" }, diff --git a/packages/schema/src/cli/cli-util.ts b/packages/schema/src/cli/cli-util.ts index bab3b8d4a..27f2619c7 100644 --- a/packages/schema/src/cli/cli-util.ts +++ b/packages/schema/src/cli/cli-util.ts @@ -207,6 +207,8 @@ export async function runGenerator( if (err instanceof GeneratorError) { console.error(colors.red(err.message)); throw new CliError(err.message); + } else { + throw err; } } } diff --git a/packages/schema/src/generator/react-hooks/index.ts b/packages/schema/src/generator/react-hooks/index.ts index 013dc3190..242d1b964 100644 --- a/packages/schema/src/generator/react-hooks/index.ts +++ b/packages/schema/src/generator/react-hooks/index.ts @@ -137,7 +137,7 @@ export default class ReactHooksGenerator implements Generator { }, { name: 'options?', - type: 'RequestOptions', + type: `RequestOptions, Array>>>`, }, ], }) @@ -163,7 +163,7 @@ export default class ReactHooksGenerator implements Generator { }, { name: 'options?', - type: 'RequestOptions', + type: `RequestOptions>>`, }, ], }) diff --git a/samples/todo/components/BreadCrumb.tsx b/samples/todo/components/BreadCrumb.tsx index 78de41bf9..f537f9c1b 100644 --- a/samples/todo/components/BreadCrumb.tsx +++ b/samples/todo/components/BreadCrumb.tsx @@ -1,15 +1,17 @@ -import { useCurrentSpace } from '@lib/context'; -import { useList } from '@zenstackhq/runtime/client'; +import { List, Space } from '@zenstackhq/runtime/types'; import Link from 'next/link'; import { useRouter } from 'next/router'; -export default function BreadCrumb() { +type Props = { + space: Space; + list?: List; +}; + +export default function BreadCrumb({ space, list }: Props) { const router = useRouter(); - const space = useCurrentSpace(); - const { get: getList } = useList(); const parts = router.asPath.split('/').filter((p) => p); - const [base, slug, listId] = parts; + const [base] = parts; if (base !== 'space') { return <>; } @@ -17,13 +19,12 @@ export default function BreadCrumb() { const items: Array<{ text: string; link: string }> = []; items.push({ text: 'Home', link: '/' }); - items.push({ text: space?.name || '', link: `/space/${slug}` }); + items.push({ text: space.name || '', link: `/space/${space.slug}` }); - const { data: list } = getList(listId); if (list) { items.push({ text: list?.title || '', - link: `/space/${slug}/${listId}`, + link: `/space/${space.slug}/${list.id}`, }); } diff --git a/samples/todo/lib/query-utils.ts b/samples/todo/lib/query-utils.ts new file mode 100644 index 000000000..572d8eb85 --- /dev/null +++ b/samples/todo/lib/query-utils.ts @@ -0,0 +1,11 @@ +import service, { QueryContext } from '@zenstackhq/runtime/server'; + +export async function getSpaceBySlug(queryContext: QueryContext, slug: string) { + const spaces = await service.space.find(queryContext, { + where: { slug }, + }); + if (spaces.length === 0) { + throw new Error('Space not found: ' + slug); + } + return spaces[0]; +} diff --git a/samples/todo/package-lock.json b/samples/todo/package-lock.json index 069d0ad3c..650aa31c2 100644 --- a/samples/todo/package-lock.json +++ b/samples/todo/package-lock.json @@ -1,16 +1,16 @@ { "name": "todo", - "version": "0.3.20", + "version": "0.3.21", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "todo", - "version": "0.3.20", + "version": "0.3.21", "dependencies": { "@heroicons/react": "^2.0.12", "@prisma/client": "^4.4.0", - "@zenstackhq/runtime": "^0.3.20", + "@zenstackhq/runtime": "^0.3.21", "bcryptjs": "^2.4.3", "daisyui": "^2.31.0", "moment": "^2.29.4", @@ -20,8 +20,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-toastify": "^9.0.8", - "swr": "^1.3.0", - "zod": "^3.19.1" + "swr": "^1.3.0" }, "devDependencies": { "@tailwindcss/line-clamp": "^0.4.2", @@ -35,7 +34,7 @@ "postcss": "^8.4.16", "tailwindcss": "^3.1.8", "typescript": "^4.6.2", - "zenstack": "^0.3.20" + "zenstack": "^0.3.21" } }, "../../packages/runtime": { @@ -752,9 +751,9 @@ } }, "node_modules/@zenstackhq/runtime": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.3.20.tgz", - "integrity": "sha512-ntNKcKhQJL94SvXdpZtbYavP+e6AHomXkNzGRSeLrLq74FbQD8ldOAvDxmbZpdgltXk8Vxa2oIiY1GgrNROWBg==", + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.3.21.tgz", + "integrity": "sha512-4dYc+yfIoJ3n6UA+U3GcTNj5lKFbTVHC/ApO8p0EiWbjcsZ8Y/L+lSiY+C6NbiVOid0bjqLp4WZd1KWh1wMjYw==", "dependencies": { "@types/bcryptjs": "^2.4.2", "bcryptjs": "^2.4.3", @@ -4605,13 +4604,13 @@ } }, "node_modules/zenstack": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.3.20.tgz", - "integrity": "sha512-3tD9WojTnyt2BQqfFfDKwVZnh1snm5fiNX7RhbqlELXzM43/MwPAeL04Z0AfV5nmwYkgevGxcbaaHboOb0XkUQ==", + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.3.21.tgz", + "integrity": "sha512-ILWecID1SnsIMuoz5TQvZeZNjolNZ0Lmu8kqAgONoiDH9ad55ZbRTGh07V3cfBRDQ4BcEvG/rU3apwqBynPXCw==", "dev": true, "hasInstallScript": true, "dependencies": { - "@zenstackhq/runtime": "0.3.20", + "@zenstackhq/runtime": "0.3.21", "async-exit-hook": "^2.0.1", "change-case": "^4.1.2", "chevrotain": "^9.1.0", @@ -5130,9 +5129,9 @@ } }, "@zenstackhq/runtime": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.3.20.tgz", - "integrity": "sha512-ntNKcKhQJL94SvXdpZtbYavP+e6AHomXkNzGRSeLrLq74FbQD8ldOAvDxmbZpdgltXk8Vxa2oIiY1GgrNROWBg==", + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.3.21.tgz", + "integrity": "sha512-4dYc+yfIoJ3n6UA+U3GcTNj5lKFbTVHC/ApO8p0EiWbjcsZ8Y/L+lSiY+C6NbiVOid0bjqLp4WZd1KWh1wMjYw==", "requires": { "@types/bcryptjs": "^2.4.2", "bcryptjs": "^2.4.3", @@ -7927,12 +7926,12 @@ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" }, "zenstack": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.3.20.tgz", - "integrity": "sha512-3tD9WojTnyt2BQqfFfDKwVZnh1snm5fiNX7RhbqlELXzM43/MwPAeL04Z0AfV5nmwYkgevGxcbaaHboOb0XkUQ==", + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.3.21.tgz", + "integrity": "sha512-ILWecID1SnsIMuoz5TQvZeZNjolNZ0Lmu8kqAgONoiDH9ad55ZbRTGh07V3cfBRDQ4BcEvG/rU3apwqBynPXCw==", "dev": true, "requires": { - "@zenstackhq/runtime": "0.3.20", + "@zenstackhq/runtime": "0.3.21", "async-exit-hook": "^2.0.1", "change-case": "^4.1.2", "chevrotain": "^9.1.0", diff --git a/samples/todo/package.json b/samples/todo/package.json index d84874863..f013fbeff 100644 --- a/samples/todo/package.json +++ b/samples/todo/package.json @@ -1,6 +1,6 @@ { "name": "todo", - "version": "0.3.20", + "version": "0.3.21", "private": true, "scripts": { "dev": "next dev", @@ -21,7 +21,7 @@ "dependencies": { "@heroicons/react": "^2.0.12", "@prisma/client": "^4.4.0", - "@zenstackhq/runtime": "^0.3.20", + "@zenstackhq/runtime": "^0.3.21", "bcryptjs": "^2.4.3", "daisyui": "^2.31.0", "moment": "^2.29.4", @@ -31,8 +31,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-toastify": "^9.0.8", - "swr": "^1.3.0", - "zod": "^3.19.1" + "swr": "^1.3.0" }, "devDependencies": { "@tailwindcss/line-clamp": "^0.4.2", @@ -46,6 +45,6 @@ "postcss": "^8.4.16", "tailwindcss": "^3.1.8", "typescript": "^4.6.2", - "zenstack": "^0.3.20" + "zenstack": "^0.3.21" } } diff --git a/samples/todo/pages/space/[slug]/[listId]/index.tsx b/samples/todo/pages/space/[slug]/[listId]/index.tsx index a0e698992..93260dadf 100644 --- a/samples/todo/pages/space/[slug]/[listId]/index.tsx +++ b/samples/todo/pages/space/[slug]/[listId]/index.tsx @@ -1,24 +1,32 @@ -import { useList, useTodo } from '@zenstackhq/runtime/client'; -import { useRouter } from 'next/router'; +import { authOptions } from '@api/auth/[...nextauth]'; +import { useTodo } from '@zenstackhq/runtime/client'; import { PlusIcon } from '@heroicons/react/24/outline'; import { ChangeEvent, KeyboardEvent, useState } from 'react'; import { useCurrentUser } from '@lib/context'; import TodoComponent from 'components/Todo'; import BreadCrumb from 'components/BreadCrumb'; import WithNavBar from 'components/WithNavBar'; +import { List, Space, Todo, User } from '@zenstackhq/runtime/types'; +import { GetServerSideProps } from 'next'; +import { unstable_getServerSession } from 'next-auth'; +import service from '@zenstackhq/runtime/server'; +import { getSpaceBySlug } from '@lib/query-utils'; -export default function TodoList() { +type Props = { + space: Space; + list: List; + todos: (Todo & { owner: User })[]; +}; + +export default function TodoList(props: Props) { const user = useCurrentUser(); - const router = useRouter(); - const { get: getList } = useList(); const { create: createTodo, find: findTodos } = useTodo(); const [title, setTitle] = useState(''); - const { data: list } = getList(router.query.listId as string); const { data: todos, mutate: invalidateTodos } = findTodos( { where: { - listId: list?.id, + listId: props.list.id, }, include: { owner: true, @@ -27,19 +35,15 @@ export default function TodoList() { updatedAt: 'desc', }, }, - { disabled: !list } + { initialData: props.todos } ); - if (!list) { - return

    Loading ...

    ; - } - const _createTodo = async () => { const todo = await createTodo({ data: { title, ownerId: user!.id, - listId: list!.id, + listId: props.list.id, }, }); console.log(`Todo created: ${todo}`); @@ -49,10 +53,12 @@ export default function TodoList() { return (
    - +
    -

    {list?.title}

    +

    + {props.list?.title} +

    ); } + +export const getServerSideProps: GetServerSideProps = async ({ + req, + res, + params, +}) => { + const session = await unstable_getServerSession(req, res, authOptions); + const queryContext = { user: session?.user }; + + const space = await getSpaceBySlug(queryContext, params?.slug as string); + + const list = await service.list.get(queryContext, params?.listId as string); + + const todos = await service.todo.find(queryContext, { + where: { + listId: params?.id as string, + }, + include: { + owner: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + + return { + props: { space, list, todos }, + }; +}; diff --git a/samples/todo/pages/space/[slug]/index.tsx b/samples/todo/pages/space/[slug]/index.tsx index e7814498b..9f767374c 100644 --- a/samples/todo/pages/space/[slug]/index.tsx +++ b/samples/todo/pages/space/[slug]/index.tsx @@ -1,3 +1,4 @@ +import { authOptions } from '@api/auth/[...nextauth]'; import { SpaceContext, UserContext } from '@lib/context'; import { ChangeEvent, @@ -13,6 +14,12 @@ import TodoList from 'components/TodoList'; import BreadCrumb from 'components/BreadCrumb'; import SpaceMembers from 'components/SpaceMembers'; import WithNavBar from 'components/WithNavBar'; +import { List, Space, User } from '@zenstackhq/runtime/types'; +import { GetServerSideProps } from 'next'; +import { unstable_getServerSession } from 'next-auth'; +import service from '@zenstackhq/runtime/server'; +import { useRouter } from 'next/router'; +import { getSpaceBySlug } from '@lib/query-utils'; function CreateDialog() { const user = useContext(UserContext); @@ -135,15 +142,21 @@ function CreateDialog() { ); } -export default function SpaceHome() { +type Props = { + space: Space; + lists: (List & { owner: User })[]; +}; + +export default function SpaceHome(props: Props) { const space = useContext(SpaceContext); const { find } = useList(); + const router = useRouter(); const { data: lists, mutate: invalidateLists } = find( { where: { space: { - id: space?.id, + slug: router.query.slug as string, }, }, include: { @@ -155,13 +168,14 @@ export default function SpaceHome() { }, { disabled: !space, + initialData: props.lists, } ); return (
    - +
    @@ -190,3 +204,31 @@ export default function SpaceHome() { ); } + +export const getServerSideProps: GetServerSideProps = async ({ + req, + res, + params, +}) => { + const session = await unstable_getServerSession(req, res, authOptions); + const queryContext = { user: session?.user }; + + const space = await getSpaceBySlug(queryContext, params?.slug as string); + + const lists = await service.list.find(queryContext, { + where: { + space: { + slug: params?.slug as string, + }, + }, + include: { + owner: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + return { + props: { space, lists }, + }; +}; From 4c11ead1e17f383efbb0a3b8c25d5ecb7c5e5e1c Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Thu, 1 Dec 2022 00:23:57 +0800 Subject: [PATCH 4/4] update docs --- docs/_sidebar.md | 1 + docs/building-your-app.md | 23 +----- docs/runtime-api.md | 82 ++++++++++++++++--- docs/server-side-rendering.md | 26 ++++++ package.json | 2 +- packages/runtime/package.json | 2 +- packages/runtime/src/handler/data/crud.ts | 41 ++++------ packages/runtime/src/handler/data/handler.ts | 11 ++- .../runtime/src/handler/data/policy-utils.ts | 10 +-- packages/runtime/src/handler/types.ts | 4 +- packages/schema/package.json | 2 +- .../schema/src/generator/service/index.ts | 2 +- samples/todo/package-lock.json | 39 +++++---- samples/todo/package.json | 9 +- .../pages/space/[slug]/[listId]/index.tsx | 3 + 15 files changed, 163 insertions(+), 94 deletions(-) create mode 100644 docs/server-side-rendering.md diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 87cf08cc3..e065cea09 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -29,6 +29,7 @@ - [Choosing a database](choosing-a-database.md) - [Evolving model with migration](evolving-model-with-migration.md) - [Integrating authentication](integrating-authentication.md) + - [Server-side rendering](server-side-rendering.md) - [Set up logging](setup-logging.md) - [Telemetry](telemetry.md) diff --git a/docs/building-your-app.md b/docs/building-your-app.md index 41eafdfdf..ab3cf4950 100644 --- a/docs/building-your-app.md +++ b/docs/building-your-app.md @@ -144,24 +144,7 @@ const post = await del(id); Since doing CRUD with hooks is already secure, in many cases, you can implement your business logic right in the frontend code. -In case you need to do server-side coding, either through implementing an API endpoint or by using `getServerSideProps` for SSR, you can directly access the database client generated by Prisma: +ZenStack also supports server-side programming for conducting CRUD without sending HTTP requests, or even direct database access (bypassing access policy checks). Please check the following documentation for details: -```ts -import service from '@zenstackhq/runtime'; - -export const getServerSideProps: GetServerSideProps = async () => { - const posts = await service.db.post.findMany({ - where: { published: true }, - include: { author: true }, - }); - return { - props: { posts }, - }; -}; -``` - -The Typescript types of data models, filters, sorting, etc., are all shared between the frontend and the backend. - -_Note_ Server-side database access is **NOT PROTECTED** by access policies. This is by-design so as to provide a way of bypassing the policies. Please make sure you implement authorization properly. - -_TBD_ In the future we'll provide a utility for explicitly validating access policies in backend code, so that you can reuse your policy definitions in the model. +- [Server runtime API](runtime-api.md#zenstackhqruntimeserver) +- [Server-side rendering](server-side-rendering.md) diff --git a/docs/runtime-api.md b/docs/runtime-api.md index b6b805782..42282bf84 100644 --- a/docs/runtime-api.md +++ b/docs/runtime-api.md @@ -44,7 +44,7 @@ A `useXXX` API is generated fo each data model for getting the React hooks. The const { get, find, create, update, del } = useUser(); ``` -### RequestOptions +### `RequestOptions` Options controlling hooks' fetch behavior. @@ -52,11 +52,14 @@ Options controlling hooks' fetch behavior. type RequestOptions = { // indicates if fetch should be disabled disabled?: boolean; + + // provides initial data, which is immediately available + // before fresh data is fetched (usually used with SSR) initialData?: T; }; ``` -### HooksError +### `HooksError` Error thrown for failure of `create`, `update` and `delete` hooks. @@ -70,7 +73,7 @@ export type HooksError = { }; ``` -#### ServerErrorCode +#### `ServerErrorCode` | Code | Description | | ------------------------------ | --------------------------------------------------------------------------------------------- | @@ -81,7 +84,7 @@ export type HooksError = { | REFERENCE_CONSTRAINT_VIOLATION | Violation of database reference constraint (aka. foreign key constraints) | | READ_BACK_AFTER_WRITE_DENIED | A write operation succeeded but the result cannot be read back due to policy control | -### get +### `get` ```ts function get( @@ -91,7 +94,7 @@ function get( ): SWRResponse; ``` -### find +### `find` ```ts function find( @@ -100,34 +103,91 @@ function find( ): SWRResponse; ``` -### create +### `create` ```ts function create(args?: UserCreateArgs): Promise; ``` -### update +### `update` ```ts function update(id: string, args?: UserUpdateArgs): Promise; ``` -### del +### `del` ```ts -function del(id: string): Promise; +function del(id: string, args?: UserDeleteArgs): Promise; ``` ## `@zenstackhq/runtime/server` This module contains API for server-side programming. The following declarations are exported: -### **default** +### `service` The default export of this module is a `service` object which encapsulates most of the server-side APIs. +#### Server-side CRUD + +The `service` object contains members for each of the data models, each containing server-side CRUD APIs. These APIs can be used for doing CRUD operations without HTTP request overhead, while still fully protected by access policies. + +The server-side CRUD APIs have similar signature with client-side hooks, except that they take an extra `queryContext` parameter for passing in the current login user. They're usually used for implementing SSR or custom API endpoints. + +- get + + ```ts + async get( + context: QueryContext, + id: string, + args?: UserFindFirstArgs + ): Promise; + ``` + +- find + + ```ts + async find( + context: QueryContext, + args?: UserFindManyArgs + ): Promise; + ``` + +- create + + ```ts + async find( + context: QueryContext, + args?: UserCreateArgs + ): Promise; + ``` + +- update + + ```ts + async get( + context: QueryContext, + id: string, + args?: UserUpdateArgs + ): Promise; + ``` + +- del + ```ts + async get( + context: QueryContext, + id: string, + args?: UserDeleteArgs + ): Promise; + ``` + +#### Direct database access + The `service.db` object contains a member field for each data model defined, which you can use to conduct database operations for that model. +_NOTE_ These database operations are **NOT** protected by access policies. + Take `User` model for example: ```ts @@ -145,7 +205,7 @@ await service.db.user.update({ The server-side database access API uses the [same set of typing](#zenstackhqruntimetypes) as the client side. The `service.db` object is a Prisma Client, and you can find all API documentations [here](https://www.prisma.io/docs/reference/api-reference/prisma-client-reference ':target=blank'). -### **requestHandler** +### `requestHandler` Function for handling API endpoint requests. Used for installing the generated CRUD services onto an API route: diff --git a/docs/server-side-rendering.md b/docs/server-side-rendering.md new file mode 100644 index 000000000..e2ce76829 --- /dev/null +++ b/docs/server-side-rendering.md @@ -0,0 +1,26 @@ +# Server-side rendering + +You can use the `service` object to conduct CRUD operations on the server side directly without the overhead of HTTP requests. The `service` object contains members for each of the data model defined. + +The server-side CRUD methods are similar signature with client-side hooks, except that they take an extra `queryContext` parameter for passing in the current login user. Like client-side hooks, the CRUD operations are fully protected by access policies defined in ZModel. + +These methods are handy for implementing SSR (or custom API endpoints). Here's an example (using Next-Auth for authentication): + +```ts +import service from '@zenstackhq/runtime/server'; +import { unstable_getServerSession } from 'next-auth'; +... + +export const getServerSideProps = async ({ + req, + res, + params, +}) => { + const session = await unstable_getServerSession(req, res, authOptions); + const queryContext = { user: session?.user }; + const posts = await service.post.find(queryContext); + return { + props: { posts }, + }; +}; +``` diff --git a/package.json b/package.json index ee484064d..a6f88f6ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "0.3.21", + "version": "0.3.22", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 85b467094..2261ea886 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "0.3.21", + "version": "0.3.22", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/handler/data/crud.ts b/packages/runtime/src/handler/data/crud.ts index 872577a97..4d6a6a7d7 100644 --- a/packages/runtime/src/handler/data/crud.ts +++ b/packages/runtime/src/handler/data/crud.ts @@ -10,7 +10,7 @@ import { Service, } from '../../types'; import { ValidationError } from '../../validation'; -import { RequestHandlerError } from '../types'; +import { CRUDError } from '../types'; import { and, checkPolicyForIds, @@ -59,9 +59,6 @@ export class CRUD { throw this.processError(err, 'get', model); } - if (entities.length === 0) { - throw new RequestHandlerError(ServerErrorCode.ENTITY_NOT_FOUND); - } return entities[0]; } @@ -89,13 +86,13 @@ export class CRUD { context: QueryContext ): Promise { if (!args) { - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.INVALID_REQUEST_PARAMS, 'body is required' ); } if (!args.data) { - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.INVALID_REQUEST_PARAMS, 'data field is required' ); @@ -183,17 +180,17 @@ export class CRUD { this.db ); if (result.length === 0) { - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED ); } return result[0]; } catch (err) { if ( - err instanceof RequestHandlerError && + err instanceof CRUDError && err.code === ServerErrorCode.DENIED_BY_POLICY ) { - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED ); } else { @@ -209,7 +206,7 @@ export class CRUD { context: QueryContext ): Promise { if (!args) { - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.INVALID_REQUEST_PARAMS, 'body is required' ); @@ -292,17 +289,17 @@ export class CRUD { this.db ); if (result.length === 0) { - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED ); } return result[0]; } catch (err) { if ( - err instanceof RequestHandlerError && + err instanceof CRUDError && err.code === ServerErrorCode.DENIED_BY_POLICY ) { - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED ); } else { @@ -348,7 +345,7 @@ export class CRUD { readResult = items[0]; } catch (err) { if ( - err instanceof RequestHandlerError && + err instanceof CRUDError && err.code === ServerErrorCode.DENIED_BY_POLICY ) { // can't read back, just return undefined, outer logic handles it @@ -373,9 +370,7 @@ export class CRUD { if (result) { return result; } else { - throw new RequestHandlerError( - ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED - ); + throw new CRUDError(ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED); } } @@ -398,7 +393,7 @@ export class CRUD { operation: 'get' | 'find' | 'create' | 'update' | 'del', model: string ) { - if (err instanceof RequestHandlerError) { + if (err instanceof CRUDError) { return err; } @@ -409,12 +404,12 @@ export class CRUD { // errors thrown by Prisma, try mapping to a known error if (PRISMA_ERROR_MAPPING[err.code]) { - return new RequestHandlerError( + return new CRUDError( PRISMA_ERROR_MAPPING[err.code], getServerErrorMessage(PRISMA_ERROR_MAPPING[err.code]) ); } else { - return new RequestHandlerError( + return new CRUDError( ServerErrorCode.UNKNOWN, 'an unhandled Prisma error occurred: ' + err.code ); @@ -425,7 +420,7 @@ export class CRUD { ); // prisma validation error - return new RequestHandlerError( + return new CRUDError( ServerErrorCode.INVALID_REQUEST_PARAMS, getServerErrorMessage(ServerErrorCode.INVALID_REQUEST_PARAMS) ); @@ -434,7 +429,7 @@ export class CRUD { `Field constraint validation error: ${operation} ${model}: ${err.message}` ); - return new RequestHandlerError( + return new CRUDError( ServerErrorCode.INVALID_REQUEST_PARAMS, err.message ); @@ -446,7 +441,7 @@ export class CRUD { if (err instanceof Error && err.stack) { this.service.error(err.stack); } - return new RequestHandlerError( + return new CRUDError( ServerErrorCode.UNKNOWN, getServerErrorMessage(ServerErrorCode.UNKNOWN) ); diff --git a/packages/runtime/src/handler/data/handler.ts b/packages/runtime/src/handler/data/handler.ts index 8d6745177..baab57bd5 100644 --- a/packages/runtime/src/handler/data/handler.ts +++ b/packages/runtime/src/handler/data/handler.ts @@ -6,7 +6,7 @@ import { ServerErrorCode, Service, } from '../../types'; -import { RequestHandler, RequestHandlerError } from '../types'; +import { RequestHandler, CRUDError } from '../types'; import { CRUD } from './crud'; /** @@ -63,7 +63,7 @@ export default class DataHandler break; } } catch (err: unknown) { - if (err instanceof RequestHandlerError) { + if (err instanceof CRUDError) { this.service.warn(`${method} ${model}: ${err}`); // in case of errors thrown directly by ZenStack @@ -113,6 +113,9 @@ export default class DataHandler if (id) { // GET /:id, make sure "id" is injected const result = await this.crud.get(model, id, args, context); + if (!result) { + throw new CRUDError(ServerErrorCode.ENTITY_NOT_FOUND); + } res.status(200).send(result); } else { // GET /, get list @@ -139,7 +142,7 @@ export default class DataHandler context: QueryContext ) { if (!id) { - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.INVALID_REQUEST_PARAMS, 'missing "id" parameter' ); @@ -157,7 +160,7 @@ export default class DataHandler context: QueryContext ) { if (!id) { - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.INVALID_REQUEST_PARAMS, 'missing "id" parameter' ); diff --git a/packages/runtime/src/handler/data/policy-utils.ts b/packages/runtime/src/handler/data/policy-utils.ts index 5c15df1ea..6cd6e120e 100644 --- a/packages/runtime/src/handler/data/policy-utils.ts +++ b/packages/runtime/src/handler/data/policy-utils.ts @@ -15,7 +15,7 @@ import { ServerErrorCode, Service, } from '../../types'; -import { PrismaWriteActionType, RequestHandlerError } from '../types'; +import { PrismaWriteActionType, CRUDError } from '../types'; import { NestedWriteVisitor } from './nested-write-vistor'; //#region General helpers @@ -77,7 +77,7 @@ export async function queryIds( * * For to-many relations involved, items not satisfying policy are * silently trimmed. For to-one relation, if relation data fails policy - * an RequestHandlerError is thrown. + * an CRUDError is thrown. * * @param model the model to query for * @param queryArgs the Prisma query args @@ -395,7 +395,7 @@ export async function preUpdateCheck( /** * Given a list of ids for a model, check if they all match policy rules, and if not, - * throw a RequestHandlerError. + * throw a CRUDError. * * @param model the model * @param ids the entity ids @@ -435,7 +435,7 @@ export async function checkPolicyForIds( const filteredIds = filteredResult.map((item) => item.id); if (filteredIds.length < ids.length) { const gap = ids.filter((id) => !filteredIds.includes(id)); - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.DENIED_BY_POLICY, `denied by policy: entities failed '${operation}' check, ${model}#[${gap.join( ', ' @@ -514,7 +514,7 @@ function collectTerminalEntityIds( } if (!curr) { - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.UNKNOWN, 'an unexpected error occurred' ); diff --git a/packages/runtime/src/handler/types.ts b/packages/runtime/src/handler/types.ts index b481f6208..54701690d 100644 --- a/packages/runtime/src/handler/types.ts +++ b/packages/runtime/src/handler/types.ts @@ -20,9 +20,9 @@ export interface RequestHandler { } /** - * Error thrown during request handling + * Error thrown during CRUD operations */ -export class RequestHandlerError extends Error { +export class CRUDError extends Error { constructor(public readonly code: ServerErrorCode, message?: string) { message = message ? `${getServerErrorMessage(code)}: ${message}` diff --git a/packages/schema/package.json b/packages/schema/package.json index 8cdc49ea7..d07c55d7d 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for modeling data and access policies in full-stack development with Next.js and Typescript", - "version": "0.3.21", + "version": "0.3.22", "author": { "name": "ZenStack Team" }, diff --git a/packages/schema/src/generator/service/index.ts b/packages/schema/src/generator/service/index.ts index 4d5766d4c..3216832dd 100644 --- a/packages/schema/src/generator/service/index.ts +++ b/packages/schema/src/generator/service/index.ts @@ -76,7 +76,7 @@ export default class ServiceGenerator implements Generator { }).setBodyText(` return { get: (context: QueryContext, id: string, args?: P.SelectSubset>) => - this.crud.get('${model.name}', id, args, context) as Promise>>, + this.crud.get('${model.name}', id, args, context) as Promise> | undefined>, find: (context: QueryContext, args?: P.SelectSubset) => this.crud.find('${model.name}', args, context) as Promise, Array>>>, create: (context: QueryContext, args: P.${model.name}CreateArgs) => diff --git a/samples/todo/package-lock.json b/samples/todo/package-lock.json index 650aa31c2..194654e27 100644 --- a/samples/todo/package-lock.json +++ b/samples/todo/package-lock.json @@ -1,16 +1,16 @@ { "name": "todo", - "version": "0.3.21", + "version": "0.3.22", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "todo", - "version": "0.3.21", + "version": "0.3.22", "dependencies": { "@heroicons/react": "^2.0.12", "@prisma/client": "^4.4.0", - "@zenstackhq/runtime": "^0.3.21", + "@zenstackhq/runtime": "^0.3.22", "bcryptjs": "^2.4.3", "daisyui": "^2.31.0", "moment": "^2.29.4", @@ -19,8 +19,7 @@ "next-auth": "^4.15.1", "react": "18.2.0", "react-dom": "18.2.0", - "react-toastify": "^9.0.8", - "swr": "^1.3.0" + "react-toastify": "^9.0.8" }, "devDependencies": { "@tailwindcss/line-clamp": "^0.4.2", @@ -34,7 +33,7 @@ "postcss": "^8.4.16", "tailwindcss": "^3.1.8", "typescript": "^4.6.2", - "zenstack": "^0.3.21" + "zenstack": "^0.3.22" } }, "../../packages/runtime": { @@ -751,9 +750,9 @@ } }, "node_modules/@zenstackhq/runtime": { - "version": "0.3.21", - "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.3.21.tgz", - "integrity": "sha512-4dYc+yfIoJ3n6UA+U3GcTNj5lKFbTVHC/ApO8p0EiWbjcsZ8Y/L+lSiY+C6NbiVOid0bjqLp4WZd1KWh1wMjYw==", + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.3.22.tgz", + "integrity": "sha512-TLh/EyK/4nTKkZ4j9BcXS5kfEgEhkhx7aAAfT8vi45/uGccYDyBD0dPksv/AEtLw27jihUp7sydam5yyc8ITDQ==", "dependencies": { "@types/bcryptjs": "^2.4.2", "bcryptjs": "^2.4.3", @@ -4604,13 +4603,13 @@ } }, "node_modules/zenstack": { - "version": "0.3.21", - "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.3.21.tgz", - "integrity": "sha512-ILWecID1SnsIMuoz5TQvZeZNjolNZ0Lmu8kqAgONoiDH9ad55ZbRTGh07V3cfBRDQ4BcEvG/rU3apwqBynPXCw==", + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.3.22.tgz", + "integrity": "sha512-fJWM7GhK+9OsJTRkaHj2zzcJk6fIRUwZysnJWbPqsfkcGiJYreEqvkz7YVWgb9qoUWQzLBqzoKLrsjWiR8zBTg==", "dev": true, "hasInstallScript": true, "dependencies": { - "@zenstackhq/runtime": "0.3.21", + "@zenstackhq/runtime": "0.3.22", "async-exit-hook": "^2.0.1", "change-case": "^4.1.2", "chevrotain": "^9.1.0", @@ -5129,9 +5128,9 @@ } }, "@zenstackhq/runtime": { - "version": "0.3.21", - "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.3.21.tgz", - "integrity": "sha512-4dYc+yfIoJ3n6UA+U3GcTNj5lKFbTVHC/ApO8p0EiWbjcsZ8Y/L+lSiY+C6NbiVOid0bjqLp4WZd1KWh1wMjYw==", + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.3.22.tgz", + "integrity": "sha512-TLh/EyK/4nTKkZ4j9BcXS5kfEgEhkhx7aAAfT8vi45/uGccYDyBD0dPksv/AEtLw27jihUp7sydam5yyc8ITDQ==", "requires": { "@types/bcryptjs": "^2.4.2", "bcryptjs": "^2.4.3", @@ -7926,12 +7925,12 @@ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" }, "zenstack": { - "version": "0.3.21", - "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.3.21.tgz", - "integrity": "sha512-ILWecID1SnsIMuoz5TQvZeZNjolNZ0Lmu8kqAgONoiDH9ad55ZbRTGh07V3cfBRDQ4BcEvG/rU3apwqBynPXCw==", + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.3.22.tgz", + "integrity": "sha512-fJWM7GhK+9OsJTRkaHj2zzcJk6fIRUwZysnJWbPqsfkcGiJYreEqvkz7YVWgb9qoUWQzLBqzoKLrsjWiR8zBTg==", "dev": true, "requires": { - "@zenstackhq/runtime": "0.3.21", + "@zenstackhq/runtime": "0.3.22", "async-exit-hook": "^2.0.1", "change-case": "^4.1.2", "chevrotain": "^9.1.0", diff --git a/samples/todo/package.json b/samples/todo/package.json index f013fbeff..2bd3fbe61 100644 --- a/samples/todo/package.json +++ b/samples/todo/package.json @@ -1,6 +1,6 @@ { "name": "todo", - "version": "0.3.21", + "version": "0.3.22", "private": true, "scripts": { "dev": "next dev", @@ -21,7 +21,7 @@ "dependencies": { "@heroicons/react": "^2.0.12", "@prisma/client": "^4.4.0", - "@zenstackhq/runtime": "^0.3.21", + "@zenstackhq/runtime": "^0.3.22", "bcryptjs": "^2.4.3", "daisyui": "^2.31.0", "moment": "^2.29.4", @@ -30,8 +30,7 @@ "next-auth": "^4.15.1", "react": "18.2.0", "react-dom": "18.2.0", - "react-toastify": "^9.0.8", - "swr": "^1.3.0" + "react-toastify": "^9.0.8" }, "devDependencies": { "@tailwindcss/line-clamp": "^0.4.2", @@ -45,6 +44,6 @@ "postcss": "^8.4.16", "tailwindcss": "^3.1.8", "typescript": "^4.6.2", - "zenstack": "^0.3.21" + "zenstack": "^0.3.22" } } diff --git a/samples/todo/pages/space/[slug]/[listId]/index.tsx b/samples/todo/pages/space/[slug]/[listId]/index.tsx index 93260dadf..9bb4e2569 100644 --- a/samples/todo/pages/space/[slug]/[listId]/index.tsx +++ b/samples/todo/pages/space/[slug]/[listId]/index.tsx @@ -108,6 +108,9 @@ export const getServerSideProps: GetServerSideProps = async ({ const space = await getSpaceBySlug(queryContext, params?.slug as string); const list = await service.list.get(queryContext, params?.listId as string); + if (!list) { + throw new Error(`List not found: ${params?.listId}`); + } const todos = await service.todo.find(queryContext, { where: {