diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index e4601bf1c..585d96213 100644 --- a/packages/plugins/tanstack-query/package.json +++ b/packages/plugins/tanstack-query/package.json @@ -13,6 +13,7 @@ "build": "pnpm lint && pnpm clean && tsc && copyfiles ./package.json ./README.md ./LICENSE 'res/**/*' dist", "watch": "tsc --watch", "lint": "eslint src --ext ts", + "test": "jest", "prepublishOnly": "pnpm build", "publish-dev": "pnpm publish --tag dev" }, diff --git a/packages/plugins/tanstack-query/res/marshal-json.ts b/packages/plugins/tanstack-query/res/marshal-json.ts new file mode 100644 index 000000000..1f00abc79 --- /dev/null +++ b/packages/plugins/tanstack-query/res/marshal-json.ts @@ -0,0 +1,12 @@ +function marshal(value: unknown) { + return JSON.stringify(value); +} + +function unmarshal(value: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return JSON.parse(value) as any; +} + +function makeUrl(url: string, args: unknown) { + return args ? url + `?q=${encodeURIComponent(JSON.stringify(args))}` : url; +} diff --git a/packages/plugins/tanstack-query/res/marshal-superjson.ts b/packages/plugins/tanstack-query/res/marshal-superjson.ts new file mode 100644 index 000000000..559b11b4e --- /dev/null +++ b/packages/plugins/tanstack-query/res/marshal-superjson.ts @@ -0,0 +1,20 @@ +import superjson from 'superjson'; + +function marshal(value: unknown) { + return superjson.stringify(value); +} + +function unmarshal(value: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const j = JSON.parse(value) as any; + if (j?.json) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return superjson.parse(value); + } else { + return j; + } +} + +function makeUrl(url: string, args: unknown) { + return args ? url + `?q=${encodeURIComponent(superjson.stringify(args))}` : url; +} diff --git a/packages/plugins/tanstack-query/res/react/helper.ts b/packages/plugins/tanstack-query/res/react/helper.ts index 3a9811802..5ede0d694 100644 --- a/packages/plugins/tanstack-query/res/react/helper.ts +++ b/packages/plugins/tanstack-query/res/react/helper.ts @@ -28,7 +28,7 @@ export const Provider = RequestHandlerContext.Provider; * * @param model The name of the model under query. * @param url The request URL. - * @param args The request args object, which will be superjson-stringified and appended as "?q=" parameter + * @param args The request args object, URL-encoded and appended as "?q=" parameter * @param options The react-query options object * @returns useQuery hook */ diff --git a/packages/plugins/tanstack-query/res/shared.ts b/packages/plugins/tanstack-query/res/shared.ts index ba002b249..bfa88be9b 100644 --- a/packages/plugins/tanstack-query/res/shared.ts +++ b/packages/plugins/tanstack-query/res/shared.ts @@ -1,5 +1,3 @@ -import superjson from 'superjson'; - /** * The default query endpoint. */ @@ -17,13 +15,6 @@ export type RequestHandlerContext = { endpoint: string; }; -/** - * Builds a request URL with optional args. - */ -export function makeUrl(url: string, args: unknown) { - return args ? url + `?q=${encodeURIComponent(marshal(args))}` : url; -} - async function fetcher(url: string, options?: RequestInit) { const res = await fetch(url, options); if (!res.ok) { @@ -43,12 +34,3 @@ async function fetcher(url: string, options?: RequestInit) { throw err; } } - -export function marshal(value: unknown) { - return superjson.stringify(value); -} - -export function unmarshal(value: string) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return superjson.parse(value); -} diff --git a/packages/plugins/tanstack-query/res/svelte/helper.ts b/packages/plugins/tanstack-query/res/svelte/helper.ts index 3407e98df..508927fbf 100644 --- a/packages/plugins/tanstack-query/res/svelte/helper.ts +++ b/packages/plugins/tanstack-query/res/svelte/helper.ts @@ -20,7 +20,7 @@ export const SvelteQueryContextKey = 'zenstack-svelte-query-context'; * * @param model The name of the model under query. * @param url The request URL. - * @param args The request args object, which will be superjson-stringified and appended as "?q=" parameter + * @param args The request args object, URL-encoded and appended as "?q=" parameter * @param options The svelte-query options object * @returns useQuery hook */ diff --git a/packages/plugins/tanstack-query/src/generator.ts b/packages/plugins/tanstack-query/src/generator.ts index 26243345e..9ef1cafc2 100644 --- a/packages/plugins/tanstack-query/src/generator.ts +++ b/packages/plugins/tanstack-query/src/generator.ts @@ -35,7 +35,7 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. } generateIndex(project, outDir, models); - generateHelper(target, project, outDir); + generateHelper(target, project, outDir, options.useSuperJson === true); models.forEach((dataModel) => { const mapping = dmmf.mappings.modelOperations.find((op) => op.model === dataModel.name); @@ -385,7 +385,7 @@ function generateIndex(project: Project, outDir: string, models: DataModel[]) { sf.addStatements(`export * from './_helper';`); } -function generateHelper(target: TargetFramework, project: Project, outDir: string) { +function generateHelper(target: TargetFramework, project: Project, outDir: string, useSuperJson: boolean) { let srcFile: string; switch (target) { case 'react': @@ -398,10 +398,15 @@ function generateHelper(target: TargetFramework, project: Project, outDir: strin throw new PluginError(`Unsupported target: ${target}`); } - // merge content of `shared.ts` and `helper.ts` + // merge content of `shared.ts`, `helper.ts` and `marshal-?.ts` const sharedContent = fs.readFileSync(path.join(__dirname, './res/shared.ts'), 'utf-8'); const helperContent = fs.readFileSync(srcFile, 'utf-8'); - project.createSourceFile(path.join(outDir, '_helper.ts'), `${sharedContent}\n${helperContent}`, { + const marshalContent = fs.readFileSync( + path.join(__dirname, useSuperJson ? './res/marshal-superjson.ts' : './res/marshal-json.ts'), + 'utf-8' + ); + + project.createSourceFile(path.join(outDir, '_helper.ts'), `${sharedContent}\n${helperContent}\n${marshalContent}`, { overwrite: true, }); } diff --git a/packages/plugins/tanstack-query/tests/plugin.test.ts b/packages/plugins/tanstack-query/tests/plugin.test.ts index 603e5f321..b3a7b2c36 100644 --- a/packages/plugins/tanstack-query/tests/plugin.test.ts +++ b/packages/plugins/tanstack-query/tests/plugin.test.ts @@ -40,7 +40,7 @@ model Foo { } `; - it('react-query generator', async () => { + it('react-query generator regular json', async () => { await loadSchema( ` plugin tanstack { @@ -49,6 +49,25 @@ plugin tanstack { target = 'react' } +${sharedModel} + `, + true, + false, + [`${origDir}/dist`, 'react', '@types/react', '@tanstack/react-query'], + true + ); + }); + + it('react-query generator superjson', async () => { + await loadSchema( + ` +plugin tanstack { + provider = '${process.cwd()}/dist' + output = '$projectRoot/hooks' + target = 'react' + useSuperJson = true +} + ${sharedModel} `, true, @@ -58,13 +77,32 @@ ${sharedModel} ); }); - it('svelte-query generator', async () => { + it('svelte-query generator regular json', async () => { + await loadSchema( + ` +plugin tanstack { + provider = '${process.cwd()}/dist' + output = '$projectRoot/hooks' + target = 'svelte' +} + +${sharedModel} + `, + true, + false, + [`${origDir}/dist`, 'svelte', '@types/react', '@tanstack/svelte-query'], + true + ); + }); + + it('svelte-query generator superjson', async () => { await loadSchema( ` plugin tanstack { provider = '${process.cwd()}/dist' output = '$projectRoot/hooks' target = 'svelte' + useSuperJson = true } ${sharedModel} diff --git a/packages/server/package.json b/packages/server/package.json index 2605e9b90..65093273f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -28,6 +28,7 @@ "@zenstackhq/sdk": "workspace:*", "change-case": "^4.1.2", "lower-case-first": "^2.0.2", + "superjson": "^1.11.0", "tiny-invariant": "^1.3.1", "ts-japi": "^1.8.0", "upper-case-first": "^2.0.2", @@ -36,6 +37,7 @@ "zod-validation-error": "^0.2.1" }, "devDependencies": { + "@sveltejs/kit": "^1.16.3", "@types/body-parser": "^1.19.2", "@types/express": "^4.17.17", "@types/jest": "^29.5.0", @@ -48,6 +50,7 @@ "express": "^4.18.2", "fastify": "^4.14.1", "fastify-plugin": "^4.5.0", + "isomorphic-fetch": "^3.0.0", "jest": "^29.5.0", "rimraf": "^3.0.2", "supertest": "^6.3.3", diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 93a5c1671..5437ee1a5 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { DbClientContract, FieldInfo, @@ -16,7 +15,7 @@ import { DataDocument, Linker, Paginator, Relator, Serializer, SerializerOptions import UrlPattern from 'url-pattern'; import z from 'zod'; import { fromZodError } from 'zod-validation-error'; -import { LoggerConfig, RequestContext, Response } from '../types'; +import { LoggerConfig, RequestContext, Response } from '../../types'; import { getZodSchema, logWarning, stripAuxFields } from '../utils'; const urlPatterns = { @@ -320,7 +319,7 @@ class RequestHandler { let match = urlPatterns.single.match(path); if (match) { // resource deletion - return await this.processDelete(prisma, match.type, match.id, query); + return await this.processDelete(prisma, match.type, match.id); } match = urlPatterns.relationship.match(path); @@ -899,12 +898,7 @@ class RequestHandler { } } - private async processDelete( - prisma: DbClientContract, - type: any, - resourceId: string, - query: Record | undefined - ): Promise { + private async processDelete(prisma: DbClientContract, type: any, resourceId: string): Promise { const typeInfo = this.typeMap[type]; if (!typeInfo) { return this.makeUnsupportedModelError(type); diff --git a/packages/server/src/api/rpc/index.ts b/packages/server/src/api/rpc/index.ts index fb18d4a5d..2b1692b9c 100644 --- a/packages/server/src/api/rpc/index.ts +++ b/packages/server/src/api/rpc/index.ts @@ -5,7 +5,7 @@ import { isPrismaClientValidationError, } from '@zenstackhq/runtime'; import { ModelZodSchema } from '@zenstackhq/runtime/zod'; -import { LoggerConfig, RequestContext, Response } from '../types'; +import { LoggerConfig, RequestContext, Response } from '../../types'; import { logError, stripAuxFields, zodValidate } from '../utils'; /** diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts deleted file mode 100644 index dd053102f..000000000 --- a/packages/server/src/api/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { DbClientContract } from '@zenstackhq/runtime'; - -type LoggerMethod = (message: string, code?: string) => void; - -/** - * Logger config. - */ -export type LoggerConfig = { - debug?: LoggerMethod; - info?: LoggerMethod; - warn?: LoggerMethod; - error?: LoggerMethod; -}; - -/** - * Options for initializing an API endpoint request handler. - * @see requestHandler - */ -export type RequestHandlerOptions = { - /** - * Logger configuration. By default log to console. Set to null to turn off logging. - */ - logger?: LoggerConfig | null; -}; - -/** - * API request context - */ -export type RequestContext = { - prisma: DbClientContract; - method: string; - path: string; - query?: Record; - requestBody?: unknown; -}; - -/** - * API response - */ -export type Response = { - status: number; - body: unknown; -}; - -/** - * API request handler function - */ -export type HandleRequestFn = (req: RequestContext) => Promise; diff --git a/packages/server/src/api/utils.ts b/packages/server/src/api/utils.ts index eb1828ad3..08472d228 100644 --- a/packages/server/src/api/utils.ts +++ b/packages/server/src/api/utils.ts @@ -3,7 +3,7 @@ import type { ModelZodSchema } from '@zenstackhq/runtime/zod'; import { upperCaseFirst } from 'upper-case-first'; import { fromZodError } from 'zod-validation-error'; import { AUXILIARY_FIELDS } from '@zenstackhq/sdk'; -import { LoggerConfig } from './types'; +import { LoggerConfig } from '../types'; export function getZodSchema(zodSchemas: ModelZodSchema, model: string, operation: keyof DbOperations) { if (zodSchemas[model]) { @@ -42,11 +42,19 @@ export function logError(logger: LoggerConfig | undefined | null, message: strin } } -export function logWarning(logger: LoggerConfig | undefined | null, message: string, code?: string) { +export function logWarning(logger: LoggerConfig | undefined | null, message: string) { if (logger === undefined) { - console.warn(`@zenstackhq/server: error ${code ? '[' + code + ']' : ''}, ${message}`); + console.warn(`@zenstackhq/server: ${message}`); } else if (logger?.warn) { - logger.warn(message, code); + logger.warn(message); + } +} + +export function logInfo(logger: LoggerConfig | undefined | null, message: string) { + if (logger === undefined) { + console.log(`@zenstackhq/server: ${message}`); + } else if (logger?.info) { + logger.info(message); } } diff --git a/packages/server/src/express/index.ts b/packages/server/src/express/index.ts index bccd206a5..7cbc0a0c4 100644 --- a/packages/server/src/express/index.ts +++ b/packages/server/src/express/index.ts @@ -1 +1,2 @@ export { default as ZenStackMiddleware } from './middleware'; +export * from './middleware'; diff --git a/packages/server/src/express/middleware.ts b/packages/server/src/express/middleware.ts index 76029cd8c..f088663f5 100644 --- a/packages/server/src/express/middleware.ts +++ b/packages/server/src/express/middleware.ts @@ -3,32 +3,17 @@ import { DbClientContract } from '@zenstackhq/runtime'; import { getModelZodSchemas, ModelZodSchema } from '@zenstackhq/runtime/zod'; import type { Handler, Request, Response } from 'express'; import RPCAPIHandler from '../api/rpc'; -import { HandleRequestFn, LoggerConfig } from '../api/types'; +import { AdapterBaseOptions } from '../types'; +import { buildUrlQuery, marshalToObject, unmarshalFromObject } from '../utils'; /** * Express middleware options */ -export interface MiddlewareOptions { +export interface MiddlewareOptions extends AdapterBaseOptions { /** * Callback for getting a PrismaClient for the given request */ getPrisma: (req: Request, res: Response) => unknown | Promise; - - /** - * Logger settings - */ - logger?: LoggerConfig; - - /** - * Zod schemas for validating request input. Pass `true` to load from standard location - * (need to enable `@core/zod` plugin in schema.zmodel) or omit to disable input validation. - */ - zodSchemas?: ModelZodSchema | boolean; - - /** - * Api request handler function - */ - handler?: HandleRequestFn; } /** @@ -43,22 +28,34 @@ const factory = (options: MiddlewareOptions): Handler => { } const requestHandler = options.handler || RPCAPIHandler({ logger: options.logger, zodSchemas }); + const useSuperJson = options.useSuperJson === true; return async (request, response) => { const prisma = (await options.getPrisma(request, response)) as DbClientContract; if (!prisma) { - throw new Error('unable to get prisma from request context'); + response + .status(500) + .json(marshalToObject({ message: 'unable to get prisma from request context' }, useSuperJson)); + return; + } + + let query: Record = {}; + try { + query = buildUrlQuery(request.query, useSuperJson); + } catch { + response.status(400).json(marshalToObject({ message: 'invalid query parameters' }, useSuperJson)); + return; } const r = await requestHandler({ method: request.method, path: request.path, - query: request.query as Record, - requestBody: request.body, + query, + requestBody: unmarshalFromObject(request.body, useSuperJson), prisma, }); - response.status(r.status).json(r.body); + response.status(r.status).json(marshalToObject(r.body, useSuperJson)); }; }; diff --git a/packages/server/src/fastify/index.ts b/packages/server/src/fastify/index.ts index 665697985..29eb98aed 100644 --- a/packages/server/src/fastify/index.ts +++ b/packages/server/src/fastify/index.ts @@ -1 +1,2 @@ export { default as ZenStackFastifyPlugin } from './plugin'; +export * from './plugin'; diff --git a/packages/server/src/fastify/plugin.ts b/packages/server/src/fastify/plugin.ts index be6ac3a9b..5e0ae6bf4 100644 --- a/packages/server/src/fastify/plugin.ts +++ b/packages/server/src/fastify/plugin.ts @@ -4,12 +4,14 @@ import { getModelZodSchemas, ModelZodSchema } from '@zenstackhq/runtime/zod'; import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify'; import fp from 'fastify-plugin'; import RPCApiHandler from '../api/rpc'; -import { HandleRequestFn, LoggerConfig } from '../api/types'; +import { logInfo } from '../api/utils'; +import { AdapterBaseOptions } from '../types'; +import { buildUrlQuery, marshalToObject, unmarshalFromObject } from '../utils'; /** * Fastify plugin options */ -export interface PluginOptions { +export interface PluginOptions extends AdapterBaseOptions { /** * Url prefix, e.g.: /api */ @@ -19,22 +21,6 @@ export interface PluginOptions { * Callback for getting a PrismaClient for the given request */ getPrisma: (request: FastifyRequest, reply: FastifyReply) => unknown | Promise; - - /** - * Logger settings - */ - logger?: LoggerConfig; - - /** - * Zod schemas for validating request input. Pass `true` to load from standard location - * (need to enable `@core/zod` plugin in schema.zmodel) or omit to disable input validation. - */ - zodSchemas?: ModelZodSchema | boolean; - - /** - * Api request handler function - */ - api: HandleRequestFn; } /** @@ -42,12 +28,7 @@ export interface PluginOptions { */ const pluginHandler: FastifyPluginCallback = (fastify, options, done) => { const prefix = options.prefix ?? ''; - - if (options.logger?.info === undefined) { - console.log(`ZenStackPlugin installing routes at prefix: ${prefix}`); - } else { - options.logger?.info?.(`ZenStackPlugin installing routes at prefix: ${prefix}`); - } + logInfo(options.logger, `ZenStackPlugin installing routes at prefix: ${prefix}`); let schemas: ModelZodSchema | undefined; if (typeof options.zodSchemas === 'object') { @@ -56,24 +37,35 @@ const pluginHandler: FastifyPluginCallback = (fastify, options, d schemas = getModelZodSchemas(); } - const requestHanler = options.api ?? RPCApiHandler({ logger: options.logger, zodSchemas: schemas }); + const requestHanler = options.handler ?? RPCApiHandler({ logger: options.logger, zodSchemas: schemas }); + const useSuperJson = options.useSuperJson === true; fastify.all(`${prefix}/*`, async (request, reply) => { const prisma = (await options.getPrisma(request, reply)) as DbClientContract; if (!prisma) { - throw new Error('unable to get prisma from request context'); + reply + .status(500) + .send(marshalToObject({ message: 'unable to get prisma from request context' }, useSuperJson)); + return; + } + + let query: Record = {}; + try { + query = buildUrlQuery(request.query, useSuperJson); + } catch { + reply.status(400).send(marshalToObject({ message: 'invalid query parameters' }, useSuperJson)); + return; } - const query = request.query as Record; const response = await requestHanler({ method: request.method, path: (request.params as any)['*'], query, - requestBody: request.body, + requestBody: unmarshalFromObject(request.body, useSuperJson), prisma, }); - reply.status(response.status).send(response.body); + reply.status(response.status).send(marshalToObject(response.body, useSuperJson)); }); done(); diff --git a/packages/server/src/sveltekit/handler.ts b/packages/server/src/sveltekit/handler.ts new file mode 100644 index 000000000..0ec4cf3ac --- /dev/null +++ b/packages/server/src/sveltekit/handler.ts @@ -0,0 +1,101 @@ +import type { Handle, RequestEvent } from '@sveltejs/kit'; +import { DbClientContract } from '@zenstackhq/runtime'; +import { ModelZodSchema, getModelZodSchemas } from '@zenstackhq/runtime/zod'; +import RPCApiHandler from '../api/rpc'; +import { logInfo } from '../api/utils'; +import { AdapterBaseOptions } from '../types'; +import { buildUrlQuery, marshalToString, unmarshalFromString } from '../utils'; + +/** + * SvelteKit request handler options + */ +export interface HandlerOptions extends AdapterBaseOptions { + /** + * Url prefix, e.g.: /api + */ + prefix: string; + + /** + * Callback for getting a PrismaClient for the given request + */ + getPrisma: (event: RequestEvent) => unknown | Promise; +} + +/** + * SvelteKit server hooks handler for handling CRUD requests. + */ +export default function createHandler(options: HandlerOptions): Handle { + logInfo(options.logger, `ZenStackHandler installing routes at prefix: ${options.prefix}`); + + let schemas: ModelZodSchema | undefined; + if (typeof options.zodSchemas === 'object') { + schemas = options.zodSchemas; + } else if (options.zodSchemas === true) { + schemas = getModelZodSchemas(); + } + + const requestHanler = options.handler ?? RPCApiHandler({ logger: options.logger, zodSchemas: schemas }); + const useSuperJson = options.useSuperJson === true; + + return async ({ event, resolve }) => { + if (event.url.pathname.startsWith(options.prefix)) { + const prisma = (await options.getPrisma(event)) as DbClientContract; + if (!prisma) { + return new Response( + marshalToString({ message: 'unable to get prisma from request context' }, useSuperJson), + { + status: 400, + headers: { + 'content-type': 'application/json', + }, + } + ); + } + + const queryObj: Record = {}; + for (const key of event.url.searchParams.keys()) { + const values = event.url.searchParams.getAll(key); + queryObj[key] = values; + } + + let query: Record = {}; + try { + query = buildUrlQuery(queryObj, useSuperJson); + } catch { + return new Response(marshalToString({ message: 'invalid query parameters' }, useSuperJson), { + status: 400, + headers: { + 'content-type': 'application/json', + }, + }); + } + + let requestBody: unknown; + if (event.request.body) { + const text = await event.request.text(); + if (text) { + requestBody = unmarshalFromString(text, useSuperJson); + } + } + + const path = event.url.pathname.substring(options.prefix.length); + + const response = await requestHanler({ + method: event.request.method, + path, + query, + requestBody, + prisma, + }); + + return new Response(marshalToString(response.body, useSuperJson), { + status: response.status, + headers: { + 'content-type': 'application/json', + }, + }); + } + + return resolve(event); + }; +} diff --git a/packages/server/src/sveltekit/index.ts b/packages/server/src/sveltekit/index.ts new file mode 100644 index 000000000..83f2980bb --- /dev/null +++ b/packages/server/src/sveltekit/index.ts @@ -0,0 +1,2 @@ +export { default as SvelteKitHandler } from './handler'; +export * from './handler'; diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts new file mode 100644 index 000000000..eb6b57bdb --- /dev/null +++ b/packages/server/src/types.ts @@ -0,0 +1,65 @@ +import { DbClientContract } from '@zenstackhq/runtime'; +import { ModelZodSchema } from '@zenstackhq/runtime/zod'; + +type LoggerMethod = (message: string, code?: string) => void; + +/** + * Logger config. + */ +export type LoggerConfig = { + debug?: LoggerMethod; + info?: LoggerMethod; + warn?: LoggerMethod; + error?: LoggerMethod; +}; + +/** + * API request context + */ +export type RequestContext = { + prisma: DbClientContract; + method: string; + path: string; + query?: Record; + requestBody?: unknown; +}; + +/** + * API response + */ +export type Response = { + status: number; + body: unknown; +}; + +/** + * API request handler function + */ +export type HandleRequestFn = (req: RequestContext) => Promise; + +/** + * Base type for options used to create a server adapter. + */ +export interface AdapterBaseOptions { + /** + * Logger settings + */ + logger?: LoggerConfig; + + /** + * Zod schemas for validating request input. Pass `true` to load from standard location + * (need to enable `@core/zod` plugin in schema.zmodel) or omit to disable input validation. + */ + zodSchemas?: ModelZodSchema | boolean; + + /** + * Api request handler function. Can be created using `/api/rest` or `/api/rpc` factory functions. + * Defaults to RCP-style API handler created with `/api/rpc`. + */ + handler?: HandleRequestFn; + + /** + * Whether to use superjson for serialization/deserialization. Defaults to `false`. + */ + useSuperJson?: boolean; +} diff --git a/packages/server/src/utils.ts b/packages/server/src/utils.ts new file mode 100644 index 000000000..c68d161d3 --- /dev/null +++ b/packages/server/src/utils.ts @@ -0,0 +1,87 @@ +import superjson from 'superjson'; + +/** + * Marshal an object to string + */ +export function marshalToString(value: unknown, useSuperJson = false) { + return useSuperJson ? superjson.stringify(value) : JSON.stringify(value); +} + +/** + * Marshals an object + */ +export function marshalToObject(value: unknown, useSuperJson = false) { + return useSuperJson ? JSON.parse(superjson.stringify(value)) : value; +} + +/** + * Unmarshal a string to object + */ +export function unmarshalFromString(value: string, useSuperJson = false) { + if (value === undefined || value === null) { + return value; + } + + const j = JSON.parse(value); + if (useSuperJson) { + if (j?.json) { + // parse with superjson + return superjson.parse(value); + } else { + // parse as regular json + return j; + } + } else { + return j; + } +} + +/** + * Unmarshal an object + */ +export function unmarshalFromObject(value: unknown, useSuperJson = false) { + if (value === undefined || value === null) { + return value; + } + + if (useSuperJson) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((value as any).json) { + // parse with superjson + return superjson.parse(JSON.stringify(value)); + } else { + // parse as regular json + return value; + } + } else { + return value; + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function buildUrlQuery(query: unknown, useSuperJson: boolean) { + const result: Record = {}; + + if (typeof query !== 'object' || query === null) { + return result; + } + + for (const [key, v] of Object.entries(query)) { + if (typeof v !== 'string' && !Array.isArray(v)) { + continue; + } + + let value = v as string | string[]; + + if (key === 'q') { + // handle parameter marshalling (potentially using superjson) + if (Array.isArray(value)) { + value = value.map((v) => JSON.stringify(unmarshalFromString(v as string, useSuperJson))); + } else { + value = JSON.stringify(unmarshalFromString(value as string, useSuperJson)); + } + } + result[key] = value; + } + return result; +} diff --git a/packages/server/tests/api/rest/express-adapter.test.ts b/packages/server/tests/adapter/express-rest.test.ts similarity index 89% rename from packages/server/tests/api/rest/express-adapter.test.ts rename to packages/server/tests/adapter/express-rest.test.ts index 20c843991..f34a94ee9 100644 --- a/packages/server/tests/api/rest/express-adapter.test.ts +++ b/packages/server/tests/adapter/express-rest.test.ts @@ -4,11 +4,11 @@ import { loadSchema } from '@zenstackhq/testtools'; import bodyParser from 'body-parser'; import express from 'express'; import request from 'supertest'; -import { ZenStackMiddleware } from '../../../src/express'; -import { makeUrl, schema } from '../../utils'; -import RESTAPIHandler from '../../../src/api/rest'; +import { ZenStackMiddleware } from '../../src/express'; +import { makeUrl, schema } from '../utils'; +import RESTAPIHandler from '../../src/api/rest'; -describe('Express adapter tests', () => { +describe('Express adapter tests - rest handler', () => { it('run middleware', async () => { const { prisma, zodSchemas, modelMeta } = await loadSchema(schema); diff --git a/packages/server/tests/api/rpc/express-adapter.test.ts b/packages/server/tests/adapter/express-rpc.test.ts similarity index 50% rename from packages/server/tests/api/rpc/express-adapter.test.ts rename to packages/server/tests/adapter/express-rpc.test.ts index ce003964c..944611697 100644 --- a/packages/server/tests/api/rpc/express-adapter.test.ts +++ b/packages/server/tests/adapter/express-rpc.test.ts @@ -1,14 +1,16 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /// import { loadSchema } from '@zenstackhq/testtools'; import bodyParser from 'body-parser'; import express from 'express'; import request from 'supertest'; -import { ZenStackMiddleware } from '../../../src/express'; -import { makeUrl, schema } from '../../utils'; +import { ZenStackMiddleware } from '../../src/express'; +import { makeUrl, schema } from '../utils'; +import superjson from 'superjson'; -describe('Express adapter tests', () => { - it('run plugin', async () => { +describe('Express adapter tests - rpc handler', () => { + it('run plugin regular json', async () => { const { prisma, zodSchemas } = await loadSchema(schema); const app = express(); @@ -104,4 +106,90 @@ describe('Express adapter tests', () => { r = await request(app).get('/api/post/findMany?q=abc'); expect(r.status).toBe(400); }); + + it('run plugin superjson', async () => { + const { prisma, zodSchemas } = await loadSchema(schema); + + const app = express(); + app.use(bodyParser.json()); + app.use('/api', ZenStackMiddleware({ getPrisma: () => prisma, zodSchemas, useSuperJson: true })); + + let r = await request(app).get(makeUrl('/api/post/findMany', { where: { id: { equals: '1' } } }, true)); + expect(r.status).toBe(200); + expect(unmarshal(r.body)).toHaveLength(0); + + r = await request(app) + .post('/api/user/create') + .send({ + include: { posts: true }, + data: { + id: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { title: 'post1', published: true, viewCount: 1 }, + { title: 'post2', published: false, viewCount: 2 }, + ], + }, + }, + }); + + expect(r.status).toBe(201); + expect(unmarshal(r.body)).toEqual( + expect.objectContaining({ + email: 'user1@abc.com', + posts: expect.arrayContaining([ + expect.objectContaining({ title: 'post1' }), + expect.objectContaining({ title: 'post2' }), + ]), + }) + ); + // aux fields should have been removed + const data = unmarshal(r.body); + expect(data.zenstack_guard).toBeUndefined(); + expect(data.zenstack_transaction).toBeUndefined(); + expect(data.posts[0].zenstack_guard).toBeUndefined(); + expect(data.posts[0].zenstack_transaction).toBeUndefined(); + + r = await request(app).get(makeUrl('/api/post/findMany')); + expect(r.status).toBe(200); + expect(unmarshal(r.body)).toHaveLength(2); + + r = await request(app).get(makeUrl('/api/post/findMany', { where: { viewCount: { gt: 1 } } }, true)); + expect(r.status).toBe(200); + expect(unmarshal(r.body)).toHaveLength(1); + + r = await request(app) + .put('/api/user/update') + .send({ where: { id: 'user1' }, data: { email: 'user1@def.com' } }); + expect(r.status).toBe(200); + expect(unmarshal(r.body).email).toBe('user1@def.com'); + + r = await request(app).get(makeUrl('/api/post/count', { where: { viewCount: { gt: 1 } } }, true)); + expect(r.status).toBe(200); + expect(unmarshal(r.body)).toBe(1); + + r = await request(app).get(makeUrl('/api/post/aggregate', { _sum: { viewCount: true } }, true)); + expect(r.status).toBe(200); + expect(unmarshal(r.body)._sum.viewCount).toBe(3); + + r = await request(app).get( + makeUrl('/api/post/groupBy', { by: ['published'], _sum: { viewCount: true } }, true) + ); + expect(r.status).toBe(200); + expect(unmarshal(r.body)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ published: true, _sum: { viewCount: 1 } }), + expect.objectContaining({ published: false, _sum: { viewCount: 2 } }), + ]) + ); + + r = await request(app).delete(makeUrl('/api/user/deleteMany', { where: { id: 'user1' } }, true)); + expect(r.status).toBe(200); + expect(unmarshal(r.body).count).toBe(1); + }); }); + +function unmarshal(value: any) { + return superjson.parse(JSON.stringify(value)) as any; +} diff --git a/packages/server/tests/api/rpc/fastify-adapter.test.ts b/packages/server/tests/adapter/fastify-rpc.test.ts similarity index 51% rename from packages/server/tests/api/rpc/fastify-adapter.test.ts rename to packages/server/tests/adapter/fastify-rpc.test.ts index b18b2f2e6..f00192ea5 100644 --- a/packages/server/tests/api/rpc/fastify-adapter.test.ts +++ b/packages/server/tests/adapter/fastify-rpc.test.ts @@ -1,13 +1,15 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /// import { loadSchema } from '@zenstackhq/testtools'; import fastify from 'fastify'; -import { ZenStackFastifyPlugin } from '../../../src/fastify'; -import { makeUrl, schema } from '../../utils'; -import Prisma from '../../../src/api/rpc'; +import superjson from 'superjson'; +import Prisma from '../../src/api/rpc'; +import { ZenStackFastifyPlugin } from '../../src/fastify'; +import { makeUrl, schema } from '../utils'; describe('Fastify adapter tests', () => { - it('run plugin', async () => { + it('run plugin regular json', async () => { const { prisma, zodSchemas } = await loadSchema(schema); const app = fastify(); @@ -15,7 +17,7 @@ describe('Fastify adapter tests', () => { prefix: '/api', getPrisma: () => prisma, zodSchemas, - api: Prisma(), + handler: Prisma(), }); let r = await app.inject({ @@ -123,7 +125,7 @@ describe('Fastify adapter tests', () => { prefix: '/api', getPrisma: () => prisma, zodSchemas, - api: Prisma(), + handler: Prisma(), }); let r = await app.inject({ @@ -144,4 +146,117 @@ describe('Fastify adapter tests', () => { }); expect(r.statusCode).toBe(400); }); + + it('run plugin superjson', async () => { + const { prisma, zodSchemas } = await loadSchema(schema); + + const app = fastify(); + app.register(ZenStackFastifyPlugin, { + prefix: '/api', + getPrisma: () => prisma, + zodSchemas, + handler: Prisma(), + useSuperJson: true, + }); + + let r = await app.inject({ + method: 'GET', + url: makeUrl('/api/post/findMany', { where: { id: { equals: '1' } } }, true), + }); + expect(r.statusCode).toBe(200); + expect(unmarshal(r.json())).toHaveLength(0); + + r = await app.inject({ + method: 'POST', + url: '/api/user/create', + payload: { + include: { posts: true }, + data: { + id: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { title: 'post1', published: true, viewCount: 1 }, + { title: 'post2', published: false, viewCount: 2 }, + ], + }, + }, + }, + }); + expect(r.statusCode).toBe(201); + expect(unmarshal(r.json())).toEqual( + expect.objectContaining({ + email: 'user1@abc.com', + posts: expect.arrayContaining([ + expect.objectContaining({ title: 'post1' }), + expect.objectContaining({ title: 'post2' }), + ]), + }) + ); + // aux fields should have been removed + const data = unmarshal(r.json()); + expect(data.zenstack_guard).toBeUndefined(); + expect(data.zenstack_transaction).toBeUndefined(); + expect(data.posts[0].zenstack_guard).toBeUndefined(); + expect(data.posts[0].zenstack_transaction).toBeUndefined(); + + r = await app.inject({ + method: 'GET', + url: makeUrl('/api/post/findMany'), + }); + expect(r.statusCode).toBe(200); + expect(unmarshal(r.json())).toHaveLength(2); + + r = await app.inject({ + method: 'GET', + url: makeUrl('/api/post/findMany', { where: { viewCount: { gt: 1 } } }, true), + }); + expect(r.statusCode).toBe(200); + expect(unmarshal(r.json())).toHaveLength(1); + + r = await app.inject({ + method: 'PUT', + url: '/api/user/update', + payload: { where: { id: 'user1' }, data: { email: 'user1@def.com' } }, + }); + expect(r.statusCode).toBe(200); + expect(unmarshal(r.json()).email).toBe('user1@def.com'); + + r = await app.inject({ + method: 'GET', + url: makeUrl('/api/post/count', { where: { viewCount: { gt: 1 } } }, true), + }); + expect(r.statusCode).toBe(200); + expect(unmarshal(r.json())).toBe(1); + + r = await app.inject({ + method: 'GET', + url: makeUrl('/api/post/aggregate', { _sum: { viewCount: true } }, true), + }); + expect(r.statusCode).toBe(200); + expect(unmarshal(r.json())._sum.viewCount).toBe(3); + + r = await app.inject({ + method: 'GET', + url: makeUrl('/api/post/groupBy', { by: ['published'], _sum: { viewCount: true } }, true), + }); + expect(r.statusCode).toBe(200); + expect(unmarshal(r.json())).toEqual( + expect.arrayContaining([ + expect.objectContaining({ published: true, _sum: { viewCount: 1 } }), + expect.objectContaining({ published: false, _sum: { viewCount: 2 } }), + ]) + ); + + r = await app.inject({ + method: 'DELETE', + url: makeUrl('/api/user/deleteMany', { where: { id: 'user1' } }, true), + }); + expect(r.statusCode).toBe(200); + expect(unmarshal(r.json()).count).toBe(1); + }); }); + +function unmarshal(value: any) { + return superjson.parse(JSON.stringify(value)) as any; +} diff --git a/packages/server/tests/adapter/sveltekit-rpc.test.ts b/packages/server/tests/adapter/sveltekit-rpc.test.ts new file mode 100644 index 000000000..a7fa0934f --- /dev/null +++ b/packages/server/tests/adapter/sveltekit-rpc.test.ts @@ -0,0 +1,173 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/// +import { loadSchema } from '@zenstackhq/testtools'; +import { SvelteKitHandler } from '../../src/sveltekit'; +import { schema, makeUrl } from '../utils'; +import 'isomorphic-fetch'; +import superjson from 'superjson'; + +describe('SvelteKit adapter tests', () => { + it('run hooks regular json', async () => { + const { prisma, zodSchemas } = await loadSchema(schema); + + const handler = SvelteKitHandler({ prefix: '/api', getPrisma: () => prisma, zodSchemas }); + + let r = await handler(makeRequest('GET', makeUrl('/api/post/findMany', { where: { id: { equals: '1' } } }))); + expect(r.status).toBe(200); + expect(await unmarshal(r)).toHaveLength(0); + + r = await handler( + makeRequest('POST', '/api/user/create', { + include: { posts: true }, + data: { + id: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { title: 'post1', published: true, viewCount: 1 }, + { title: 'post2', published: false, viewCount: 2 }, + ], + }, + }, + }) + ); + // console.log(JSON.stringify(await r.json(), null, 2)); + expect(r.status).toBe(201); + expect(await unmarshal(r)).toMatchObject({ + email: 'user1@abc.com', + posts: expect.arrayContaining([ + expect.objectContaining({ title: 'post1' }), + expect.objectContaining({ title: 'post2' }), + ]), + }); + + r = await handler(makeRequest('GET', makeUrl('/api/post/findMany'))); + expect(r.status).toBe(200); + expect(await unmarshal(r)).toHaveLength(2); + + r = await handler(makeRequest('GET', makeUrl('/api/post/findMany', { where: { viewCount: { gt: 1 } } }))); + expect(r.status).toBe(200); + expect(await unmarshal(r)).toHaveLength(1); + + r = await handler( + makeRequest('PUT', '/api/user/update', { where: { id: 'user1' }, data: { email: 'user1@def.com' } }) + ); + expect(r.status).toBe(200); + expect((await unmarshal(r)).email).toBe('user1@def.com'); + + r = await handler(makeRequest('GET', makeUrl('/api/post/count', { where: { viewCount: { gt: 1 } } }))); + expect(r.status).toBe(200); + expect(await unmarshal(r)).toBe(1); + + r = await handler(makeRequest('GET', makeUrl('/api/post/aggregate', { _sum: { viewCount: true } }))); + expect(r.status).toBe(200); + expect((await unmarshal(r))._sum.viewCount).toBe(3); + + r = await handler( + makeRequest('GET', makeUrl('/api/post/groupBy', { by: ['published'], _sum: { viewCount: true } })) + ); + expect(r.status).toBe(200); + expect(await unmarshal(r)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ published: true, _sum: { viewCount: 1 } }), + expect.objectContaining({ published: false, _sum: { viewCount: 2 } }), + ]) + ); + + r = await handler(makeRequest('DELETE', makeUrl('/api/user/deleteMany', { where: { id: 'user1' } }))); + expect(r.status).toBe(200); + expect((await unmarshal(r)).count).toBe(1); + }); + + it('run hooks superjson', async () => { + const { prisma, zodSchemas } = await loadSchema(schema); + + const handler = SvelteKitHandler({ prefix: '/api', getPrisma: () => prisma, zodSchemas, useSuperJson: true }); + + let r = await handler( + makeRequest('GET', makeUrl('/api/post/findMany', { where: { id: { equals: '1' } } }, true)) + ); + expect(r.status).toBe(200); + expect(await unmarshal(r, true)).toHaveLength(0); + + r = await handler( + makeRequest('POST', '/api/user/create', { + include: { posts: true }, + data: { + id: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { title: 'post1', published: true, viewCount: 1 }, + { title: 'post2', published: false, viewCount: 2 }, + ], + }, + }, + }) + ); + // console.log(JSON.stringify(await r.json(), null, 2)); + expect(r.status).toBe(201); + expect(await unmarshal(r, true)).toMatchObject({ + email: 'user1@abc.com', + posts: expect.arrayContaining([ + expect.objectContaining({ title: 'post1' }), + expect.objectContaining({ title: 'post2' }), + ]), + }); + + r = await handler(makeRequest('GET', makeUrl('/api/post/findMany', undefined))); + expect(r.status).toBe(200); + expect(await unmarshal(r, true)).toHaveLength(2); + + r = await handler(makeRequest('GET', makeUrl('/api/post/findMany', { where: { viewCount: { gt: 1 } } }, true))); + expect(r.status).toBe(200); + expect(await unmarshal(r, true)).toHaveLength(1); + + r = await handler( + makeRequest('PUT', '/api/user/update', { where: { id: 'user1' }, data: { email: 'user1@def.com' } }) + ); + expect(r.status).toBe(200); + expect((await unmarshal(r, true)).email).toBe('user1@def.com'); + + r = await handler(makeRequest('GET', makeUrl('/api/post/count', { where: { viewCount: { gt: 1 } } }, true))); + expect(r.status).toBe(200); + expect(await unmarshal(r, true)).toBe(1); + + r = await handler(makeRequest('GET', makeUrl('/api/post/aggregate', { _sum: { viewCount: true } }, true))); + expect(r.status).toBe(200); + expect((await unmarshal(r, true))._sum.viewCount).toBe(3); + + r = await handler( + makeRequest('GET', makeUrl('/api/post/groupBy', { by: ['published'], _sum: { viewCount: true } }, true)) + ); + expect(r.status).toBe(200); + expect(await unmarshal(r, true)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ published: true, _sum: { viewCount: 1 } }), + expect.objectContaining({ published: false, _sum: { viewCount: 2 } }), + ]) + ); + + r = await handler(makeRequest('DELETE', makeUrl('/api/user/deleteMany', { where: { id: 'user1' } }, true))); + expect(r.status).toBe(200); + expect((await unmarshal(r, true)).count).toBe(1); + }); +}); + +function makeRequest(method: string, path: string, body?: any) { + const payload = body ? JSON.stringify(body) : undefined; + return { + event: { + request: new Request(`http://localhost${path}`, { method, body: payload }), + url: new URL(`http://localhost${path}`), + } as any, + resolve: async () => { + throw new Error('should not be called'); + }, + }; +} + +async function unmarshal(r: Response, useSuperJson = false) { + const text = await r.text(); + return (useSuperJson ? superjson.parse(text) : JSON.parse(text)) as any; +} diff --git a/packages/server/tests/api/rest/rest.test.ts b/packages/server/tests/api/rest.test.ts similarity index 99% rename from packages/server/tests/api/rest/rest.test.ts rename to packages/server/tests/api/rest.test.ts index 20f936cf2..8350595bb 100644 --- a/packages/server/tests/api/rest/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -3,8 +3,8 @@ import { loadSchema, run } from '@zenstackhq/testtools'; import { ModelMeta } from '@zenstackhq/runtime/enhancements/types'; -import makeHandler from '../../../src/api/rest'; -import { HandleRequestFn } from '../../../src/api/types'; +import makeHandler from '../../src/api/rest'; +import { HandleRequestFn } from '../../src/types'; let prisma: any; let zodSchemas: any; diff --git a/packages/server/tests/api/rpc/rpc.test.ts b/packages/server/tests/api/rpc.test.ts similarity index 98% rename from packages/server/tests/api/rpc/rpc.test.ts rename to packages/server/tests/api/rpc.test.ts index 587153394..e3cb2b85e 100644 --- a/packages/server/tests/api/rpc/rpc.test.ts +++ b/packages/server/tests/api/rpc.test.ts @@ -2,8 +2,8 @@ /// import { loadSchema } from '@zenstackhq/testtools'; -import RPCAPIHandler from '../../../src/api/rpc'; -import { schema } from '../../utils'; +import RPCAPIHandler from '../../src/api/rpc'; +import { schema } from '../utils'; describe('OpenAPI server tests', () => { let prisma: any; diff --git a/packages/server/tests/utils.ts b/packages/server/tests/utils.ts index 34f7e48b6..b46055b57 100644 --- a/packages/server/tests/utils.ts +++ b/packages/server/tests/utils.ts @@ -1,3 +1,5 @@ +import superjson from 'superjson'; + export const schema = ` model User { id String @id @default(cuid()) @@ -19,6 +21,6 @@ model Post { } `; -export function makeUrl(path: string, q?: object) { - return q ? `${path}?q=${encodeURIComponent(JSON.stringify(q))}` : path; +export function makeUrl(path: string, q?: object, useSuperJson = false) { + return q ? `${path}?q=${encodeURIComponent(useSuperJson ? superjson.stringify(q) : JSON.stringify(q))}` : path; } diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 36d8bdce1..f6dff1942 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES6", "module": "CommonJS", - "lib": ["ESNext"], + "lib": ["ESNext", "DOM"], "sourceMap": true, "outDir": "dist", "strict": true, diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index e00fb6158..ed8bc5ce9 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -96,66 +96,61 @@ export async function loadSchema( ) { const { name: projectRoot } = tmp.dirSync({ unsafeCleanup: true }); - try { - const root = getWorkspaceRoot(__dirname); + const root = getWorkspaceRoot(__dirname); - if (!root) { - throw new Error('Could not find workspace root'); - } - - const pkgContent = fs.readFileSync(path.join(__dirname, 'package.template.json'), { encoding: 'utf-8' }); - fs.writeFileSync(path.join(projectRoot, 'package.json'), pkgContent.replaceAll('', root)); + if (!root) { + throw new Error('Could not find workspace root'); + } - const npmrcContent = fs.readFileSync(path.join(__dirname, '.npmrc.template'), { encoding: 'utf-8' }); - fs.writeFileSync(path.join(projectRoot, '.npmrc'), npmrcContent.replaceAll('', root)); + const pkgContent = fs.readFileSync(path.join(__dirname, 'package.template.json'), { encoding: 'utf-8' }); + fs.writeFileSync(path.join(projectRoot, 'package.json'), pkgContent.replaceAll('', root)); - console.log('Workdir:', projectRoot); - process.chdir(projectRoot); + const npmrcContent = fs.readFileSync(path.join(__dirname, '.npmrc.template'), { encoding: 'utf-8' }); + fs.writeFileSync(path.join(projectRoot, '.npmrc'), npmrcContent.replaceAll('', root)); - schema = schema.replaceAll('$projectRoot', projectRoot); + console.log('Workdir:', projectRoot); + process.chdir(projectRoot); - const content = addPrelude ? `${MODEL_PRELUDE}\n${schema}` : schema; - fs.writeFileSync('schema.zmodel', content); - run('npm install'); - run('npx zenstack generate --no-dependency-check', { NODE_PATH: './node_modules' }); + schema = schema.replaceAll('$projectRoot', projectRoot); - if (pushDb) { - run('npx prisma db push'); - } + const content = addPrelude ? `${MODEL_PRELUDE}\n${schema}` : schema; + fs.writeFileSync('schema.zmodel', content); + run('npm install'); + run('npx zenstack generate --no-dependency-check', { NODE_PATH: './node_modules' }); - const PrismaClient = require(path.join(projectRoot, 'node_modules/.prisma/client')).PrismaClient; - const prisma = new PrismaClient({ log: ['info', 'warn', 'error'] }); + if (pushDb) { + run('npx prisma db push'); + } - extraDependencies.forEach((dep) => { - console.log(`Installing dependency ${dep}`); - run(`npm install ${dep}`); - }); + const PrismaClient = require(path.join(projectRoot, 'node_modules/.prisma/client')).PrismaClient; + const prisma = new PrismaClient({ log: ['info', 'warn', 'error'] }); - if (compile) { - console.log('Compiling...'); - run('npx tsc --init'); - run('npx tsc --project tsconfig.json'); - } + extraDependencies.forEach((dep) => { + console.log(`Installing dependency ${dep}`); + run(`npm install ${dep}`); + }); - const policy = require(path.join(projectRoot, '.zenstack/policy')).default; - const modelMeta = require(path.join(projectRoot, '.zenstack/model-meta')).default; - const zodSchemas = require(path.join(projectRoot, '.zenstack/zod')); - - return { - projectDir: projectRoot, - prisma, - withPolicy: (user?: AuthUser) => withPolicy(prisma, { user }, policy, modelMeta), - withOmit: () => withOmit(prisma, modelMeta), - withPassword: () => withPassword(prisma, modelMeta), - withPresets: (user?: AuthUser) => withPresets(prisma, { user }, policy, modelMeta), - policy, - modelMeta, - zodSchemas, - }; - } catch (err) { - fs.rmSync(projectRoot, { recursive: true, force: true }); - throw err; + if (compile) { + console.log('Compiling...'); + run('npx tsc --init'); + run('npx tsc --project tsconfig.json'); } + + const policy = require(path.join(projectRoot, '.zenstack/policy')).default; + const modelMeta = require(path.join(projectRoot, '.zenstack/model-meta')).default; + const zodSchemas = require(path.join(projectRoot, '.zenstack/zod')); + + return { + projectDir: projectRoot, + prisma, + withPolicy: (user?: AuthUser) => withPolicy(prisma, { user }, policy, modelMeta), + withOmit: () => withOmit(prisma, modelMeta), + withPassword: () => withPassword(prisma, modelMeta), + withPresets: (user?: AuthUser) => withPresets(prisma, { user }, policy, modelMeta), + policy, + modelMeta, + zodSchemas, + }; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f27e6f692..afca58271 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -679,6 +679,9 @@ importers: lower-case-first: specifier: ^2.0.2 version: 2.0.2 + superjson: + specifier: ^1.11.0 + version: 1.12.1 tiny-invariant: specifier: ^1.3.1 version: 1.3.1 @@ -698,6 +701,9 @@ importers: specifier: ^0.2.1 version: 0.2.1(zod@3.21.1) devDependencies: + '@sveltejs/kit': + specifier: ^1.16.3 + version: 1.16.3(svelte@3.59.1)(vite@4.2.1) '@types/body-parser': specifier: ^1.19.2 version: 1.19.2 @@ -734,6 +740,9 @@ importers: fastify-plugin: specifier: ^4.5.0 version: 4.5.0 + isomorphic-fetch: + specifier: ^3.0.0 + version: 3.0.0 jest: specifier: ^29.5.0 version: 29.5.0 @@ -2328,6 +2337,10 @@ packages: config-chain: 1.1.13 dev: false + /@polka/url@1.0.0-next.21: + resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} + dev: true + /@prisma/client@4.7.1: resolution: {integrity: sha512-/GbnOwIPtjiveZNUzGXOdp7RxTEkHL4DZP3vBaFNadfr6Sf0RshU5EULFzVaSi9i9PIK9PYd+1Rn7z2B2npb9w==} engines: {node: '>=14.17'} @@ -2553,6 +2566,53 @@ packages: '@sinonjs/commons': 2.0.0 dev: true + /@sveltejs/kit@1.16.3(svelte@3.59.1)(vite@4.2.1): + resolution: {integrity: sha512-8uv0udYRpVuE1BweFidcWHfL+u2gAANKmvIal1dN/FWPBl7DJYbt9zYEtr3bNTiXystT8Sn0Wp54RfwpbPqHjQ==} + engines: {node: ^16.14 || >=18} + hasBin: true + requiresBuild: true + peerDependencies: + svelte: ^3.54.0 + vite: ^4.0.0 + dependencies: + '@sveltejs/vite-plugin-svelte': 2.2.0(svelte@3.59.1)(vite@4.2.1) + '@types/cookie': 0.5.1 + cookie: 0.5.0 + devalue: 4.3.1 + esm-env: 1.0.0 + kleur: 4.1.5 + magic-string: 0.30.0 + mime: 3.0.0 + sade: 1.8.1 + set-cookie-parser: 2.6.0 + sirv: 2.0.3 + svelte: 3.59.1 + tiny-glob: 0.2.9 + undici: 5.22.1 + vite: 4.2.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@sveltejs/vite-plugin-svelte@2.2.0(svelte@3.59.1)(vite@4.2.1): + resolution: {integrity: sha512-KDtdva+FZrZlyug15KlbXuubntAPKcBau0K7QhAIqC5SAy0uDbjZwoexDRx0L0J2T4niEfC6FnA9GuQQJKg+Aw==} + engines: {node: ^14.18.0 || >= 16} + peerDependencies: + svelte: ^3.54.0 + vite: ^4.0.0 + dependencies: + debug: 4.3.4 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.0 + svelte: 3.59.1 + svelte-hmr: 0.15.1(svelte@3.59.1) + vite: 4.2.1 + vitefu: 0.2.4(vite@4.2.1) + transitivePeerDependencies: + - supports-color + dev: true + /@swc/helpers@0.4.11: resolution: {integrity: sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw==} dependencies: @@ -2699,6 +2759,10 @@ packages: '@types/node': 18.14.2 dev: true + /@types/cookie@0.5.1: + resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==} + dev: true + /@types/cookiejar@2.1.2: resolution: {integrity: sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==} dev: true @@ -4419,8 +4483,8 @@ packages: type-detect: 4.0.8 dev: false - /deepmerge@4.2.2: - resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} + /deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} dev: true @@ -4481,6 +4545,10 @@ packages: engines: {node: '>=8'} dev: true + /devalue@4.3.1: + resolution: {integrity: sha512-Kc0TSP9IUU9eg55au5Q3YtqaYI2cgntVpunJV9Exbm9nvlBeTE5p2NqYHfpuXK6+VF2hF5PI+BPFPUti7e2N1g==} + dev: true + /dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} dependencies: @@ -5180,6 +5248,10 @@ packages: - supports-color dev: true + /esm-env@1.0.0: + resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} + dev: true + /espree@9.4.1: resolution: {integrity: sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -5791,6 +5863,10 @@ packages: define-properties: 1.1.4 dev: true + /globalyzer@0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + dev: true + /globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -5802,6 +5878,10 @@ packages: merge2: 1.4.1 slash: 3.0.0 + /globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + dev: true + /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -6250,6 +6330,15 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + /isomorphic-fetch@3.0.0: + resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} + dependencies: + node-fetch: 2.6.7 + whatwg-fetch: 3.6.2 + transitivePeerDependencies: + - encoding + dev: true + /istanbul-lib-coverage@3.2.0: resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==} engines: {node: '>=8'} @@ -6434,7 +6523,7 @@ packages: babel-jest: 29.5.0(@babel/core@7.20.5) chalk: 4.1.2 ci-info: 3.7.1 - deepmerge: 4.2.2 + deepmerge: 4.3.1 glob: 7.2.3 graceful-fs: 4.2.10 jest-circus: 29.5.0 @@ -6473,7 +6562,7 @@ packages: babel-jest: 29.5.0(@babel/core@7.20.5) chalk: 4.1.2 ci-info: 3.7.1 - deepmerge: 4.2.2 + deepmerge: 4.3.1 glob: 7.2.3 graceful-fs: 4.2.10 jest-circus: 29.5.0 @@ -6513,7 +6602,7 @@ packages: babel-jest: 29.5.0(@babel/core@7.20.5) chalk: 4.1.2 ci-info: 3.7.1 - deepmerge: 4.2.2 + deepmerge: 4.3.1 glob: 7.2.3 graceful-fs: 4.2.10 jest-circus: 29.5.0 @@ -6553,7 +6642,7 @@ packages: babel-jest: 29.5.0(@babel/core@7.20.5) chalk: 4.1.2 ci-info: 3.7.1 - deepmerge: 4.2.2 + deepmerge: 4.3.1 glob: 7.2.3 graceful-fs: 4.2.10 jest-circus: 29.5.0 @@ -6592,7 +6681,7 @@ packages: babel-jest: 29.5.0(@babel/core@7.20.5) chalk: 4.1.2 ci-info: 3.7.1 - deepmerge: 4.2.2 + deepmerge: 4.3.1 glob: 7.2.3 graceful-fs: 4.2.10 jest-circus: 29.5.0 @@ -7161,7 +7250,7 @@ packages: dependencies: cookie: 0.5.0 process-warning: 2.1.0 - set-cookie-parser: 2.5.1 + set-cookie-parser: 2.6.0 dev: true /lines-and-columns@1.2.4: @@ -7284,6 +7373,13 @@ packages: dependencies: yallist: 4.0.0 + /magic-string@0.30.0: + resolution: {integrity: sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.14 + dev: true + /make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -7394,6 +7490,12 @@ packages: hasBin: true dev: true + /mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + dev: true + /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -7462,6 +7564,16 @@ packages: ufo: 1.1.1 dev: true + /mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + dev: true + + /mrmime@1.0.1: + resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} + engines: {node: '>=10'} + dev: true + /ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} dev: true @@ -8442,6 +8554,13 @@ packages: tslib: 2.4.1 dev: true + /sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + dependencies: + mri: 1.2.0 + dev: true + /safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -8544,8 +8663,8 @@ packages: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} dev: true - /set-cookie-parser@2.5.1: - resolution: {integrity: sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ==} + /set-cookie-parser@2.6.0: + resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} dev: true /setprototypeof@1.2.0: @@ -8607,6 +8726,15 @@ packages: dev: true optional: true + /sirv@2.0.3: + resolution: {integrity: sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==} + engines: {node: '>= 10'} + dependencies: + '@polka/url': 1.0.0-next.21 + mrmime: 1.0.1 + totalist: 3.0.1 + dev: true + /sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -8943,6 +9071,15 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + /svelte-hmr@0.15.1(svelte@3.59.1): + resolution: {integrity: sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: '>=3.19.0' + dependencies: + svelte: 3.59.1 + dev: true + /svelte@3.59.1: resolution: {integrity: sha512-pKj8fEBmqf6mq3/NfrB9SLtcJcUvjYSWyePlfCqN9gujLB25RitWK8PvFzlwim6hD/We35KbPlRteuA6rnPGcQ==} engines: {node: '>= 8'} @@ -9053,6 +9190,13 @@ packages: readable-stream: 2.3.7 xtend: 4.0.2 + /tiny-glob@0.2.9: + resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + dev: true + /tiny-invariant@1.3.1: resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} dev: false @@ -9108,6 +9252,11 @@ packages: engines: {node: '>=0.6'} dev: true + /totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + dev: true + /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -9541,6 +9690,13 @@ packages: dependencies: busboy: 1.6.0 + /undici@5.22.1: + resolution: {integrity: sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==} + engines: {node: '>=14.0'} + dependencies: + busboy: 1.6.0 + dev: true + /unique-string@2.0.0: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} @@ -9675,6 +9831,39 @@ packages: - terser dev: true + /vite@4.2.1: + resolution: {integrity: sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + esbuild: 0.17.14 + postcss: 8.4.21 + resolve: 1.22.1 + rollup: 3.20.2 + optionalDependencies: + fsevents: 2.3.2 + dev: true + /vite@4.2.1(@types/node@18.14.2): resolution: {integrity: sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -9709,6 +9898,17 @@ packages: fsevents: 2.3.2 dev: true + /vitefu@0.2.4(vite@4.2.1): + resolution: {integrity: sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + vite: + optional: true + dependencies: + vite: 4.2.1 + dev: true + /vitest@0.29.7: resolution: {integrity: sha512-aWinOSOu4jwTuZHkb+cCyrqQ116Q9TXaJrNKTHudKBknIpR0VplzeaOUuDF9jeZcrbtQKZQt6yrtd+eakbaxHg==} engines: {node: '>=v14.16.0'} @@ -9826,6 +10026,10 @@ packages: /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + /whatwg-fetch@3.6.2: + resolution: {integrity: sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==} + dev: true + /whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} dependencies: