From 342a2e6e0c6d3ecde55fa3f4559073deb5a1aae9 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Tue, 16 Apr 2024 20:58:51 +0800 Subject: [PATCH] Add example project Include example project and requests. --- packages/core/examples/basic/data-source.ts | 16 --- packages/core/examples/basic/serializers.ts | 29 ----- packages/core/examples/basic/server.ts | 75 ------------ packages/core/examples/basic/users.jsonl | 1 - .../core/src/backend/servers/http/core.ts | 22 ++-- packages/core/src/common/delta/core.ts | 2 +- packages/core/test/utils.ts | 1 + packages/data-sources/file-jsonl/package.json | 19 ++- packages/examples/cms-web-api/.gitignore | 108 ++++++++++++++++++ .../cms-web-api/bruno/Create Post.bru | 18 +++ .../cms-web-api/bruno/Delete Post.bru | 11 ++ .../cms-web-api/bruno/Get Single Post.bru | 11 ++ .../cms-web-api/bruno/Modify Post (Delta).bru | 25 ++++ .../cms-web-api/bruno/Modify Post (Merge).bru | 23 ++++ .../cms-web-api/bruno/Query Posts.bru | 11 ++ .../cms-web-api/bruno/Replace Post.bru | 19 +++ .../examples/cms-web-api/bruno/bruno.json | 13 +++ packages/examples/cms-web-api/package.json | 50 ++++++++ packages/examples/cms-web-api/pridepack.json | 3 + packages/examples/cms-web-api/src/index.ts | 67 +++++++++++ .../examples/cms-web-api/test/index.test.ts | 8 ++ packages/examples/cms-web-api/tsconfig.json | 23 ++++ pnpm-lock.yaml | 28 +++++ 23 files changed, 451 insertions(+), 132 deletions(-) delete mode 100644 packages/core/examples/basic/data-source.ts delete mode 100644 packages/core/examples/basic/serializers.ts delete mode 100644 packages/core/examples/basic/server.ts delete mode 100644 packages/core/examples/basic/users.jsonl create mode 100644 packages/examples/cms-web-api/.gitignore create mode 100644 packages/examples/cms-web-api/bruno/Create Post.bru create mode 100644 packages/examples/cms-web-api/bruno/Delete Post.bru create mode 100644 packages/examples/cms-web-api/bruno/Get Single Post.bru create mode 100644 packages/examples/cms-web-api/bruno/Modify Post (Delta).bru create mode 100644 packages/examples/cms-web-api/bruno/Modify Post (Merge).bru create mode 100644 packages/examples/cms-web-api/bruno/Query Posts.bru create mode 100644 packages/examples/cms-web-api/bruno/Replace Post.bru create mode 100644 packages/examples/cms-web-api/bruno/bruno.json create mode 100644 packages/examples/cms-web-api/package.json create mode 100644 packages/examples/cms-web-api/pridepack.json create mode 100644 packages/examples/cms-web-api/src/index.ts create mode 100644 packages/examples/cms-web-api/test/index.test.ts create mode 100644 packages/examples/cms-web-api/tsconfig.json diff --git a/packages/core/examples/basic/data-source.ts b/packages/core/examples/basic/data-source.ts deleted file mode 100644 index e89393c..0000000 --- a/packages/core/examples/basic/data-source.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {DataSource} from '../../src/backend/data-source'; - -export const autoIncrement = async (dataSource: DataSource) => { - const data = await dataSource.getMultiple() as Record[]; - - const highestId = data.reduce( - (highestId, d) => (Number(d.id) > highestId ? Number(d.id) : highestId), - -Infinity - ); - - if (Number.isFinite(highestId)) { - return (highestId + 1); - } - - return 1; -}; diff --git a/packages/core/examples/basic/serializers.ts b/packages/core/examples/basic/serializers.ts deleted file mode 100644 index 5fba021..0000000 --- a/packages/core/examples/basic/serializers.ts +++ /dev/null @@ -1,29 +0,0 @@ -export const TEXT_SERIALIZER_PAIR = { - name: 'text/plain', - serialize(obj: unknown, level = 0): string { - if (Array.isArray(obj)) { - return obj.map((o) => this.serialize(o)).join('\n\n'); - } - - if (typeof obj === 'object') { - if (obj !== null) { - return Object.entries(obj) - .map(([key, value]) => `${Array(level * 2).fill(' ').join('')}${key}: ${this.serialize(value, level + 1)}`) - .join('\n'); - } - - return ''; - } - - if (typeof obj === 'number' && Number.isFinite(obj)) { - return obj.toString(); - } - - if (typeof obj === 'string') { - return obj; - } - - return ''; - }, - deserialize: (str: string) => str as T -}; diff --git a/packages/core/examples/basic/server.ts b/packages/core/examples/basic/server.ts deleted file mode 100644 index 5cf1e07..0000000 --- a/packages/core/examples/basic/server.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - application, - resource, - validation as v, -} from '../../src/common'; -import { dataSources } from '../../src/backend'; -import {TEXT_SERIALIZER_PAIR} from './serializers'; -import {autoIncrement} from './data-source'; - -const Piano = resource(v.object( - { - brand: v.string() - }, - v.never() -)) - .name('Piano') - .route('pianos') - .canFetchItem() - .canFetchCollection() - .canCreate() - .canEmplace() - .canPatch() - .canDelete() - .id('id', { - generationStrategy: autoIncrement, - serialize: (id) => id?.toString() ?? '0', - deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, - schema: v.number(), - }); - -const User = resource(v.object( - { - firstName: v.string(), - middleName: v.string(), - lastName: v.string(), - bio: v.string(), - birthday: v.datelike() - }, - v.never() -)) - .name('User') - .route('users') - .fullText('bio') - .id('id' as const, { - generationStrategy: autoIncrement, - serialize: (id) => id?.toString() ?? '0', - deserialize: (id) => Number.isFinite(Number(id)) ? Number(id) : 0, - schema: v.number(), - }); - -const app = application({ - name: 'piano-service', -}) - .mediaType(TEXT_SERIALIZER_PAIR) - .resource(Piano) - .resource(User); - -const backend = app.createBackend({ - dataSource: new dataSources.jsonlFile.DataSource('examples/basic'), -}); - -const server = backend.createHttpServer({ - basePath: '/api' -}); - -server.listen(3000); - -setTimeout(() => { - // Allow user operations after 5 seconds from startup - User - .canFetchItem() - .canFetchCollection() - .canCreate() - .canPatch(); -}, 5000); diff --git a/packages/core/examples/basic/users.jsonl b/packages/core/examples/basic/users.jsonl deleted file mode 100644 index 000112e..0000000 --- a/packages/core/examples/basic/users.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"firstName":"John","middleName":"Smith","lastName":"Doe","bio":"This is my updated bio!","birthday":"1986-04-20T00:00:00.000Z","id":1} \ No newline at end of file diff --git a/packages/core/src/backend/servers/http/core.ts b/packages/core/src/backend/servers/http/core.ts index 30afe25..1509784 100644 --- a/packages/core/src/backend/servers/http/core.ts +++ b/packages/core/src/backend/servers/http/core.ts @@ -261,7 +261,9 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr const theBodyStr = encodingPair.decode(theBodyBuffer); const theBody = deserializerPair.deserialize(theBodyStr); try { + // for validation, I wonder why an empty object is returned for PATCH when both methods are enabled req.body = await v.parseAsync(bodySchema, theBody, {abortEarly: false, abortPipeEarly: false}); + req.body = theBody; } catch (errRaw) { const err = errRaw as v.ValiError; // todo use error message key for each method @@ -432,11 +434,12 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr const handleRequest = async (reqRaw: RequestContext, res: http.ServerResponse) => { const plainReq = await decorateRequest(reqRaw); // TODO add type safety here, put handleGetRoot as its own middleware as it does not concern over any resource + const language = plainReq.cn.language ?? plainReq.backend.cn.language; + const mediaType = plainReq.cn.mediaType ?? plainReq.backend.cn.mediaType; + const charset = plainReq.cn.charset ?? plainReq.backend.cn.charset; + if (typeof plainReq.resource !== 'undefined') { const resourceReq = plainReq as ResourceRequestContext; - const language = resourceReq.cn.language ?? resourceReq.backend.cn.language; - const mediaType = resourceReq.cn.mediaType ?? resourceReq.backend.cn.mediaType; - const charset = resourceReq.cn.charset ?? resourceReq.backend.cn.charset; // TODO custom middlewares const effectiveMiddlewares = ( @@ -451,6 +454,7 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr try { middlewareState = await processRequestFn(resourceReq) as any; // TODO fix this } catch (processRequestErrRaw) { + // TODO add error handlers handleMiddlewareError(processRequestErrRaw as Error)(resourceReq, res); return; } @@ -528,28 +532,28 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr } if (middlewares.length > 0) { - res.statusMessage = resourceReq.backend.cn.language.statusMessages.methodNotAllowed.replace(/\$RESOURCE/g, + res.statusMessage = language.statusMessages.methodNotAllowed.replace(/\$RESOURCE/g, resourceReq.resource!.state.itemName) ?? ''; res.writeHead(constants.HTTP_STATUS_METHOD_NOT_ALLOWED, { Allow: middlewares.map((m) => m.method).join(', '), - 'Content-Language': resourceReq.backend.cn.language.name, + 'Content-Language': language.name, }); res.end(); return; } - res.statusMessage = resourceReq.backend.cn.language.statusMessages.urlNotFound.replace(/\$RESOURCE/g, + res.statusMessage = language.statusMessages.urlNotFound.replace(/\$RESOURCE/g, resourceReq.resource!.state.itemName) ?? ''; res.writeHead(constants.HTTP_STATUS_NOT_FOUND, { - 'Content-Language': resourceReq.backend.cn.language.name, + 'Content-Language': language.name, }); res.end(); return; } - res.statusMessage = reqRaw.backend.cn.language.statusMessages.notImplemented.replace(/\$RESOURCE/g, + res.statusMessage = language.statusMessages.notImplemented.replace(/\$RESOURCE/g, reqRaw.resource!.state.itemName) ?? ''; res.writeHead(constants.HTTP_STATUS_NOT_IMPLEMENTED, { - 'Content-Language': reqRaw.backend.cn.language.name, + 'Content-Language': language.name, }); res.end(); }; diff --git a/packages/core/src/common/delta/core.ts b/packages/core/src/common/delta/core.ts index a073f5a..68651f5 100644 --- a/packages/core/src/common/delta/core.ts +++ b/packages/core/src/common/delta/core.ts @@ -176,7 +176,7 @@ export const applyDelta = async ( } mutateObject(mutablePreviousObject, deltaItem, pathSchema); - if (!v.is(resourceObjectSchema, mutablePreviousObject)) { + if (!v.is(resourceObjectSchema, mutablePreviousObject, { skipPipe: true })) { throw new InvalidOperationError(); } diff --git a/packages/core/test/utils.ts b/packages/core/test/utils.ts index dd39fbc..5fb159d 100644 --- a/packages/core/test/utils.ts +++ b/packages/core/test/utils.ts @@ -27,6 +27,7 @@ export const createTestClient = (options: Omit", "publishConfig": { "access": "public" + }, + "types": "./dist/types/index.d.ts", + "main": "./dist/cjs/production/index.js", + "module": "./dist/esm/production/index.js", + "exports": { + ".": { + "development": { + "require": "./dist/cjs/development/index.js", + "import": "./dist/esm/development/index.js" + }, + "require": "./dist/cjs/production/index.js", + "import": "./dist/esm/production/index.js", + "types": "./dist/types/index.d.ts" + } + }, + "typesVersions": { + "*": {} } } diff --git a/packages/examples/cms-web-api/.gitignore b/packages/examples/cms-web-api/.gitignore new file mode 100644 index 0000000..8383a05 --- /dev/null +++ b/packages/examples/cms-web-api/.gitignore @@ -0,0 +1,108 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.production +.env.development + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +.npmrc +*.jsonl diff --git a/packages/examples/cms-web-api/bruno/Create Post.bru b/packages/examples/cms-web-api/bruno/Create Post.bru new file mode 100644 index 0000000..f647859 --- /dev/null +++ b/packages/examples/cms-web-api/bruno/Create Post.bru @@ -0,0 +1,18 @@ +meta { + name: Create Post + type: http + seq: 2 +} + +post { + url: http://localhost:6969/api/posts + body: json + auth: none +} + +body:json { + { + "title": "New Post", + "content": "Hello there" + } +} diff --git a/packages/examples/cms-web-api/bruno/Delete Post.bru b/packages/examples/cms-web-api/bruno/Delete Post.bru new file mode 100644 index 0000000..ae405d9 --- /dev/null +++ b/packages/examples/cms-web-api/bruno/Delete Post.bru @@ -0,0 +1,11 @@ +meta { + name: Delete Post + type: http + seq: 8 +} + +delete { + url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 + body: none + auth: none +} diff --git a/packages/examples/cms-web-api/bruno/Get Single Post.bru b/packages/examples/cms-web-api/bruno/Get Single Post.bru new file mode 100644 index 0000000..349f79f --- /dev/null +++ b/packages/examples/cms-web-api/bruno/Get Single Post.bru @@ -0,0 +1,11 @@ +meta { + name: Get Single Post + type: http + seq: 3 +} + +get { + url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 + body: none + auth: none +} diff --git a/packages/examples/cms-web-api/bruno/Modify Post (Delta).bru b/packages/examples/cms-web-api/bruno/Modify Post (Delta).bru new file mode 100644 index 0000000..7856344 --- /dev/null +++ b/packages/examples/cms-web-api/bruno/Modify Post (Delta).bru @@ -0,0 +1,25 @@ +meta { + name: Modify Post (Delta) + type: http + seq: 5 +} + +patch { + url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 + body: json + auth: none +} + +headers { + Content-Type: application/json-patch+json +} + +body:json { + [ + { + "op": "replace", + "path": "content", + "value": "I changed the value via delta." + } + ] +} diff --git a/packages/examples/cms-web-api/bruno/Modify Post (Merge).bru b/packages/examples/cms-web-api/bruno/Modify Post (Merge).bru new file mode 100644 index 0000000..8e68d99 --- /dev/null +++ b/packages/examples/cms-web-api/bruno/Modify Post (Merge).bru @@ -0,0 +1,23 @@ +meta { + name: Modify Post (Merge) + type: http + seq: 4 +} + +patch { + url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 + body: json + auth: none +} + +headers { + Content-Type: application/merge-patch+json +} + +body:json { + { + "title": "Modified Post", + "content": "I changed the content via merge." + } + +} diff --git a/packages/examples/cms-web-api/bruno/Query Posts.bru b/packages/examples/cms-web-api/bruno/Query Posts.bru new file mode 100644 index 0000000..1c1f8c3 --- /dev/null +++ b/packages/examples/cms-web-api/bruno/Query Posts.bru @@ -0,0 +1,11 @@ +meta { + name: Query Posts + type: http + seq: 1 +} + +get { + url: http://localhost:6969/api/posts + body: none + auth: none +} diff --git a/packages/examples/cms-web-api/bruno/Replace Post.bru b/packages/examples/cms-web-api/bruno/Replace Post.bru new file mode 100644 index 0000000..8cc6909 --- /dev/null +++ b/packages/examples/cms-web-api/bruno/Replace Post.bru @@ -0,0 +1,19 @@ +meta { + name: Replace Post + type: http + seq: 7 +} + +put { + url: http://localhost:6969/api/posts/5fac64d6-d261-42bb-a67b-bc7e1955a7e2 + body: json + auth: none +} + +body:json { + { + "title": "Replaced Post", + "content": "The old content is gone.", + "id": "5fac64d6-d261-42bb-a67b-bc7e1955a7e2" + } +} diff --git a/packages/examples/cms-web-api/bruno/bruno.json b/packages/examples/cms-web-api/bruno/bruno.json new file mode 100644 index 0000000..e833fff --- /dev/null +++ b/packages/examples/cms-web-api/bruno/bruno.json @@ -0,0 +1,13 @@ +{ + "version": "1", + "name": "cms-web-api", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ], + "presets": { + "requestType": "http", + "requestUrl": "http://localhost:6969/api/" + } +} \ No newline at end of file diff --git a/packages/examples/cms-web-api/package.json b/packages/examples/cms-web-api/package.json new file mode 100644 index 0000000..31f45e3 --- /dev/null +++ b/packages/examples/cms-web-api/package.json @@ -0,0 +1,50 @@ +{ + "name": "@modal-sh/yasumi-example-cms-web-api", + "version": "0.0.0", + "files": [ + "dist", + "src" + ], + "engines": { + "node": ">=16" + }, + "keywords": [ + "pridepack" + ], + "devDependencies": { + "@types/node": "^20.11.0", + "pridepack": "2.6.0", + "tslib": "^2.6.2", + "typescript": "^5.3.3", + "vitest": "^1.2.0" + }, + "dependencies": { + "@modal-sh/yasumi": "*", + "@modal-sh/yasumi-data-source-file-jsonl": "*", + "tsx": "^4.7.1" + }, + "scripts": { + "prepublishOnly": "pridepack clean && pridepack build", + "build": "pridepack build", + "type-check": "pridepack check", + "clean": "pridepack clean", + "watch": "pridepack watch", + "start": "tsx src/index.ts", + "dev": "tsx watch src/index.ts", + "test": "vitest" + }, + "private": true, + "description": "CMS example service for Yasumi", + "repository": { + "url": "", + "type": "git" + }, + "homepage": "", + "bugs": { + "url": "" + }, + "author": "TheoryOfNekomata ", + "publishConfig": { + "access": "restricted" + } +} diff --git a/packages/examples/cms-web-api/pridepack.json b/packages/examples/cms-web-api/pridepack.json new file mode 100644 index 0000000..0bc7a8f --- /dev/null +++ b/packages/examples/cms-web-api/pridepack.json @@ -0,0 +1,3 @@ +{ + "target": "es2018" +} diff --git a/packages/examples/cms-web-api/src/index.ts b/packages/examples/cms-web-api/src/index.ts new file mode 100644 index 0000000..9d5ba44 --- /dev/null +++ b/packages/examples/cms-web-api/src/index.ts @@ -0,0 +1,67 @@ +import { application, resource, validation as v } from '@modal-sh/yasumi'; +import { randomUUID } from 'crypto'; +import {JsonLinesDataSource} from '@modal-sh/yasumi-data-source-file-jsonl'; + +const User = resource( + v.object({ + email: v.string([v.email()]), + password: v.string(), + createdAt: v.datelike(), + updatedAt: v.datelike(), + }) +) + .name('User') + .route('users') + .id('id', { + generationStrategy: () => Promise.resolve(randomUUID()), + schema: v.string(), + serialize: (v: unknown) => v?.toString() ?? '', + deserialize: (v: string) => v, + }) + .canFetchItem() + .canFetchCollection() + .canCreate() + .canEmplace() + .canPatch() + .canDelete(); + +const now = () => new Date(); + +const Post = resource( + v.object({ + title: v.string(), + content: v.string(), + createdAt: v.optional(v.datelike(), now), + updatedAt: v.optional(v.datelike(), now), + }) +) + .name('Post') + .route('posts') + .id('id', { + generationStrategy: () => Promise.resolve(randomUUID()), + schema: v.string(), + serialize: (v: unknown) => v?.toString() ?? '', + deserialize: (v: string) => v, + }) + .canFetchItem() + .canFetchCollection() + .canCreate() + .canEmplace() + .canPatch() + .canDelete(); + +const app = application({ + name: 'cms' +}) + .resource(User) + .resource(Post); + +const backend = app.createBackend({ + dataSource: new JsonLinesDataSource(), +}); + +const server = backend.createHttpServer({ + basePath: '/api', +}); + +server.listen(6969); diff --git a/packages/examples/cms-web-api/test/index.test.ts b/packages/examples/cms-web-api/test/index.test.ts new file mode 100644 index 0000000..441ca94 --- /dev/null +++ b/packages/examples/cms-web-api/test/index.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; +import add from '../src'; + +describe('blah', () => { + it('works', () => { + expect(add(1, 1)).toEqual(2); + }); +}); diff --git a/packages/examples/cms-web-api/tsconfig.json b/packages/examples/cms-web-api/tsconfig.json new file mode 100644 index 0000000..74083d7 --- /dev/null +++ b/packages/examples/cms-web-api/tsconfig.json @@ -0,0 +1,23 @@ +{ + "exclude": ["node_modules"], + "include": ["src", "types"], + "compilerOptions": { + "module": "ESNext", + "lib": ["ESNext"], + "importHelpers": true, + "declaration": true, + "sourceMap": true, + "rootDir": "./src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "bundler", + "jsx": "react", + "esModuleInterop": true, + "target": "es2018", + "useDefineForClassFields": false, + "declarationMap": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47ee74f..7cca7f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,34 @@ importers: specifier: ^1.2.0 version: 1.5.0(@types/node@20.12.7) + packages/examples/cms-web-api: + dependencies: + '@modal-sh/yasumi': + specifier: '*' + version: link:../../core + '@modal-sh/yasumi-data-source-file-jsonl': + specifier: '*' + version: link:../../data-sources/file-jsonl + tsx: + specifier: ^4.7.1 + version: 4.7.2 + devDependencies: + '@types/node': + specifier: ^20.11.0 + version: 20.12.7 + pridepack: + specifier: 2.6.0 + version: 2.6.0(tslib@2.6.2)(typescript@5.4.5) + tslib: + specifier: ^2.6.2 + version: 2.6.2 + typescript: + specifier: ^5.3.3 + version: 5.4.5 + vitest: + specifier: ^1.2.0 + version: 1.5.0(@types/node@20.12.7) + packages: /@esbuild/aix-ppc64@0.19.12: