diff --git a/README.md b/README.md index 64e0e3c8..4da4a3f4 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ ZenStack is a TypeScript database toolkit for developing full-stack or backend N - Automatic CRUD web APIs with adapters for popular frameworks (coming soon) - Automatic [TanStack Query](https://github.com/TanStack/query) hooks for easy CRUD from the frontend (coming soon) -# What's new with V3 +# What's New in V3 ZenStack V3 is a major rewrite of [V2](https://github.com/zenstackhq/zenstack). The biggest change is V3 doesn't have a runtime dependency to Prisma anymore. Instead of working as a big wrapper of Prisma as in V2, V3 made a bold move and implemented the entire ORM engine using [Kysely](https://github.com/kysely-org/kysely), while keeping the query API fully compatible with Prisma. @@ -49,7 +49,7 @@ Even without using advanced features, ZenStack offers the following benefits as > Although ZenStack v3's ORM runtime doesn't depend on Prisma anymore (specifically, `@prisma/client`), it still relies on Prisma to handle database migration. See [database migration](https://zenstack.dev/docs/3.x/orm/migration) for more details. -# Quick start +# Quick Start - [ORM](./samples/orm): A simple example demonstrating ZenStack ORM usage. - [Next.js + TanStack Query](./samples/next.js): A full-stack sample demonstrating using TanStack Query to consume ZenStack's automatic CRUD services in a Next.js app. @@ -72,7 +72,7 @@ Or, if you have an existing project, use the CLI to initialize it: npx @zenstackhq/cli@next init ``` -### 3. Manual setup +### 3. Setting up manually Alternatively, you can set it up manually: diff --git a/package.json b/package.json index 8ba3e8ab..7630ddc9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "description": "ZenStack", "packageManager": "pnpm@10.20.0", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 2fb5bbc7..6fe96eee 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "type": "module", "author": { "name": "ZenStack Team" diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index b139544f..e8bb62a3 100644 --- a/packages/clients/tanstack-query/package.json +++ b/packages/clients/tanstack-query/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tanstack-query", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "description": "TanStack Query Client for consuming ZenStack v3's CRUD service", "main": "index.js", "type": "module", diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 8660a14b..338c5ed7 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/common-helpers", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index 4e334630..bbaa866e 100644 --- a/packages/config/eslint-config/package.json +++ b/packages/config/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 59948ded..9d069661 100644 --- a/packages/config/typescript-config/package.json +++ b/packages/config/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index 665adccc..0dad87b3 100644 --- a/packages/config/vitest-config/package.json +++ b/packages/config/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 29ec8bbb..c1012c86 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -1,6 +1,6 @@ { "name": "create-zenstack", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/dialects/sql.js/package.json b/packages/dialects/sql.js/package.json index dff554c4..d577277f 100644 --- a/packages/dialects/sql.js/package.json +++ b/packages/dialects/sql.js/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/kysely-sql-js", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "description": "Kysely dialect for sql.js", "type": "module", "scripts": { diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 634044c6..ad823edd 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.0.11", + "version": "3.0.12", "displayName": "ZenStack V3 Language Tools", "description": "VSCode extension for ZenStack (v3) ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index aadc945d..afb000e5 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/language", "description": "ZenStack ZModel language specification", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "license": "MIT", "author": "ZenStack Team", "files": [ @@ -57,7 +57,8 @@ "dependencies": { "langium": "catalog:", "pluralize": "^8.0.0", - "ts-pattern": "catalog:" + "ts-pattern": "catalog:", + "vscode-languageserver": "^9.0.1" }, "devDependencies": { "@types/pluralize": "^0.0.33", diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index 7df81364..269a0f9c 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -382,12 +382,14 @@ attribute @map(_ name: String) @@@prisma attribute @@map(_ name: String) @@@prisma /** - * Exclude a field from the Prisma Client (for example, a field that you do not want Prisma users to update). + * Exclude a field from the ORM Client (for example, a field that you do not want Prisma users to update). + * The field is still recognized by database schema migrations. */ attribute @ignore() @@@prisma /** - * Exclude a model from the Prisma Client (for example, a model that you do not want Prisma users to update). + * Exclude a model from the ORM Client (for example, a model that you do not want Prisma users to update). + * The model is still recognized by database schema migrations. */ attribute @@ignore() @@@prisma diff --git a/packages/language/src/index.ts b/packages/language/src/index.ts index 6edc0494..f11ede5e 100644 --- a/packages/language/src/index.ts +++ b/packages/language/src/index.ts @@ -1,2 +1,3 @@ export { loadDocument } from './document'; export * from './module'; +export { ZModelCodeGenerator } from './zmodel-code-generator'; diff --git a/packages/language/src/module.ts b/packages/language/src/module.ts index df83d4e0..51146949 100644 --- a/packages/language/src/module.ts +++ b/packages/language/src/module.ts @@ -14,9 +14,15 @@ import type { Model } from './ast'; import { ZModelGeneratedModule, ZModelGeneratedSharedModule, ZModelLanguageMetaData } from './generated/module'; import { getPluginDocuments } from './utils'; import { registerValidationChecks, ZModelValidator } from './validator'; +import { ZModelCommentProvider } from './zmodel-comment-provider'; +import { ZModelCompletionProvider } from './zmodel-completion-provider'; +import { ZModelDefinitionProvider } from './zmodel-definition'; import { ZModelDocumentBuilder } from './zmodel-document-builder'; +import { ZModelDocumentationProvider } from './zmodel-documentation-provider'; +import { ZModelFormatter } from './zmodel-formatter'; import { ZModelLinker } from './zmodel-linker'; import { ZModelScopeComputation, ZModelScopeProvider } from './zmodel-scope'; +import { ZModelSemanticTokenProvider } from './zmodel-semantic'; import { ZModelWorkspaceManager } from './zmodel-workspace-manager'; export { ZModelLanguageMetaData }; @@ -49,6 +55,16 @@ export const ZModelLanguageModule: Module new ZModelValidator(services), }, + lsp: { + Formatter: (services) => new ZModelFormatter(services), + DefinitionProvider: (services) => new ZModelDefinitionProvider(services), + CompletionProvider: (services) => new ZModelCompletionProvider(services), + SemanticTokenProvider: (services) => new ZModelSemanticTokenProvider(services), + }, + documentation: { + CommentProvider: (services) => new ZModelCommentProvider(services), + DocumentationProvider: (services) => new ZModelDocumentationProvider(services), + }, }; export type ZModelSharedServices = LangiumSharedServices; @@ -109,15 +125,20 @@ export function createZModelLanguageServices( const schemaPath = fileURLToPath(doc.uri.toString()); const pluginSchemas = getPluginDocuments(doc.parseResult.value as Model, schemaPath); + + // ensure plugin docs are loaded for (const plugin of pluginSchemas) { - // load the plugin model document - const pluginDoc = await shared.workspace.LangiumDocuments.getOrCreateDocument( - URI.file(path.resolve(plugin)), - ); - // add to indexer so the plugin model's definitions are globally visible - shared.workspace.IndexManager.updateContent(pluginDoc); - if (logToConsole) { - console.log(`Loaded plugin model: ${plugin}`); + const pluginDocUri = URI.file(path.resolve(plugin)); + let pluginDoc = shared.workspace.LangiumDocuments.getDocument(pluginDocUri); + if (!pluginDoc) { + pluginDoc = await shared.workspace.LangiumDocuments.getOrCreateDocument(pluginDocUri); + if (pluginDoc) { + // add to indexer so the plugin model's definitions are globally visible + shared.workspace.IndexManager.updateContent(pluginDoc); + if (logToConsole) { + console.log(`Loaded plugin model: ${plugin}`); + } + } } } } diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index c7563bab..80fa1668 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -158,7 +158,6 @@ export default class AttributeApplicationValidator implements AstValidator | undefined> { + const result: CodeAction[] = []; + const acceptor = (ca: CodeAction | undefined) => ca && result.push(ca); + for (const diagnostic of params.context.diagnostics) { + this.createCodeActions(diagnostic, document, acceptor); + } + return result; + } + + private createCodeActions( + diagnostic: Diagnostic, + document: LangiumDocument, + accept: (ca: CodeAction | undefined) => void, + ) { + switch (diagnostic.code) { + case IssueCodes.MissingOppositeRelation: + accept(this.fixMissingOppositeRelation(diagnostic, document)); + } + + return undefined; + } + + private fixMissingOppositeRelation(diagnostic: Diagnostic, document: LangiumDocument): CodeAction | undefined { + const data = diagnostic.data as MissingOppositeRelationData; + + const rootCst = + data.relationFieldDocUri == document.textDocument.uri + ? document.parseResult.value + : this.documents.all.find((doc) => doc.textDocument.uri === data.relationFieldDocUri)?.parseResult + .value; + + if (rootCst) { + const fieldModel = rootCst as Model; + const fieldAstNode = ( + fieldModel.declarations.find( + (x) => isDataModel(x) && x.name === data.relationDataModelName, + ) as DataModel + )?.fields.find((x) => x.name === data.relationFieldName) as DataField; + + if (!fieldAstNode) return undefined; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const oppositeModel = fieldAstNode.type.reference!.ref! as DataModel; + + const currentModel = document.parseResult.value as Model; + + const container = currentModel.declarations.find( + (decl) => decl.name === data.dataModelName && isDataModel(decl), + ) as DataModel; + + if (container && container.$cstNode) { + // indent + let indent = '\t'; + const formatOptions = this.formatter.getFormatOptions(); + if (formatOptions?.insertSpaces) { + indent = ' '.repeat(formatOptions.tabSize); + } + indent = indent.repeat(this.formatter.getIndent()); + + let newText = ''; + if (fieldAstNode.type.array) { + // post Post[] + const idField = getAllFields(container).find((f) => + f.attributes.find((attr) => attr.decl.ref?.name === '@id'), + ); + + // if no id field, we can't generate reference + if (!idField) { + return undefined; + } + + const typeName = container.name; + const fieldName = this.lowerCaseFirstLetter(typeName); + + // might already exist + let referenceField = ''; + + const idFieldName = idField.name; + const referenceIdFieldName = fieldName + this.upperCaseFirstLetter(idFieldName); + + if (!getAllFields(oppositeModel).find((f) => f.name === referenceIdFieldName)) { + referenceField = '\n' + indent + `${referenceIdFieldName} ${idField.type.type}`; + } + + newText = + '\n' + + indent + + `${fieldName} ${typeName} @relation(fields: [${referenceIdFieldName}], references: [${idFieldName}])` + + referenceField + + '\n'; + } else { + // user User @relation(fields: [userAbc], references: [id]) + const typeName = container.name; + const fieldName = this.lowerCaseFirstLetter(typeName); + newText = '\n' + indent + `${fieldName} ${typeName}[]` + '\n'; + } + + // the opposite model might be in the imported file + const targetDocument = getDocument(oppositeModel); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const endOffset = oppositeModel.$cstNode!.end - 1; + const position = targetDocument.textDocument.positionAt(endOffset); + + return { + title: `Add opposite relation fields on ${oppositeModel.name}`, + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + isPreferred: false, + edit: { + changes: { + [targetDocument.textDocument.uri]: [ + { + range: { + start: position, + end: position, + }, + newText, + }, + ], + }, + }, + }; + } + } + + return undefined; + } + + private lowerCaseFirstLetter(str: string) { + return str.charAt(0).toLowerCase() + str.slice(1); + } + + private upperCaseFirstLetter(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); + } +} diff --git a/packages/sdk/src/zmodel-code-generator.ts b/packages/language/src/zmodel-code-generator.ts similarity index 99% rename from packages/sdk/src/zmodel-code-generator.ts rename to packages/language/src/zmodel-code-generator.ts index 2e010884..2a63969f 100644 --- a/packages/sdk/src/zmodel-code-generator.ts +++ b/packages/language/src/zmodel-code-generator.ts @@ -40,8 +40,8 @@ import { TypeDef, UnaryExpr, type AstNode, -} from '@zenstackhq/language/ast'; -import { resolved } from './model-utils'; +} from './ast'; +import { resolved } from './utils'; /** * Options for the generator. diff --git a/packages/language/src/zmodel-comment-provider.ts b/packages/language/src/zmodel-comment-provider.ts new file mode 100644 index 00000000..9353f225 --- /dev/null +++ b/packages/language/src/zmodel-comment-provider.ts @@ -0,0 +1,21 @@ +import { DefaultCommentProvider, type AstNode } from 'langium'; +import { match } from 'ts-pattern'; +import { isDataField, isDataModel, isEnum, isEnumField, isFunctionDecl, isTypeDef } from './ast'; + +export class ZModelCommentProvider extends DefaultCommentProvider { + override getComment(node: AstNode): string | undefined { + let comment = super.getComment(node); + if (!comment) { + // default comment + comment = match(node) + .when(isDataModel, (d) => `/**\n * Model *${d.name}*\n */`) + .when(isTypeDef, (d) => `/**\n * Type *${d.name}*\n */`) + .when(isEnum, (e) => `/**\n * Enum *${e.name}*\n */`) + .when(isEnumField, (f) => `/**\n * Value of enum *${f.$container?.name}*\n */`) + .when(isDataField, (f) => `/**\n * Field of *${f.$container?.name}*\n */`) + .when(isFunctionDecl, (f) => `/**\n * Function *${f.name}*\n */`) + .otherwise(() => ''); + } + return comment; + } +} diff --git a/packages/language/src/zmodel-completion-provider.ts b/packages/language/src/zmodel-completion-provider.ts new file mode 100644 index 00000000..eff2aad4 --- /dev/null +++ b/packages/language/src/zmodel-completion-provider.ts @@ -0,0 +1,397 @@ +import { type AstNode, type AstNodeDescription, type LangiumDocument, type MaybePromise } from 'langium'; +import { + DefaultCompletionProvider, + type CompletionAcceptor, + type CompletionContext, + type CompletionProviderOptions, + type CompletionValueItem, + type LangiumServices, + type NextFeature, +} from 'langium/lsp'; +import fs from 'node:fs'; +import { P, match } from 'ts-pattern'; +import { CompletionItemKind, CompletionList, MarkupContent, type CompletionParams } from 'vscode-languageserver'; +import { + DataFieldAttribute, + DataModelAttribute, + ReferenceExpr, + StringLiteral, + isArrayExpr, + isAttribute, + isDataField, + isDataFieldAttribute, + isDataModel, + isDataModelAttribute, + isEnum, + isEnumField, + isFunctionDecl, + isInvocationExpr, + isMemberAccessExpr, + isTypeDef, +} from './ast'; +import { getAttribute, isEnumFieldReference, isFromStdlib } from './utils'; +import { ZModelCodeGenerator } from './zmodel-code-generator'; + +export class ZModelCompletionProvider extends DefaultCompletionProvider { + constructor(private readonly services: LangiumServices) { + super(services); + } + + override readonly completionOptions?: CompletionProviderOptions = { + triggerCharacters: ['@', '(', ',', '.'], + }; + + override async getCompletion( + document: LangiumDocument, + params: CompletionParams, + ): Promise { + try { + return await super.getCompletion(document, params); + } catch (e) { + console.error('Completion error:', (e as Error).message); + return undefined; + } + } + + override completionFor( + context: CompletionContext, + next: NextFeature, + acceptor: CompletionAcceptor, + ): MaybePromise { + if (isDataModelAttribute(context.node) || isDataFieldAttribute(context.node)) { + const completions = this.getCompletionFromHint(context.node); + if (completions) { + completions.forEach((c) => acceptor(context, c)); + return; + } + } + return super.completionFor(context, next, acceptor); + } + + private getCompletionFromHint( + contextNode: DataModelAttribute | DataFieldAttribute, + ): CompletionValueItem[] | undefined { + // get completion based on the hint on the next unfilled parameter + const unfilledParams = this.getUnfilledAttributeParams(contextNode); + const nextParam = unfilledParams[0]; + if (!nextParam) { + return undefined; + } + + const hintAttr = getAttribute(nextParam, '@@@completionHint'); + if (hintAttr) { + const hint = hintAttr.args[0]; + if (hint?.value) { + if (isArrayExpr(hint.value)) { + return hint.value.items.map((item) => { + return { + label: `${(item as StringLiteral).value}`, + kind: CompletionItemKind.Value, + detail: 'Parameter', + sortText: '0', + }; + }); + } + } + } + return undefined; + } + + // TODO: this doesn't work when the file contains parse errors + private getUnfilledAttributeParams(contextNode: DataModelAttribute | DataFieldAttribute) { + try { + const params = contextNode.decl.ref?.params; + if (params) { + const args = contextNode.args; + let unfilledParams = [...params]; + args.forEach((arg) => { + if (arg.name) { + unfilledParams = unfilledParams.filter((p) => p.name !== arg.name); + } else { + unfilledParams.shift(); + } + }); + + return unfilledParams; + } + } catch { + // noop + } + return []; + } + + override completionForCrossReference( + context: CompletionContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + crossRef: any, + acceptor: CompletionAcceptor, + ): MaybePromise { + if (crossRef.property === 'member' && !isMemberAccessExpr(context.node)) { + // for guarding an error in the base implementation + return; + } + + const customAcceptor = (context: CompletionContext, item: CompletionValueItem) => { + // attributes starting with @@@ are for internal use only + if (item.insertText?.startsWith('@@@') || item.label?.startsWith('@@@')) { + return; + } + + if ('nodeDescription' in item) { + const node = this.getAstNode(item.nodeDescription); + if (!node) { + return; + } + + // enums in stdlib are not supposed to be referenced directly + if ((isEnum(node) || isEnumField(node)) && isFromStdlib(node)) { + return; + } + + if ( + (isDataModelAttribute(context.node) || isDataFieldAttribute(context.node)) && + !this.filterAttributeApplicationCompletion(context.node, node) + ) { + // node not matching attribute context + return; + } + } + acceptor(context, item); + }; + + return super.completionForCrossReference(context, crossRef, customAcceptor); + } + + override completionForKeyword( + context: CompletionContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + keyword: any, + acceptor: CompletionAcceptor, + ): MaybePromise { + const customAcceptor = (context: CompletionContext, item: CompletionValueItem) => { + if (!this.filterKeywordForContext(context, keyword.value)) { + return; + } + acceptor(context, item); + }; + return super.completionForKeyword(context, keyword, customAcceptor); + } + + private filterKeywordForContext(context: CompletionContext, keyword: string) { + if (isInvocationExpr(context.node)) { + return ['true', 'false', 'null', 'this'].includes(keyword); + } else if (isDataModelAttribute(context.node) || isDataFieldAttribute(context.node)) { + const exprContext = this.getAttributeContextType(context.node); + if (exprContext === 'DefaultValue') { + return ['true', 'false', 'null'].includes(keyword); + } else { + return ['true', 'false', 'null', 'this'].includes(keyword); + } + } else { + return true; + } + } + + private filterAttributeApplicationCompletion(contextNode: DataModelAttribute | DataFieldAttribute, node: AstNode) { + const attrContextType = this.getAttributeContextType(contextNode); + + if (isFunctionDecl(node) && attrContextType) { + // functions are excluded if they are not allowed in the current context + const funcExprContextAttr = getAttribute(node, '@@@expressionContext'); + if (funcExprContextAttr && funcExprContextAttr.args[0]) { + const arg = funcExprContextAttr.args[0]; + if (isArrayExpr(arg.value)) { + return arg.value.items.some( + (item) => + isEnumFieldReference(item) && (item as ReferenceExpr).target.$refText === attrContextType, + ); + } + } + return false; + } + + if (isDataField(node)) { + // model fields are not allowed in @default + return attrContextType !== 'DefaultValue'; + } + + return true; + } + + private getAttributeContextType(node: DataModelAttribute | DataFieldAttribute) { + return match(node.decl.$refText) + .with('@default', () => 'DefaultValue') + .with(P.union('@@allow', '@allow', '@@deny', '@deny'), () => 'AccessPolicy') + .with('@@validate', () => 'ValidationRule') + .otherwise(() => undefined); + } + + override createReferenceCompletionItem(nodeDescription: AstNodeDescription): CompletionValueItem { + const node = this.getAstNode(nodeDescription); + const documentation = this.getNodeDocumentation(node); + + return match(node) + .when(isDataModel, () => ({ + nodeDescription, + kind: CompletionItemKind.Class, + detail: 'Model', + sortText: '1', + documentation, + })) + .when(isTypeDef, () => ({ + nodeDescription, + kind: CompletionItemKind.Class, + detail: 'Type', + sortText: '1', + documentation, + })) + .when(isDataField, () => ({ + nodeDescription, + kind: CompletionItemKind.Field, + detail: 'Field', + sortText: '0', + documentation, + })) + .when(isEnum, () => ({ + nodeDescription, + kind: CompletionItemKind.Class, + detail: 'Enum', + sortText: '1', + documentation, + })) + .when(isEnumField, (d) => { + const container = d.$container; + return { + nodeDescription, + kind: CompletionItemKind.Enum, + detail: `Value of enum "${container.name}"`, + sortText: '1', + documentation, + }; + }) + .when(isFunctionDecl, () => ({ + nodeDescription, + insertText: this.getFunctionInsertText(nodeDescription), + kind: CompletionItemKind.Function, + detail: 'Function', + sortText: '1', + documentation, + })) + .when(isAttribute, () => ({ + nodeDescription, + insertText: this.getAttributeInsertText(nodeDescription), + kind: CompletionItemKind.Property, + detail: 'Attribute', + sortText: '1', + documentation, + })) + .otherwise(() => ({ + nodeDescription, + kind: CompletionItemKind.Reference, + detail: nodeDescription.type, + sortText: '2', + documentation, + })); + } + + private getFunctionInsertText(nodeDescription: AstNodeDescription): string { + const node = this.getAstNode(nodeDescription); + if (isFunctionDecl(node)) { + if (node.params.some((p) => !p.optional)) { + return nodeDescription.name; + } + } + return `${nodeDescription.name}()`; + } + + private getAttributeInsertText(nodeDescription: AstNodeDescription): string { + const node = this.getAstNode(nodeDescription); + if (isAttribute(node)) { + if (node.name === '@relation') { + return `${nodeDescription.name}(fields: [], references: [])`; + } + } + return nodeDescription.name; + } + + private getAstNode(nodeDescription: AstNodeDescription) { + let node = nodeDescription.node; + if (!node) { + const doc = this.getOrCreateDocumentSync(nodeDescription); + if (!doc) { + return undefined; + } + node = this.services.workspace.AstNodeLocator.getAstNode(doc.parseResult.value, nodeDescription.path); + if (!node) { + return undefined; + } + } + return node; + } + + private getOrCreateDocumentSync(nodeDescription: AstNodeDescription) { + let doc = this.services.shared.workspace.LangiumDocuments.getDocument(nodeDescription.documentUri); + if (!doc) { + try { + const content = fs.readFileSync(nodeDescription.documentUri.fsPath, 'utf-8'); + doc = this.services.shared.workspace.LangiumDocuments.createDocument( + nodeDescription.documentUri, + content, + ); + } catch { + console.warn('Failed to read or create document:', nodeDescription.documentUri); + return undefined; + } + } + return doc; + } + + private getNodeDocumentation(node?: AstNode): MarkupContent | undefined { + if (!node) { + return undefined; + } + const md = this.commentsToMarkdown(node); + return { + kind: 'markdown', + value: md, + }; + } + + private commentsToMarkdown(node: AstNode): string { + const md = this.services.documentation.DocumentationProvider.getDocumentation(node) ?? ''; + const zModelGenerator = new ZModelCodeGenerator(); + const docs: string[] = []; + + try { + match(node) + .when(isAttribute, (attr) => { + docs.push('```prisma', zModelGenerator.generate(attr), '```'); + }) + .when(isFunctionDecl, (func) => { + docs.push('```ts', zModelGenerator.generate(func), '```'); + }) + .when(isDataModel, (model) => { + docs.push('```prisma', `model ${model.name} { ... }`, '```'); + }) + .when(isEnum, (enumDecl) => { + docs.push('```prisma', zModelGenerator.generate(enumDecl), '```'); + }) + .when(isDataField, (field) => { + docs.push(`${field.name}: ${field.type.type ?? field.type.reference?.$refText}`); + }) + .otherwise((ast) => { + const name = (ast as any).name; + if (name) { + docs.push(name); + } + }); + } catch { + // noop + } + + if (md) { + docs.push('___', md); + } + return docs.join('\n'); + } +} diff --git a/packages/language/src/zmodel-definition.ts b/packages/language/src/zmodel-definition.ts new file mode 100644 index 00000000..f1e8984e --- /dev/null +++ b/packages/language/src/zmodel-definition.ts @@ -0,0 +1,37 @@ +import type { LangiumDocuments, LeafCstNode, MaybePromise } from 'langium'; +import { DefaultDefinitionProvider, type LangiumServices } from 'langium/lsp'; +import { type DefinitionParams, LocationLink, Range } from 'vscode-languageserver'; +import { isModelImport } from './ast'; +import { resolveImport } from './utils'; + +export class ZModelDefinitionProvider extends DefaultDefinitionProvider { + protected documents: LangiumDocuments; + + constructor(services: LangiumServices) { + super(services); + this.documents = services.shared.workspace.LangiumDocuments; + } + protected override collectLocationLinks( + sourceCstNode: LeafCstNode, + _params: DefinitionParams, + ): MaybePromise { + if (isModelImport(sourceCstNode.astNode)) { + const importedModel = resolveImport(this.documents, sourceCstNode.astNode); + if (importedModel?.$document) { + const targetObject = importedModel; + const selectionRange = this.nameProvider.getNameNode(targetObject)?.range ?? Range.create(0, 0, 0, 0); + const previewRange = targetObject.$cstNode?.range ?? Range.create(0, 0, 0, 0); + return [ + LocationLink.create( + importedModel.$document.uri.toString(), + previewRange, + selectionRange, + sourceCstNode.range, + ), + ]; + } + return undefined; + } + return super.collectLocationLinks(sourceCstNode, _params); + } +} diff --git a/packages/language/src/zmodel-documentation-provider.ts b/packages/language/src/zmodel-documentation-provider.ts new file mode 100644 index 00000000..5b21213f --- /dev/null +++ b/packages/language/src/zmodel-documentation-provider.ts @@ -0,0 +1,15 @@ +import { type AstNode, JSDocDocumentationProvider } from 'langium'; + +/** + * Documentation provider that first tries to use triple-slash comments and falls back to JSDoc comments. + */ +export class ZModelDocumentationProvider extends JSDocDocumentationProvider { + override getDocumentation(node: AstNode): string | undefined { + // prefer to use triple-slash comments + if ('comments' in node && Array.isArray(node.comments) && node.comments.length > 0) { + return node.comments.map((c: string) => c.replace(/^[/]*\s*/, '')).join('\n'); + } + + return super.getDocumentation(node); + } +} diff --git a/packages/language/src/zmodel-formatter.ts b/packages/language/src/zmodel-formatter.ts new file mode 100644 index 00000000..efcd7806 --- /dev/null +++ b/packages/language/src/zmodel-formatter.ts @@ -0,0 +1,114 @@ +import { type AstNode, type ConfigurationProvider, type LangiumDocument, type MaybePromise } from 'langium'; +import { AbstractFormatter, Formatting, type LangiumServices } from 'langium/lsp'; +import type { DocumentFormattingParams, FormattingOptions, TextEdit } from 'vscode-languageserver'; +import * as ast from './ast'; +import { ZModelLanguageMetaData } from './generated/module'; + +export class ZModelFormatter extends AbstractFormatter { + private formatOptions?: FormattingOptions; + + protected readonly configurationProvider: ConfigurationProvider; + + constructor(services: LangiumServices) { + super(); + this.configurationProvider = services.shared.workspace.ConfigurationProvider; + } + + protected format(node: AstNode): void { + const formatter = this.getNodeFormatter(node); + + if (ast.isDataField(node)) { + if (ast.isDataModel(node.$container) || ast.isTypeDef(node.$container)) { + const dataModel = node.$container; + + const compareFn = (a: number, b: number) => b - a; + const maxNameLength = dataModel.fields.map((x) => x.name.length).sort(compareFn)[0] ?? 0; + const maxTypeLength = dataModel.fields.map(this.getFieldTypeLength).sort(compareFn)[0] ?? 0; + + formatter.property('type').prepend(Formatting.spaces(maxNameLength - node.name.length + 1)); + if (node.attributes.length > 0) { + formatter + .node(node.attributes[0]!) + .prepend(Formatting.spaces(maxTypeLength - this.getFieldTypeLength(node) + 1)); + + formatter.nodes(...node.attributes.slice(1)).prepend(Formatting.oneSpace()); + } + } else { + formatter.property('type').prepend(Formatting.oneSpace()); + if (node.attributes.length > 0) { + formatter.properties('attributes').prepend(Formatting.oneSpace()); + } + } + } else if (ast.isDataFieldAttribute(node)) { + formatter.keyword('(').surround(Formatting.noSpace()); + formatter.keyword(')').prepend(Formatting.noSpace()); + formatter.keyword(',').append(Formatting.oneSpace()); + if (node.args.length > 1) { + formatter.nodes(...node.args.slice(1)).prepend(Formatting.oneSpace()); + } + } else if (ast.isAttributeArg(node)) { + formatter.keyword(':').prepend(Formatting.noSpace()); + formatter.keyword(':').append(Formatting.oneSpace()); + } else if (ast.isAbstractDeclaration(node)) { + const bracesOpen = formatter.keyword('{'); + const bracesClose = formatter.keyword('}'); + // allow extra blank lines between declarations + formatter.interior(bracesOpen, bracesClose).prepend(Formatting.indent({ allowMore: true })); + bracesOpen.prepend(Formatting.oneSpace()); + bracesClose.prepend(Formatting.newLine()); + } else if (ast.isModel(node)) { + const model = node as ast.Model; + const nodes = formatter.nodes(...model.declarations); + nodes.prepend(Formatting.noIndent()); + } + } + + override formatDocument( + document: LangiumDocument, + params: DocumentFormattingParams, + ): MaybePromise { + this.formatOptions = params.options; + + this.configurationProvider.getConfiguration(ZModelLanguageMetaData.languageId, 'format').then((config) => { + if (config) { + // configuration for future use + } + }); + + return super.formatDocument(document, params); + } + + public getFormatOptions(): FormattingOptions | undefined { + return this.formatOptions; + } + + public getIndent() { + return 1; + } + + private getFieldTypeLength(field: ast.DataField) { + 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 (ast.isDataField(field) && field.type.unsupported) { + const name = `Unsupported("${field.type.unsupported.value.value}")`; + length = name.length; + } else { + // we shouldn't get here + length = 1; + } + + if (field.type.optional) { + length += 1; + } + + if (field.type.array) { + length += 2; + } + + return length; + } +} diff --git a/packages/language/src/zmodel-semantic.ts b/packages/language/src/zmodel-semantic.ts new file mode 100644 index 00000000..7910c78a --- /dev/null +++ b/packages/language/src/zmodel-semantic.ts @@ -0,0 +1,111 @@ +import { AbstractSemanticTokenProvider, type SemanticTokenAcceptor } from 'langium/lsp'; +import { SemanticTokenTypes } from 'vscode-languageserver'; +import { + isAttribute, + isAttributeArg, + isConfigField, + isDataField, + isDataFieldAttribute, + isDataFieldType, + isDataModel, + isDataModelAttribute, + isDataSource, + isEnum, + isEnumField, + isFunctionDecl, + isGeneratorDecl, + isInternalAttribute, + isInvocationExpr, + isMemberAccessExpr, + isPlugin, + isPluginField, + isReferenceExpr, + isTypeDef, + type AstNode, +} from './ast'; + +export class ZModelSemanticTokenProvider extends AbstractSemanticTokenProvider { + protected highlightElement(node: AstNode, acceptor: SemanticTokenAcceptor): void { + if (isDataModel(node)) { + acceptor({ + node, + property: 'name', + type: SemanticTokenTypes.type, + }); + + acceptor({ + node, + property: 'mixins', + type: SemanticTokenTypes.type, + }); + + acceptor({ + node, + property: 'baseModel', + type: SemanticTokenTypes.type, + }); + } else if (isDataSource(node) || isGeneratorDecl(node) || isPlugin(node) || isEnum(node) || isTypeDef(node)) { + acceptor({ + node, + property: 'name', + type: SemanticTokenTypes.type, + }); + } else if ( + isDataField(node) || + isConfigField(node) || + isAttributeArg(node) || + isPluginField(node) || + isEnumField(node) + ) { + acceptor({ + node, + property: 'name', + type: SemanticTokenTypes.variable, + }); + } else if (isDataFieldType(node)) { + if (node.type) { + acceptor({ + node, + property: 'type', + type: SemanticTokenTypes.type, + }); + } else { + acceptor({ + node, + property: 'reference', + type: SemanticTokenTypes.macro, + }); + } + } else if (isDataModelAttribute(node) || isDataFieldAttribute(node) || isInternalAttribute(node)) { + acceptor({ + node, + property: 'decl', + type: SemanticTokenTypes.function, + }); + } else if (isInvocationExpr(node)) { + acceptor({ + node, + property: 'function', + type: SemanticTokenTypes.function, + }); + } else if (isFunctionDecl(node) || isAttribute(node)) { + acceptor({ + node, + property: 'name', + type: SemanticTokenTypes.function, + }); + } else if (isReferenceExpr(node)) { + acceptor({ + node, + property: 'target', + type: SemanticTokenTypes.variable, + }); + } else if (isMemberAccessExpr(node)) { + acceptor({ + node, + property: 'member', + type: SemanticTokenTypes.property, + }); + } + } +} diff --git a/packages/language/tsconfig.json b/packages/language/tsconfig.json index 8ef64682..accdce5e 100644 --- a/packages/language/tsconfig.json +++ b/packages/language/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "@zenstackhq/typescript-config/base.json", - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "compilerOptions": { + "noUnusedLocals": false + } } diff --git a/packages/orm/package.json b/packages/orm/package.json index fa5dbc4b..3adc96dc 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/orm", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "description": "ZenStack ORM", "type": "module", "scripts": { diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 150e00df..834aef79 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -11,6 +11,7 @@ import { type KyselyProps, } from 'kysely'; import type { GetModels, ProcedureDef, SchemaDef } from '../schema'; +import type { AnyKysely } from '../utils/kysely-utils'; import type { UnwrapTuplePromises } from '../utils/type-utils'; import type { AuthType, @@ -29,7 +30,7 @@ import { FindOperationHandler } from './crud/operations/find'; import { GroupByOperationHandler } from './crud/operations/group-by'; import { UpdateOperationHandler } from './crud/operations/update'; import { InputValidator } from './crud/validator'; -import { NotFoundError, QueryError } from './errors'; +import { createConfigError, createNotFoundError } from './errors'; import { ZenStackDriver } from './executor/zenstack-driver'; import { ZenStackQueryExecutor } from './executor/zenstack-query-executor'; import * as BuiltinFunctions from './functions'; @@ -53,7 +54,7 @@ export const ZenStackClient = function ( export class ClientImpl { private kysely: ToKysely; - private kyselyRaw: ToKysely; + private kyselyRaw: AnyKysely; public readonly $options: ClientOptions; public readonly $schema: Schema; readonly kyselyProps: KyselyProps; @@ -192,7 +193,7 @@ export class ClientImpl { arg: ZenStackPromise[], options?: { isolationLevel?: TransactionIsolationLevel }, ) { - const execute = async (tx: Kysely) => { + const execute = async (tx: AnyKysely) => { const txClient = new ClientImpl(this.schema, this.$options, this); txClient.kysely = tx; const result: any[] = []; @@ -210,7 +211,7 @@ export class ClientImpl { if (options?.isolationLevel) { txBuilder = txBuilder.setIsolationLevel(options.isolationLevel); } - return txBuilder.execute((tx) => execute(tx as Kysely)); + return txBuilder.execute((tx) => execute(tx as AnyKysely)); } } @@ -223,7 +224,7 @@ export class ClientImpl { private async handleProc(name: string, args: unknown[]) { if (!('procedures' in this.$options) || !this.$options || typeof this.$options.procedures !== 'object') { - throw new QueryError('Procedures are not configured for the client.'); + throw createConfigError('Procedures are not configured for the client.'); } const procOptions = this.$options.procedures as ProceduresOptions< @@ -389,7 +390,7 @@ function createModelCrudHandler = { /** * The raw Kysely query builder without any ZenStack enhancements. */ - readonly $qbRaw: ToKysely; + readonly $qbRaw: AnyKysely; /** * Starts an interactive transaction. diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index b4173c22..e0e3cb18 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -14,7 +14,7 @@ import type { SortOrder, StringFilter, } from '../../crud-types'; -import { InternalError, QueryError } from '../../errors'; +import { createConfigError, createInvalidInputError, createNotSupportedError } from '../../errors'; import type { ClientOptions } from '../../options'; import { aggregate, @@ -64,7 +64,7 @@ export abstract class BaseCrudDialect { } buildFilterSortTake( - model: GetModels, + model: string, args: FindArgs, true>, query: SelectQueryBuilder, modelAlias: string, @@ -95,7 +95,7 @@ export abstract class BaseCrudDialect { if (this.supportsDistinctOn) { result = result.distinctOn(distinct.map((f) => this.eb.ref(`${modelAlias}.${f}`))); } else { - throw new QueryError(`"distinct" is not supported by "${this.schema.provider.type}" provider`); + throw createNotSupportedError(`"distinct" is not supported by "${this.schema.provider.type}" provider`); } } @@ -482,7 +482,7 @@ export abstract class BaseCrudDialect { } default: { - throw new InternalError(`Invalid array filter key: ${key}`); + throw createInvalidInputError(`Invalid array filter key: ${key}`); } } } @@ -510,10 +510,10 @@ export abstract class BaseCrudDialect { .with('Bytes', () => this.buildBytesFilter(fieldRef, payload)) // TODO: JSON filters .with('Json', () => { - throw new InternalError('JSON filters are not supported yet'); + throw createNotSupportedError('JSON filters are not supported yet'); }) .with('Unsupported', () => { - throw new QueryError(`Unsupported field cannot be used in filters`); + throw createInvalidInputError(`Unsupported field cannot be used in filters`); }) .exhaustive() ); @@ -589,7 +589,7 @@ export abstract class BaseCrudDialect { }) .otherwise(() => { if (throwIfInvalid) { - throw new QueryError(`Invalid filter key: ${op}`); + throw createInvalidInputError(`Invalid filter key: ${op}`); } else { return undefined; } @@ -642,7 +642,7 @@ export abstract class BaseCrudDialect { : this.eb(fieldRef, 'like', sql.val(`%${value}`)), ) .otherwise(() => { - throw new QueryError(`Invalid string filter key: ${key}`); + throw createInvalidInputError(`Invalid string filter key: ${key}`); }); if (condition) { @@ -767,7 +767,7 @@ export abstract class BaseCrudDialect { invariant(v === 'asc' || v === 'desc', `invalid orderBy value for field "${field}"`); result = result.orderBy( (eb) => aggregate(eb, buildFieldRef(model, k, modelAlias), field as AGGREGATE_OPERATORS), - sql.raw(this.negateSort(v, negated)), + this.negateSort(v, negated), ); } continue; @@ -780,7 +780,7 @@ export abstract class BaseCrudDialect { invariant(v === 'asc' || v === 'desc', `invalid orderBy value for field "${field}"`); result = result.orderBy( (eb) => eb.fn.count(buildFieldRef(model, k, modelAlias)), - sql.raw(this.negateSort(v, negated)), + this.negateSort(v, negated), ); } continue; @@ -803,10 +803,12 @@ export abstract class BaseCrudDialect { (value.sort === 'asc' || value.sort === 'desc') && (value.nulls === 'first' || value.nulls === 'last') ) { - result = result.orderBy( - fieldRef, - sql.raw(`${this.negateSort(value.sort, negated)} nulls ${value.nulls}`), - ); + result = result.orderBy(fieldRef, (ob) => { + const dir = this.negateSort(value.sort, negated); + ob = dir === 'asc' ? ob.asc() : ob.desc(); + ob = value.nulls === 'first' ? ob.nullsFirst() : ob.nullsLast(); + return ob; + }); } } else { // order by relation @@ -815,7 +817,7 @@ export abstract class BaseCrudDialect { if (fieldDef.array) { // order by to-many relation if (typeof value !== 'object') { - throw new QueryError(`invalid orderBy value for field "${field}"`); + throw createInvalidInputError(`invalid orderBy value for field "${field}"`); } if ('_count' in value) { invariant( @@ -1084,7 +1086,7 @@ export abstract class BaseCrudDialect { computer = computedFields?.[fieldDef.originModel ?? model]?.[field]; } if (!computer) { - throw new QueryError(`Computed field "${field}" implementation not provided for model "${model}"`); + throw createConfigError(`Computed field "${field}" implementation not provided for model "${model}"`); } return computer(this.eb, { modelAlias }); } diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index 82eeb9a8..a37e603b 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -12,7 +12,6 @@ import { match } from 'ts-pattern'; import type { BuiltinType, FieldDef, GetModels, SchemaDef } from '../../../schema'; import { DELEGATE_JOINED_FIELD_PREFIX } from '../../constants'; import type { FindArgs } from '../../crud-types'; -import { QueryError } from '../../errors'; import type { ClientOptions } from '../../options'; import { buildJoinPairs, @@ -24,6 +23,7 @@ import { requireModel, } from '../../query-utils'; import { BaseCrudDialect } from './base-dialect'; +import { createInternalError } from '../../errors'; export class PostgresCrudDialect extends BaseCrudDialect { constructor(schema: Schema, options: ClientOptions) { @@ -438,7 +438,7 @@ export class PostgresCrudDialect extends BaseCrudDiale override getFieldSqlType(fieldDef: FieldDef) { // TODO: respect `@db.x` attributes if (fieldDef.relation) { - throw new QueryError('Cannot get SQL type of a relation field'); + throw createInternalError('Cannot get SQL type of a relation field'); } let result: string; diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index e163f464..464e30dc 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -12,7 +12,7 @@ import { match } from 'ts-pattern'; import type { BuiltinType, FieldDef, GetModels, SchemaDef } from '../../../schema'; import { DELEGATE_JOINED_FIELD_PREFIX } from '../../constants'; import type { FindArgs } from '../../crud-types'; -import { QueryError } from '../../errors'; +import { createInternalError } from '../../errors'; import { getDelegateDescendantModels, getManyToManyRelation, @@ -121,7 +121,7 @@ export class SqliteCrudDialect extends BaseCrudDialect try { return JSON.parse(value); } catch (e) { - throw new QueryError('Invalid JSON returned', e); + throw createInternalError('Invalid JSON returned', undefined, { cause: e }); } } return value; @@ -376,10 +376,10 @@ export class SqliteCrudDialect extends BaseCrudDialect override getFieldSqlType(fieldDef: FieldDef) { // TODO: respect `@db.x` attributes if (fieldDef.relation) { - throw new QueryError('Cannot get SQL type of a relation field'); + throw createInternalError('Cannot get SQL type of a relation field'); } if (fieldDef.array) { - throw new QueryError('SQLite does not support scalar list type'); + throw createInternalError('SQLite does not support scalar list type'); } if (this.schema.enums?.[fieldDef.type]) { diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index a309b268..ce788831 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -1,6 +1,7 @@ import { createId } from '@paralleldrive/cuid2'; import { clone, enumerate, invariant, isPlainObject } from '@zenstackhq/common-helpers'; import { + createQueryId, DeleteResult, expressionBuilder, sql, @@ -14,14 +15,22 @@ import { nanoid } from 'nanoid'; import { match } from 'ts-pattern'; import { ulid } from 'ulid'; import * as uuid from 'uuid'; -import type { ClientContract } from '../..'; import type { BuiltinType, Expression, FieldDef } from '../../../schema'; import { ExpressionUtils, type GetModels, type ModelDef, type SchemaDef } from '../../../schema'; +import type { AnyKysely } from '../../../utils/kysely-utils'; import { extractFields, fieldsToSelectObject } from '../../../utils/object-utils'; import { NUMERIC_FIELD_TYPES } from '../../constants'; -import { TransactionIsolationLevel, type CRUD } from '../../contract'; +import { TransactionIsolationLevel, type ClientContract, type CRUD } from '../../contract'; import type { FindArgs, SelectIncludeOmit, WhereInput } from '../../crud-types'; -import { InternalError, NotFoundError, QueryError } from '../../errors'; +import { + createDBQueryError, + createInternalError, + createInvalidInputError, + createNotFoundError, + createNotSupportedError, + ORMError, + ORMErrorReason, +} from '../../errors'; import type { ToKysely } from '../../query-builder'; import { ensureArray, @@ -64,9 +73,9 @@ export type CoreCrudOperation = export type AllCrudOperation = CoreCrudOperation | 'findUniqueOrThrow' | 'findFirstOrThrow'; // context for nested relation operations -export type FromRelationContext = { +export type FromRelationContext = { // the model where the relation field is defined - model: GetModels; + model: string; // the relation field name field: string; // the parent entity's id fields and values @@ -94,7 +103,7 @@ export abstract class BaseOperationHandler { return this.client.$options; } - protected get kysely() { + protected get kysely(): AnyKysely { return this.client.$qb; } @@ -137,8 +146,8 @@ export abstract class BaseOperationHandler { } protected async read( - kysely: ToKysely, - model: GetModels, + kysely: AnyKysely, + model: string, args: FindArgs, true> | undefined, ): Promise { // table @@ -166,24 +175,18 @@ export abstract class BaseOperationHandler { query = query.modifyEnd(this.makeContextComment({ model, operation: 'read' })); let result: any[] = []; - const queryId = { queryId: `zenstack-${createId()}` }; - const compiled = kysely.getExecutor().compileQuery(query.toOperationNode(), queryId); + const compiled = kysely.getExecutor().compileQuery(query.toOperationNode(), createQueryId()); try { - const r = await kysely.getExecutor().executeQuery(compiled, queryId); + const r = await kysely.getExecutor().executeQuery(compiled); result = r.rows; } catch (err) { - const message = `Failed to execute query: ${err}, sql: ${compiled.sql}`; - throw new QueryError(message, err); + throw createDBQueryError('Failed to execute query', err, compiled.sql, compiled.parameters); } return result; } - protected async readUnique( - kysely: ToKysely, - model: GetModels, - args: FindArgs, true>, - ) { + protected async readUnique(kysely: AnyKysely, model: string, args: FindArgs, true>) { const result = await this.read(kysely, model, { ...args, take: 1 }); return result[0] ?? null; } @@ -212,7 +215,7 @@ export abstract class BaseOperationHandler { result = this.dialect.buildSelectField(result, model, parentAlias, field); } else { if (!fieldDef.array && !fieldDef.optional && payload.where) { - throw new QueryError(`Field "${field}" doesn't support filtering`); + throw createInternalError(`Field "${field}" does not support filtering`, model); } if (fieldDef.originModel) { result = this.dialect.buildRelationSelection( @@ -242,10 +245,10 @@ export abstract class BaseOperationHandler { } protected async create( - kysely: ToKysely, - model: GetModels, + kysely: AnyKysely, + model: string, data: any, - fromRelation?: FromRelationContext, + fromRelation?: FromRelationContext, creatingForDelegate = false, returnFields?: string[], ): Promise { @@ -253,7 +256,7 @@ export abstract class BaseOperationHandler { // additional validations if (modelDef.isDelegate && !creatingForDelegate) { - throw new QueryError(`Model "${this.model}" is a delegate and cannot be created directly.`); + throw createNotSupportedError(`Model "${model}" is a delegate and cannot be created directly.`); } let createFields: any = {}; @@ -420,12 +423,7 @@ export abstract class BaseOperationHandler { return { baseEntity, remainingFields }; } - private async buildFkAssignments( - kysely: ToKysely, - model: GetModels, - relationField: string, - entity: any, - ) { + private async buildFkAssignments(kysely: AnyKysely, model: string, relationField: string, entity: any) { const parentFkFields: any = {}; invariant(relationField, 'parentField must be defined if parentModel is defined'); @@ -442,7 +440,7 @@ export abstract class BaseOperationHandler { select: { [pair.pk]: true }, } as any); if (!extraRead) { - throw new QueryError(`Field "${pair.pk}" not found in parent created data`); + throw createInternalError(`Field "${pair.pk}" not found in parent created data`, model); } else { // update the parent entity Object.assign(entity, extraRead); @@ -456,7 +454,7 @@ export abstract class BaseOperationHandler { } private async handleManyToManyRelation( - kysely: ToKysely, + kysely: AnyKysely, action: Action, leftModel: string, leftField: string, @@ -511,7 +509,7 @@ export abstract class BaseOperationHandler { } } - private resetManyToManyRelation(kysely: ToKysely, model: GetModels, field: string, parentIds: any) { + private resetManyToManyRelation(kysely: AnyKysely, model: string, field: string, parentIds: any) { invariant(Object.keys(parentIds).length === 1, 'parentIds must have exactly one field'); const parentId = Object.values(parentIds)[0]!; @@ -560,7 +558,7 @@ export abstract class BaseOperationHandler { select: fieldsToSelectObject(referencedPkFields) as any, }); if (!relationEntity) { - throw new NotFoundError( + throw createNotFoundError( relationModel, `Could not find the entity to connect for the relation "${relationField.name}"`, ); @@ -584,7 +582,7 @@ export abstract class BaseOperationHandler { } default: - throw new QueryError(`Invalid relation action: ${action}`); + throw createInvalidInputError(`Invalid relation action: ${action}`); } } @@ -592,15 +590,15 @@ export abstract class BaseOperationHandler { } private async processNoneOwnedRelationForCreate( - kysely: ToKysely, - contextModel: GetModels, + kysely: AnyKysely, + contextModel: string, relationFieldName: string, payload: any, parentEntity: any, ) { const relationFieldDef = this.requireField(contextModel, relationFieldName); const relationModel = relationFieldDef.type as GetModels; - const fromRelationContext: FromRelationContext = { + const fromRelationContext: FromRelationContext = { model: contextModel, field: relationFieldName, ids: parentEntity, @@ -650,7 +648,7 @@ export abstract class BaseOperationHandler { } default: - throw new QueryError(`Invalid relation action: ${action}`); + throw createInvalidInputError(`Invalid relation action: ${action}`); } } } @@ -663,7 +661,7 @@ export abstract class BaseOperationHandler { model: GetModels, input: { data: any; skipDuplicates?: boolean }, returnData: ReturnData, - fromRelation?: FromRelationContext, + fromRelation?: FromRelationContext, fieldsToReturn?: string[], ): Promise { if (!input.data || (Array.isArray(input.data) && input.data.length === 0)) { @@ -681,7 +679,7 @@ export abstract class BaseOperationHandler { fromRelation.field, ); if (ownedByModel) { - throw new QueryError('incorrect relation hierarchy for createMany'); + throw createInvalidInputError('incorrect relation hierarchy for createMany', model); } relationKeyPairs = keyPairs; } @@ -739,7 +737,7 @@ export abstract class BaseOperationHandler { if (modelDef.baseModel) { if (input.skipDuplicates) { // TODO: simulate createMany with create in this case - throw new QueryError('"skipDuplicates" options is not supported for polymorphic models'); + throw createNotSupportedError('"skipDuplicates" options is not supported for polymorphic models'); } // create base hierarchy const baseCreateResult = await this.processBaseModelCreateMany( @@ -896,17 +894,17 @@ export abstract class BaseOperationHandler { } protected async update( - kysely: ToKysely, - model: GetModels, + kysely: AnyKysely, + model: string, where: any, data: any, - fromRelation?: FromRelationContext, + fromRelation?: FromRelationContext, allowRelationUpdate = true, throwIfNotFound = true, fieldsToReturn?: string[], ): Promise { if (!data || typeof data !== 'object') { - throw new InternalError('data must be an object'); + throw createInvalidInputError('data must be an object'); } const parentWhere: any = {}; @@ -982,7 +980,7 @@ export abstract class BaseOperationHandler { select: this.makeIdSelect(model), }); if (!readResult && throwIfNotFound) { - throw new NotFoundError(model); + throw createNotFoundError(model); } combinedWhere = readResult; } @@ -1010,13 +1008,13 @@ export abstract class BaseOperationHandler { updateFields[field] = this.processScalarFieldUpdateData(model, field, finalData); } else { if (!allowRelationUpdate) { - throw new QueryError(`Relation update not allowed for field "${field}"`); + throw createNotSupportedError(`Relation update not allowed for field "${field}"`); } if (!thisEntity) { thisEntity = await this.getEntityIds(kysely, model, combinedWhere); if (!thisEntity) { if (throwIfNotFound) { - throw new NotFoundError(model); + throw createNotFoundError(model); } else { return null; } @@ -1065,7 +1063,7 @@ export abstract class BaseOperationHandler { const updatedEntity = await this.executeQueryTakeFirst(kysely, query, 'update'); if (!updatedEntity) { if (throwIfNotFound) { - throw new NotFoundError(model); + throw createNotFoundError(model); } else { return null; } @@ -1075,7 +1073,7 @@ export abstract class BaseOperationHandler { } } - private processScalarFieldUpdateData(model: GetModels, field: string, data: any): any { + private processScalarFieldUpdateData(model: string, field: string, data: any): any { const fieldDef = this.requireField(model, field); if (this.isNumericIncrementalUpdate(fieldDef, data[field])) { // numeric fields incremental updates @@ -1100,7 +1098,7 @@ export abstract class BaseOperationHandler { return ['increment', 'decrement', 'multiply', 'divide', 'set'].some((key) => key in value); } - private isIdFilter(model: GetModels, filter: any) { + private isIdFilter(model: string, filter: any) { if (!filter || typeof filter !== 'object') { return false; } @@ -1141,7 +1139,7 @@ export abstract class BaseOperationHandler { } private transformIncrementalUpdate( - model: GetModels, + model: string, field: string, fieldDef: FieldDef, payload: Record, @@ -1163,12 +1161,12 @@ export abstract class BaseOperationHandler { .with('multiply', () => eb(fieldRef, '*', value)) .with('divide', () => eb(fieldRef, '/', value)) .otherwise(() => { - throw new InternalError(`Invalid incremental update operation: ${key}`); + throw createInvalidInputError(`Invalid incremental update operation: ${key}`); }); } private transformScalarListUpdate( - model: GetModels, + model: string, field: string, fieldDef: FieldDef, payload: Record, @@ -1185,7 +1183,7 @@ export abstract class BaseOperationHandler { return eb(fieldRef, '||', eb.val(ensureArray(value))); }) .otherwise(() => { - throw new InternalError(`Invalid array update operation: ${key}`); + throw createInvalidInputError(`Invalid array update operation: ${key}`); }); } @@ -1193,7 +1191,7 @@ export abstract class BaseOperationHandler { return NUMERIC_FIELD_TYPES.includes(fieldDef.type) && !fieldDef.array; } - private makeContextComment(_context: { model: GetModels; operation: CRUD }) { + private makeContextComment(_context: { model: string; operation: CRUD }) { return sql``; // return sql.raw(`${CONTEXT_COMMENT_PREFIX}${JSON.stringify(context)}`); } @@ -1202,17 +1200,17 @@ export abstract class BaseOperationHandler { ReturnData extends boolean, Result = ReturnData extends true ? unknown[] : { count: number }, >( - kysely: ToKysely, - model: GetModels, + kysely: AnyKysely, + model: string, where: any, data: any, limit: number | undefined, returnData: ReturnData, - filterModel?: GetModels, + filterModel?: string, fieldsToReturn?: string[], ): Promise { if (typeof data !== 'object') { - throw new InternalError('data must be an object'); + throw createInvalidInputError('data must be an object'); } if (Object.keys(data).length === 0) { @@ -1221,7 +1219,7 @@ export abstract class BaseOperationHandler { const modelDef = this.requireModel(model); if (modelDef.baseModel && limit !== undefined) { - throw new QueryError('Updating with a limit is not supported for polymorphic models'); + throw createNotSupportedError('Updating with a limit is not supported for polymorphic models'); } filterModel ??= model; @@ -1306,11 +1304,11 @@ export abstract class BaseOperationHandler { } private async processBaseModelUpdateMany( - kysely: ToKysely, + kysely: AnyKysely, model: string, where: any, updateFields: any, - filterModel: GetModels, + filterModel: string, ) { const thisUpdateFields: any = {}; const remainingFields: any = {}; @@ -1337,14 +1335,14 @@ export abstract class BaseOperationHandler { return { baseResult, remainingFields }; } - private buildIdFieldRefs(kysely: ToKysely, model: GetModels) { + private buildIdFieldRefs(kysely: AnyKysely, model: string) { const idFields = requireIdFields(this.schema, model); return idFields.map((f) => kysely.dynamic.ref(`${model}.${f}`)); } private async processRelationUpdates( - kysely: ToKysely, - model: GetModels, + kysely: AnyKysely, + model: string, field: string, fieldDef: FieldDef, parentIds: any, @@ -1352,7 +1350,7 @@ export abstract class BaseOperationHandler { throwIfNotFound: boolean, ) { const fieldModel = fieldDef.type as GetModels; - const fromRelationContext: FromRelationContext = { + const fromRelationContext: FromRelationContext = { model, field, ids: parentIds, @@ -1465,7 +1463,7 @@ export abstract class BaseOperationHandler { } default: { - throw new Error('Not implemented yet'); + throw createInvalidInputError(`Invalid relation update operation: ${key}`); } } } @@ -1475,12 +1473,7 @@ export abstract class BaseOperationHandler { // #region relation manipulation - protected async connectRelation( - kysely: ToKysely, - model: GetModels, - data: any, - fromRelation: FromRelationContext, - ) { + protected async connectRelation(kysely: AnyKysely, model: string, data: any, fromRelation: FromRelationContext) { const _data = this.normalizeRelationManipulationInput(model, data); if (_data.length === 0) { return; @@ -1493,7 +1486,7 @@ export abstract class BaseOperationHandler { for (const d of _data) { const ids = await this.getEntityIds(kysely, model, d); if (!ids) { - throw new NotFoundError(model); + throw createNotFoundError(model); } const r = await this.handleManyToManyRelation( kysely, @@ -1511,7 +1504,7 @@ export abstract class BaseOperationHandler { // validate connect result if (_data.length > results.filter((r) => !!r).length) { - throw new NotFoundError(model); + throw createNotFoundError(model); } } else { const { ownedByModel, keyPairs } = getRelationForeignKeyFieldPairs( @@ -1527,7 +1520,7 @@ export abstract class BaseOperationHandler { where: _data[0], }); if (!target) { - throw new NotFoundError(model); + throw createNotFoundError(model); } for (const { fk, pk } of keyPairs) { @@ -1577,7 +1570,7 @@ export abstract class BaseOperationHandler { // validate connect result if (!updateResult.numAffectedRows || _data.length > updateResult.numAffectedRows) { // some entities were not connected - throw new NotFoundError(model); + throw createNotFoundError(model); } } } @@ -1587,7 +1580,7 @@ export abstract class BaseOperationHandler { kysely: ToKysely, model: GetModels, data: any, - fromRelation: FromRelationContext, + fromRelation: FromRelationContext, ) { const _data = enumerate(data); if (_data.length === 0) { @@ -1604,12 +1597,7 @@ export abstract class BaseOperationHandler { } } - protected async disconnectRelation( - kysely: ToKysely, - model: GetModels, - data: any, - fromRelation: FromRelationContext, - ) { + protected async disconnectRelation(kysely: AnyKysely, model: string, data: any, fromRelation: FromRelationContext) { let disconnectConditions: any[] = []; if (typeof data === 'boolean') { if (data === false) { @@ -1721,12 +1709,7 @@ export abstract class BaseOperationHandler { } } - protected async setRelation( - kysely: ToKysely, - model: GetModels, - data: any, - fromRelation: FromRelationContext, - ) { + protected async setRelation(kysely: AnyKysely, model: string, data: any, fromRelation: FromRelationContext) { const _data = this.normalizeRelationManipulationInput(model, data); const m2m = getManyToManyRelation(this.schema, fromRelation.model, fromRelation.field); @@ -1742,7 +1725,7 @@ export abstract class BaseOperationHandler { for (const d of _data) { const ids = await this.getEntityIds(kysely, model, d); if (!ids) { - throw new NotFoundError(model); + throw createNotFoundError(model); } results.push( await this.handleManyToManyRelation( @@ -1761,7 +1744,7 @@ export abstract class BaseOperationHandler { // validate connect result if (_data.length > results.filter((r) => !!r).length) { - throw new NotFoundError(model); + throw createNotFoundError(model); } } else { const { ownedByModel, keyPairs } = getRelationForeignKeyFieldPairs( @@ -1771,7 +1754,7 @@ export abstract class BaseOperationHandler { ); if (ownedByModel) { - throw new InternalError('relation can only be set from the non-owning side'); + throw createInternalError('relation can only be set from the non-owning side', fromRelation.model); } const fkConditions = keyPairs.reduce( @@ -1827,7 +1810,7 @@ export abstract class BaseOperationHandler { // validate result if (!r.numAffectedRows || _data.length > r.numAffectedRows) { // some entities were not connected - throw new NotFoundError(model); + throw createNotFoundError(model); } } } @@ -1837,7 +1820,7 @@ export abstract class BaseOperationHandler { kysely: ToKysely, model: GetModels, data: any, - fromRelation: FromRelationContext, + fromRelation: FromRelationContext, throwForNotFound: boolean, ) { let deleteConditions: any[] = []; @@ -1858,7 +1841,7 @@ export abstract class BaseOperationHandler { } let deleteResult: QueryResult; - let deleteFromModel: GetModels; + let deleteFromModel: string; const m2m = getManyToManyRelation(this.schema, fromRelation.model, fromRelation.field); if (m2m) { @@ -1894,7 +1877,7 @@ export abstract class BaseOperationHandler { where: fromRelation.ids, }); if (!fromEntity) { - throw new NotFoundError(fromRelation.model); + throw createNotFoundError(fromRelation.model); } const fieldDef = this.requireField(fromRelation.model, fromRelation.field); @@ -1924,22 +1907,22 @@ export abstract class BaseOperationHandler { // validate result if (throwForNotFound && expectedDeleteCount > deleteResult.rows.length) { // some entities were not deleted - throw new NotFoundError(deleteFromModel); + throw createNotFoundError(deleteFromModel); } } - private normalizeRelationManipulationInput(model: GetModels, data: any) { + private normalizeRelationManipulationInput(model: string, data: any) { return enumerate(data).map((item) => flattenCompoundUniqueFilters(this.schema, model, item)); } // #endregion protected async delete( - kysely: ToKysely, - model: GetModels, + kysely: AnyKysely, + model: string, where: any, limit?: number, - filterModel?: GetModels, + filterModel?: string, fieldsToReturn?: string[], ): Promise> { filterModel ??= model; @@ -1948,7 +1931,7 @@ export abstract class BaseOperationHandler { if (modelDef.baseModel) { if (limit !== undefined) { - throw new QueryError('Deleting with a limit is not supported for polymorphic models'); + throw createNotSupportedError('Deleting with a limit is not supported for polymorphic models'); } // just delete base and it'll cascade back to this model return this.processBaseModelDelete(kysely, modelDef.baseModel, where, limit, filterModel); @@ -2013,7 +1996,7 @@ export abstract class BaseOperationHandler { const oppositeRelation = this.requireField(fieldDef.type, fieldDef.relation.opposite); if (oppositeModelDef.baseModel && oppositeRelation.relation?.onDelete === 'Cascade') { if (limit !== undefined) { - throw new QueryError('Deleting with a limit is not supported for polymorphic models'); + throw createNotSupportedError('Deleting with a limit is not supported for polymorphic models'); } // the deletion will propagate upward to the base model chain await this.delete( @@ -2030,13 +2013,13 @@ export abstract class BaseOperationHandler { } private async processBaseModelDelete( - kysely: ToKysely, + kysely: AnyKysely, model: string, where: any, limit: number | undefined, - filterModel: GetModels, + filterModel: string, ) { - return this.delete(kysely, model as GetModels, where, limit, filterModel); + return this.delete(kysely, model, where, limit, filterModel); } protected makeIdSelect(model: string) { @@ -2071,10 +2054,7 @@ export abstract class BaseOperationHandler { return returnRelation; } - protected async safeTransaction( - callback: (tx: ToKysely) => Promise, - isolationLevel?: IsolationLevel, - ) { + protected async safeTransaction(callback: (tx: AnyKysely) => Promise, isolationLevel?: IsolationLevel) { if (this.kysely.isTransaction) { // proceed directly if already in a transaction return callback(this.kysely); @@ -2087,7 +2067,7 @@ export abstract class BaseOperationHandler { } // Given a unique filter of a model, load the entity and return its id fields - private getEntityIds(kysely: ToKysely, model: GetModels, uniqueFilter: any) { + private getEntityIds(kysely: AnyKysely, model: string, uniqueFilter: any) { return this.readUnique(kysely, model, { where: uniqueFilter, select: this.makeIdSelect(model), @@ -2118,23 +2098,19 @@ export abstract class BaseOperationHandler { } } - protected makeQueryId(operation: string) { - return { queryId: `${operation}-${createId()}` }; - } - - protected executeQuery(kysely: ToKysely, query: Compilable, operation: string) { - return kysely.executeQuery(query.compile(), this.makeQueryId(operation)); + protected executeQuery(kysely: ToKysely, query: Compilable, _operation: string) { + return kysely.executeQuery(query.compile()); } - protected async executeQueryTakeFirst(kysely: ToKysely, query: Compilable, operation: string) { - const result = await kysely.executeQuery(query.compile(), this.makeQueryId(operation)); + protected async executeQueryTakeFirst(kysely: ToKysely, query: Compilable, _operation: string) { + const result = await kysely.executeQuery(query.compile()); return result.rows[0]; } - protected async executeQueryTakeFirstOrThrow(kysely: ToKysely, query: Compilable, operation: string) { - const result = await kysely.executeQuery(query.compile(), this.makeQueryId(operation)); + protected async executeQueryTakeFirstOrThrow(kysely: ToKysely, query: Compilable, _operation: string) { + const result = await kysely.executeQuery(query.compile()); if (result.rows.length === 0) { - throw new QueryError('No rows found'); + throw new ORMError(ORMErrorReason.NOT_FOUND, 'No rows found'); } return result.rows[0]; } diff --git a/packages/orm/src/client/crud/operations/create.ts b/packages/orm/src/client/crud/operations/create.ts index 98124838..1e1c9869 100644 --- a/packages/orm/src/client/crud/operations/create.ts +++ b/packages/orm/src/client/crud/operations/create.ts @@ -1,7 +1,7 @@ import { match } from 'ts-pattern'; import type { GetModels, SchemaDef } from '../../../schema'; import type { CreateArgs, CreateManyAndReturnArgs, CreateManyArgs, WhereInput } from '../../crud-types'; -import { RejectedByPolicyError, RejectedByPolicyReason } from '../../errors'; +import { createRejectedByPolicyError, RejectedByPolicyReason } from '../../errors'; import { getIdValues } from '../../query-utils'; import { BaseOperationHandler } from './base'; @@ -48,7 +48,7 @@ export class CreateOperationHandler extends BaseOperat }); if (!result && this.hasPolicyEnabled) { - throw new RejectedByPolicyError( + throw createRejectedByPolicyError( this.model, RejectedByPolicyReason.CANNOT_READ_BACK, `result is not allowed to be read back`, diff --git a/packages/orm/src/client/crud/operations/delete.ts b/packages/orm/src/client/crud/operations/delete.ts index e6fb3c2a..af9942a9 100644 --- a/packages/orm/src/client/crud/operations/delete.ts +++ b/packages/orm/src/client/crud/operations/delete.ts @@ -1,7 +1,7 @@ import { match } from 'ts-pattern'; import type { SchemaDef } from '../../../schema'; import type { DeleteArgs, DeleteManyArgs } from '../../crud-types'; -import { NotFoundError, RejectedByPolicyError, RejectedByPolicyReason } from '../../errors'; +import { createNotFoundError, createRejectedByPolicyError, RejectedByPolicyReason } from '../../errors'; import { BaseOperationHandler } from './base'; export class DeleteOperationHandler extends BaseOperationHandler { @@ -34,13 +34,13 @@ export class DeleteOperationHandler extends BaseOperat } const deleteResult = await this.delete(tx, this.model, args.where, undefined, undefined, selectedFields); if (deleteResult.rows.length === 0) { - throw new NotFoundError(this.model); + throw createNotFoundError(this.model); } return needReadBack ? preDeleteRead : deleteResult.rows[0]; }); if (!result && this.hasPolicyEnabled) { - throw new RejectedByPolicyError( + throw createRejectedByPolicyError( this.model, RejectedByPolicyReason.CANNOT_READ_BACK, 'result is not allowed to be read back', diff --git a/packages/orm/src/client/crud/operations/group-by.ts b/packages/orm/src/client/crud/operations/group-by.ts index 44829ee2..cae9f65f 100644 --- a/packages/orm/src/client/crud/operations/group-by.ts +++ b/packages/orm/src/client/crud/operations/group-by.ts @@ -12,7 +12,7 @@ export class GroupByOperationHandler extends BaseOpera const parsedArgs = this.inputValidator.validateGroupByArgs(this.model, normalizedArgs); let query = this.kysely - .selectFrom(this.model) + .selectFrom(this.model as string) .where(() => this.dialect.buildFilter(this.model, this.model, parsedArgs?.where)); const fieldRef = (field: string) => this.dialect.fieldRef(this.model, field); diff --git a/packages/orm/src/client/crud/operations/update.ts b/packages/orm/src/client/crud/operations/update.ts index 5d8d7b19..9c81d169 100644 --- a/packages/orm/src/client/crud/operations/update.ts +++ b/packages/orm/src/client/crud/operations/update.ts @@ -1,7 +1,7 @@ import { match } from 'ts-pattern'; import type { GetModels, SchemaDef } from '../../../schema'; import type { UpdateArgs, UpdateManyAndReturnArgs, UpdateManyArgs, UpsertArgs, WhereInput } from '../../crud-types'; -import { RejectedByPolicyError, RejectedByPolicyReason } from '../../errors'; +import { createRejectedByPolicyError, RejectedByPolicyReason } from '../../errors'; import { getIdValues } from '../../query-utils'; import { BaseOperationHandler } from './base'; @@ -61,7 +61,7 @@ export class UpdateOperationHandler extends BaseOperat // update succeeded but result cannot be read back if (this.hasPolicyEnabled) { // if access policy is enabled, we assume it's due to read violation (not guaranteed though) - throw new RejectedByPolicyError( + throw createRejectedByPolicyError( this.model, RejectedByPolicyReason.CANNOT_READ_BACK, 'result is not allowed to be read back', @@ -120,7 +120,7 @@ export class UpdateOperationHandler extends BaseOperat if (readBackResult.length < updateResult.length && this.hasPolicyEnabled) { // some of the updated entities cannot be read back - throw new RejectedByPolicyError( + throw createRejectedByPolicyError( this.model, RejectedByPolicyReason.CANNOT_READ_BACK, 'result is not allowed to be read back', @@ -168,7 +168,7 @@ export class UpdateOperationHandler extends BaseOperat }); if (!result && this.hasPolicyEnabled) { - throw new RejectedByPolicyError( + throw createRejectedByPolicyError( this.model, RejectedByPolicyReason.CANNOT_READ_BACK, 'result is not allowed to be read back', diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index b4c012ed..7d97c942 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -31,7 +31,6 @@ import { type UpdateManyArgs, type UpsertArgs, } from '../../crud-types'; -import { InputValidationError, InternalError } from '../../errors'; import { fieldHasDefaultValue, getDiscriminatorField, @@ -48,6 +47,7 @@ import { addNumberValidation, addStringValidation, } from './utils'; +import { createInternalError, createInvalidInputError } from '../../errors'; const schemaCache = new WeakMap>(); @@ -230,10 +230,12 @@ export class InputValidator { } const { error, data } = schema.safeParse(args); if (error) { - throw new InputValidationError( - model, + throw createInvalidInputError( `Invalid ${operation} args for model "${model}": ${formatError(error)}`, - error, + model, + { + cause: error, + }, ); } return data as T; @@ -471,7 +473,7 @@ export class InputValidator { // requires at least one unique field (field set) is required const uniqueFields = getUniqueFields(this.schema, model); if (uniqueFields.length === 0) { - throw new InternalError(`Model "${model}" has no unique fields`); + throw createInternalError(`Model "${model}" has no unique fields`); } if (uniqueFields.length === 1) { diff --git a/packages/orm/src/client/crud/validator/utils.ts b/packages/orm/src/client/crud/validator/utils.ts index 5024b07d..94f4baca 100644 --- a/packages/orm/src/client/crud/validator/utils.ts +++ b/packages/orm/src/client/crud/validator/utils.ts @@ -13,7 +13,7 @@ import { match, P } from 'ts-pattern'; import { z } from 'zod'; import { ZodIssueCode } from 'zod/v3'; import { ExpressionUtils } from '../../../schema'; -import { QueryError } from '../../errors'; +import { createNotSupportedError } from '../../errors'; function getArgValue(expr: Expression | undefined): T | undefined { if (!expr || !ExpressionUtils.isLiteral(expr)) { @@ -452,7 +452,7 @@ function evalCall(data: any, expr: CallExpression) { return fieldArg.length === 0; }) .otherwise(() => { - throw new QueryError(`Unknown function "${expr.function}"`); + throw createNotSupportedError(`Unsupported function "${expr.function}"`); }) ); } diff --git a/packages/orm/src/client/errors.ts b/packages/orm/src/client/errors.ts index 89ef02f2..9908a6b2 100644 --- a/packages/orm/src/client/errors.ts +++ b/packages/orm/src/client/errors.ts @@ -1,45 +1,43 @@ -/** - * Base for all ZenStack runtime errors. - */ -export class ZenStackError extends Error {} +import { getDbErrorCode } from './executor/error-processor'; /** - * Error thrown when input validation fails. + * Reason code for ORM errors. */ -export class InputValidationError extends ZenStackError { - constructor( - public readonly model: string, - message: string, - cause?: unknown, - ) { - super(message, { cause }); - } -} +export enum ORMErrorReason { + /** + * ORM client configuration error. + */ + CONFIG_ERROR = 'config-error', -/** - * Error thrown when a query fails. - */ -export class QueryError extends ZenStackError { - constructor(message: string, cause?: unknown) { - super(message, { cause }); - } -} + /** + * Invalid input error. + */ + INVALID_INPUT = 'invalid-input', -/** - * Error thrown when an internal error occurs. - */ -export class InternalError extends ZenStackError {} + /** + * The specified record was not found. + */ + NOT_FOUND = 'not-found', -/** - * Error thrown when an entity is not found. - */ -export class NotFoundError extends ZenStackError { - constructor( - public readonly model: string, - details?: string, - ) { - super(`Entity not found for model "${model}"${details ? `: ${details}` : ''}`); - } + /** + * Operation is rejected by access policy. + */ + REJECTED_BY_POLICY = 'rejected-by-policy', + + /** + * Error was thrown by the underlying database driver. + */ + DB_QUERY_ERROR = 'db-query-error', + + /** + * The requested operation is not supported. + */ + NOT_SUPPORTED = 'not-supported', + + /** + * An internal error occurred. + */ + INTERNAL_ERROR = 'internal-error', } /** @@ -63,14 +61,91 @@ export enum RejectedByPolicyReason { } /** - * Error thrown when an operation is rejected by access policy. + * ZenStack ORM error. */ -export class RejectedByPolicyError extends ZenStackError { +export class ORMError extends Error { constructor( - public readonly model: string | undefined, - public readonly reason: RejectedByPolicyReason = RejectedByPolicyReason.NO_ACCESS, + public reason: ORMErrorReason, message?: string, + options?: ErrorOptions, ) { - super(message ?? `Operation rejected by policy${model ? ': ' + model : ''}`); + super(message, options); } + + /** + * The name of the model that the error pertains to. + */ + public model?: string; + + /** + * The error code given by the underlying database driver. + */ + public dbErrorCode?: unknown; + + /** + * The error message given by the underlying database driver. + */ + public dbErrorMessage?: string; + + /** + * The reason code for policy rejection. Only available when `reason` is `REJECTED_BY_POLICY`. + */ + public rejectedByPolicyReason?: RejectedByPolicyReason; + + /** + * The SQL query that was executed. Only available when `reason` is `DB_QUERY_ERROR`. + */ + public sql?: string; + + /** + * The parameters used in the SQL query. Only available when `reason` is `DB_QUERY_ERROR`. + */ + public sqlParams?: readonly unknown[]; +} + +export function createConfigError(message: string, options?: ErrorOptions) { + return new ORMError(ORMErrorReason.CONFIG_ERROR, message, options); +} + +export function createNotFoundError(model: string, message?: string, options?: ErrorOptions) { + const error = new ORMError(ORMErrorReason.NOT_FOUND, message ?? 'Record not found', options); + error.model = model; + return error; +} + +export function createInvalidInputError(message: string, model?: string, options?: ErrorOptions) { + const error = new ORMError(ORMErrorReason.INVALID_INPUT, message, options); + error.model = model; + return error; +} + +export function createDBQueryError(message: string, dbError: unknown, sql: string, parameters: readonly unknown[]) { + const error = new ORMError(ORMErrorReason.DB_QUERY_ERROR, message, { cause: dbError }); + error.dbErrorCode = getDbErrorCode(dbError); + error.dbErrorMessage = dbError instanceof Error ? dbError.message : undefined; + error.sql = sql; + error.sqlParams = parameters; + return error; +} + +export function createRejectedByPolicyError( + model: string, + reason: RejectedByPolicyReason, + message: string, + options?: ErrorOptions, +) { + const error = new ORMError(ORMErrorReason.REJECTED_BY_POLICY, message, options); + error.model = model; + error.rejectedByPolicyReason = reason; + return error; +} + +export function createNotSupportedError(message: string, options?: ErrorOptions) { + return new ORMError(ORMErrorReason.NOT_SUPPORTED, message, options); +} + +export function createInternalError(message: string, model?: string, options?: ErrorOptions) { + const error = new ORMError(ORMErrorReason.INTERNAL_ERROR, message, options); + error.model = model; + return error; } diff --git a/packages/orm/src/client/executor/error-processor.ts b/packages/orm/src/client/executor/error-processor.ts new file mode 100644 index 00000000..e00b5b5c --- /dev/null +++ b/packages/orm/src/client/executor/error-processor.ts @@ -0,0 +1,12 @@ +/** + * Extracts database error code from an error thrown by the database driver. + * + * @todo currently assumes the error has a code field + */ +export function getDbErrorCode(error: unknown): unknown | undefined { + if (error instanceof Error && 'code' in error) { + return error.code; + } else { + return undefined; + } +} diff --git a/packages/orm/src/client/executor/zenstack-query-executor.ts b/packages/orm/src/client/executor/zenstack-query-executor.ts index a2005bff..e53552c5 100644 --- a/packages/orm/src/client/executor/zenstack-query-executor.ts +++ b/packages/orm/src/client/executor/zenstack-query-executor.ts @@ -1,7 +1,9 @@ import { invariant } from '@zenstackhq/common-helpers'; +import type { QueryId } from 'kysely'; import { AndNode, CompiledQuery, + createQueryId, DefaultQueryExecutor, DeleteQueryNode, InsertQueryNode, @@ -25,14 +27,12 @@ import { match } from 'ts-pattern'; import type { GetModels, ModelDef, SchemaDef, TypeDefDef } from '../../schema'; import { type ClientImpl } from '../client-impl'; import { TransactionIsolationLevel, type ClientContract } from '../contract'; -import { InternalError, QueryError, ZenStackError } from '../errors'; +import { createDBQueryError, createInternalError, ORMError } from '../errors'; import type { AfterEntityMutationCallback, OnKyselyQueryCallback } from '../plugin'; import { stripAlias } from '../query-utils'; import { QueryNameMapper } from './name-mapper'; import type { ZenStackDriver } from './zenstack-driver'; -type QueryId = { queryId: string }; - type MutationQueryNode = InsertQueryNode | UpdateQueryNode | DeleteQueryNode; type MutationInfo = { @@ -79,7 +79,7 @@ export class ZenStackQueryExecutor extends DefaultQuer return this.client.$options; } - override executeQuery(compiledQuery: CompiledQuery, queryId: QueryId) { + override executeQuery(compiledQuery: CompiledQuery) { // proceed with the query with kysely interceptors // if the query is a raw query, we need to carry over the parameters const queryParams = (compiledQuery as any).$raw ? compiledQuery.parameters : undefined; @@ -98,7 +98,7 @@ export class ZenStackQueryExecutor extends DefaultQuer connection, compiledQuery.query, queryParams, - queryId.queryId, + compiledQuery.queryId, ); if (startedTx) { await this.driver.commitTransaction(connection); @@ -108,12 +108,16 @@ export class ZenStackQueryExecutor extends DefaultQuer if (startedTx) { await this.driver.rollbackTransaction(connection); } - if (err instanceof ZenStackError) { + if (err instanceof ORMError) { throw err; } else { // wrap error - const message = `Failed to execute query: ${err}, sql: ${compiledQuery?.sql}`; - throw new QueryError(message, err); + throw createDBQueryError( + 'Failed to execute query', + err, + compiledQuery.sql, + compiledQuery.parameters, + ); } } }); @@ -123,7 +127,7 @@ export class ZenStackQueryExecutor extends DefaultQuer connection: DatabaseConnection, queryNode: RootOperationNode, parameters: readonly unknown[] | undefined, - queryId: string, + queryId: QueryId, ) { let proceed = (q: RootOperationNode) => this.proceedQuery(connection, q, parameters, queryId); @@ -178,14 +182,16 @@ export class ZenStackQueryExecutor extends DefaultQuer connection: DatabaseConnection, query: RootOperationNode, parameters: readonly unknown[] | undefined, - queryId: string, + queryId: QueryId, ) { let compiled: CompiledQuery | undefined; if (this.suppressMutationHooks || !this.isMutationNode(query) || !this.hasEntityMutationPlugins) { // no need to handle mutation hooks, just proceed const finalQuery = this.processNameMapping(query); - compiled = this.compileQuery(finalQuery); + + // inherit the original queryId + compiled = this.compileQuery(finalQuery, queryId); if (parameters) { compiled = { ...compiled, parameters }; } @@ -204,7 +210,9 @@ export class ZenStackQueryExecutor extends DefaultQuer }; } const finalQuery = this.processNameMapping(query); - compiled = this.compileQuery(finalQuery); + + // inherit the original queryId + compiled = this.compileQuery(finalQuery, queryId); if (parameters) { compiled = { ...compiled, parameters }; } @@ -361,7 +369,7 @@ export class ZenStackQueryExecutor extends DefaultQuer return tableNode.table.identifier.name; }) .otherwise((node) => { - throw new InternalError(`Invalid query node: ${node}`); + throw createInternalError(`Invalid query node: ${node}`); }) as GetModels; } @@ -370,7 +378,7 @@ export class ZenStackQueryExecutor extends DefaultQuer mutationInfo: MutationInfo, loadBeforeMutationEntities: () => Promise[] | undefined>, client: ClientContract, - queryId: string, + queryId: QueryId, ) { if (this.options.plugins) { for (const plugin of this.options.plugins) { @@ -397,7 +405,7 @@ export class ZenStackQueryExecutor extends DefaultQuer mutationInfo: MutationInfo, client: ClientContract, filterFor: 'inTx' | 'outTx' | 'all', - queryId: string, + queryId: QueryId, ) { const hooks: AfterEntityMutationCallback[] = []; @@ -456,7 +464,7 @@ export class ZenStackQueryExecutor extends DefaultQuer ...selectQueryNode, where: this.andNodes(selectQueryNode.where, where), }; - const compiled = this.compileQuery(selectQueryNode); + const compiled = this.compileQuery(selectQueryNode, createQueryId()); // execute the query directly with the given connection to avoid triggering // any other side effects const result = await connection.executeQuery(compiled); diff --git a/packages/orm/src/client/index.ts b/packages/orm/src/client/index.ts index 225aeba5..6a320300 100644 --- a/packages/orm/src/client/index.ts +++ b/packages/orm/src/client/index.ts @@ -3,7 +3,7 @@ export * from './contract'; export type * from './crud-types'; export { getCrudDialect } from './crud/dialects'; export { BaseCrudDialect } from './crud/dialects/base-dialect'; -export * from './errors'; +export { ORMError, ORMErrorReason, RejectedByPolicyReason } from './errors'; export * from './options'; export * from './plugin'; export type { ZenStackPromise } from './promise'; diff --git a/packages/orm/src/client/plugin.ts b/packages/orm/src/client/plugin.ts index eda9e4a7..edf5c86a 100644 --- a/packages/orm/src/client/plugin.ts +++ b/packages/orm/src/client/plugin.ts @@ -1,4 +1,4 @@ -import type { OperationNode, QueryResult, RootOperationNode, UnknownRow } from 'kysely'; +import type { OperationNode, QueryId, QueryResult, RootOperationNode, UnknownRow } from 'kysely'; import type { ClientContract } from '.'; import type { GetModels, SchemaDef } from '../schema'; import type { MaybePromise } from '../utils/type-utils'; @@ -138,7 +138,7 @@ type MutationHooksArgs = { * A query ID that uniquely identifies the mutation operation. You can use it to correlate * data between the before and after mutation hooks. */ - queryId: string; + queryId: QueryId; }; export type BeforeEntityMutationCallback = ( diff --git a/packages/orm/src/client/query-utils.ts b/packages/orm/src/client/query-utils.ts index 6bd51435..9797584c 100644 --- a/packages/orm/src/client/query-utils.ts +++ b/packages/orm/src/client/query-utils.ts @@ -13,7 +13,7 @@ import { ExpressionUtils, type FieldDef, type GetModels, type ModelDef, type Sch import { extractFields } from '../utils/object-utils'; import type { AGGREGATE_OPERATORS } from './constants'; import type { OrderBy } from './crud-types'; -import { InternalError, QueryError } from './errors'; +import { createInternalError } from './errors'; export function hasModel(schema: SchemaDef, model: string) { return Object.keys(schema.models) @@ -32,7 +32,7 @@ export function getTypeDef(schema: SchemaDef, type: string) { export function requireModel(schema: SchemaDef, model: string) { const modelDef = getModel(schema, model); if (!modelDef) { - throw new QueryError(`Model "${model}" not found in schema`); + throw createInternalError(`Model "${model}" not found in schema`, model); } return modelDef; } @@ -46,7 +46,7 @@ export function requireField(schema: SchemaDef, modelOrType: string, field: stri const modelDef = getModel(schema, modelOrType); if (modelDef) { if (!modelDef.fields[field]) { - throw new QueryError(`Field "${field}" not found in model "${modelOrType}"`); + throw createInternalError(`Field "${field}" not found in model "${modelOrType}"`, modelOrType); } else { return modelDef.fields[field]; } @@ -54,12 +54,12 @@ export function requireField(schema: SchemaDef, modelOrType: string, field: stri const typeDef = getTypeDef(schema, modelOrType); if (typeDef) { if (!typeDef.fields[field]) { - throw new QueryError(`Field "${field}" not found in type "${modelOrType}"`); + throw createInternalError(`Field "${field}" not found in type "${modelOrType}"`, modelOrType); } else { return typeDef.fields[field]; } } - throw new QueryError(`Model or type "${modelOrType}" not found in schema`); + throw createInternalError(`Model or type "${modelOrType}" not found in schema`, modelOrType); } export function getIdFields(schema: SchemaDef, model: GetModels) { @@ -71,7 +71,7 @@ export function requireIdFields(schema: SchemaDef, model: string) { const modelDef = requireModel(schema, model); const result = modelDef?.idFields; if (!result) { - throw new InternalError(`Model "${model}" does not have ID field(s)`); + throw createInternalError(`Model "${model}" does not have ID field(s)`, model); } return result; } @@ -80,12 +80,12 @@ export function getRelationForeignKeyFieldPairs(schema: SchemaDef, model: string const fieldDef = requireField(schema, model, relationField); if (!fieldDef?.relation) { - throw new InternalError(`Field "${relationField}" is not a relation`); + throw createInternalError(`Field "${relationField}" is not a relation`, model); } if (fieldDef.relation.fields) { if (!fieldDef.relation.references) { - throw new InternalError(`Relation references not defined for field "${relationField}"`); + throw createInternalError(`Relation references not defined for field "${relationField}"`, model); } // this model owns the fk return { @@ -97,19 +97,19 @@ export function getRelationForeignKeyFieldPairs(schema: SchemaDef, model: string }; } else { if (!fieldDef.relation.opposite) { - throw new InternalError(`Opposite relation not defined for field "${relationField}"`); + throw createInternalError(`Opposite relation not defined for field "${relationField}"`, model); } const oppositeField = requireField(schema, fieldDef.type, fieldDef.relation.opposite); if (!oppositeField.relation) { - throw new InternalError(`Field "${fieldDef.relation.opposite}" is not a relation`); + throw createInternalError(`Field "${fieldDef.relation.opposite}" is not a relation`, model); } if (!oppositeField.relation.fields) { - throw new InternalError(`Relation fields not defined for field "${relationField}"`); + throw createInternalError(`Relation fields not defined for field "${relationField}"`, model); } if (!oppositeField.relation.references) { - throw new InternalError(`Relation references not defined for field "${relationField}"`); + throw createInternalError(`Relation references not defined for field "${relationField}"`, model); } // the opposite model owns the fk @@ -153,7 +153,7 @@ export function getUniqueFields(schema: SchemaDef, model: string) { > = []; for (const [key, value] of Object.entries(modelDef.uniqueFields)) { if (typeof value !== 'object') { - throw new InternalError(`Invalid unique field definition for "${key}"`); + throw createInternalError(`Invalid unique field definition for "${key}"`, model); } if (typeof value.type === 'string') { @@ -173,7 +173,7 @@ export function getUniqueFields(schema: SchemaDef, model: string) { export function getIdValues(schema: SchemaDef, model: string, data: any): Record { const idFields = getIdFields(schema, model); if (!idFields) { - throw new InternalError(`ID fields not defined for model "${model}"`); + throw createInternalError(`ID fields not defined for model "${model}"`, model); } return idFields.reduce((acc, field) => ({ ...acc, [field]: data[field] }), {}); } @@ -328,7 +328,7 @@ export function getDiscriminatorField(schema: SchemaDef, model: string) { } const discriminator = delegateAttr.args?.find((arg) => arg.name === 'discriminator'); if (!discriminator || !ExpressionUtils.isField(discriminator.value)) { - throw new InternalError(`Discriminator field not defined for model "${model}"`); + throw createInternalError(`Discriminator field not defined for model "${model}"`, model); } return discriminator.value.field; } diff --git a/packages/orm/src/utils/kysely-utils.ts b/packages/orm/src/utils/kysely-utils.ts index 404b33a1..140cb6a8 100644 --- a/packages/orm/src/utils/kysely-utils.ts +++ b/packages/orm/src/utils/kysely-utils.ts @@ -11,6 +11,7 @@ import { CaseNode, CastNode, CheckConstraintNode, + CollateNode, ColumnDefinitionNode, ColumnNode, ColumnUpdateNode, @@ -48,6 +49,7 @@ import { JSONPathLegNode, JSONPathNode, JSONReferenceNode, + Kysely, LimitNode, ListNode, MatchedNode, @@ -59,6 +61,7 @@ import { OnNode, OperationNodeVisitor, OperatorNode, + OrActionNode, OrderByItemNode, OrderByNode, OrNode, @@ -71,7 +74,9 @@ import { RawNode, ReferenceNode, ReferencesNode, + RefreshMaterializedViewNode, RenameColumnNode, + RenameConstraintNode, ReturningNode, SchemableIdentifierNode, SelectAllNode, @@ -390,4 +395,18 @@ export class DefaultOperationNodeVisitor extends OperationNodeVisitor { protected override visitOutput(node: OutputNode): void { this.defaultVisit(node); } + protected override visitRenameConstraint(node: RenameConstraintNode): void { + this.defaultVisit(node); + } + protected override visitRefreshMaterializedView(node: RefreshMaterializedViewNode): void { + this.defaultVisit(node); + } + protected override visitOrAction(node: OrActionNode): void { + this.defaultVisit(node); + } + protected override visitCollate(node: CollateNode): void { + this.defaultVisit(node); + } } + +export type AnyKysely = Kysely; diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index 0d5a3cbf..c374d135 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/plugin-policy", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "description": "ZenStack Policy Plugin", "type": "module", "scripts": { diff --git a/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index ad448805..8036eb16 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -1,13 +1,5 @@ import { invariant } from '@zenstackhq/common-helpers'; -import { - getCrudDialect, - InternalError, - QueryError, - QueryUtils, - type BaseCrudDialect, - type ClientContract, - type CRUD_EXT, -} from '@zenstackhq/orm'; +import { getCrudDialect, QueryUtils, type BaseCrudDialect, type ClientContract, type CRUD_EXT } from '@zenstackhq/orm'; import type { BinaryExpression, BinaryOperator, @@ -48,10 +40,18 @@ import { } from 'kysely'; import { match } from 'ts-pattern'; import { ExpressionEvaluator } from './expression-evaluator'; -import { conjunction, disjunction, falseNode, isBeforeInvocation, logicalNot, trueNode } from './utils'; - -export type ExpressionTransformerContext = { - model: GetModels; +import { + conjunction, + createUnsupportedError, + disjunction, + falseNode, + isBeforeInvocation, + logicalNot, + trueNode, +} from './utils'; + +export type ExpressionTransformerContext = { + model: string; alias?: string; operation: CRUD_EXT; memberFilter?: OperationNode; @@ -92,12 +92,12 @@ export class ExpressionTransformer { get authType() { if (!this.schema.authType) { - throw new InternalError('Schema does not have an "authType" specified'); + invariant(false, 'Schema does not have an "authType" specified'); } return this.schema.authType!; } - transform(expression: Expression, context: ExpressionTransformerContext): OperationNode { + transform(expression: Expression, context: ExpressionTransformerContext): OperationNode { const handler = expressionHandlers.get(expression.kind); if (!handler) { throw new Error(`Unsupported expression kind: ${expression.kind}`); @@ -116,12 +116,12 @@ export class ExpressionTransformer { @expr('array') // @ts-expect-error - private _array(expr: ArrayExpression, context: ExpressionTransformerContext) { + private _array(expr: ArrayExpression, context: ExpressionTransformerContext) { return ValueListNode.create(expr.items.map((item) => this.transform(item, context))); } @expr('field') - private _field(expr: FieldExpression, context: ExpressionTransformerContext) { + private _field(expr: FieldExpression, context: ExpressionTransformerContext) { const fieldDef = QueryUtils.requireField(this.schema, context.model, expr.field); if (!fieldDef.relation) { return this.createColumnRef(expr.field, context); @@ -154,7 +154,7 @@ export class ExpressionTransformer { @expr('binary') // @ts-ignore - private _binary(expr: BinaryExpression, context: ExpressionTransformerContext) { + private _binary(expr: BinaryExpression, context: ExpressionTransformerContext) { if (expr.op === '&&') { return conjunction(this.dialect, [this.transform(expr.left, context), this.transform(expr.right, context)]); } else if (expr.op === '||') { @@ -216,7 +216,7 @@ export class ExpressionTransformer { } } - private normalizeBinaryOperationOperands(expr: BinaryExpression, context: ExpressionTransformerContext) { + private normalizeBinaryOperationOperands(expr: BinaryExpression, context: ExpressionTransformerContext) { // if relation fields are used directly in comparison, it can only be compared with null, // so we normalize the args with the id field (use the first id field if multiple) let normalizedLeft: Expression = expr.left; @@ -238,7 +238,7 @@ export class ExpressionTransformer { return { normalizedLeft, normalizedRight }; } - private transformCollectionPredicate(expr: BinaryExpression, context: ExpressionTransformerContext) { + private transformCollectionPredicate(expr: BinaryExpression, context: ExpressionTransformerContext) { invariant(expr.op === '?' || expr.op === '!' || expr.op === '^', 'expected "?" or "!" or "^" operator'); if (this.isAuthCall(expr.left) || this.isAuthMember(expr.left)) { @@ -273,7 +273,7 @@ export class ExpressionTransformer { let predicateFilter = this.transform(expr.right, { ...context, - model: newContextModel as GetModels, + model: newContextModel, alias: undefined, }); @@ -296,9 +296,9 @@ export class ExpressionTransformer { }); } - private transformAuthBinary(expr: BinaryExpression, context: ExpressionTransformerContext) { + private transformAuthBinary(expr: BinaryExpression, context: ExpressionTransformerContext) { if (expr.op !== '==' && expr.op !== '!=') { - throw new QueryError( + throw createUnsupportedError( `Unsupported operator for \`auth()\` in policy of model "${context.model}": ${expr.op}`, ); } @@ -318,7 +318,7 @@ export class ExpressionTransformer { } else { const authModel = QueryUtils.getModel(this.schema, this.authType); if (!authModel) { - throw new QueryError( + throw createUnsupportedError( `Unsupported use of \`auth()\` in policy of model "${context.model}", comparing with \`auth()\` is only possible when auth type is a model`, ); } @@ -364,7 +364,7 @@ export class ExpressionTransformer { @expr('unary') // @ts-ignore - private _unary(expr: UnaryExpression, context: ExpressionTransformerContext) { + private _unary(expr: UnaryExpression, context: ExpressionTransformerContext) { // only '!' operator for now invariant(expr.op === '!', 'only "!" operator is supported'); return logicalNot(this.dialect, this.transform(expr.operand, context)); @@ -379,15 +379,15 @@ export class ExpressionTransformer { @expr('call') // @ts-ignore - private _call(expr: CallExpression, context: ExpressionTransformerContext) { + private _call(expr: CallExpression, context: ExpressionTransformerContext) { const result = this.transformCall(expr, context); return result.toOperationNode(); } - private transformCall(expr: CallExpression, context: ExpressionTransformerContext) { + private transformCall(expr: CallExpression, context: ExpressionTransformerContext) { const func = this.getFunctionImpl(expr.function); if (!func) { - throw new QueryError(`Function not implemented: ${expr.function}`); + throw createUnsupportedError(`Function not implemented: ${expr.function}`); } const eb = expressionBuilder(); return func( @@ -396,7 +396,7 @@ export class ExpressionTransformer { { client: this.client, dialect: this.dialect, - model: context.model, + model: context.model as GetModels, modelAlias: context.alias ?? context.model, operation: context.operation, }, @@ -421,7 +421,7 @@ export class ExpressionTransformer { private transformCallArg( eb: ExpressionBuilder, arg: Expression, - context: ExpressionTransformerContext, + context: ExpressionTransformerContext, ): OperandExpression { if (ExpressionUtils.isLiteral(arg)) { return eb.val(arg.value); @@ -444,12 +444,12 @@ export class ExpressionTransformer { // if (Expression.isMember(arg)) { // } - throw new InternalError(`Unsupported argument expression: ${arg.kind}`); + throw createUnsupportedError(`Unsupported argument expression: ${arg.kind}`); } @expr('member') // @ts-ignore - private _member(expr: MemberExpression, context: ExpressionTransformerContext) { + private _member(expr: MemberExpression, context: ExpressionTransformerContext) { // `auth()` member access if (this.isAuthCall(expr.receiver)) { return this.valueMemberAccess(this.auth, expr, this.authType); @@ -516,7 +516,7 @@ export class ExpressionTransformer { if (fieldDef.relation) { const relation = this.transformRelationAccess(member, fieldDef.type, { ...restContext, - model: fromModel as GetModels, + model: fromModel, alias: undefined, }); @@ -567,7 +567,7 @@ export class ExpressionTransformer { private transformRelationAccess( field: string, relationModel: string, - context: ExpressionTransformerContext, + context: ExpressionTransformerContext, ): SelectQueryNode { const m2m = QueryUtils.getManyToManyRelation(this.schema, context.model, field); if (m2m) { @@ -627,7 +627,7 @@ export class ExpressionTransformer { private transformManyToManyRelationAccess( m2m: NonNullable>, - context: ExpressionTransformerContext, + context: ExpressionTransformerContext, ) { const eb = expressionBuilder(); const relationQuery = eb @@ -647,7 +647,7 @@ export class ExpressionTransformer { return relationQuery.toOperationNode(); } - private createColumnRef(column: string, context: ExpressionTransformerContext) { + private createColumnRef(column: string, context: ExpressionTransformerContext) { // if field comes from a delegate base model, we need to use the join alias // of that base model @@ -716,12 +716,12 @@ export class ExpressionTransformer { } } - private isRelationField(expr: Expression, model: GetModels) { + private isRelationField(expr: Expression, model: string) { const fieldDef = this.getFieldDefFromFieldRef(expr, model); return !!fieldDef?.relation; } - private getFieldDefFromFieldRef(expr: Expression, model: GetModels): FieldDef | undefined { + private getFieldDefFromFieldRef(expr: Expression, model: string): FieldDef | undefined { if (ExpressionUtils.isField(expr)) { return QueryUtils.requireField(this.schema, model, expr.field); } else if ( diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index a9473f31..e1c24d6c 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -1,20 +1,10 @@ import { invariant } from '@zenstackhq/common-helpers'; import type { BaseCrudDialect, ClientContract, ProceedKyselyQueryFunction } from '@zenstackhq/orm'; -import { - getCrudDialect, - InternalError, - QueryError, - QueryUtils, - RejectedByPolicyError, - RejectedByPolicyReason, - SchemaUtils, - type CRUD_EXT, -} from '@zenstackhq/orm'; +import { getCrudDialect, QueryUtils, RejectedByPolicyReason, SchemaUtils, type CRUD_EXT } from '@zenstackhq/orm'; import { ExpressionUtils, type BuiltinType, type Expression, - type GetModels, type MemberExpression, type SchemaDef, } from '@zenstackhq/orm/schema'; @@ -55,7 +45,17 @@ import { match } from 'ts-pattern'; import { ColumnCollector } from './column-collector'; import { ExpressionTransformer } from './expression-transformer'; import type { Policy, PolicyOperation } from './types'; -import { buildIsFalse, conjunction, disjunction, falseNode, getTableName, isBeforeInvocation, trueNode } from './utils'; +import { + buildIsFalse, + conjunction, + createRejectedByPolicyError, + createUnsupportedError, + disjunction, + falseNode, + getTableName, + isBeforeInvocation, + trueNode, +} from './utils'; export type CrudQueryNode = SelectQueryNode | InsertQueryNode | UpdateQueryNode | DeleteQueryNode; @@ -76,7 +76,7 @@ export class PolicyHandler extends OperationNodeTransf async handle(node: RootOperationNode, proceed: ProceedKyselyQueryFunction) { if (!this.isCrudQueryNode(node)) { // non-CRUD queries are not allowed - throw new RejectedByPolicyError( + throw createRejectedByPolicyError( undefined, RejectedByPolicyReason.OTHER, 'non-CRUD queries are not allowed', @@ -104,7 +104,7 @@ export class PolicyHandler extends OperationNodeTransf if (constCondition === true) { needCheckPreCreate = false; } else if (constCondition === false) { - throw new RejectedByPolicyError(mutationModel, RejectedByPolicyReason.NO_ACCESS); + throw createRejectedByPolicyError(mutationModel, RejectedByPolicyReason.NO_ACCESS); } } @@ -134,7 +134,9 @@ export class PolicyHandler extends OperationNodeTransf for (const postRow of result.rows) { const beforeRow = beforeUpdateInfo.rows.find((r) => idFields.every((f) => r[f] === postRow[f])); if (!beforeRow) { - throw new QueryError( + throw createRejectedByPolicyError( + mutationModel, + RejectedByPolicyReason.OTHER, 'Before-update and after-update rows do not match by id. If you have post-update policies on a model, updating id fields is not supported.', ); } @@ -194,7 +196,7 @@ export class PolicyHandler extends OperationNodeTransf const postUpdateResult = await proceed(postUpdateQuery.toOperationNode()); if (!postUpdateResult.rows[0]?.$condition) { - throw new RejectedByPolicyError( + throw createRejectedByPolicyError( mutationModel, RejectedByPolicyReason.NO_ACCESS, 'some or all updated rows failed to pass post-update policy check', @@ -210,7 +212,7 @@ export class PolicyHandler extends OperationNodeTransf } else { const readBackResult = await this.processReadBack(node, result, proceed); if (readBackResult.rows.length !== result.rows.length) { - throw new RejectedByPolicyError( + throw createRejectedByPolicyError( mutationModel, RejectedByPolicyReason.CANNOT_READ_BACK, 'result is not allowed to be read back', @@ -234,13 +236,13 @@ export class PolicyHandler extends OperationNodeTransf } } - hasPostUpdatePolicies(model: GetModels) { + hasPostUpdatePolicies(model: string) { const policies = this.getModelPolicies(model, 'post-update'); return policies.length > 0; } private async loadBeforeUpdateEntities( - model: GetModels, + model: string, where: WhereNode | undefined, proceed: ProceedKyselyQueryFunction, ) { @@ -263,7 +265,7 @@ export class PolicyHandler extends OperationNodeTransf return { fields: beforeUpdateAccessFields, rows: result.rows }; } - private getFieldsAccessForBeforeUpdatePolicies(model: GetModels) { + private getFieldsAccessForBeforeUpdatePolicies(model: string) { const policies = this.getModelPolicies(model, 'post-update'); if (policies.length === 0) { return undefined; @@ -468,7 +470,7 @@ export class PolicyHandler extends OperationNodeTransf private async enforcePreCreatePolicy( node: InsertQueryNode, - mutationModel: GetModels, + mutationModel: string, isManyToManyJoinTable: boolean, proceed: ProceedKyselyQueryFunction, ) { @@ -496,7 +498,7 @@ export class PolicyHandler extends OperationNodeTransf } private async enforcePreCreatePolicyForManyToManyJoinTable( - tableName: GetModels, + tableName: string, fields: string[], values: OperationNode[], proceed: ProceedKyselyQueryFunction, @@ -520,13 +522,13 @@ export class PolicyHandler extends OperationNodeTransf const eb = expressionBuilder(); - const filterA = this.buildPolicyFilter(m2m.firstModel as GetModels, undefined, 'update'); + const filterA = this.buildPolicyFilter(m2m.firstModel, undefined, 'update'); const queryA = eb .selectFrom(m2m.firstModel) .where(eb(eb.ref(`${m2m.firstModel}.${m2m.firstIdField}`), '=', aValue)) .select(() => new ExpressionWrapper(filterA).as('$t')); - const filterB = this.buildPolicyFilter(m2m.secondModel as GetModels, undefined, 'update'); + const filterB = this.buildPolicyFilter(m2m.secondModel, undefined, 'update'); const queryB = eb .selectFrom(m2m.secondModel) .where(eb(eb.ref(`${m2m.secondModel}.${m2m.secondIdField}`), '=', bValue)) @@ -543,15 +545,15 @@ export class PolicyHandler extends OperationNodeTransf const result = await proceed(queryNode); if (!result.rows[0]?.$conditionA) { - throw new RejectedByPolicyError( - m2m.firstModel as GetModels, + throw createRejectedByPolicyError( + m2m.firstModel, RejectedByPolicyReason.CANNOT_READ_BACK, `many-to-many relation participant model "${m2m.firstModel}" not updatable`, ); } if (!result.rows[0]?.$conditionB) { - throw new RejectedByPolicyError( - m2m.secondModel as GetModels, + throw createRejectedByPolicyError( + m2m.secondModel, RejectedByPolicyReason.NO_ACCESS, `many-to-many relation participant model "${m2m.secondModel}" not updatable`, ); @@ -559,7 +561,7 @@ export class PolicyHandler extends OperationNodeTransf } private async enforcePreCreatePolicyForOne( - model: GetModels, + model: string, fields: string[], values: OperationNode[], proceed: ProceedKyselyQueryFunction, @@ -621,13 +623,13 @@ export class PolicyHandler extends OperationNodeTransf const result = await proceed(preCreateCheck); if (!result.rows[0]?.$condition) { - throw new RejectedByPolicyError(model, RejectedByPolicyReason.NO_ACCESS); + throw createRejectedByPolicyError(model, RejectedByPolicyReason.NO_ACCESS); } } private unwrapCreateValueRows( node: OperationNode, - model: GetModels, + model: string, fields: string[], isManyToManyJoinTable: boolean, ) { @@ -636,13 +638,13 @@ export class PolicyHandler extends OperationNodeTransf } else if (PrimitiveValueListNode.is(node)) { return [this.unwrapCreateValueRow(node.values, model, fields, isManyToManyJoinTable)]; } else { - throw new InternalError(`Unexpected node kind: ${node.kind} for unwrapping create values`); + invariant(false, `Unexpected node kind: ${node.kind} for unwrapping create values`); } } private unwrapCreateValueRow( data: readonly unknown[], - model: GetModels, + model: string, fields: string[], isImplicitManyToManyJoinTable: boolean, ) { @@ -686,7 +688,7 @@ export class PolicyHandler extends OperationNodeTransf return result; } - private tryGetConstantPolicy(model: GetModels, operation: PolicyOperation) { + private tryGetConstantPolicy(model: string, operation: PolicyOperation) { const policies = this.getModelPolicies(model, operation); if (!policies.some((p) => p.kind === 'allow')) { // no allow -> unconditional deny @@ -757,26 +759,26 @@ export class PolicyHandler extends OperationNodeTransf private getMutationModel(node: InsertQueryNode | UpdateQueryNode | DeleteQueryNode) { const r = match(node) .when(InsertQueryNode.is, (node) => ({ - mutationModel: getTableName(node.into) as GetModels, + mutationModel: getTableName(node.into)!, alias: undefined, })) .when(UpdateQueryNode.is, (node) => { if (!node.table) { - throw new QueryError('Update query must have a table'); + invariant(false, 'Update query must have a table'); } const r = this.extractTableName(node.table); return r ? { mutationModel: r.model, alias: r.alias } : undefined; }) .when(DeleteQueryNode.is, (node) => { if (node.from.froms.length !== 1) { - throw new QueryError('Only one from table is supported for delete'); + throw createUnsupportedError('Only one from table is supported for delete'); } const r = this.extractTableName(node.from.froms[0]!); return r ? { mutationModel: r.model, alias: r.alias } : undefined; }) .exhaustive(); if (!r) { - throw new InternalError(`Unable to get table name for query node: ${node}`); + invariant(false, `Unable to get table name for query node: ${node}`); } return r; } @@ -791,7 +793,7 @@ export class PolicyHandler extends OperationNodeTransf return InsertQueryNode.is(node) || UpdateQueryNode.is(node) || DeleteQueryNode.is(node); } - buildPolicyFilter(model: GetModels, alias: string | undefined, operation: CRUD_EXT): OperationNode { + buildPolicyFilter(model: string, alias: string | undefined, operation: CRUD_EXT): OperationNode { // first check if it's a many-to-many join table, and if so, handle specially const m2mFilter = this.getModelPolicyFilterForManyToManyJoinTable(model, alias, operation); if (m2mFilter) { @@ -838,9 +840,9 @@ export class PolicyHandler extends OperationNodeTransf return combinedPolicy; } - private extractTableName(node: OperationNode): { model: GetModels; alias?: string } | undefined { + private extractTableName(node: OperationNode): { model: string; alias?: string } | undefined { if (TableNode.is(node)) { - return { model: node.table.identifier.name as GetModels }; + return { model: node.table.identifier.name }; } if (AliasNode.is(node)) { const inner = this.extractTableName(node.node); @@ -877,12 +879,7 @@ export class PolicyHandler extends OperationNodeTransf }, undefined); } - private compilePolicyCondition( - model: GetModels, - alias: string | undefined, - operation: CRUD_EXT, - policy: Policy, - ) { + private compilePolicyCondition(model: string, alias: string | undefined, operation: CRUD_EXT, policy: Policy) { return new ExpressionTransformer(this.client).transform(policy.condition, { model, alias, @@ -995,18 +992,18 @@ export class PolicyHandler extends OperationNodeTransf .selectFrom(m2m.firstModel) .whereRef(`${m2m.firstModel}.${m2m.firstIdField}`, '=', `${joinTable}.A`) .select(() => - new ExpressionWrapper( - this.buildPolicyFilter(m2m.firstModel as GetModels, undefined, checkForOperation), - ).as('$conditionA'), + new ExpressionWrapper(this.buildPolicyFilter(m2m.firstModel, undefined, checkForOperation)).as( + '$conditionA', + ), ); const bQuery = eb .selectFrom(m2m.secondModel) .whereRef(`${m2m.secondModel}.${m2m.secondIdField}`, '=', `${joinTable}.B`) .select(() => - new ExpressionWrapper( - this.buildPolicyFilter(m2m.secondModel as GetModels, undefined, checkForOperation), - ).as('$conditionB'), + new ExpressionWrapper(this.buildPolicyFilter(m2m.secondModel, undefined, checkForOperation)).as( + '$conditionB', + ), ); return eb.and([aQuery, bQuery]).toOperationNode(); diff --git a/packages/plugins/policy/src/utils.ts b/packages/plugins/policy/src/utils.ts index 321a2191..e15e0ccd 100644 --- a/packages/plugins/policy/src/utils.ts +++ b/packages/plugins/policy/src/utils.ts @@ -1,4 +1,4 @@ -import type { BaseCrudDialect } from '@zenstackhq/orm'; +import { ORMError, ORMErrorReason, RejectedByPolicyReason, type BaseCrudDialect } from '@zenstackhq/orm'; import { ExpressionUtils, type Expression, type SchemaDef } from '@zenstackhq/orm/schema'; import type { OperationNode } from 'kysely'; import { @@ -158,3 +158,18 @@ export function getTableName(node: OperationNode | undefined) { export function isBeforeInvocation(expr: Expression) { return ExpressionUtils.isCall(expr) && expr.function === 'before'; } + +export function createRejectedByPolicyError( + model: string | undefined, + reason: RejectedByPolicyReason, + message?: string, +) { + const err = new ORMError(ORMErrorReason.REJECTED_BY_POLICY, message ?? 'operation is rejected by access policies'); + err.rejectedByPolicyReason = reason; + err.model = model; + return err; +} + +export function createUnsupportedError(message: string) { + return new ORMError(ORMErrorReason.NOT_SUPPORTED, message); +} diff --git a/packages/schema/package.json b/packages/schema/package.json index 8e1ddf32..a8ea28ed 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/schema", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "description": "ZenStack Runtime Schema", "type": "module", "scripts": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index c74e1d3f..83ca0c46 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "description": "ZenStack SDK", "type": "module", "scripts": { diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 649a7201..b213ff89 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -2,5 +2,4 @@ import * as ModelUtils from './model-utils'; export * from './cli-plugin'; export { PrismaSchemaGenerator } from './prisma/prisma-schema-generator'; export * from './ts-schema-generator'; -export * from './zmodel-code-generator'; export { ModelUtils }; diff --git a/packages/sdk/src/prisma/prisma-schema-generator.ts b/packages/sdk/src/prisma/prisma-schema-generator.ts index 179ee480..3f3ba823 100644 --- a/packages/sdk/src/prisma/prisma-schema-generator.ts +++ b/packages/sdk/src/prisma/prisma-schema-generator.ts @@ -1,4 +1,5 @@ import { lowerCaseFirst } from '@zenstackhq/common-helpers'; +import { ZModelCodeGenerator } from '@zenstackhq/language'; import { AttributeArg, BooleanLiteral, @@ -33,7 +34,7 @@ import { import { getAllAttributes, getAllFields, isAuthInvocation, isDelegateModel } from '@zenstackhq/language/utils'; import { AstUtils } from 'langium'; import { match } from 'ts-pattern'; -import { ModelUtils, ZModelCodeGenerator } from '..'; +import { ModelUtils } from '..'; import { DELEGATE_AUX_RELATION_PREFIX, getIdFields } from '../model-utils'; import { AttributeArgValue, diff --git a/packages/server/package.json b/packages/server/package.json index ef152890..af6eebb4 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "description": "ZenStack automatic CRUD API handlers and server adapters", "type": "module", "scripts": { diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index bd65470c..536a45a5 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -1,16 +1,10 @@ import { clone, enumerate, lowerCaseFirst, paramCase } from '@zenstackhq/common-helpers'; -import { - InputValidationError, - NotFoundError, - QueryError, - RejectedByPolicyError, - ZenStackError, - type ClientContract, -} from '@zenstackhq/orm'; +import { ORMError, ORMErrorReason, type ClientContract } from '@zenstackhq/orm'; import type { FieldDef, ModelDef, SchemaDef } from '@zenstackhq/orm/schema'; import { Decimal } from 'decimal.js'; import SuperJSON from 'superjson'; import tsjapi, { type Linker, type Paginator, type Relator, type Serializer, type SerializerOptions } from 'ts-japi'; +import { match } from 'ts-pattern'; import UrlPattern from 'url-pattern'; import z from 'zod'; import type { ApiHandler, LogConfig, RequestContext, Response } from '../../types'; @@ -467,8 +461,8 @@ export class RestApiHandler implements ApiHandler implements ApiHandler { + return this.makeError('validationError', err.message, 422); + }) + .with(ORMErrorReason.REJECTED_BY_POLICY, () => { + return this.makeError('forbidden', err.message, 403, { reason: err.rejectedByPolicyReason }); + }) + .with(ORMErrorReason.NOT_FOUND, () => { + return this.makeError('notFound', err.message, 404); + }) + .with(ORMErrorReason.DB_QUERY_ERROR, () => { + return this.makeError('queryError', err.message, 400, { + dbErrorCode: err.dbErrorCode, + }); + }) + .otherwise(() => { + return this.makeError('unknownError', err.message); + }); } - private makeError(code: keyof typeof this.errors, detail?: string, status?: number, reason?: string) { + private makeError( + code: keyof typeof this.errors, + detail?: string, + status?: number, + otherFields: Record = {}, + ) { status = status ?? this.errors[code]?.status ?? 500; const error: any = { status, @@ -2057,9 +2053,7 @@ export class RestApiHandler implements ApiHandler implements ApiHandler implements ApiHandler { + status = 404; + error.model = err.model; + }) + .with(ORMErrorReason.INVALID_INPUT, () => { + status = 422; + error.rejectedByValidation = true; + error.model = err.model; + }) + .with(ORMErrorReason.REJECTED_BY_POLICY, () => { + status = 403; + error.rejectedByPolicy = true; + error.rejectReason = err.rejectedByPolicyReason; + error.model = err.model; + }) + .with(ORMErrorReason.DB_QUERY_ERROR, () => { + status = 400; + error.dbErrorCode = err.dbErrorCode; + }) + .otherwise(() => {}); const resp = { status, body: { error } }; log(this.options.log, 'debug', () => `sending error response: ${safeJSONStringify(resp)}`); diff --git a/packages/server/test/api/rest.test.ts b/packages/server/test/api/rest.test.ts index b309b5ae..b40ff604 100644 --- a/packages/server/test/api/rest.test.ts +++ b/packages/server/test/api/rest.test.ts @@ -2544,7 +2544,6 @@ describe('REST server tests', () => { expect(r.status).toBe(422); expect(r.body.errors[0].code).toBe('validation-error'); expect(r.body.errors[0].detail).toContain('Invalid email'); - expect(r.body.errors[0].reason).toContain('Invalid email'); }); }); }); diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 6b3d40f3..653427b9 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/testtools/src/vitest-ext.ts b/packages/testtools/src/vitest-ext.ts index ab01d47c..64d5684f 100644 --- a/packages/testtools/src/vitest-ext.ts +++ b/packages/testtools/src/vitest-ext.ts @@ -1,19 +1,19 @@ -import { InputValidationError, NotFoundError, RejectedByPolicyError } from '@zenstackhq/orm'; +import { ORMError, ORMErrorReason } from '@zenstackhq/orm'; import { expect } from 'vitest'; function isPromise(value: any) { return typeof value.then === 'function' && typeof value.catch === 'function'; } -function expectError(err: any, errorType: any) { - if (err instanceof errorType) { +function expectErrorReason(err: any, errorReason: ORMErrorReason) { + if (err instanceof ORMError && err.reason === errorReason) { return { message: () => '', pass: true, }; } else { return { - message: () => `expected ${errorType}, got ${err}`, + message: () => `expected ORMError of reason ${errorReason}, got ${err}`, pass: false, }; } @@ -80,7 +80,7 @@ expect.extend({ try { await received; } catch (err) { - return expectError(err, NotFoundError); + return expectErrorReason(err, ORMErrorReason.NOT_FOUND); } return { message: () => `expected NotFoundError, got no error`, @@ -95,13 +95,13 @@ expect.extend({ try { await received; } catch (err) { - if (expectedMessages && err instanceof RejectedByPolicyError) { + if (expectedMessages && err instanceof ORMError && err.reason === ORMErrorReason.REJECTED_BY_POLICY) { const r = expectErrorMessages(expectedMessages, err.message || ''); if (r) { return r; } } - return expectError(err, RejectedByPolicyError); + return expectErrorReason(err, ORMErrorReason.REJECTED_BY_POLICY); } return { message: () => `expected PolicyError, got no error`, @@ -116,13 +116,13 @@ expect.extend({ try { await received; } catch (err) { - if (expectedMessages && err instanceof InputValidationError) { + if (expectedMessages && err instanceof ORMError && err.reason === ORMErrorReason.INVALID_INPUT) { const r = expectErrorMessages(expectedMessages, err.message || ''); if (r) { return r; } } - return expectError(err, InputValidationError); + return expectErrorReason(err, ORMErrorReason.INVALID_INPUT); } return { message: () => `expected InputValidationError, got no error`, diff --git a/packages/zod/package.json b/packages/zod/package.json index 592445f2..fac1d39e 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "description": "", "type": "module", "main": "index.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cea73983..23f034f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,8 +13,8 @@ catalogs: specifier: ^7.6.13 version: 7.6.13 '@types/node': - specifier: ^20.17.24 - version: 20.17.24 + specifier: ^20.19.0 + version: 20.19.24 '@types/react': specifier: 19.2.0 version: 19.2.0 @@ -31,8 +31,8 @@ catalogs: specifier: ^10.4.3 version: 10.6.0 kysely: - specifier: ^0.27.6 - version: 0.27.6 + specifier: ~0.28.8 + version: 0.28.8 langium: specifier: 3.5.0 version: 3.5.0 @@ -82,7 +82,7 @@ importers: version: 9.29.0 '@types/node': specifier: 'catalog:' - version: 20.17.24 + version: 20.19.24 eslint: specifier: ~9.29.0 version: 9.29.0(jiti@2.6.1) @@ -112,7 +112,7 @@ importers: version: 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.8.3) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@20.17.24)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0) + version: 3.2.4(@types/node@20.19.24)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0) yaml: specifier: ^2.8.0 version: 2.8.0 @@ -304,7 +304,7 @@ importers: version: link:../../config/vitest-config kysely: specifier: 'catalog:' - version: 0.27.6 + version: 0.28.8 sql.js: specifier: ^1.13.0 version: 1.13.0 @@ -345,6 +345,9 @@ importers: ts-pattern: specifier: 'catalog:' version: 5.7.1 + vscode-languageserver: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@types/pluralize': specifier: ^0.0.33 @@ -396,7 +399,7 @@ importers: version: 1.3.0 kysely: specifier: 'catalog:' - version: 0.27.6 + version: 0.28.8 nanoid: specifier: ^5.0.9 version: 5.0.9 @@ -454,7 +457,7 @@ importers: version: link:../../orm kysely: specifier: 'catalog:' - version: 0.27.6 + version: 0.28.8 ts-pattern: specifier: 'catalog:' version: 5.7.1 @@ -517,7 +520,7 @@ importers: version: 10.6.0 kysely: specifier: 'catalog:' - version: 0.27.6 + version: 0.28.8 packages/server: dependencies: @@ -548,7 +551,7 @@ importers: devDependencies: '@sveltejs/kit': specifier: ^2.48.3 - version: 2.48.3(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 2.48.3(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) '@types/body-parser': specifier: ^1.19.6 version: 1.19.6 @@ -596,7 +599,7 @@ importers: version: 16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) nuxt: specifier: ^4.2.0 - version: 4.2.0(@parcel/watcher@2.5.1)(@types/node@20.17.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1) + version: 4.2.0(@parcel/watcher@2.5.1)(@types/node@20.19.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1) supertest: specifier: ^7.1.4 version: 7.1.4 @@ -632,7 +635,7 @@ importers: version: 11.0.2 kysely: specifier: 'catalog:' - version: 0.27.6 + version: 0.28.8 pg: specifier: 'catalog:' version: 8.16.3 @@ -651,7 +654,7 @@ importers: version: 7.6.13 '@types/node': specifier: 'catalog:' - version: 20.17.24 + version: 20.19.24 '@types/pg': specifier: ^8.11.11 version: 8.11.11 @@ -706,7 +709,7 @@ importers: version: 12.2.0 kysely: specifier: 'catalog:' - version: 0.27.6 + version: 0.28.8 lorem-ipsum: specifier: ^2.0.8 version: 2.0.8 @@ -727,8 +730,8 @@ importers: specifier: 'catalog:' version: 7.6.13 '@types/node': - specifier: ^20 - version: 20.17.24 + specifier: 'catalog:' + version: 20.19.24 '@types/react': specifier: 'catalog:' version: 19.2.0 @@ -764,7 +767,7 @@ importers: version: 12.2.0 kysely: specifier: 'catalog:' - version: 0.27.6 + version: 0.28.8 devDependencies: '@types/better-sqlite3': specifier: 'catalog:' @@ -813,7 +816,7 @@ importers: version: 10.6.0 kysely: specifier: 'catalog:' - version: 0.27.6 + version: 0.28.8 ulid: specifier: ^3.0.0 version: 3.0.0 @@ -2804,6 +2807,9 @@ packages: '@types/node@20.17.24': resolution: {integrity: sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA==} + '@types/node@20.19.24': + resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} + '@types/parse-path@7.1.0': resolution: {integrity: sha512-EULJ8LApcVEPbrfND0cRQqutIOdiIgJ1Mgrhpy755r14xMohPTEpkV/k28SJvuOs9bHRFW8x+KeDAEPiGQPB9Q==} deprecated: This is a stub types definition. parse-path provides its own type definitions, so you do not need this installed. @@ -5019,9 +5025,9 @@ packages: knitwork@1.2.0: resolution: {integrity: sha512-xYSH7AvuQ6nXkq42x0v5S8/Iry+cfulBz/DJQzhIyESdLD7425jXsPy4vn5cCXU+HhRN2kVw51Vd1K6/By4BQg==} - kysely@0.27.6: - resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==} - engines: {node: '>=14.0.0'} + kysely@0.28.8: + resolution: {integrity: sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA==} + engines: {node: '>=20.0.0'} langium-cli@3.5.0: resolution: {integrity: sha512-TPIzIiMAQwTPPphtHGSrFXo4t0orx3aRh0syg9jnOihvBkBDvsQdJP9fBo9hp5Qaosklpc2CfbH0wh/dkgZcJA==} @@ -6943,6 +6949,9 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.16.0: resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} engines: {node: '>=20.18.1'} @@ -8288,11 +8297,11 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@2.7.0(magicast@0.3.5)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))': + '@nuxt/devtools-kit@2.7.0(magicast@0.3.5)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: '@nuxt/kit': 3.20.0(magicast@0.3.5) execa: 8.0.1 - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) transitivePeerDependencies: - magicast @@ -8307,12 +8316,12 @@ snapshots: prompts: 2.4.2 semver: 7.7.3 - '@nuxt/devtools@2.7.0(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3))': + '@nuxt/devtools@2.7.0(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3))': dependencies: - '@nuxt/devtools-kit': 2.7.0(magicast@0.3.5)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) + '@nuxt/devtools-kit': 2.7.0(magicast@0.3.5)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) '@nuxt/devtools-wizard': 2.7.0 '@nuxt/kit': 3.20.0(magicast@0.3.5) - '@vue/devtools-core': 7.7.7(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)) + '@vue/devtools-core': 7.7.7(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)) '@vue/devtools-kit': 7.7.7 birpc: 2.6.1 consola: 3.4.2 @@ -8337,9 +8346,9 @@ snapshots: sirv: 3.0.2 structured-clone-es: 1.0.0 tinyglobby: 0.2.15 - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) - vite-plugin-inspect: 11.3.3(@nuxt/kit@3.20.0(magicast@0.3.5))(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) - vite-plugin-vue-tracer: 1.0.1(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite-plugin-inspect: 11.3.3(@nuxt/kit@3.20.0(magicast@0.3.5))(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) + vite-plugin-vue-tracer: 1.0.1(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)) which: 5.0.0 ws: 8.18.3 transitivePeerDependencies: @@ -8425,7 +8434,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.2.0(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(ioredis@5.8.2)(magicast@0.5.0)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@20.17.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1))(typescript@5.8.3)': + '@nuxt/nitro-server@4.2.0(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(ioredis@5.8.2)(magicast@0.5.0)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@20.19.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1))(typescript@5.8.3)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 4.2.0(magicast@0.5.0) @@ -8443,7 +8452,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.12.9(better-sqlite3@12.2.0) - nuxt: 4.2.0(@parcel/watcher@2.5.1)(@types/node@20.17.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1) + nuxt: 4.2.0(@parcel/watcher@2.5.1)(@types/node@20.19.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1) pathe: 2.0.3 pkg-types: 2.3.0 radix3: 1.1.2 @@ -8514,12 +8523,12 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/vite-builder@4.2.0(@types/node@20.17.24)(eslint@9.29.0(jiti@2.6.1))(lightningcss@1.30.2)(magicast@0.5.0)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@20.17.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1))(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vue@3.5.22(typescript@5.8.3))(yaml@2.8.1)': + '@nuxt/vite-builder@4.2.0(@types/node@20.19.24)(eslint@9.29.0(jiti@2.6.1))(lightningcss@1.30.2)(magicast@0.5.0)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@20.19.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1))(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vue@3.5.22(typescript@5.8.3))(yaml@2.8.1)': dependencies: '@nuxt/kit': 4.2.0(magicast@0.5.0) '@rollup/plugin-replace': 6.0.3(rollup@4.52.5) - '@vitejs/plugin-vue': 6.0.1(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)) - '@vitejs/plugin-vue-jsx': 5.1.1(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)) + '@vitejs/plugin-vue': 6.0.1(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)) + '@vitejs/plugin-vue-jsx': 5.1.1(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)) autoprefixer: 10.4.21(postcss@8.5.6) consola: 3.4.2 cssnano: 7.1.2(postcss@8.5.6) @@ -8534,7 +8543,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.0 mocked-exports: 0.1.1 - nuxt: 4.2.0(@parcel/watcher@2.5.1)(@types/node@20.17.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1) + nuxt: 4.2.0(@parcel/watcher@2.5.1)(@types/node@20.19.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1) pathe: 2.0.3 pkg-types: 2.3.0 postcss: 8.5.6 @@ -8543,9 +8552,9 @@ snapshots: std-env: 3.10.0 ufo: 1.6.1 unenv: 2.0.0-rc.24 - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) - vite-plugin-checker: 0.11.0(eslint@9.29.0(jiti@2.6.1))(optionator@0.9.4)(typescript@5.8.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite-plugin-checker: 0.11.0(eslint@9.29.0(jiti@2.6.1))(optionator@0.9.4)(typescript@5.8.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) vue: 3.5.22(typescript@5.8.3) vue-bundle-renderer: 2.2.0 transitivePeerDependencies: @@ -9054,11 +9063,11 @@ snapshots: dependencies: acorn: 8.15.0 - '@sveltejs/kit@2.48.3(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))': + '@sveltejs/kit@2.48.3(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: '@standard-schema/spec': 1.0.0 '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -9071,26 +9080,26 @@ snapshots: set-cookie-parser: 2.7.2 sirv: 3.0.2 svelte: 5.43.3 - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte': 6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) debug: 4.4.1 svelte: 5.43.3 - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))': + '@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) debug: 4.4.1 deepmerge: 4.3.1 magic-string: 0.30.21 svelte: 5.43.3 - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) - vitefu: 1.1.1(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vitefu: 1.1.1(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) transitivePeerDependencies: - supports-color @@ -9340,6 +9349,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/node@20.19.24': + dependencies: + undici-types: 6.21.0 + '@types/parse-path@7.1.0': dependencies: parse-path: 7.1.0 @@ -9681,22 +9694,22 @@ snapshots: - rollup - supports-color - '@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3))': + '@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) '@rolldown/pluginutils': 1.0.0-beta.45 '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.5) - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) vue: 3.5.22(typescript@5.8.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.1(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3))': + '@vitejs/plugin-vue@6.0.1(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.29 - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) vue: 3.5.22(typescript@5.8.3) '@vitest/expect@3.2.4': @@ -9707,13 +9720,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0))': + '@vitest/mocker@3.2.4(vite@6.3.5(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.3.5(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0) + vite: 6.3.5(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -9818,14 +9831,14 @@ snapshots: '@vue/devtools-api@6.6.4': {} - '@vue/devtools-core@7.7.7(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3))': + '@vue/devtools-core@7.7.7(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3))': dependencies: '@vue/devtools-kit': 7.7.7 '@vue/devtools-shared': 7.7.7 mitt: 3.0.1 nanoid: 5.1.6 pathe: 2.0.3 - vite-hot-client: 2.1.0(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) + vite-hot-client: 2.1.0(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) vue: 3.5.22(typescript@5.8.3) transitivePeerDependencies: - vite @@ -11971,7 +11984,7 @@ snapshots: knitwork@1.2.0: {} - kysely@0.27.6: {} + kysely@0.28.8: {} langium-cli@3.5.0: dependencies: @@ -12487,16 +12500,16 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@20.17.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1): + nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@20.19.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1): dependencies: '@dxup/nuxt': 0.2.0(magicast@0.5.0) '@nuxt/cli': 3.29.3(magicast@0.5.0) - '@nuxt/devtools': 2.7.0(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)) + '@nuxt/devtools': 2.7.0(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)) '@nuxt/kit': 4.2.0(magicast@0.5.0) - '@nuxt/nitro-server': 4.2.0(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(ioredis@5.8.2)(magicast@0.5.0)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@20.17.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1))(typescript@5.8.3) + '@nuxt/nitro-server': 4.2.0(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(ioredis@5.8.2)(magicast@0.5.0)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@20.19.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1))(typescript@5.8.3) '@nuxt/schema': 4.2.0 '@nuxt/telemetry': 2.6.6(magicast@0.5.0) - '@nuxt/vite-builder': 4.2.0(@types/node@20.17.24)(eslint@9.29.0(jiti@2.6.1))(lightningcss@1.30.2)(magicast@0.5.0)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@20.17.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1))(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vue@3.5.22(typescript@5.8.3))(yaml@2.8.1) + '@nuxt/vite-builder': 4.2.0(@types/node@20.19.24)(eslint@9.29.0(jiti@2.6.1))(lightningcss@1.30.2)(magicast@0.5.0)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@20.19.24)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.2.0)(db0@0.3.4(better-sqlite3@12.2.0))(eslint@9.29.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.5.0)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1))(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.3)(typescript@5.8.3)(vue@3.5.22(typescript@5.8.3))(yaml@2.8.1) '@unhead/vue': 2.0.19(vue@3.5.22(typescript@5.8.3)) '@vue/shared': 3.5.22 c12: 3.3.1(magicast@0.5.0) @@ -12548,7 +12561,7 @@ snapshots: vue-router: 4.6.3(vue@3.5.22(typescript@5.8.3)) optionalDependencies: '@parcel/watcher': 2.5.1 - '@types/node': 20.17.24 + '@types/node': 20.19.24 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -14248,6 +14261,8 @@ snapshots: undici-types@6.19.8: {} + undici-types@6.21.0: {} + undici@7.16.0: {} unenv@2.0.0-rc.24: @@ -14405,23 +14420,23 @@ snapshots: vary@1.1.2: {} - vite-dev-rpc@1.1.0(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)): + vite-dev-rpc@1.1.0(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)): dependencies: birpc: 2.6.1 - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) - vite-hot-client: 2.1.0(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite-hot-client: 2.1.0(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) - vite-hot-client@2.1.0(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)): + vite-hot-client@2.1.0(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)): dependencies: - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) - vite-node@3.2.4(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0): + vite-node@3.2.4(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0) + vite: 6.3.5(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0) transitivePeerDependencies: - '@types/node' - jiti @@ -14436,13 +14451,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1): + vite-node@3.2.4(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite: 6.3.5(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -14457,7 +14472,7 @@ snapshots: - tsx - yaml - vite-plugin-checker@0.11.0(eslint@9.29.0(jiti@2.6.1))(optionator@0.9.4)(typescript@5.8.3)(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)): + vite-plugin-checker@0.11.0(eslint@9.29.0(jiti@2.6.1))(optionator@0.9.4)(typescript@5.8.3)(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)): dependencies: '@babel/code-frame': 7.27.1 chokidar: 4.0.3 @@ -14466,14 +14481,14 @@ snapshots: picomatch: 4.0.3 tiny-invariant: 1.3.3 tinyglobby: 0.2.15 - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) vscode-uri: 3.1.0 optionalDependencies: eslint: 9.29.0(jiti@2.6.1) optionator: 0.9.4 typescript: 5.8.3 - vite-plugin-inspect@11.3.3(@nuxt/kit@3.20.0(magicast@0.3.5))(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)): + vite-plugin-inspect@11.3.3(@nuxt/kit@3.20.0(magicast@0.3.5))(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)): dependencies: ansis: 4.2.0 debug: 4.4.1 @@ -14483,24 +14498,24 @@ snapshots: perfect-debounce: 2.0.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) - vite-dev-rpc: 1.1.0(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite-dev-rpc: 1.1.0(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) optionalDependencies: '@nuxt/kit': 3.20.0(magicast@0.3.5) transitivePeerDependencies: - supports-color - vite-plugin-vue-tracer@1.0.1(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)): + vite-plugin-vue-tracer@1.0.1(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)): dependencies: estree-walker: 3.0.3 exsolve: 1.0.7 magic-string: 0.30.21 pathe: 2.0.3 source-map-js: 1.2.1 - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) vue: 3.5.22(typescript@5.8.3) - vite@6.3.5(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0): + vite@6.3.5(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0): dependencies: esbuild: 0.25.5 fdir: 6.4.6(picomatch@4.0.2) @@ -14509,7 +14524,7 @@ snapshots: rollup: 4.44.0 tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 20.17.24 + '@types/node': 20.19.24 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 @@ -14517,7 +14532,7 @@ snapshots: tsx: 4.20.3 yaml: 2.8.0 - vite@6.3.5(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1): + vite@6.3.5(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1): dependencies: esbuild: 0.25.5 fdir: 6.4.6(picomatch@4.0.2) @@ -14526,7 +14541,7 @@ snapshots: rollup: 4.44.0 tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 20.17.24 + '@types/node': 20.19.24 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 @@ -14534,7 +14549,7 @@ snapshots: tsx: 4.20.3 yaml: 2.8.1 - vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1): + vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1): dependencies: esbuild: 0.25.11 fdir: 6.5.0(picomatch@4.0.3) @@ -14543,7 +14558,7 @@ snapshots: rollup: 4.44.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 20.17.24 + '@types/node': 20.19.24 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 @@ -14551,15 +14566,15 @@ snapshots: tsx: 4.20.3 yaml: 2.8.1 - vitefu@1.1.1(vite@7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)): + vitefu@1.1.1(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)): optionalDependencies: - vite: 7.1.12(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1) - vitest@3.2.4(@types/node@20.17.24)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0): + vitest@3.2.4(@types/node@20.19.24)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@27.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(vite@6.3.5(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -14577,11 +14592,11 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0) - vite-node: 3.2.4(@types/node@20.17.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0) + vite: 6.3.5(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0) + vite-node: 3.2.4(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 20.17.24 + '@types/node': 20.19.24 happy-dom: 20.0.10 jsdom: 27.1.0 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 85916fa7..50f3e3fb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,14 +3,14 @@ packages: - samples/** - tests/** catalog: - kysely: ^0.27.6 + kysely: ~0.28.8 zod: ^4.0.0 prisma: ^6.10.0 langium: 3.5.0 langium-cli: 3.5.0 ts-pattern: ^5.7.1 typescript: ^5.8.0 - '@types/node': ^20.17.24 + '@types/node': ^20.19.0 tmp: ^0.2.3 '@types/tmp': ^0.2.6 'zod-validation-error': ^4.0.1 diff --git a/samples/next.js/package.json b/samples/next.js/package.json index 690215fd..65b6ebb5 100644 --- a/samples/next.js/package.json +++ b/samples/next.js/package.json @@ -1,6 +1,6 @@ { "name": "next.js", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "private": true, "scripts": { "generate": "zen generate --lite", @@ -25,7 +25,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/better-sqlite3": "catalog:", - "@types/node": "^20", + "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@zenstackhq/cli": "workspace:*", diff --git a/samples/orm/package.json b/samples/orm/package.json index bd06e556..fe66c962 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-blog", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "description": "", "main": "index.js", "private": true, diff --git a/tests/e2e/orm/client-api/delegate.test.ts b/tests/e2e/orm/client-api/delegate.test.ts index 704f134d..1497f91b 100644 --- a/tests/e2e/orm/client-api/delegate.test.ts +++ b/tests/e2e/orm/client-api/delegate.test.ts @@ -182,7 +182,7 @@ describe('Delegate model tests ', () => { rating: 3, }, }), - ).rejects.toThrow('constraint'); + ).rejects.toSatisfy((e) => e.cause.message.toLowerCase().includes('constraint')); await expect(client.ratedVideo.findMany()).toResolveWithLength(1); await expect(client.video.findMany()).toResolveWithLength(1); diff --git a/tests/e2e/orm/client-api/error-handling.test.ts b/tests/e2e/orm/client-api/error-handling.test.ts new file mode 100644 index 00000000..1227e525 --- /dev/null +++ b/tests/e2e/orm/client-api/error-handling.test.ts @@ -0,0 +1,52 @@ +import { ORMError, ORMErrorReason, RejectedByPolicyReason } from '@zenstackhq/orm'; +import { createPolicyTestClient, createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Error handling tests', () => { + const schema = ` +model User { + id String @id @default(cuid()) + name String? + email String @unique @email +} +`; + + it('throws invalid input errors', async () => { + const db: any = await createTestClient(schema); + await expect(db.user.create({ data: { name: 'user' } })).toBeRejectedByValidation(); + await expect(db.user.create({ data: { name: 'user', email: 'foo' } })).toBeRejectedByValidation([ + 'Invalid email', + ]); + }); + + it('throws not found errors', async () => { + const db: any = await createTestClient(schema); + await expect(db.user.findUniqueOrThrow({ where: { id: 'non-existent-id' } })).toBeRejectedNotFound(); + }); + + it('throws rejected by policy errors', async () => { + const db: any = await createPolicyTestClient(schema); + await expect(db.user.create({ data: { name: 'user', email: 'user@example.com' } })).rejects.toSatisfy( + (e) => + e instanceof ORMError && + e.reason === ORMErrorReason.REJECTED_BY_POLICY && + e.rejectedByPolicyReason === RejectedByPolicyReason.NO_ACCESS, + ); + }); + + it('throws db query errors', async () => { + const db: any = await createTestClient(schema); + await db.user.create({ data: { email: 'user1@example.com' } }); + + const provider = db.$schema.provider.type; + const expectedCode = provider === 'sqlite' ? 'SQLITE_CONSTRAINT_UNIQUE' : '23505'; + + await expect(db.user.create({ data: { email: 'user1@example.com' } })).rejects.toSatisfy( + (e) => + e instanceof ORMError && + e.reason === ORMErrorReason.DB_QUERY_ERROR && + e.dbErrorCode === expectedCode && + !!e.dbErrorMessage?.includes('constraint'), + ); + }); +}); diff --git a/tests/e2e/orm/client-api/find.test.ts b/tests/e2e/orm/client-api/find.test.ts index 7e2194d4..765492cc 100644 --- a/tests/e2e/orm/client-api/find.test.ts +++ b/tests/e2e/orm/client-api/find.test.ts @@ -1,8 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ClientContract } from '@zenstackhq/orm'; -import { InputValidationError, NotFoundError } from '@zenstackhq/orm'; -import { schema } from '../schemas/basic'; import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { schema } from '../schemas/basic'; import { createPosts, createUser } from './utils'; describe('Client find tests ', () => { @@ -53,7 +52,7 @@ describe('Client find tests ', () => { await expect(client.user.findMany({ take: 4 })).resolves.toHaveLength(3); // findFirst's take must be 1 - await expect(client.user.findFirst({ take: 2 })).rejects.toThrow(InputValidationError); + await expect(client.user.findFirst({ take: 2 })).toBeRejectedByValidation(); await expect(client.user.findFirst({ take: 1 })).toResolveTruthy(); // skip @@ -389,7 +388,7 @@ describe('Client find tests ', () => { r = await client.user.findUnique({ where: { id: 'none' } }); expect(r).toBeNull(); - await expect(client.user.findUniqueOrThrow({ where: { id: 'none' } })).rejects.toThrow(NotFoundError); + await expect(client.user.findUniqueOrThrow({ where: { id: 'none' } })).toBeRejectedNotFound(); }); it('works with non-unique finds', async () => { @@ -403,7 +402,7 @@ describe('Client find tests ', () => { r = await client.user.findFirst({ where: { name: 'User2' } }); expect(r).toBeNull(); - await expect(client.user.findFirstOrThrow({ where: { name: 'User2' } })).rejects.toThrow(NotFoundError); + await expect(client.user.findFirstOrThrow({ where: { name: 'User2' } })).toBeRejectedNotFound(); }); it('works with boolean composition', async () => { diff --git a/tests/e2e/orm/client-api/mixin.test.ts b/tests/e2e/orm/client-api/mixin.test.ts index 1e6d0f41..e373b8fe 100644 --- a/tests/e2e/orm/client-api/mixin.test.ts +++ b/tests/e2e/orm/client-api/mixin.test.ts @@ -75,7 +75,7 @@ model Bar with CommonFields { description: 'Bar', }, }), - ).rejects.toThrow('constraint'); + ).rejects.toSatisfy((e) => e.cause.message.toLowerCase().includes('constraint')); }); it('supports multiple-level mixins', async () => { diff --git a/tests/e2e/orm/client-api/name-mapping.test.ts b/tests/e2e/orm/client-api/name-mapping.test.ts index d95616d5..5d9151e7 100644 --- a/tests/e2e/orm/client-api/name-mapping.test.ts +++ b/tests/e2e/orm/client-api/name-mapping.test.ts @@ -121,7 +121,7 @@ describe('Name mapping tests', () => { .selectFrom('User') .select(['email']) .whereRef('email', '=', 'email') - .orderBy(['email']) + .orderBy('email') .executeTakeFirst(), ).resolves.toMatchObject({ email: 'u1@test.com', diff --git a/tests/e2e/orm/client-api/update.test.ts b/tests/e2e/orm/client-api/update.test.ts index 88001f36..c79396d7 100644 --- a/tests/e2e/orm/client-api/update.test.ts +++ b/tests/e2e/orm/client-api/update.test.ts @@ -1050,7 +1050,7 @@ describe('Client update tests', () => { }, }, }), - ).rejects.toThrow('constraint'); + ).rejects.toSatisfy((e) => e.cause.message.toLowerCase().includes('constraint')); // transaction fails as a whole await expect(client.comment.findUnique({ where: { id: '3' } })).resolves.toMatchObject({ content: 'Comment3', diff --git a/tests/e2e/orm/plugin-infra/entity-mutation-hooks.test.ts b/tests/e2e/orm/plugin-infra/entity-mutation-hooks.test.ts index 6a64b3de..1b0eea2f 100644 --- a/tests/e2e/orm/plugin-infra/entity-mutation-hooks.test.ts +++ b/tests/e2e/orm/plugin-infra/entity-mutation-hooks.test.ts @@ -77,12 +77,12 @@ describe('Entity mutation hooks tests', () => { email: args.action === 'update' ? 'u1@test.com' : 'u3@test.com', }), ]); - queryIds[args.action].before = args.queryId; + queryIds[args.action].before = args.queryId.queryId; } }, async afterEntityMutation(args) { if (args.action === 'update' || args.action === 'delete') { - queryIds[args.action].after = args.queryId; + queryIds[args.action].after = args.queryId.queryId; } }, }, diff --git a/tests/e2e/orm/plugin-infra/on-kysely-query.test.ts b/tests/e2e/orm/plugin-infra/on-kysely-query.test.ts index c6216c4a..68602613 100644 --- a/tests/e2e/orm/plugin-infra/on-kysely-query.test.ts +++ b/tests/e2e/orm/plugin-infra/on-kysely-query.test.ts @@ -211,7 +211,7 @@ describe('On kysely query tests', () => { data: { email: 'u1@test.com', name: 'Marvin' }, }), ), - ).rejects.toThrow('test error'); + ).rejects.toSatisfy((e) => (e as any).cause.message === 'test error'); await expect(called1).toBe(true); await expect(called2).toBe(true); diff --git a/tests/e2e/orm/policy/crud/update.test.ts b/tests/e2e/orm/policy/crud/update.test.ts index 9a83577f..7f060c88 100644 --- a/tests/e2e/orm/policy/crud/update.test.ts +++ b/tests/e2e/orm/policy/crud/update.test.ts @@ -758,7 +758,7 @@ model Post { }, }, }), - ).rejects.toThrow('constraint'); + ).rejects.toSatisfy((e) => e.cause.message.toLowerCase().includes('constraint')); await db.$unuseAll().post.update({ where: { id: 1 }, data: { title: 'Bar Post' } }); // can update await expect( @@ -1124,7 +1124,7 @@ model Foo { // can't update, but create violates unique constraint await expect( db.foo.upsert({ where: { id: 1 }, create: { id: 1, x: 1 }, update: { x: 1 } }), - ).rejects.toThrow('constraint'); + ).rejects.toSatisfy((e) => e.cause.message.toLowerCase().includes('constraint')); await db.$unuseAll().foo.update({ where: { id: 1 }, data: { x: 2 } }); // can update now await expect( diff --git a/tests/e2e/orm/policy/migrated/auth.test.ts b/tests/e2e/orm/policy/migrated/auth.test.ts index fb8f30de..b3e49980 100644 --- a/tests/e2e/orm/policy/migrated/auth.test.ts +++ b/tests/e2e/orm/policy/migrated/auth.test.ts @@ -536,7 +536,9 @@ model Post { ); await expect(db.user.create({ data: { id: 'userId-1' } })).toResolveTruthy(); - await expect(db.post.create({ data: { title: 'title' } })).rejects.toThrow('constraint'); + await expect(db.post.create({ data: { title: 'title' } })).rejects.toSatisfy((e) => + e.cause.message.toLowerCase().includes('constraint'), + ); await expect(db.post.findMany({})).toResolveTruthy(); }); diff --git a/tests/e2e/orm/policy/migrated/deep-nested.test.ts b/tests/e2e/orm/policy/migrated/deep-nested.test.ts index 6bd38e1f..f8bcea93 100644 --- a/tests/e2e/orm/policy/migrated/deep-nested.test.ts +++ b/tests/e2e/orm/policy/migrated/deep-nested.test.ts @@ -482,7 +482,7 @@ describe('deep nested operations tests', () => { }, }, }), - ).rejects.toThrow('constraint'); + ).rejects.toSatisfy((e) => e.cause.message.toLowerCase().includes('constraint')); // createMany skip duplicate await db.m1.update({ diff --git a/tests/e2e/orm/policy/migrated/multi-field-unique.test.ts b/tests/e2e/orm/policy/migrated/multi-field-unique.test.ts index 2cc265bc..b64d33f9 100644 --- a/tests/e2e/orm/policy/migrated/multi-field-unique.test.ts +++ b/tests/e2e/orm/policy/migrated/multi-field-unique.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; -import { QueryError } from '@zenstackhq/orm'; +import { ORMError, ORMErrorReason } from '@zenstackhq/orm'; import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; describe('Policy tests multi-field unique', () => { it('toplevel crud test unnamed constraint', async () => { @@ -20,7 +20,9 @@ describe('Policy tests multi-field unique', () => { ); await expect(db.model.create({ data: { a: 'a1', b: 'b1', x: 1 } })).toResolveTruthy(); - await expect(db.model.create({ data: { a: 'a1', b: 'b1', x: 2 } })).rejects.toThrow(QueryError); + await expect(db.model.create({ data: { a: 'a1', b: 'b1', x: 2 } })).rejects.toSatisfy( + (e) => e instanceof ORMError && e.reason === ORMErrorReason.DB_QUERY_ERROR, + ); await expect(db.model.create({ data: { a: 'a2', b: 'b2', x: 0 } })).toBeRejectedByPolicy(); await expect(db.model.findUnique({ where: { a_b: { a: 'a1', b: 'b1' } } })).toResolveTruthy(); @@ -83,8 +85,8 @@ describe('Policy tests multi-field unique', () => { ); await expect(db.m1.create({ data: { id: '1', m2: { create: { a: 'a1', b: 'b1', x: 1 } } } })).toResolveTruthy(); - await expect(db.m1.create({ data: { id: '2', m2: { create: { a: 'a1', b: 'b1', x: 2 } } } })).rejects.toThrow( - QueryError, + await expect(db.m1.create({ data: { id: '2', m2: { create: { a: 'a1', b: 'b1', x: 2 } } } })).rejects.toSatisfy( + (e) => e instanceof ORMError && e.reason === ORMErrorReason.DB_QUERY_ERROR, ); await expect( db.m1.create({ data: { id: '3', m2: { create: { a: 'a1', b: 'b2', x: 0 } } } }), diff --git a/tests/e2e/orm/policy/migrated/todo-sample.test.ts b/tests/e2e/orm/policy/migrated/todo-sample.test.ts deleted file mode 100644 index 0a19065d..00000000 --- a/tests/e2e/orm/policy/migrated/todo-sample.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import type { ClientContract } from '@zenstackhq/orm'; -import { schema, type SchemaType } from '../../schemas/todo/schema'; -import { createPolicyTestClient } from '@zenstackhq/testtools'; - -describe('Todo Policy Tests', () => { - let db: ClientContract; - - beforeEach(async () => { - db = await createPolicyTestClient(schema); - }); - - it('user', async () => { - const user1 = { - id: 'user1', - email: 'user1@zenstack.dev', - name: 'User 1', - }; - const user2 = { - id: 'user2', - email: 'user2@zenstack.dev', - name: 'User 2', - }; - - const anonDb = db; - const user1Db = db.$setAuth({ id: user1.id }); - const user2Db = db.$setAuth({ id: user2.id }); - - // create user1 - // create should succeed but result can be read back anonymously - await expect(anonDb.user.create({ data: user1 })).toBeRejectedByPolicy([ - 'result is not allowed to be read back', - ]); - await expect(user1Db.user.findUnique({ where: { id: user1.id } })).toResolveTruthy(); - await expect(user2Db.user.findUnique({ where: { id: user1.id } })).toResolveNull(); - - // create user2 - await expect(anonDb.user.create({ data: user2 })).toBeRejectedByPolicy(); - - // find with user1 should only get user1 - const r = await user1Db.user.findMany(); - expect(r).toHaveLength(1); - expect(r[0]).toEqual(expect.objectContaining(user1)); - - // get user2 as user1 - await expect(user1Db.user.findUnique({ where: { id: user2.id } })).toResolveNull(); - - // add both users into the same space - await expect( - user1Db.space.create({ - data: { - name: 'Space 1', - slug: 'space1', - owner: { connect: { id: user1.id } }, - members: { - create: [ - { - user: { connect: { id: user1.id } }, - role: 'ADMIN', - }, - { - user: { connect: { id: user2.id } }, - role: 'USER', - }, - ], - }, - }, - }), - ).toResolveTruthy(); - - // now both user1 and user2 should be visible - await expect(user1Db.user.findMany()).resolves.toHaveLength(2); - await expect(user2Db.user.findMany()).resolves.toHaveLength(2); - - // update user2 as user1 - await expect( - user2Db.user.update({ - where: { id: user1.id }, - data: { name: 'hello' }, - }), - ).toBeRejectedNotFound(); - - // update user1 as user1 - await expect( - user1Db.user.update({ - where: { id: user1.id }, - data: { name: 'hello' }, - }), - ).toResolveTruthy(); - - // delete user2 as user1 - await expect(user1Db.user.delete({ where: { id: user2.id } })).toBeRejectedNotFound(); - - // delete user1 as user1 - await expect(user1Db.user.delete({ where: { id: user1.id } })).toResolveTruthy(); - await expect(user1Db.user.findUnique({ where: { id: user1.id } })).toResolveNull(); - }); - - it('todo list', async () => { - await createSpaceAndUsers(db.$unuseAll()); - - const anonDb = db; - const emptyUIDDb = db.$setAuth({ id: '' }); - const user1Db = db.$setAuth({ id: user1.id }); - const user2Db = db.$setAuth({ id: user2.id }); - const user3Db = db.$setAuth({ id: user3.id }); - - await expect( - anonDb.list.create({ - data: { - id: 'list1', - title: 'List 1', - owner: { connect: { id: user1.id } }, - space: { connect: { id: space1.id } }, - }, - }), - ).toBeRejectedByPolicy(); - - await expect( - user1Db.list.create({ - data: { - id: 'list1', - title: 'List 1', - owner: { connect: { id: user1.id } }, - space: { connect: { id: space1.id } }, - }, - }), - ).toResolveTruthy(); - - await expect(user1Db.list.findMany()).resolves.toHaveLength(1); - await expect(anonDb.list.findMany()).resolves.toHaveLength(0); - await expect(emptyUIDDb.list.findMany()).resolves.toHaveLength(0); - await expect(anonDb.list.findUnique({ where: { id: 'list1' } })).toResolveNull(); - - // accessible to owner - await expect(user1Db.list.findUnique({ where: { id: 'list1' } })).resolves.toEqual( - expect.objectContaining({ id: 'list1', title: 'List 1' }), - ); - - // accessible to user in the space - await expect(user2Db.list.findUnique({ where: { id: 'list1' } })).toResolveTruthy(); - - // inaccessible to user not in the space - await expect(user3Db.list.findUnique({ where: { id: 'list1' } })).toResolveNull(); - - // make a private list - await user1Db.list.create({ - data: { - id: 'list2', - title: 'List 2', - private: true, - owner: { connect: { id: user1.id } }, - space: { connect: { id: space1.id } }, - }, - }); - - // accessible to owner - await expect(user1Db.list.findUnique({ where: { id: 'list2' } })).toResolveTruthy(); - - // inaccessible to other user in the space - await expect(user2Db.list.findUnique({ where: { id: 'list2' } })).toResolveNull(); - - // create a list which doesn't match credential should fail - await expect( - user1Db.list.create({ - data: { - id: 'list3', - title: 'List 3', - owner: { connect: { id: user2.id } }, - space: { connect: { id: space1.id } }, - }, - }), - ).toBeRejectedByPolicy(); - - // create a list which doesn't match credential's space should fail - await expect( - user1Db.list.create({ - data: { - id: 'list3', - title: 'List 3', - owner: { connect: { id: user1.id } }, - space: { connect: { id: space2.id } }, - }, - }), - ).toBeRejectedByPolicy(); - - // update list - await expect( - user1Db.list.update({ - where: { id: 'list1' }, - data: { - title: 'List 1 updated', - }, - }), - ).resolves.toEqual(expect.objectContaining({ title: 'List 1 updated' })); - - await expect( - user2Db.list.update({ - where: { id: 'list1' }, - data: { - title: 'List 1 updated', - }, - }), - ).toBeRejectedNotFound(); - - // delete list - await expect(user2Db.list.delete({ where: { id: 'list1' } })).toBeRejectedNotFound(); - await expect(user1Db.list.delete({ where: { id: 'list1' } })).toResolveTruthy(); - await expect(user1Db.list.findUnique({ where: { id: 'list1' } })).toResolveNull(); - }); - - it('todo', async () => { - await createSpaceAndUsers(db.$unuseAll()); - - const user1Db = db.$setAuth({ id: user1.id }); - const user2Db = db.$setAuth({ id: user2.id }); - - // create a public list - await user1Db.list.create({ - data: { - id: 'list1', - title: 'List 1', - owner: { connect: { id: user1.id } }, - space: { connect: { id: space1.id } }, - }, - }); - - // create - await expect( - user1Db.todo.create({ - data: { - id: 'todo1', - title: 'Todo 1', - owner: { connect: { id: user1.id } }, - list: { - connect: { id: 'list1' }, - }, - }, - }), - ).toResolveTruthy(); - - await expect( - user2Db.todo.create({ - data: { - id: 'todo2', - title: 'Todo 2', - owner: { connect: { id: user2.id } }, - list: { - connect: { id: 'list1' }, - }, - }, - }), - ).toResolveTruthy(); - - // read - await expect(user1Db.todo.findMany()).resolves.toHaveLength(2); - await expect(user2Db.todo.findMany()).resolves.toHaveLength(2); - - // update, user in the same space can freely update - await expect( - user1Db.todo.update({ - where: { id: 'todo1' }, - data: { - title: 'Todo 1 updated', - }, - }), - ).toResolveTruthy(); - await expect( - user1Db.todo.update({ - where: { id: 'todo2' }, - data: { - title: 'Todo 2 updated', - }, - }), - ).toResolveTruthy(); - - // create a private list - await user1Db.list.create({ - data: { - id: 'list2', - private: true, - title: 'List 2', - owner: { connect: { id: user1.id } }, - space: { connect: { id: space1.id } }, - }, - }); - - // create - await expect( - user1Db.todo.create({ - data: { - id: 'todo3', - title: 'Todo 3', - owner: { connect: { id: user1.id } }, - list: { - connect: { id: 'list2' }, - }, - }, - }), - ).toResolveTruthy(); - - // reject because list2 is private - await expect( - user2Db.todo.create({ - data: { - id: 'todo4', - title: 'Todo 4', - owner: { connect: { id: user2.id } }, - list: { - connect: { id: 'list2' }, - }, - }, - }), - ).toBeRejectedByPolicy(); - - // update, only owner can update todo in a private list - await expect( - user1Db.todo.update({ - where: { id: 'todo3' }, - data: { - title: 'Todo 3 updated', - }, - }), - ).toResolveTruthy(); - await expect( - user2Db.todo.update({ - where: { id: 'todo3' }, - data: { - title: 'Todo 3 updated', - }, - }), - ).toBeRejectedNotFound(); - }); - - it('relation query', async () => { - await createSpaceAndUsers(db.$unuseAll()); - - const user1Db = db.$setAuth({ id: user1.id }); - const user2Db = db.$setAuth({ id: user2.id }); - - await user1Db.list.create({ - data: { - id: 'list1', - title: 'List 1', - owner: { connect: { id: user1.id } }, - space: { connect: { id: space1.id } }, - }, - }); - - await user1Db.list.create({ - data: { - id: 'list2', - title: 'List 2', - private: true, - owner: { connect: { id: user1.id } }, - space: { connect: { id: space1.id } }, - }, - }); - - const r = await user1Db.space.findFirstOrThrow({ - where: { id: 'space1' }, - include: { lists: true }, - }); - expect(r.lists).toHaveLength(2); - - const r1 = await user2Db.space.findFirstOrThrow({ - where: { id: 'space1' }, - include: { lists: true }, - }); - expect(r1.lists).toHaveLength(1); - }); - - it('post-update checks', async () => { - await createSpaceAndUsers(db.$unuseAll()); - - const user1Db = db.$setAuth({ id: user1.id }); - - await user1Db.list.create({ - data: { - id: 'list1', - title: 'List 1', - owner: { connect: { id: user1.id } }, - space: { connect: { id: space1.id } }, - todos: { - create: { - id: 'todo1', - title: 'Todo 1', - owner: { connect: { id: user1.id } }, - }, - }, - }, - }); - - // change list's owner - await expect( - user1Db.list.update({ - where: { id: 'list1' }, - data: { - owner: { connect: { id: user2.id } }, - }, - }), - ).toBeRejectedByPolicy(); - - // change todo's owner - await expect( - user1Db.todo.update({ - where: { id: 'todo1' }, - data: { - owner: { connect: { id: user2.id } }, - }, - }), - ).toBeRejectedByPolicy(); - - // nested change todo's owner - await expect( - user1Db.list.update({ - where: { id: 'list1' }, - data: { - todos: { - update: { - where: { id: 'todo1' }, - data: { - owner: { connect: { id: user2.id } }, - }, - }, - }, - }, - }), - ).toBeRejectedByPolicy(); - }); -}); - -const user1 = { - id: 'user1', - email: 'user1@zenstack.dev', - name: 'User 1', -}; - -const user2 = { - id: 'user2', - email: 'user2@zenstack.dev', - name: 'User 2', -}; - -const user3 = { - id: 'user3', - email: 'user3@zenstack.dev', - name: 'User 3', -}; - -const space1 = { - id: 'space1', - name: 'Space 1', - slug: 'space1', -}; - -const space2 = { - id: 'space2', - name: 'Space 2', - slug: 'space2', -}; - -async function createSpaceAndUsers(db: ClientContract) { - // create users - await db.user.create({ data: user1 }); - await db.user.create({ data: user2 }); - await db.user.create({ data: user3 }); - - // add user1 and user2 into space1 - await db.space.create({ - data: { - ...space1, - members: { - create: [ - { - user: { connect: { id: user1.id } }, - role: 'ADMIN', - }, - { - user: { connect: { id: user2.id } }, - role: 'USER', - }, - ], - }, - }, - }); - - // add user3 to space2 - await db.space.create({ - data: { - ...space2, - members: { - create: [ - { - user: { connect: { id: user3.id } }, - role: 'ADMIN', - }, - ], - }, - }, - }); -} diff --git a/tests/e2e/orm/validation/toplevel.test.ts b/tests/e2e/orm/validation/toplevel.test.ts index f4204b62..fab16d63 100644 --- a/tests/e2e/orm/validation/toplevel.test.ts +++ b/tests/e2e/orm/validation/toplevel.test.ts @@ -21,7 +21,6 @@ describe('Toplevel field validation tests', () => { await db.foo.create({ data: { id: 100 } }); for (const action of ['create', 'update', 'upsert', 'updateMany']) { - console.log(`Testing action: ${action}`); const _t = action === 'create' ? (data: any) => db.foo.create({ data }) diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 3cd81ee0..d6564bff 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index 664d1990..18b957e5 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.0.0-beta.19", + "version": "3.0.0-beta.20", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/test/issue-204/regression.test.ts b/tests/regression/test/issue-204/regression.test.ts index d7d78948..48ae4cc1 100644 --- a/tests/regression/test/issue-204/regression.test.ts +++ b/tests/regression/test/issue-204/regression.test.ts @@ -3,7 +3,9 @@ import { type Configuration, ShirtColor } from './models'; it('tests issue 204', () => { const config: Configuration = { teamColors: [ShirtColor.Black, ShirtColor.Blue] }; - console.log(config.teamColors?.[0]); + check(config.teamColors?.[0]); const config1: Configuration = {}; - console.log(config1); + check(config1); }); + +function check(_arg: unknown) {} diff --git a/tests/regression/test/v2-migrated/issue-1271.test.ts b/tests/regression/test/v2-migrated/issue-1271.test.ts index 8c264a36..c8e5ef8a 100644 --- a/tests/regression/test/v2-migrated/issue-1271.test.ts +++ b/tests/regression/test/v2-migrated/issue-1271.test.ts @@ -123,7 +123,6 @@ model AnotherTest { locale: 'locale3', }, }); - console.log('test3 created:', test3); const updated2 = await db.linkingTable.update({ where: { test_id_another_test_id: { diff --git a/tests/regression/test/v2-migrated/issue-1698.test.ts b/tests/regression/test/v2-migrated/issue-1698.test.ts index 3df2e823..b5cea267 100644 --- a/tests/regression/test/v2-migrated/issue-1698.test.ts +++ b/tests/regression/test/v2-migrated/issue-1698.test.ts @@ -42,31 +42,25 @@ describe('Regression for issue #1698', () => { const door1 = await db.ironDoor.create({ data: { strength: 100, color: 'blue' }, }); - console.log(door1); const door2 = await db.woodenDoor.create({ data: { texture: 'pine', color: 'red' }, }); - console.log(door2); - const house1 = await db.privateHouse.create({ + await db.privateHouse.create({ data: { size: 5000, door: { connect: { id: door1.id } } }, }); - console.log(house1); - const house2 = await db.skyscraper.create({ + await db.skyscraper.create({ data: { height: 3000, door: { connect: { id: door2.id } } }, }); - console.log(house2); const r1 = await db.privateHouse.findFirst({ include: { door: true } }); - console.log(r1); expect(r1).toMatchObject({ door: { color: 'blue', strength: 100 }, }); const r2 = (await db.skyscraper.findMany({ include: { door: true } }))[0]; - console.log(r2); expect(r2).toMatchObject({ door: { color: 'red', texture: 'pine' }, });