diff --git a/SECURITY.md b/SECURITY.md index 36c4089e..d79cbe67 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,11 +4,10 @@ | Version | Supported | | ------- | ------------------ | -| >=0.4.0 | :white_check_mark: | +| >=1.0.0 | :white_check_mark: | ## Reporting a Vulnerability -Please [create an issue](https://github.com/zenstackhq/zenstack/issues) and add a "security" label. We'll actively watch, verify and fix them with high priority. -Alternatively, you can also reach out to us at contact@zenstack.dev. +Please send an email to contact@zenstack.dev. We'll actively watch, verify, and fix them with high priority. Thank you for helping us make a better project! diff --git a/package.json b/package.json index 5d266c0a..0bd3a8a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "2.9.2", + "version": "2.9.3", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/ide/jetbrains/CHANGELOG.md b/packages/ide/jetbrains/CHANGELOG.md index 79da788c..48cdd8b6 100644 --- a/packages/ide/jetbrains/CHANGELOG.md +++ b/packages/ide/jetbrains/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Fixed + +- Proper semantic highlighting and formatting for type declarations. + +## 2.9.0 + ### Added - Support for using `@@validate` attribute inside type declarations. diff --git a/packages/ide/jetbrains/build.gradle.kts b/packages/ide/jetbrains/build.gradle.kts index 09fefac3..2ed65c07 100644 --- a/packages/ide/jetbrains/build.gradle.kts +++ b/packages/ide/jetbrains/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "dev.zenstack" -version = "2.9.2" +version = "2.9.3" repositories { mavenCentral() diff --git a/packages/ide/jetbrains/package.json b/packages/ide/jetbrains/package.json index 69e770b9..f4622937 100644 --- a/packages/ide/jetbrains/package.json +++ b/packages/ide/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "jetbrains", - "version": "2.9.2", + "version": "2.9.3", "displayName": "ZenStack JetBrains IDE Plugin", "description": "ZenStack JetBrains IDE plugin", "homepage": "https://zenstack.dev", diff --git a/packages/language/package.json b/packages/language/package.json index a48fdb4d..a8e24b90 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "2.9.2", + "version": "2.9.3", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/misc/redwood/package.json b/packages/misc/redwood/package.json index 46f9a724..dbb9d539 100644 --- a/packages/misc/redwood/package.json +++ b/packages/misc/redwood/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/redwood", "displayName": "ZenStack RedwoodJS Integration", - "version": "2.9.2", + "version": "2.9.3", "description": "CLI and runtime for integrating ZenStack with RedwoodJS projects.", "repository": { "type": "git", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 0bf7771b..19336263 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/openapi", "displayName": "ZenStack Plugin and Runtime for OpenAPI", - "version": "2.9.2", + "version": "2.9.3", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index 255df564..247cfbab 100644 --- a/packages/plugins/swr/package.json +++ b/packages/plugins/swr/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/swr", "displayName": "ZenStack plugin for generating SWR hooks", - "version": "2.9.2", + "version": "2.9.3", "description": "ZenStack plugin for generating SWR hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index 63d05b2a..3445bfde 100644 --- a/packages/plugins/tanstack-query/package.json +++ b/packages/plugins/tanstack-query/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack plugin for generating tanstack-query hooks", - "version": "2.9.2", + "version": "2.9.3", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "exports": { diff --git a/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx b/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx index 0a1810ed..7739e67e 100644 --- a/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx +++ b/packages/plugins/tanstack-query/tests/react-hooks-v5.test.tsx @@ -11,6 +11,8 @@ import { RequestHandlerContext, useInfiniteModelQuery, useModelMutation, useMode import { getQueryKey } from '../src/runtime/common'; import { modelMeta } from './test-model-meta'; +const BASE_URL = 'http://localhost'; + describe('Tanstack Query React Hooks V5 Test', () => { function createWrapper() { const queryClient = new QueryClient(); @@ -25,7 +27,7 @@ describe('Tanstack Query React Hooks V5 Test', () => { } function makeUrl(model: string, operation: string, args?: unknown) { - let r = `http://localhost/api/model/${model}/${operation}`; + let r = `${BASE_URL}/api/model/${model}/${operation}`; if (args) { r += `?q=${encodeURIComponent(JSON.stringify(args))}`; } @@ -345,6 +347,350 @@ describe('Tanstack Query React Hooks V5 Test', () => { }); }); + it('optimistic create updating deeply nested query', async () => { + const { queryClient, wrapper } = createWrapper(); + + // populate the cache with a user + + const userData: any[] = [{ id: '1', name: 'user1', posts: [] }]; + + nock(BASE_URL) + .get('/api/model/User/findMany') + .query(true) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(userData)); + return { data: userData }; + }) + .persist(); + + const { result: userResult } = renderHook( + () => + useModelQuery( + 'User', + makeUrl('User', 'findMany'), + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { optimisticUpdate: true } + ), + { + wrapper, + } + ); + await waitFor(() => { + expect(userResult.current.data).toHaveLength(1); + }); + + // pupulate the cache with a category + const categoryData: any[] = [{ id: '1', name: 'category1', posts: [] }]; + + nock(BASE_URL) + .get('/api/model/Category/findMany') + .query(true) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(categoryData)); + return { data: categoryData }; + }) + .persist(); + + const { result: categoryResult } = renderHook( + () => + useModelQuery( + 'Category', + makeUrl('Category', 'findMany'), + { include: { posts: true } }, + { optimisticUpdate: true } + ), + { + wrapper, + } + ); + await waitFor(() => { + expect(categoryResult.current.data).toHaveLength(1); + }); + + // create a post and connect it to the category + nock(BASE_URL) + .post('/api/model/Post/create') + .reply(200, () => { + console.log('Not mutating data'); + return { data: null }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('Post', 'POST', makeUrl('Post', 'create'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => + mutationResult.current.mutate({ + data: { title: 'post1', owner: { connect: { id: '1' } }, category: { connect: { id: '1' } } }, + }) + ); + + // assert that the post was created and connected to the category + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'Category', + 'findMany', + { + include: { + posts: true, + }, + }, + { infinite: false, optimisticUpdate: true } + ) + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + console.log('category.posts', posts[0]); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'post1', + ownerId: '1', + }); + }); + + // assert that the post was created and connected to the user, and included the category + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findMany', + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { infinite: false, optimisticUpdate: true } + ) + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + console.log('user.posts', posts[0]); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'post1', + ownerId: '1', + categoryId: '1', + // TODO: should this include the category object and not just the foreign key? + // category: { $optimistic: true, id: '1', name: 'category1' }, + }); + }); + }); + + it('optimistic update with optional one-to-many relationship', async () => { + const { queryClient, wrapper } = createWrapper(); + + // populate the cache with a post, with an optional category relatonship + const postData: any = { + id: '1', + title: 'post1', + ownerId: '1', + categoryId: null, + category: null, + }; + + const data: any[] = [postData]; + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .query(true) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(data)); + return { data }; + }) + .persist(); + + const { result: postResult } = renderHook( + () => + useModelQuery( + 'Post', + makeUrl('Post', 'findMany'), + { + include: { + category: true, + }, + }, + { optimisticUpdate: true } + ), + { + wrapper, + } + ); + await waitFor(() => { + expect(postResult.current.data).toHaveLength(1); + }); + + // mock a put request to update the post title + nock(makeUrl('Post', 'update')) + .put(/.*/) + .reply(200, () => { + console.log('Mutating data'); + postData.title = 'postA'; + return { data: postData }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('Post', 'PUT', makeUrl('Post', 'update'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'postA' } })); + + // assert that the post was updated despite the optional (null) category relationship + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'Post', + 'findMany', + { + include: { + category: true, + }, + }, + { infinite: false, optimisticUpdate: true } + ) + ); + const posts = cacheData; + expect(posts).toHaveLength(1); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'postA', + ownerId: '1', + categoryId: null, + category: null, + }); + }); + }); + + it('optimistic update with nested optional one-to-many relationship', async () => { + const { queryClient, wrapper } = createWrapper(); + + // populate the cache with a user and a post, with an optional category + const postData: any = { + id: '1', + title: 'post1', + ownerId: '1', + categoryId: null, + category: null, + }; + + const userData: any[] = [{ id: '1', name: 'user1', posts: [postData] }]; + + nock(BASE_URL) + .get('/api/model/User/findMany') + .query(true) + .reply(200, () => { + console.log('Querying data:', JSON.stringify(userData)); + return { data: userData }; + }) + .persist(); + + const { result: userResult } = renderHook( + () => + useModelQuery( + 'User', + makeUrl('User', 'findMany'), + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { optimisticUpdate: true } + ), + { + wrapper, + } + ); + await waitFor(() => { + expect(userResult.current.data).toHaveLength(1); + }); + + // mock a put request to update the post title + nock(BASE_URL) + .put('/api/model/Post/update') + .reply(200, () => { + console.log('Mutating data'); + postData.title = 'postA'; + return { data: postData }; + }); + + const { result: mutationResult } = renderHook( + () => + useModelMutation('Post', 'PUT', makeUrl('Post', 'update'), modelMeta, { + optimisticUpdate: true, + invalidateQueries: false, + }), + { + wrapper, + } + ); + + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'postA' } })); + + // assert that the post was updated + await waitFor(() => { + const cacheData: any = queryClient.getQueryData( + getQueryKey( + 'User', + 'findMany', + { + include: { + posts: { + include: { + category: true, + }, + }, + }, + }, + { infinite: false, optimisticUpdate: true } + ) + ); + const posts = cacheData[0].posts; + expect(posts).toHaveLength(1); + console.log('user.posts', posts[0]); + expect(posts[0]).toMatchObject({ + $optimistic: true, + id: expect.any(String), + title: 'postA', + ownerId: '1', + categoryId: null, + category: null, + }); + }); + }); + it('optimistic nested create updating query', async () => { const { queryClient, wrapper } = createWrapper(); diff --git a/packages/plugins/tanstack-query/tests/test-model-meta.ts b/packages/plugins/tanstack-query/tests/test-model-meta.ts index 174abd1b..1c59b956 100644 --- a/packages/plugins/tanstack-query/tests/test-model-meta.ts +++ b/packages/plugins/tanstack-query/tests/test-model-meta.ts @@ -54,9 +54,46 @@ export const modelMeta: ModelMeta = { foreignKeyMapping: { id: 'ownerId' }, }, ownerId: { ...fieldDefaults, type: 'String', name: 'ownerId', isForeignKey: true }, + category: { + ...fieldDefaults, + type: 'Category', + name: 'category', + isDataModel: true, + isOptional: true, + isRelationOwner: true, + backLink: 'posts', + foreignKeyMapping: { id: 'categoryId' }, + }, + categoryId: { + ...fieldDefaults, + type: 'String', + name: 'categoryId', + isForeignKey: true, + relationField: 'category', + }, }, uniqueConstraints: { id: { name: 'id', fields: ['id'] } }, }, + category: { + name: 'category', + fields: { + id: { + ...fieldDefaults, + type: 'String', + isId: true, + name: 'id', + isOptional: false, + }, + name: { ...fieldDefaults, type: 'String', name: 'name' }, + posts: { + ...fieldDefaults, + type: 'Post', + isDataModel: true, + isArray: true, + name: 'posts', + }, + }, + }, }, deleteCascade: { user: ['Post'], diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index cce867a8..28afe51f 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "2.9.2", + "version": "2.9.3", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index a519f4d3..be3a898f 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "2.9.2", + "version": "2.9.3", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/cross/mutator.ts b/packages/runtime/src/cross/mutator.ts index 4ec76cbd..af56e6cd 100644 --- a/packages/runtime/src/cross/mutator.ts +++ b/packages/runtime/src/cross/mutator.ts @@ -151,7 +151,7 @@ async function doApplyMutation( logging ); - if (r) { + if (r && typeof r === 'object') { if (!arrayCloned) { resultData = [...resultData]; arrayCloned = true; @@ -160,9 +160,12 @@ async function doApplyMutation( updated = true; } } - } else { + } else if (resultData !== null && typeof resultData === 'object') { + // Clone resultData to prevent mutations affecting the loop + const currentData = { ...resultData }; + // iterate over each field and apply mutation to nested data models - for (const [key, value] of Object.entries(resultData)) { + for (const [key, value] of Object.entries(currentData)) { const fieldInfo = modelFields[key]; if (!fieldInfo?.isDataModel) { continue; @@ -178,7 +181,7 @@ async function doApplyMutation( logging ); - if (r) { + if (r && typeof r === 'object') { resultData = { ...resultData, [key]: r }; updated = true; } diff --git a/packages/schema/package.json b/packages/schema/package.json index 88ab19b0..d2e5ec0c 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "FullStack enhancement for Prisma ORM: seamless integration from database to UI", - "version": "2.9.2", + "version": "2.9.3", "author": { "name": "ZenStack Team" }, diff --git a/packages/schema/src/language-server/zmodel-formatter.ts b/packages/schema/src/language-server/zmodel-formatter.ts index 3a17520b..92296176 100644 --- a/packages/schema/src/language-server/zmodel-formatter.ts +++ b/packages/schema/src/language-server/zmodel-formatter.ts @@ -26,8 +26,8 @@ export class ZModelFormatter extends AbstractFormatter { protected format(node: AstNode): void { const formatter = this.getNodeFormatter(node); - if (ast.isDataModelField(node)) { - if (this.isPrismaStyle && ast.isDataModel(node.$container)) { + if (ast.isDataModelField(node) || ast.isTypeDefField(node)) { + if (this.isPrismaStyle && (ast.isDataModel(node.$container) || ast.isTypeDef(node.$container))) { const dataModel = node.$container; const compareFn = (a: number, b: number) => b - a; @@ -104,14 +104,14 @@ export class ZModelFormatter extends AbstractFormatter { this.isPrismaStyle = isPrismaStyle; } - private getFieldTypeLength(field: ast.DataModelField) { + private getFieldTypeLength(field: ast.DataModelField | ast.TypeDefField) { let length: number; if (field.type.type) { length = field.type.type.length; } else if (field.type.reference) { length = field.type.reference.$refText.length; - } else if (field.type.unsupported) { + } else if (ast.isDataModelField(field) && field.type.unsupported) { const name = `Unsupported("${field.type.unsupported.value.value}")`; length = name.length; } else { diff --git a/packages/schema/src/language-server/zmodel-semantic.ts b/packages/schema/src/language-server/zmodel-semantic.ts index 18e2e581..2e24cdb7 100644 --- a/packages/schema/src/language-server/zmodel-semantic.ts +++ b/packages/schema/src/language-server/zmodel-semantic.ts @@ -18,6 +18,8 @@ import { isPlugin, isPluginField, isReferenceExpr, + isTypeDef, + isTypeDefField, } from '@zenstackhq/language/ast'; import { AbstractSemanticTokenProvider, AstNode, SemanticTokenAcceptor } from 'langium'; import { SemanticTokenTypes } from 'vscode-languageserver'; @@ -36,7 +38,7 @@ export class ZModelSemanticTokenProvider extends AbstractSemanticTokenProvider { property: 'superTypes', type: SemanticTokenTypes.type, }); - } else if (isDataSource(node) || isGeneratorDecl(node) || isPlugin(node) || isEnum(node)) { + } else if (isDataSource(node) || isGeneratorDecl(node) || isPlugin(node) || isEnum(node) || isTypeDef(node)) { acceptor({ node, property: 'name', @@ -44,6 +46,7 @@ export class ZModelSemanticTokenProvider extends AbstractSemanticTokenProvider { }); } else if ( isDataModelField(node) || + isTypeDefField(node) || isConfigField(node) || isAttributeArg(node) || isPluginField(node) || diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 1b87df62..d6725607 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "2.9.2", + "version": "2.9.3", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index cd37c519..3c6d7526 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "2.9.2", + "version": "2.9.3", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 17d031ff..3ff48eb6 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "2.9.2", + "version": "2.9.3", "description": "ZenStack Test Tools", "main": "index.js", "private": true,