diff --git a/package.json b/package.json index 5637d83c2..aa33612b1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "zenstack-v3", "displayName": "ZenStack", "description": "ZenStack", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", @@ -63,7 +63,8 @@ "overrides": { "cookie@<0.7.0": ">=0.7.0", "lodash-es@>=4.0.0 <=4.17.22": ">=4.17.23", - "lodash@>=4.0.0 <=4.17.22": ">=4.17.23" + "lodash@>=4.0.0 <=4.17.22": ">=4.17.23", + "@better-auth/core": "1.4.19" } }, "funding": "https://github.com/sponsors/zenstackhq" diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index f823657b4..3cf3fdbe9 100644 --- a/packages/auth-adapters/better-auth/package.json +++ b/packages/auth-adapters/better-auth/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/better-auth", "displayName": "ZenStack Better Auth Adapter", "description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/auth-adapters/better-auth/src/adapter.ts b/packages/auth-adapters/better-auth/src/adapter.ts index 27b2d4f59..c393f3319 100644 --- a/packages/auth-adapters/better-auth/src/adapter.ts +++ b/packages/auth-adapters/better-auth/src/adapter.ts @@ -1,5 +1,4 @@ -import type { BetterAuthOptions } from '@better-auth/core'; -import type { DBAdapter, Where } from '@better-auth/core/db/adapter'; +import type { BetterAuthOptions, Where } from 'better-auth'; import { BetterAuthError } from '@better-auth/core/error'; import type { ClientContract, ModelOperations, UpdateInput } from '@zenstackhq/orm'; import type { GetModels, SchemaDef } from '@zenstackhq/orm/schema'; @@ -187,7 +186,9 @@ export const zenstackAdapter = (db: ClientContract { - const generateSchema = (await import('./schema-generator')).generateSchema; + // Self-import via package subpath (not a relative './schema-generator') so the + // bundler treats it as external and keeps it lazy in the CJS output — see tsdown.config.ts. + const generateSchema = (await import('@zenstackhq/better-auth/schema-generator')).generateSchema; return generateSchema(file, tables, config, options); }, }; @@ -213,7 +214,7 @@ export const zenstackAdapter = (db: ClientContract => { + return (options: BetterAuthOptions) => { lazyOptions = options; return adapter(options); }; diff --git a/packages/auth-adapters/better-auth/tsconfig.json b/packages/auth-adapters/better-auth/tsconfig.json index 8784ef545..0cecd4cdc 100644 --- a/packages/auth-adapters/better-auth/tsconfig.json +++ b/packages/auth-adapters/better-auth/tsconfig.json @@ -2,7 +2,11 @@ "extends": "@zenstackhq/typescript-config/base.json", "compilerOptions": { "rootDir": ".", - "noPropertyAccessFromIndexSignature": false + "noPropertyAccessFromIndexSignature": false, + "types": ["node"], + "paths": { + "@zenstackhq/better-auth/schema-generator": ["./src/schema-generator.ts"] + } }, "include": ["src/**/*"] } diff --git a/packages/auth-adapters/better-auth/tsdown.config.ts b/packages/auth-adapters/better-auth/tsdown.config.ts index 28af6aa31..075a684a2 100644 --- a/packages/auth-adapters/better-auth/tsdown.config.ts +++ b/packages/auth-adapters/better-auth/tsdown.config.ts @@ -1,3 +1,18 @@ import { createConfig } from '@zenstackhq/tsdown-config'; -export default createConfig({ entry: { index: 'src/index.ts', 'schema-generator': 'src/schema-generator.ts' } }); +// `index` and `schema-generator` are built as two separate tsdown invocations so that +// the lazy `await import('@zenstackhq/better-auth/schema-generator')` in the adapter +// stays lazy in the CJS output. When both entries live in a single build, Rolldown +// treats them as siblings and injects a top-level `require('./schema-generator.cjs')` +// into `index.cjs`, which eagerly pulls in `@zenstackhq/language` (Langium) at adapter +// load time. Splitting the builds hides that relationship; `neverBundle` then keeps +// the dynamic import as a package-name reference that Node resolves at first call. +export default [ + createConfig({ + entry: { index: 'src/index.ts' }, + deps: { neverBundle: ['@zenstackhq/better-auth/schema-generator'] }, + }), + createConfig({ + entry: { 'schema-generator': 'src/schema-generator.ts' }, + }), +]; diff --git a/packages/cli/package.json b/packages/cli/package.json index baff7e04e..a9e249866 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/cli", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/cli/src/actions/db.ts b/packages/cli/src/actions/db.ts index 6ffa7003c..592f9888d 100644 --- a/packages/cli/src/actions/db.ts +++ b/packages/cli/src/actions/db.ts @@ -1,5 +1,5 @@ import { formatDocument, ZModelCodeGenerator } from '@zenstackhq/language'; -import { DataModel, Enum, type Model } from '@zenstackhq/language/ast'; +import { DataModel, Enum, isDataField, type DataField, type Model } from '@zenstackhq/language/ast'; import colors from 'colors'; import fs from 'node:fs'; import path from 'node:path'; @@ -14,7 +14,7 @@ import { } from './action-utils'; import { consolidateEnums, syncEnums, syncRelation, syncTable, type Relation } from './pull'; import { providers as pullProviders } from './pull/provider'; -import { getDatasource, getDbName, getRelationFieldsKey, getRelationFkName, isDatabaseManagedAttribute } from './pull/utils'; +import { getDatasource, getDbName, getRelationFieldsKey, getRelationFkName, getRelationName, isDatabaseManagedAttribute } from './pull/utils'; import type { DataSourceProviderType } from '@zenstackhq/schema'; import { CliError } from '../cli-error'; @@ -35,6 +35,25 @@ export type PullOptions = { indent: number; }; +function hasRelationFieldsArg(field: DataField) { + const relationAttr = field.attributes.find((a) => a.decl.ref?.name === '@relation'); + return !!relationAttr?.args.some((a) => a.name === 'fields'); +} + +function getReferencedModelName(field: DataField) { + return field.type.reference?.ref ? getDbName(field.type.reference.ref) : undefined; +} + +function matchesRelationNameFallback(field: DataField, relationName: string, candidate: DataField) { + const referencedModelName = getReferencedModelName(field); + return ( + !!referencedModelName && + getRelationName(candidate) === relationName && + hasRelationFieldsArg(candidate) === hasRelationFieldsArg(field) && + getReferencedModelName(candidate) === referencedModelName + ); +} + /** * CLI action for db related commands */ @@ -283,46 +302,52 @@ async function runPull(options: PullOptions) { } newDataModel.fields.forEach((f) => { - // Prioritized matching: exact db name > relation fields key > relation FK name > type reference + // Prioritized matching: exact db name > relation fields key > relation FK name > relation name > type reference let originalFields = originalDataModel.fields.filter((d) => getDbName(d) === getDbName(f)); - // If this is a back-reference relation field (has @relation but no `fields` arg), silently skip - const isRelationField = - f.$type === 'DataField' && !!(f as any).attributes?.some((a: any) => a?.decl?.ref?.name === '@relation'); - if (originalFields.length === 0 && isRelationField && !getRelationFieldsKey(f as any)) { - return; - } - if (originalFields.length === 0) { // Try matching by relation fields key (the `fields` attribute in @relation) // This matches relation fields by their FK field references - const newFieldsKey = getRelationFieldsKey(f as any); + const newFieldsKey = isDataField(f) ? getRelationFieldsKey(f) : undefined; if (newFieldsKey) { originalFields = originalDataModel.fields.filter( - (d) => getRelationFieldsKey(d as any) === newFieldsKey, + (d) => isDataField(d) && getRelationFieldsKey(d) === newFieldsKey, ); } } if (originalFields.length === 0) { // Try matching by relation FK name (the `map` attribute in @relation) - originalFields = originalDataModel.fields.filter( - (d) => - getRelationFkName(d as any) === getRelationFkName(f as any) && - !!getRelationFkName(d as any) && - !!getRelationFkName(f as any), - ); + const newFkName = isDataField(f) ? getRelationFkName(f) : undefined; + if (newFkName) { + originalFields = originalDataModel.fields.filter( + (d) => isDataField(d) && getRelationFkName(d) === newFkName, + ); + } + } + + if (originalFields.length === 0) { + // Try matching by relation name (the `name` arg in @relation) + // This is essential for back-reference fields that only have a relation name + const newRelName = isDataField(f) ? getRelationName(f) : undefined; + if (newRelName) { + originalFields = originalDataModel.fields.filter( + (d) => + isDataField(d) && + isDataField(f) && + matchesRelationNameFallback(f, newRelName, d), + ); + } } if (originalFields.length === 0) { // Try matching by type reference // We need this because for relations that don't have @relation, we can only check if the original exists by the field type. // Yes, in this case it can potentially result in multiple original fields, but we only want to ensure that at least one relation exists. - // In the future, we might implement some logic to detect how many of these types of relations we need and add/remove fields based on this. originalFields = originalDataModel.fields.filter( (d) => - f.$type === 'DataField' && - d.$type === 'DataField' && + isDataField(f) && + isDataField(d) && f.type.reference?.ref && d.type.reference?.ref && getDbName(f.type.reference.ref) === getDbName(d.type.reference.ref), @@ -332,7 +357,7 @@ async function runPull(options: PullOptions) { if (originalFields.length > 1) { // If this is a back-reference relation field (no `fields` attribute), // silently skip when there are multiple potential matches - const isBackReferenceField = !getRelationFieldsKey(f as any); + const isBackReferenceField = isDataField(f) && !getRelationFieldsKey(f); if (!isBackReferenceField) { console.warn( colors.yellow( @@ -499,31 +524,43 @@ async function runPull(options: PullOptions) { }); originalDataModel.fields .filter((f) => { - // Prioritized matching: exact db name > relation fields key > relation FK name > type reference + // Prioritized matching: exact db name > relation fields key > relation FK name > relation name > type reference const matchByDbName = newDataModel.fields.find((d) => getDbName(d) === getDbName(f)); if (matchByDbName) return false; // Try matching by relation fields key (the `fields` attribute in @relation) - const originalFieldsKey = getRelationFieldsKey(f as any); + const originalFieldsKey = isDataField(f) ? getRelationFieldsKey(f) : undefined; if (originalFieldsKey) { const matchByFieldsKey = newDataModel.fields.find( - (d) => getRelationFieldsKey(d as any) === originalFieldsKey, + (d) => isDataField(d) && getRelationFieldsKey(d) === originalFieldsKey, ); if (matchByFieldsKey) return false; } - const matchByFkName = newDataModel.fields.find( - (d) => - getRelationFkName(d as any) === getRelationFkName(f as any) && - !!getRelationFkName(d as any) && - !!getRelationFkName(f as any), - ); - if (matchByFkName) return false; + const originalFkName = isDataField(f) ? getRelationFkName(f) : undefined; + if (originalFkName) { + const matchByFkName = newDataModel.fields.find( + (d) => isDataField(d) && getRelationFkName(d) === originalFkName, + ); + if (matchByFkName) return false; + } + + // Try matching by relation name (for named back-reference fields) + const originalRelName = isDataField(f) ? getRelationName(f) : undefined; + if (originalRelName) { + const matchByRelName = newDataModel.fields.find( + (d) => + isDataField(d) && + isDataField(f) && + matchesRelationNameFallback(f, originalRelName, d), + ); + if (matchByRelName) return false; + } const matchByTypeRef = newDataModel.fields.find( (d) => - f.$type === 'DataField' && - d.$type === 'DataField' && + isDataField(f) && + isDataField(d) && f.type.reference?.ref && d.type.reference?.ref && getDbName(f.type.reference.ref) === getDbName(d.type.reference.ref), diff --git a/packages/cli/src/actions/proxy.ts b/packages/cli/src/actions/proxy.ts index a68521189..54c29c0ab 100644 --- a/packages/cli/src/actions/proxy.ts +++ b/packages/cli/src/actions/proxy.ts @@ -11,6 +11,7 @@ import { ZenStackClient, type ClientContract } from '@zenstackhq/orm'; import { MysqlDialect } from '@zenstackhq/orm/dialects/mysql'; import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres'; import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite'; +import type { SchemaDef } from '@zenstackhq/orm/schema'; import { RPCApiHandler } from '@zenstackhq/server/api'; import { ZenStackMiddleware } from '@zenstackhq/server/express'; import type BetterSqlite3 from 'better-sqlite3'; @@ -24,7 +25,6 @@ import type { Pool as PgPoolType } from 'pg'; import { CliError } from '../cli-error'; import { getVersion } from '../utils/version-utils'; import { getOutputPath, getSchemaFile, loadSchemaDocument } from './action-utils'; -import type { SchemaDef } from '@zenstackhq/orm/schema'; type Options = { output?: string; @@ -198,7 +198,7 @@ async function createDialect(provider: string, databaseUrl: string, outputPath: } } -export function createProxyApp(client: ClientContract, schema: any): express.Application { +export function createProxyApp(client: ClientContract, schema: SchemaDef): express.Application { const app = express(); app.use(cors()); app.use(express.json({ limit: '5mb' })); @@ -219,7 +219,7 @@ export function createProxyApp(client: ClientContract, schema: any): e return app; } -function startServer(client: ClientContract, schema: any, options: Options) { +function startServer(client: ClientContract, schema: any, options: Options) { const app = createProxyApp(client, schema); const server = app.listen(options.port, () => { diff --git a/packages/cli/src/actions/pull/utils.ts b/packages/cli/src/actions/pull/utils.ts index 9ec056bc4..04e565e31 100644 --- a/packages/cli/src/actions/pull/utils.ts +++ b/packages/cli/src/actions/pull/utils.ts @@ -14,7 +14,7 @@ import { type StringLiteral, } from '@zenstackhq/language/ast'; import type { AstFactory, ExpressionBuilder } from '@zenstackhq/language/factory'; -import { getLiteralArray, getStringLiteral } from '@zenstackhq/language/utils'; +import { getAttributeArgLiteral, getLiteralArray, getStringLiteral } from '@zenstackhq/language/utils'; import type { DataSourceProviderType } from '@zenstackhq/schema'; import type { Reference } from 'langium'; import { CliError } from '../../cli-error'; @@ -122,6 +122,19 @@ export function getRelationFkName(decl: DataField): string | undefined { return schemaAttrValue?.value; } +/** + * Gets the relation name from the @relation attribute's `name` argument. + * e.g., @relation('myRelation', fields: [...], references: [...]) -> "myRelation" + * e.g., @relation(name: 'myRelation', fields: [...], references: [...]) -> "myRelation" + * e.g., @relation(fields: [...], references: [...]) -> undefined + * e.g., @relation('backRef') -> "backRef" + */ +export function getRelationName(decl: DataField): string | undefined { + const relationAttr = decl?.attributes?.find((a) => a.decl?.ref?.name === '@relation'); + if (!relationAttr) return undefined; + return getAttributeArgLiteral(relationAttr, 'name'); +} + /** * Gets the FK field names from the @relation attribute's `fields` argument. * Returns a sorted, comma-separated string of field names for comparison. diff --git a/packages/cli/test/db/pull.test.ts b/packages/cli/test/db/pull.test.ts index 2750a2228..811c20ccf 100644 --- a/packages/cli/test/db/pull.test.ts +++ b/packages/cli/test/db/pull.test.ts @@ -152,6 +152,83 @@ model Tag { expect(restoredSchema).toEqual(schema); }); + it('should restore opposite relation fields when multiple models have FKs to the same target', async () => { + const { workDir, schema } = await createProject( + `model Comment { + id Int @id @default(autoincrement()) + text String + commentCreatedBy User? @relation('Comment_createdByToUser', fields: [createdBy], references: [id]) + createdBy Int? + commentUpdatedBy User? @relation('Comment_updatedByToUser', fields: [updatedBy], references: [id]) + updatedBy Int? +} + +model Post { + id Int @id @default(autoincrement()) + title String + postCreatedBy User? @relation('Post_createdByToUser', fields: [createdBy], references: [id]) + createdBy Int? + postUpdatedBy User? @relation('Post_updatedByToUser', fields: [updatedBy], references: [id]) + updatedBy Int? +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + commentCreatedBy Comment[] @relation('Comment_createdByToUser') + commentUpdatedBy Comment[] @relation('Comment_updatedByToUser') + postCreatedBy Post[] @relation('Post_createdByToUser') + postUpdatedBy Post[] @relation('Post_updatedByToUser') +}`, + ); + runCli('db push', workDir); + + const schemaFile = path.join(workDir, 'zenstack/schema.zmodel'); + + fs.writeFileSync(schemaFile, getDefaultPrelude()); + runCli('db pull --indent 4', workDir); + + const restoredSchema = getSchema(workDir); + expect(restoredSchema).toEqual(schema); + }); + + it('should preserve opposite relation fields when multiple models have FKs to the same target', async () => { + const { workDir, schema } = await createProject( + `model Comment { + id Int @id @default(autoincrement()) + text String + commentCreatedBy User? @relation('Comment_createdByToUser', fields: [createdBy], references: [id]) + createdBy Int? + commentUpdatedBy User? @relation('Comment_updatedByToUser', fields: [updatedBy], references: [id]) + updatedBy Int? +} + +model Post { + id Int @id @default(autoincrement()) + title String + postCreatedBy User? @relation('Post_createdByToUser', fields: [createdBy], references: [id]) + createdBy Int? + postUpdatedBy User? @relation('Post_updatedByToUser', fields: [updatedBy], references: [id]) + updatedBy Int? +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + commentCreatedBy Comment[] @relation('Comment_createdByToUser') + commentUpdatedBy Comment[] @relation('Comment_updatedByToUser') + postCreatedBy Post[] @relation('Post_createdByToUser') + postUpdatedBy Post[] @relation('Post_updatedByToUser') +}`, + ); + runCli('db push', workDir); + + runCli('db pull --indent 4', workDir); + + const restoredSchema = getSchema(workDir); + expect(restoredSchema).toEqual(schema); + }); + it('should restore one-to-one relation when FK is the single-column primary key', async () => { const { workDir, schema } = await createProject( `model Profile { diff --git a/packages/cli/test/plugins/custom-plugin.test.ts b/packages/cli/test/plugins/custom-plugin.test.ts index 3492dbbe6..f20f06190 100644 --- a/packages/cli/test/plugins/custom-plugin.test.ts +++ b/packages/cli/test/plugins/custom-plugin.test.ts @@ -1,8 +1,8 @@ +import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { createProject, runCli } from '../utils'; -import { execSync } from 'node:child_process'; describe('Custom plugins tests', () => { it('runs custom plugin generator', async () => { diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index e0a6d5624..b475681d8 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -1,3 +1,5 @@ import { createConfig } from '@zenstackhq/tsdown-config'; -export default createConfig({ entry: { index: 'src/index.ts' } }); +export default createConfig({ + entry: { index: 'src/index.ts' }, +}); diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index 50152b545..0b56834c4 100644 --- a/packages/clients/client-helpers/package.json +++ b/packages/clients/client-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/client-helpers", "displayName": "ZenStack Client Helpers", "description": "Helpers for implementing clients that consume ZenStack's CRUD service", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/client-helpers/src/constants.ts b/packages/clients/client-helpers/src/constants.ts index ced31e94b..f18c68ec2 100644 --- a/packages/clients/client-helpers/src/constants.ts +++ b/packages/clients/client-helpers/src/constants.ts @@ -1,4 +1,9 @@ /** - * The default query endpoint. + * Route segment used for custom procedures. */ -export const DEFAULT_QUERY_ENDPOINT = '/api/model'; +export const CUSTOM_PROC_ROUTE_NAME = '$procs'; + +/** + * Route prefix used for transactions. + */ +export const TRANSACTION_ROUTE_PREFIX = '$transaction'; diff --git a/packages/clients/client-helpers/src/fetch.ts b/packages/clients/client-helpers/src/fetch.ts index e4f3e8c7e..de3700d6c 100644 --- a/packages/clients/client-helpers/src/fetch.ts +++ b/packages/clients/client-helpers/src/fetch.ts @@ -1,4 +1,5 @@ import { lowerCaseFirst } from '@zenstackhq/common-helpers'; +import { AnyNull, AnyNullClass, DbNull, DbNullClass, JsonNull, JsonNullClass } from '@zenstackhq/orm/common-types'; import Decimal from 'decimal.js'; import SuperJSON from 'superjson'; import type { QueryError } from './types'; @@ -65,6 +66,33 @@ SuperJSON.registerCustom( 'Decimal', ); +SuperJSON.registerCustom( + { + isApplicable: (v): v is DbNullClass => v instanceof DbNullClass, + serialize: () => 'DbNull', + deserialize: () => DbNull, + }, + 'DbNull', +); + +SuperJSON.registerCustom( + { + isApplicable: (v): v is JsonNullClass => v instanceof JsonNullClass, + serialize: () => 'JsonNull', + deserialize: () => JsonNull, + }, + 'JsonNull', +); + +SuperJSON.registerCustom( + { + isApplicable: (v): v is AnyNullClass => v instanceof AnyNullClass, + serialize: () => 'AnyNull', + deserialize: () => AnyNull, + }, + 'AnyNull', +); + /** * Serialize the given value with superjson */ diff --git a/packages/clients/client-helpers/src/index.ts b/packages/clients/client-helpers/src/index.ts index e1ea44b85..f920d3684 100644 --- a/packages/clients/client-helpers/src/index.ts +++ b/packages/clients/client-helpers/src/index.ts @@ -1,3 +1,4 @@ +export { AnyNull, DbNull, JsonNull } from '@zenstackhq/orm/common-types'; export * from './constants'; export * from './invalidation'; export * from './logging'; @@ -6,4 +7,5 @@ export * from './nested-read-visitor'; export * from './nested-write-visitor'; export * from './optimistic'; export * from './query-analysis'; +export * from './transaction'; export * from './types'; diff --git a/packages/clients/client-helpers/src/invalidation.ts b/packages/clients/client-helpers/src/invalidation.ts index 1289a881d..ef792fe2a 100644 --- a/packages/clients/client-helpers/src/invalidation.ts +++ b/packages/clients/client-helpers/src/invalidation.ts @@ -30,10 +30,11 @@ export function createInvalidator( invalidator: InvalidateFunc, logging: Logger | undefined, ) { + const normalizedModel = normalizeModelName(model, schema); return async (...args: unknown[]) => { const [_, variables] = args; const predicate = await getInvalidationPredicate( - model, + normalizedModel, operation as ORMWriteActionType, variables, schema, @@ -87,3 +88,9 @@ function findNestedRead(visitingModel: string, targetModels: string[], schema: S const modelsRead = getReadModels(visitingModel, schema, args); return targetModels.some((m) => modelsRead.includes(m)); } + +// resolves a model name to its canonical form as defined in the schema (case-insensitive match) +function normalizeModelName(model: string, schema: SchemaDef) { + const target = model.toLowerCase(); + return Object.keys(schema.models).find((k) => k.toLowerCase() === target) ?? model; +} diff --git a/packages/clients/client-helpers/src/nested-write-visitor.ts b/packages/clients/client-helpers/src/nested-write-visitor.ts index 14ca1e404..f4ec614bc 100644 --- a/packages/clients/client-helpers/src/nested-write-visitor.ts +++ b/packages/clients/client-helpers/src/nested-write-visitor.ts @@ -297,10 +297,6 @@ export class NestedWriteVisitor { } } break; - - default: { - throw new Error(`unhandled action type ${action}`); - } } } diff --git a/packages/clients/client-helpers/src/transaction.ts b/packages/clients/client-helpers/src/transaction.ts new file mode 100644 index 000000000..dfbd69e6d --- /dev/null +++ b/packages/clients/client-helpers/src/transaction.ts @@ -0,0 +1,70 @@ +import type { + CoreCrudOperations, + CrudArgsMap, + CrudReturnMap, + ExtQueryArgsBase, + ExtResultBase, + GetSlicedOperations, + ModelAllowsCreate, + OperationsRequiringCreate, + QueryOptions, +} from '@zenstackhq/orm'; +import type { GetModels, SchemaDef } from '@zenstackhq/schema'; + +/** + * Operations available in a sequential transaction. + */ +type AllowedTransactionOps< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = + ModelAllowsCreate extends true + ? GetSlicedOperations & CoreCrudOperations + : Exclude & CoreCrudOperations, OperationsRequiringCreate>; + +/** + * Represents a single operation to execute within a sequential transaction. + * + * The `model`, `op`, and `args` fields are correlated: `op` is constrained to + * the CRUD operations available on `model` (respecting `Options['slicing']`), and + * `args` is typed accordingly. + */ +export type TransactionOperation< + Schema extends SchemaDef, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = { + [Model in GetModels]: { + [Op in AllowedTransactionOps]: {} extends CrudArgsMap< + Schema, + Model, + Options, + ExtQueryArgs, + ExtResult + >[Op] + ? { model: Model; op: Op; args?: CrudArgsMap[Op] } + : { model: Model; op: Op; args: CrudArgsMap[Op] }; + }[AllowedTransactionOps]; +}[GetModels]; + +/** + * Maps each operation in a transaction tuple to its precise result type, preserving + * per-position typing. + */ +export type TransactionResults< + Schema extends SchemaDef, + Ops extends readonly TransactionOperation[], + Options extends QueryOptions = QueryOptions, + ExtResult extends ExtResultBase = {}, +> = { + [K in keyof Ops]: Ops[K] extends { model: infer M; op: infer O; args?: infer A } + ? M extends GetModels + ? O extends keyof CrudReturnMap + ? CrudReturnMap[O] + : never + : never + : never; +}; + diff --git a/packages/clients/client-helpers/src/types.ts b/packages/clients/client-helpers/src/types.ts index 5f30ac613..a4f4f68cc 100644 --- a/packages/clients/client-helpers/src/types.ts +++ b/packages/clients/client-helpers/src/types.ts @@ -1,4 +1,4 @@ -import type { ClientContract, QueryOptions } from '@zenstackhq/orm'; +import { ExtQueryArgsMarker, ExtResultMarker, type QueryOptions } from '@zenstackhq/orm'; import type { SchemaDef } from '@zenstackhq/schema'; /** @@ -11,10 +11,15 @@ export type MaybePromise = T | Promise | PromiseLike; */ export type InferSchema = T extends { $schema: infer S extends SchemaDef } ? S : T extends SchemaDef ? T : never; +/** + * Extracts the ExtQueryArgs type from a client contract, or defaults to `{}`. + */ +export type InferExtQueryArgs = T extends { [ExtQueryArgsMarker]?: infer E } ? (unknown extends E ? {} : E) : {}; + /** * Extracts the ExtResult type from a client contract, or defaults to `{}`. */ -export type InferExtResult = T extends ClientContract ? E : {}; +export type InferExtResult = T extends { [ExtResultMarker]?: infer E } ? (unknown extends E ? {} : E) : {}; /** * Infers query options from a client contract type, or defaults to `QueryOptions`. diff --git a/packages/clients/client-helpers/test/nested-write-visitor.test.ts b/packages/clients/client-helpers/test/nested-write-visitor.test.ts index 2e09e441a..3d2889fc9 100644 --- a/packages/clients/client-helpers/test/nested-write-visitor.test.ts +++ b/packages/clients/client-helpers/test/nested-write-visitor.test.ts @@ -1097,25 +1097,6 @@ describe('NestedWriteVisitor tests', () => { }), ).resolves.not.toThrow(); }); - - it('throws error for unhandled action type', async () => { - const schema = createSchema({ - User: { - name: 'User', - fields: { - id: createField('id', 'String'), - }, - uniqueFields: {}, - idFields: ['id'], - }, - }); - - const visitor = new NestedWriteVisitor(schema, {}); - - await expect(visitor.visit('User', 'invalidAction' as any, { data: {} })).rejects.toThrow( - 'unhandled action type', - ); - }); }); describe('complex real-world scenarios', () => { diff --git a/packages/clients/fetch-client/eslint.config.js b/packages/clients/fetch-client/eslint.config.js new file mode 100644 index 000000000..5698b9910 --- /dev/null +++ b/packages/clients/fetch-client/eslint.config.js @@ -0,0 +1,4 @@ +import config from '@zenstackhq/eslint-config/base.js'; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/clients/fetch-client/package.json b/packages/clients/fetch-client/package.json new file mode 100644 index 000000000..ae57eaaa4 --- /dev/null +++ b/packages/clients/fetch-client/package.json @@ -0,0 +1,51 @@ +{ + "name": "@zenstackhq/fetch-client", + "displayName": "ZenStack Fetch Client", + "description": "Simple fetch-based client for consuming ZenStack's RPC-style CRUD API", + "version": "3.7.0", + "type": "module", + "author": { + "name": "ZenStack Team", + "email": "contact@zenstack.dev" + }, + "homepage": "https://zenstack.dev", + "repository": { + "type": "git", + "url": "https://github.com/zenstackhq/zenstack" + }, + "license": "MIT", + "scripts": { + "build": "tsc --noEmit && tsdown && pnpm test:generate && pnpm test:typecheck", + "watch": "tsdown --watch", + "lint": "eslint src --ext ts", + "test": "vitest run", + "test:generate": "tsx ../../../scripts/test-generate.ts test --lite-only", + "test:typecheck": "tsc --noEmit --project tsconfig.test.json", + "pack": "pnpm pack" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + }, + "dependencies": { + "@zenstackhq/client-helpers": "workspace:*", + "@zenstackhq/common-helpers": "workspace:*", + "@zenstackhq/orm": "workspace:*", + "@zenstackhq/schema": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "@zenstackhq/cli": "workspace:*", + "@zenstackhq/eslint-config": "workspace:*", + "@zenstackhq/tsdown-config": "workspace:*", + "@zenstackhq/typescript-config": "workspace:*", + "@zenstackhq/vitest-config": "workspace:*", + "decimal.js": "catalog:" + }, + "funding": "https://github.com/sponsors/zenstackhq" +} diff --git a/packages/clients/fetch-client/src/index.ts b/packages/clients/fetch-client/src/index.ts new file mode 100644 index 000000000..92a327099 --- /dev/null +++ b/packages/clients/fetch-client/src/index.ts @@ -0,0 +1,311 @@ +import { + CUSTOM_PROC_ROUTE_NAME, + TRANSACTION_ROUTE_PREFIX, + type InferExtQueryArgs, + type InferExtResult, + type InferOptions, + type InferSchema, + type TransactionOperation, + type TransactionResults, +} from '@zenstackhq/client-helpers'; +import { fetcher, makeUrl, marshal, type FetchFn } from '@zenstackhq/client-helpers/fetch'; +import { lowerCaseFirst } from '@zenstackhq/common-helpers'; +import type { + AllModelOperations, + ClientContract, + ExtQueryArgsBase, + ExtResultBase, + GetProcedure, + GetProcedureNames, + GetSlicedModels, + GetSlicedOperations, + GetSlicedProcedures, + ProcedureEnvelope, + ProcedureFunc, + QueryOptions, +} from '@zenstackhq/orm'; +import type { GetModels, SchemaDef } from '@zenstackhq/schema'; + +export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; +export type { TransactionOperation, TransactionResults }; + +/** + * Error codes raised by {@link CrudError}. + */ +export enum CrudErrorCode { + /** A `*OrThrow` operation found no matching entity. */ + NotFound = 'NotFound', +} + +/** + * Error thrown by CRUD operations on the fetch client. + */ +export class CrudError extends Error { + readonly code: CrudErrorCode; + + /** Name of the model that caused the error, if applicable. */ + readonly model?: string; + + constructor(code: CrudErrorCode, message: string, model?: string) { + super(message); + this.name = 'CrudError'; + this.code = code; + this.model = model; + } +} + +/** + * Options for configuring the fetch client. + */ +export type FetchClientOptions = { + /** + * The base endpoint for the CRUD API. Must be a fully qualified URL, + * e.g. `https://example.com/api/model`. + */ + endpoint: string; + + /** + * A custom fetch function. Defaults to the global `fetch`. + */ + fetch?: FetchFn; +}; + +type ProcedureFn< + Schema extends SchemaDef, + ProcName extends GetProcedureNames, + Input = ProcedureEnvelope, +> = { args: undefined } extends Input + ? (input?: Input) => Promise> + : (input: Input) => Promise>; + +type ProcedureReturn> = Awaited< + ReturnType> +>; + +type ProcedureGroup> = { + [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } + ? { mutate: ProcedureFn } + : { query: ProcedureFn }; +}; + +/** + * Procedures accessor type. Exists on client only when schema has procedures. + */ +export type ProcedureOperations> = + Schema['procedures'] extends Record + ? { $procs: ProcedureGroup } + : Record; + +/** + * CRUD operations available on each model. Derived from the ORM's + * {@link AllModelOperations}, then trimmed by the model's slicing options. + * + * The mapped type below uses `T[K]` directly (no `infer A` / `infer R`), which + * preserves each method's per-call generics intact. + */ +export type ModelOperations< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = { + [K in keyof AllModelOperations as K extends GetSlicedOperations< + Schema, + Model, + Options + > + ? K + : never]: AllModelOperations[K]; +}; + +/** + * The full typed client containing per-model operations, optional procedure operations, + * and sequential transaction support. + */ +export type FetchClient< + Schema extends SchemaDef, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = { + [Model in GetSlicedModels as `${Uncapitalize}`]: ModelOperations< + Schema, + Model, + Options, + ExtQueryArgs, + ExtResult + >; +} & ProcedureOperations & { + /** + * Executes an array of operations atomically as a sequential transaction. + * + * Each operation is a typed `{ model, op, args }` object. The result tuple is typed + * per-position based on each operation's return type. + * + * @example + * ```typescript + * const [user, post] = await client.$transaction([ + * { model: 'User', op: 'create', args: { data: { email: 'alice@example.com' } } }, + * { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + * ]); + * ``` + */ + $transaction[]>( + operations: Ops, + ): Promise>; + }; + +function normalizeEndpoint(endpoint: string): string { + if (typeof endpoint !== 'string' || endpoint.length === 0) { + throw new Error('`endpoint` is required and must be a non-empty string'); + } + try { + new URL(endpoint); + } catch { + throw new Error(`\`endpoint\` must be a fully qualified URL, got: ${endpoint}`); + } + // strip trailing slash so we can safely concatenate `/model/op` + return endpoint.endsWith('/') ? endpoint.slice(0, -1) : endpoint; +} + +function makeGetRequest(endpoint: string, model: string, operation: string, args: unknown, customFetch?: FetchFn) { + return fetcher(makeUrl(endpoint, model, operation, args), undefined, customFetch); +} + +function makeWriteRequest( + endpoint: string, + model: string, + method: 'POST' | 'PUT' | 'DELETE', + operation: string, + args: unknown, + customFetch?: FetchFn, +) { + const url = method === 'DELETE' ? makeUrl(endpoint, model, operation, args) : makeUrl(endpoint, model, operation); + const fetchInit: RequestInit = { + method, + ...(method !== 'DELETE' && { + headers: { 'content-type': 'application/json' }, + body: marshal(args), + }), + }; + return fetcher(url, fetchInit, customFetch); +} + +function buildModelOperations>( + modelName: string, + endpoint: string, + customFetch?: FetchFn, +): ModelOperations { + const get = (op: string, args?: unknown) => makeGetRequest(endpoint, modelName, op, args, customFetch); + const write = (method: 'POST' | 'PUT' | 'DELETE', op: string, args?: unknown) => + makeWriteRequest(endpoint, modelName, method, op, args, customFetch); + + const findUnique = (args: any) => get('findUnique', args); + const findFirst = (args?: any) => get('findFirst', args); + const orThrow = async (op: 'findUnique' | 'findFirst', args: any) => { + const result = await (op === 'findUnique' ? findUnique(args) : findFirst(args)); + if (result == null) { + throw new CrudError(CrudErrorCode.NotFound, `No ${modelName} found`, modelName); + } + return result; + }; + + return { + findUnique, + findUniqueOrThrow: (args: any) => orThrow('findUnique', args), + findFirst, + findFirstOrThrow: (args?: any) => orThrow('findFirst', args), + findMany: (args?: any) => get('findMany', args), + exists: (args?: any) => get('exists', args), + count: (args?: any) => get('count', args), + aggregate: (args: any) => get('aggregate', args), + groupBy: (args: any) => get('groupBy', args), + create: (args: any) => write('POST', 'create', args), + createMany: (args: any) => write('POST', 'createMany', args), + createManyAndReturn: (args: any) => write('POST', 'createManyAndReturn', args), + update: (args: any) => write('PUT', 'update', args), + updateMany: (args: any) => write('PUT', 'updateMany', args), + updateManyAndReturn: (args: any) => write('PUT', 'updateManyAndReturn', args), + upsert: (args: any) => write('POST', 'upsert', args), + delete: (args: any) => write('DELETE', 'delete', args), + deleteMany: (args?: any) => write('DELETE', 'deleteMany', args), + } as ModelOperations; +} + +/** + * Creates a fetch-based client that consumes ZenStack's RPC-style auto CRUD API. + * + * Accepts either a raw `SchemaDef` or a `ClientContract` type (e.g. `typeof db`) as the + * generic parameter. When a `ClientContract` type is provided, computed fields from plugins + * are reflected in the result types. + * + * @example + * ```typescript + * import { schema } from '~/lib/schema'; + * const client = createClient(schema, { endpoint: 'https://example.com/api/model' }); + * + * const users = await client.user.findMany(); + * const post = await client.post.create({ data: { title: 'Hello' } }); + * + * const [user, newPost] = await client.$transaction([ + * { model: 'User', op: 'create', args: { data: { email: 'alice@example.com' } } }, + * { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + * ]); + * ``` + * + * @param schema The ZModel schema definition. + * @param options Client configuration options. + */ +export function createClient>( + schema: InferSchema, + options: FetchClientOptions, +): FetchClient< + InferSchema, + InferOptions>, + InferExtQueryArgs extends ExtQueryArgsBase ? InferExtQueryArgs : {}, + InferExtResult extends ExtResultBase> + ? InferExtResult + : {} +> { + const endpoint = normalizeEndpoint(options.endpoint); + const customFetch = options.fetch; + + const result = Object.values(schema.models).reduce((acc, modelDef) => { + (acc as any)[lowerCaseFirst(modelDef.name)] = buildModelOperations(modelDef.name, endpoint, customFetch); + return acc; + }, {} as any); + + const procedures = (schema as any).procedures as Record | undefined; + if (procedures) { + const procsObj: Record = {}; + for (const [name, procDef] of Object.entries(procedures)) { + if (procDef?.mutation) { + procsObj[name] = { + mutate: (input?: any) => + makeWriteRequest(endpoint, CUSTOM_PROC_ROUTE_NAME, 'POST', name, input, customFetch), + }; + } else { + procsObj[name] = { + query: (input?: any) => makeGetRequest(endpoint, CUSTOM_PROC_ROUTE_NAME, name, input, customFetch), + }; + } + } + result[CUSTOM_PROC_ROUTE_NAME] = procsObj; + } + + result.$transaction = (operations: readonly TransactionOperation[]) => { + const reqUrl = `${endpoint}/${TRANSACTION_ROUTE_PREFIX}/sequential`; + return fetcher( + reqUrl, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: marshal(operations), + }, + customFetch, + ); + }; + + return result as any; +} diff --git a/packages/clients/fetch-client/test/fetch-client.test.ts b/packages/clients/fetch-client/test/fetch-client.test.ts new file mode 100644 index 000000000..0d155c53a --- /dev/null +++ b/packages/clients/fetch-client/test/fetch-client.test.ts @@ -0,0 +1,604 @@ +import { serialize } from '@zenstackhq/client-helpers/fetch'; +import Decimal from 'decimal.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createClient, CrudError, CrudErrorCode } from '../src/index'; +import { schema } from './schemas/basic/schema-lite'; +import { schema as noProcSchema } from './schemas/no-procs/schema-lite'; + +const ENDPOINT = 'http://localhost/api/model'; + +function makeResponseText(data: unknown) { + return JSON.stringify({ data }); +} + +function makeSerializedResponseText(data: unknown) { + const { data: serializedData, meta } = serialize(data); + return JSON.stringify({ data: serializedData, meta: { serialization: meta } }); +} + +describe('createClient', () => { + let mockFetch: ReturnType; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + mockFetch = vi.fn(); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.resetAllMocks(); + }); + + describe('read operations use GET', () => { + it('findUnique - sends GET with args in query string', async () => { + const data = { id: '1', email: 'alice@example.com', name: 'Alice' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(data) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.findUnique({ where: { id: '1' } }); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/findUnique?q=`); + expect(init).toBeUndefined(); + expect(result).toEqual(data); + }); + + it('findFirst - sends GET', async () => { + const data = { id: '1', email: 'bob@example.com' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(data) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.findFirst({ where: { name: 'Bob' } }); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/findFirst?q=`); + }); + + it('findFirst - can be called with no args', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(null) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.findFirst(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/findFirst`); + }); + + it('findMany - sends GET', async () => { + const data = [ + { id: '1', email: 'a@test.com' }, + { id: '2', email: 'b@test.com' }, + ]; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(data) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.findMany(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/findMany`); + expect(result).toEqual(data); + }); + + it('exists - sends GET', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(true) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.exists({ where: { id: '1' } }); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/exists?q=`); + expect(result).toBe(true); + }); + + it('count - sends GET', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(42) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.count(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/count`); + expect(result).toBe(42); + }); + + it('aggregate - sends GET with args', async () => { + const aggResult = { _count: { id: 5 } }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(aggResult) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.aggregate({ _count: { id: true } }); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/aggregate?q=`); + expect(result).toEqual(aggResult); + }); + + it('groupBy - sends GET with args', async () => { + const groupResult = [{ name: 'Alice', _count: { id: 1 } }]; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(groupResult) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.groupBy({ by: ['name'], _count: { id: true } }); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/groupBy?q=`); + expect(result).toEqual(groupResult); + }); + }); + + describe('findUniqueOrThrow / findFirstOrThrow', () => { + it('findUniqueOrThrow returns the entity when found', async () => { + const data = { id: '1', email: 'alice@example.com' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(data) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.findUniqueOrThrow({ where: { id: '1' } }); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/findUnique?q=`); + expect(result).toEqual(data); + }); + + it('findUniqueOrThrow throws CrudError(NotFound) when not found', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(null) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect(client.user.findUniqueOrThrow({ where: { id: 'missing' } })).rejects.toMatchObject({ + name: 'CrudError', + code: CrudErrorCode.NotFound, + message: 'No User found', + model: 'User', + }); + }); + + it('findUniqueOrThrow rejects with a CrudError instance', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(null) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect(client.user.findUniqueOrThrow({ where: { id: 'missing' } })).rejects.toBeInstanceOf(CrudError); + }); + + it('findFirstOrThrow throws CrudError(NotFound) when not found', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(null) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect(client.user.findFirstOrThrow({ where: { name: 'Bob' } })).rejects.toMatchObject({ + name: 'CrudError', + code: CrudErrorCode.NotFound, + }); + }); + }); + + describe('write operations use correct HTTP methods', () => { + it('create - sends POST with body', async () => { + const created = { id: '1', email: 'new@example.com' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(created) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const args = { data: { email: 'new@example.com' } }; + const result = await client.user.create(args); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/create`); + expect(init.method).toBe('POST'); + expect(init.headers['content-type']).toBe('application/json'); + expect(JSON.parse(init.body)).toMatchObject({ data: { email: 'new@example.com' } }); + expect(result).toEqual(created); + }); + + it('createMany - sends POST', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText({ count: 2 }) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.createMany({ data: [{ email: 'a@test.com' }, { email: 'b@test.com' }] }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/createMany`); + expect(init.method).toBe('POST'); + }); + + it('createManyAndReturn - sends POST', async () => { + const created = [{ id: '1', email: 'a@test.com' }]; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(created) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.createManyAndReturn({ data: [{ email: 'a@test.com' }] }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/createManyAndReturn`); + expect(init.method).toBe('POST'); + }); + + it('update - sends PUT', async () => { + const updated = { id: '1', email: 'updated@example.com' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(updated) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.update({ where: { id: '1' }, data: { email: 'updated@example.com' } }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/update`); + expect(init.method).toBe('PUT'); + expect(init.headers['content-type']).toBe('application/json'); + }); + + it('updateMany - sends PUT', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText({ count: 3 }) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.updateMany({ where: {}, data: { name: 'Updated' } }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/updateMany`); + expect(init.method).toBe('PUT'); + }); + + it('updateManyAndReturn - sends PUT', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([]) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.updateManyAndReturn({ where: {}, data: { name: 'X' } }); + + const [, init] = mockFetch.mock.calls[0] ?? []; + expect(init.method).toBe('PUT'); + }); + + it('upsert - sends POST', async () => { + const upserted = { id: '1', email: 'u@test.com' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(upserted) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.upsert({ + where: { id: '1' }, + create: { email: 'u@test.com' }, + update: { name: 'U' }, + }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/upsert`); + expect(init.method).toBe('POST'); + }); + + it('delete - sends DELETE with args in query string', async () => { + const deleted = { id: '1', email: 'gone@example.com' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(deleted) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.delete({ where: { id: '1' } }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/delete?q=`); + expect(init.method).toBe('DELETE'); + expect(init.body).toBeUndefined(); + }); + + it('deleteMany - sends DELETE with args in query string', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText({ count: 5 }) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.deleteMany({ where: { name: null } }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/user/deleteMany?q=`); + expect(init.method).toBe('DELETE'); + }); + + it('deleteMany - can be called with no args', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText({ count: 0 }) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.deleteMany(); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/user/deleteMany`); + expect(init.method).toBe('DELETE'); + }); + }); + + describe('model name casing', () => { + it('lowercases the first letter of the model in the URL', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([]) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.post.findMany(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/post/findMany`); + }); + }); + + describe('endpoint validation', () => { + it('throws when endpoint is missing', () => { + expect(() => createClient(schema, {} as any)).toThrow(/required/); + }); + + it('throws when endpoint is empty string', () => { + expect(() => createClient(schema, { endpoint: '' })).toThrow(/required/); + }); + + it('throws when endpoint is not a fully qualified URL', () => { + expect(() => createClient(schema, { endpoint: '/api/model' })).toThrow(/fully qualified URL/); + expect(() => createClient(schema, { endpoint: 'not a url' })).toThrow(/fully qualified URL/); + }); + + it('accepts a fully qualified http(s) URL', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([]) }); + + const client = createClient(schema, { endpoint: 'https://example.com/api/model' }); + await client.user.findMany(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe('https://example.com/api/model/user/findMany'); + }); + + it('strips trailing slash from endpoint', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([]) }); + + const client = createClient(schema, { endpoint: 'http://localhost/api/model/' }); + await client.user.findMany(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe('http://localhost/api/model/user/findMany'); + }); + }); + + describe('custom fetch function', () => { + it('uses custom fetch instead of global fetch', async () => { + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + text: async () => makeResponseText({ id: '1', email: 'a@test.com' }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT, fetch: customFetch }); + await client.user.findUnique({ where: { id: '1' } }); + + expect(customFetch).toHaveBeenCalledOnce(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('throws QueryError with status and info on non-ok response', async () => { + const errorInfo = { message: 'Not found', code: 'NOT_FOUND' }; + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + text: async () => JSON.stringify({ error: errorInfo }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect(client.user.findUnique({ where: { id: 'missing' } })).rejects.toMatchObject({ + status: 404, + info: errorInfo, + }); + }); + + it('throws on 403 access denied', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + text: async () => + JSON.stringify({ + error: { message: 'Forbidden', rejectedByPolicy: true, rejectReason: 'access-denied' }, + }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect(client.user.findMany()).rejects.toThrow(); + }); + + it('returns undefined for cannot-read-back policy rejection', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + text: async () => + JSON.stringify({ error: { rejectedByPolicy: true, rejectReason: 'cannot-read-back' } }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await client.user.create({ data: { email: 'x@test.com' } }); + expect(result).toBeUndefined(); + }); + + it('throws on 500 server error', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + text: async () => JSON.stringify({ error: { message: 'Internal server error' } }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect(client.user.findMany()).rejects.toMatchObject({ status: 500 }); + }); + }); + + describe('SuperJSON serialization', () => { + it('deserializes Date values from response', async () => { + const date = new Date('2024-01-15T12:00:00Z'); + mockFetch.mockResolvedValue({ + ok: true, + text: async () => makeSerializedResponseText({ id: '1', createdAt: date }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = (await client.user.findUnique({ where: { id: '1' } })) as any; + + expect(result.createdAt).toBeInstanceOf(Date); + expect(result.createdAt.toISOString()).toBe(date.toISOString()); + }); + + it('deserializes Decimal values from response', async () => { + const price = new Decimal('123.456'); + mockFetch.mockResolvedValue({ + ok: true, + text: async () => makeSerializedResponseText({ id: '1', price }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = (await client.user.findUnique({ where: { id: '1' } })) as any; + + expect(result.price).toBeInstanceOf(Decimal); + expect(result.price.toString()).toBe('123.456'); + }); + + it('serializes args with special types into query string', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([]) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.findMany({ where: { id: '1' } }); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain('?q='); + }); + + it('marshals args with Decimal into POST body', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText({ count: 1 }) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.user.createMany({ data: [{ email: 'x@test.com' }] }); + + const [, init] = mockFetch.mock.calls[0] ?? []; + const body = JSON.parse(init.body); + expect(body).toMatchObject({ data: [{ email: 'x@test.com' }] }); + }); + }); + + describe('procedures', () => { + it('query procedure - sends GET request', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(42) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await (client as any).$procs.getStats.query(); + + const [url] = mockFetch.mock.calls[0] ?? []; + expect(url).toContain(`${ENDPOINT}/$procs/getStats`); + expect(result).toBe(42); + }); + + it('mutation procedure - sends POST request', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(true) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const result = await (client as any).$procs.sendNotification.mutate({ args: { message: 'hello' } }); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/$procs/sendNotification`); + expect(init.method).toBe('POST'); + expect(result).toBe(true); + }); + + it('query procedure has query property, mutation has mutate', async () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + const procs = (client as any).$procs; + expect(typeof procs.getStats.query).toBe('function'); + expect(procs.getStats.mutate).toBeUndefined(); + expect(typeof procs.sendNotification.mutate).toBe('function'); + expect(procs.sendNotification.query).toBeUndefined(); + }); + }); + + describe('$transaction', () => { + it('POSTs to /$transaction/sequential with the operations array', async () => { + const results = [ + { id: '1', email: 'alice@example.com' }, + { id: '2', title: 'Hello' }, + ]; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText(results) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const [user, post] = await client.$transaction([ + { model: 'User', op: 'create', args: { data: { email: 'alice@example.com' } } }, + { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + ]); + + const [url, init] = mockFetch.mock.calls[0] ?? []; + expect(url).toBe(`${ENDPOINT}/$transaction/sequential`); + expect(init.method).toBe('POST'); + expect(init.headers['content-type']).toBe('application/json'); + + const body = JSON.parse(init.body); + expect(body).toEqual([ + { model: 'User', op: 'create', args: { data: { email: 'alice@example.com' } } }, + { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + ]); + + expect(user).toEqual(results[0]); + expect(post).toEqual(results[1]); + }); + + it('preserves operation order in the result tuple', async () => { + const userResult = { id: '1', email: 'alice@example.com' }; + const postResult = { id: '2', title: 'Hello' }; + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([postResult, userResult]) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + const [post, user] = await client.$transaction([ + { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + { model: 'User', op: 'create', args: { data: { email: 'alice@example.com' } } }, + ]); + + expect(post).toEqual(postResult); + expect(user).toEqual(userResult); + }); + + it('includes all op types in the request body', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([{ count: 2 }, null]) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.$transaction([ + { model: 'User', op: 'updateMany', args: { where: {}, data: { name: 'X' } } }, + { model: 'Post', op: 'delete', args: { where: { id: '1' } } }, + ]); + + const body = JSON.parse((mockFetch.mock.calls[0] ?? [])[1].body); + expect(body[0]).toMatchObject({ model: 'User', op: 'updateMany' }); + expect(body[1]).toMatchObject({ model: 'Post', op: 'delete' }); + }); + + it('marshals args with SuperJSON when special types are present', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => makeResponseText([{ id: '1' }]) }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await client.$transaction([{ model: 'User', op: 'findMany', args: { where: { id: '1' } } }]); + + // Plain args – no meta expected + const body = JSON.parse((mockFetch.mock.calls[0] ?? [])[1].body); + expect(body[0].args).toEqual({ where: { id: '1' } }); + }); + + it('uses custom fetch in transaction', async () => { + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + text: async () => makeResponseText([{ id: '1', email: 'a@test.com' }]), + }); + + const client = createClient(schema, { endpoint: ENDPOINT, fetch: customFetch }); + await client.$transaction([{ model: 'User', op: 'findMany' }]); + + expect(customFetch).toHaveBeenCalledOnce(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('throws QueryError when server returns error', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + text: async () => JSON.stringify({ error: { message: 'Bad request' } }), + }); + + const client = createClient(schema, { endpoint: ENDPOINT }); + await expect( + client.$transaction([{ model: 'User', op: 'create', args: { data: { email: 'x@test.com' } } }]), + ).rejects.toMatchObject({ status: 400 }); + }); + }); + + describe('$procs absent when schema has no procedures', () => { + it('does not add $procs for schema without procedures', async () => { + const client = createClient(noProcSchema, { endpoint: ENDPOINT }); + expect((client as any).$procs).toBeUndefined(); + }); + }); +}); diff --git a/packages/clients/fetch-client/test/schemas/basic/schema-lite.ts b/packages/clients/fetch-client/test/schemas/basic/schema-lite.ts new file mode 100644 index 000000000..39cbdc765 --- /dev/null +++ b/packages/clients/fetch-client/test/schemas/basic/schema-lite.ts @@ -0,0 +1,96 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "sqlite" + } as const; + models = { + User: { + name: "User", + fields: { + id: { + name: "id", + type: "String", + id: true, + default: ExpressionUtils.call("cuid") as FieldDefault + }, + email: { + name: "email", + type: "String", + unique: true + }, + name: { + name: "name", + type: "String", + optional: true + }, + posts: { + name: "posts", + type: "Post", + array: true, + relation: { opposite: "author" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + email: { type: "String" } + } + }, + Post: { + name: "Post", + fields: { + id: { + name: "id", + type: "String", + id: true, + default: ExpressionUtils.call("cuid") as FieldDefault + }, + title: { + name: "title", + type: "String" + }, + author: { + name: "author", + type: "User", + optional: true, + relation: { opposite: "posts", fields: ["authorId"], references: ["id"] } + }, + authorId: { + name: "authorId", + type: "String", + optional: true, + foreignKeyFor: [ + "author" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + authType = "User" as const; + procedures = { + getStats: { + params: {}, + returnType: "Int" + }, + sendNotification: { + params: { + message: { name: "message", type: "String" } + }, + returnType: "Boolean", + mutation: true + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/packages/clients/fetch-client/test/schemas/basic/schema.zmodel b/packages/clients/fetch-client/test/schemas/basic/schema.zmodel new file mode 100644 index 000000000..677819fe7 --- /dev/null +++ b/packages/clients/fetch-client/test/schemas/basic/schema.zmodel @@ -0,0 +1,21 @@ +datasource db { + provider = 'sqlite' +} + +model User { + id String @id @default(cuid()) + email String @unique + name String? + posts Post[] +} + +model Post { + id String @id @default(cuid()) + title String + author User? @relation(fields: [authorId], references: [id]) + authorId String? +} + +procedure getStats(): Int + +mutation procedure sendNotification(message: String): Boolean diff --git a/packages/clients/fetch-client/test/schemas/no-procs/schema-lite.ts b/packages/clients/fetch-client/test/schemas/no-procs/schema-lite.ts new file mode 100644 index 000000000..6c42484a0 --- /dev/null +++ b/packages/clients/fetch-client/test/schemas/no-procs/schema-lite.ts @@ -0,0 +1,32 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "sqlite" + } as const; + models = { + Item: { + name: "Item", + fields: { + id: { + name: "id", + type: "String", + id: true, + default: ExpressionUtils.call("cuid") as FieldDefault + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/packages/clients/fetch-client/test/schemas/no-procs/schema.zmodel b/packages/clients/fetch-client/test/schemas/no-procs/schema.zmodel new file mode 100644 index 000000000..274423f47 --- /dev/null +++ b/packages/clients/fetch-client/test/schemas/no-procs/schema.zmodel @@ -0,0 +1,7 @@ +datasource db { + provider = 'sqlite' +} + +model Item { + id String @id @default(cuid()) +} diff --git a/packages/clients/fetch-client/test/typing.test-d.ts b/packages/clients/fetch-client/test/typing.test-d.ts new file mode 100644 index 000000000..5c143c8d3 --- /dev/null +++ b/packages/clients/fetch-client/test/typing.test-d.ts @@ -0,0 +1,219 @@ +import type { ClientContract, ClientOptions } from '@zenstackhq/orm'; +import { ZenStackClient } from '@zenstackhq/orm'; +import { describe, expectTypeOf, it } from 'vitest'; +import { createClient } from '../src/index'; +import { schema } from './schemas/basic/schema-lite'; + +const ENDPOINT = 'http://localhost/api/model'; + +describe('Result narrowing through AllModelOperations', () => { + it('full row shape with no select', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + expectTypeOf(client.user.findMany()).resolves.toEqualTypeOf< + Array<{ id: string; email: string; name: string | null }> + >(); + }); + + it('select narrows the result row', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + const promise = client.user.findMany({ select: { id: true } }); + expectTypeOf(promise).resolves.toEqualTypeOf>(); + }); + + it('findUniqueOrThrow returns non-null', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + expectTypeOf(client.user.findUniqueOrThrow({ where: { id: '1' } })).resolves.toEqualTypeOf<{ + id: string; + email: string; + name: string | null; + }>(); + }); +}); + +describe('Slicing', () => { + it('trims models', () => { + const _db = new ZenStackClient(schema, { + dialect: {} as any, + procedures: {} as any, + slicing: { includedModels: ['User'] }, + }); + const client = createClient(schema, { endpoint: ENDPOINT }); + + client.user.findMany(); + // @ts-expect-error – 'post' was sliced away + client.post; + }); + + it('trims operations', () => { + const _db = new ZenStackClient(schema, { + dialect: {} as any, + procedures: {} as any, + slicing: { + models: { + user: { includedOperations: ['findUnique', 'findMany'] }, + }, + }, + }); + const client = createClient(schema, { endpoint: ENDPOINT }); + + client.user.findUnique({ where: { id: '1' } }); + client.user.findMany(); + // @ts-expect-error – 'create' was sliced away + client.user.create({ data: { email: 'a@b.com' } }); + // @ts-expect-error – 'delete' was sliced away + client.user.delete({ where: { id: '1' } }); + }); + + it('trims filters', () => { + const _db = new ZenStackClient(schema, { + dialect: {} as any, + procedures: {} as any, + slicing: { + models: { + user: { + fields: { + $all: { includedFilterKinds: ['Equality'] }, + }, + }, + }, + }, + }); + const client = createClient(schema, { endpoint: ENDPOINT }); + + // Equality filter is allowed + client.user.findMany({ where: { name: { equals: 'test' } } }); + + // @ts-expect-error – `contains` is not allowed when only Equality is included + client.user.findMany({ where: { name: { contains: 'test' } } }); + }); + + it('respects slicing in transaction op union', () => { + const _db = new ZenStackClient(schema, { + dialect: {} as any, + procedures: {} as any, + slicing: { + models: { + user: { includedOperations: ['findUnique', 'findMany', 'count'] }, + }, + }, + }); + const client = createClient(schema, { endpoint: ENDPOINT }); + + void async function () { + // included read ops are allowed + await client.$transaction([ + { model: 'User', op: 'findMany' }, + { model: 'User', op: 'findUnique', args: { where: { id: '1' } } }, + { model: 'User', op: 'count' }, + ] as const); + + await client.$transaction([ + // @ts-expect-error 'create' was sliced away + { model: 'User', op: 'create', args: { data: { email: 'a@b.com' } } }, + ] as const); + + await client.$transaction([ + // @ts-expect-error 'delete' was sliced away + { model: 'User', op: 'delete', args: { where: { id: '1' } } }, + ] as const); + }; + }); +}); + +describe('Extended query args (ExtQueryArgs)', () => { + type DbType = ClientContract< + typeof schema, + ClientOptions, + // $read adds a `cache` filter to all read ops; $create adds a `bust` flag to creates + { + $read: { cache?: { ttl?: number } }; + $create: { cache?: { bust?: boolean } }; + } + >; + + it('flows through read ops', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + + client.user.findMany({ cache: { ttl: 1000 } }); + client.user.findUnique({ where: { id: '1' }, cache: { ttl: 1000 } }); + client.user.count({ cache: { ttl: 1000 } }); + + // @ts-expect-error – $read's cache shape doesn't accept `bust` + client.user.findMany({ cache: { bust: true } }); + }); + + it('flows through create', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + + client.user.create({ data: { email: 'a@b.com' }, cache: { bust: true } }); + + // @ts-expect-error – $create's cache shape doesn't accept `ttl` + client.user.create({ data: { email: 'a@b.com' }, cache: { ttl: 1000 } }); + }); + + it('flows through transaction args', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + + void async function () { + await client.$transaction([ + { model: 'User', op: 'findMany', args: { cache: { ttl: 500 } } }, + { model: 'User', op: 'create', args: { data: { email: 'a@b.com' }, cache: { bust: true } } }, + ] as const); + + await client.$transaction([ + // @ts-expect-error – $create has no `ttl` + { model: 'User', op: 'create', args: { data: { email: 'a' }, cache: { ttl: 1 } } }, + ] as const); + }; + }); +}); + +describe('Extended result fields (ExtResult)', () => { + type DbType = ClientContract< + typeof schema, + ClientOptions, + {}, + {}, + // User gains a computed `displayName` field + { + user: { + displayName: { + needs: { email: true }; + compute: (data: { email: string }) => string; + }; + }; + } + >; + + it('adds the computed field to read results', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + + expectTypeOf(client.user.findUnique({ where: { id: '1' } })).resolves.toMatchTypeOf< + { displayName: string } | null + >(); + + expectTypeOf(client.user.findMany()).resolves.toMatchTypeOf>(); + }); + + it('adds the computed field to mutation results', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + + expectTypeOf(client.user.create({ data: { email: 'a@b.com' } })).resolves.toMatchTypeOf<{ + displayName: string; + }>(); + }); + + it('flows through transaction return positions', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + + void async function () { + const r = await client.$transaction([ + { model: 'User', op: 'findMany' }, + { model: 'User', op: 'create', args: { data: { email: 'a@b.com' } } }, + ] as const); + + expectTypeOf(r[0]).toMatchTypeOf>(); + expectTypeOf(r[1]).toMatchTypeOf<{ displayName: string }>(); + }; + }); +}); diff --git a/packages/clients/fetch-client/tsconfig.json b/packages/clients/fetch-client/tsconfig.json new file mode 100644 index 000000000..a129f8b7f --- /dev/null +++ b/packages/clients/fetch-client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@zenstackhq/typescript-config/base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/clients/fetch-client/tsconfig.test.json b/packages/clients/fetch-client/tsconfig.test.json new file mode 100644 index 000000000..e88e9202a --- /dev/null +++ b/packages/clients/fetch-client/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "@zenstackhq/typescript-config/base.json", + "include": ["src/**/*.ts", "test/**/*.ts"], + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "types": ["node"] + } +} diff --git a/packages/clients/fetch-client/tsdown.config.ts b/packages/clients/fetch-client/tsdown.config.ts new file mode 100644 index 000000000..c13a48310 --- /dev/null +++ b/packages/clients/fetch-client/tsdown.config.ts @@ -0,0 +1,8 @@ +import { createConfig } from '@zenstackhq/tsdown-config'; + +export default createConfig({ + entry: { + index: 'src/index.ts', + }, + format: ['esm'], +}); diff --git a/packages/clients/fetch-client/vitest.config.ts b/packages/clients/fetch-client/vitest.config.ts new file mode 100644 index 000000000..6a5eba157 --- /dev/null +++ b/packages/clients/fetch-client/vitest.config.ts @@ -0,0 +1,14 @@ +import base from '@zenstackhq/vitest-config/base'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig( + base, + defineConfig({ + test: { + typecheck: { + enabled: true, + tsconfig: 'tsconfig.test.json', + }, + }, + }), +); diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index eaddd4aa4..8621537ca 100644 --- a/packages/clients/tanstack-query/package.json +++ b/packages/clients/tanstack-query/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack TanStack Query Integration", "description": "TanStack Query Client for consuming ZenStack v3's CRUD service", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", @@ -57,6 +57,7 @@ "@zenstackhq/client-helpers": "workspace:*", "@zenstackhq/common-helpers": "workspace:*", "@zenstackhq/schema": "workspace:*", + "@zenstackhq/orm": "workspace:*", "decimal.js": "catalog:" }, "devDependencies": { @@ -71,7 +72,6 @@ "@zenstackhq/cli": "workspace:*", "@zenstackhq/eslint-config": "workspace:*", "@zenstackhq/language": "workspace:*", - "@zenstackhq/orm": "workspace:*", "@zenstackhq/sdk": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", "@zenstackhq/vitest-config": "workspace:*", diff --git a/packages/clients/tanstack-query/src/common/client.ts b/packages/clients/tanstack-query/src/common/client.ts index 9914ea736..d28b454df 100644 --- a/packages/clients/tanstack-query/src/common/client.ts +++ b/packages/clients/tanstack-query/src/common/client.ts @@ -2,6 +2,11 @@ import type { QueryClient } from '@tanstack/query-core'; import type { InvalidationPredicate, QueryInfo } from '@zenstackhq/client-helpers'; import { parseQueryKey } from './query-key.js'; +/** Strips a trailing slash from an endpoint URL. */ +export function normalizeEndpoint(endpoint: string) { + return endpoint.replace(/\/$/, ''); +} + export function invalidateQueriesMatchingPredicate(queryClient: QueryClient, predicate: InvalidationPredicate) { return queryClient.invalidateQueries({ predicate: ({ queryKey }) => { diff --git a/packages/clients/tanstack-query/src/common/constants.ts b/packages/clients/tanstack-query/src/common/constants.ts index 15684479d..79bc335ac 100644 --- a/packages/clients/tanstack-query/src/common/constants.ts +++ b/packages/clients/tanstack-query/src/common/constants.ts @@ -1 +1,6 @@ -export const CUSTOM_PROC_ROUTE_NAME = '$procs'; +export { CUSTOM_PROC_ROUTE_NAME, TRANSACTION_ROUTE_PREFIX } from '@zenstackhq/client-helpers'; + +/** + * The default query endpoint. + */ +export const DEFAULT_QUERY_ENDPOINT = '/api/model'; diff --git a/packages/clients/tanstack-query/src/common/transaction.ts b/packages/clients/tanstack-query/src/common/transaction.ts new file mode 100644 index 000000000..396c5b3d4 --- /dev/null +++ b/packages/clients/tanstack-query/src/common/transaction.ts @@ -0,0 +1,54 @@ +import type { Logger } from '@zenstackhq/client-helpers'; +import { createInvalidator, TRANSACTION_ROUTE_PREFIX, type InvalidateFunc } from '@zenstackhq/client-helpers'; +import { fetcher, marshal, type FetchFn } from '@zenstackhq/client-helpers/fetch'; +import type { TransactionOperation } from '@zenstackhq/client-helpers'; +import { CoreReadOperations } from '@zenstackhq/orm'; +import type { SchemaDef } from '@zenstackhq/schema'; + +/** + * Builds the mutation function for a sequential transaction request. + */ +export function makeTransactionMutationFn(endpoint: string, fetch: FetchFn | undefined) { + return (operations: TransactionOperation[]) => { + const reqUrl = `${endpoint}/${TRANSACTION_ROUTE_PREFIX}/sequential`; + const fetchInit = { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: marshal(operations), + }; + return fetcher(reqUrl, fetchInit, fetch); + }; +} + +/** + * Builds the `onSuccess` handler for a sequential transaction mutation that invalidates + * all queries affected by the operations in the transaction. + * + * @param schema The schema definition. + * @param invalidateFunc Function that invalidates queries matching a predicate. + * @param logging Logging option. + * @param origOnSuccess The user-provided `onSuccess` callback to call after invalidation. + */ +export function makeTransactionOnSuccess( + schema: SchemaDef, + invalidateFunc: InvalidateFunc, + logging: Logger | undefined, + origOnSuccess: ((...args: any[]) => any) | undefined, +) { + return async (...args: any[]) => { + const variables = Array.isArray(args[1]) ? args[1] : []; + for (const op of variables) { + if (typeof op?.model !== 'string' || typeof op?.op !== 'string') { + continue; + } + // read-only ops don't mutate state, so they don't trigger invalidation + if (CoreReadOperations.includes(op.op)) { + continue; + } + const invalidator = createInvalidator(op.model, op.op, schema, invalidateFunc, logging); + // pass op.args as mutation variables so the invalidator can analyze nested writes + await invalidator(args[0], op.args, args[2]); + } + await origOnSuccess?.(...args); + }; +} diff --git a/packages/clients/tanstack-query/src/common/types.ts b/packages/clients/tanstack-query/src/common/types.ts index a967869ed..ffaad961f 100644 --- a/packages/clients/tanstack-query/src/common/types.ts +++ b/packages/clients/tanstack-query/src/common/types.ts @@ -10,6 +10,8 @@ import type { } from '@zenstackhq/orm'; import type { GetModels, SchemaDef } from '@zenstackhq/schema'; +export type { TransactionOperation, TransactionResults } from '@zenstackhq/client-helpers'; + /** * Context type for configuring the hooks. */ diff --git a/packages/clients/tanstack-query/src/react.ts b/packages/clients/tanstack-query/src/react.ts index a62421de1..d7bbb6c6c 100644 --- a/packages/clients/tanstack-query/src/react.ts +++ b/packages/clients/tanstack-query/src/react.ts @@ -19,7 +19,14 @@ import { type UseSuspenseQueryOptions, type UseSuspenseQueryResult, } from '@tanstack/react-query'; -import { createInvalidator, createOptimisticUpdater, DEFAULT_QUERY_ENDPOINT, type InferExtResult, type InferOptions, type InferSchema } from '@zenstackhq/client-helpers'; +import { + createInvalidator, + createOptimisticUpdater, + type InferExtQueryArgs, + type InferExtResult, + type InferOptions, + type InferSchema, +} from '@zenstackhq/client-helpers'; import { fetcher, makeUrl, marshal } from '@zenstackhq/client-helpers/fetch'; import { lowerCaseFirst } from '@zenstackhq/common-helpers'; import type { @@ -35,6 +42,7 @@ import type { DeleteArgs, DeleteManyArgs, ExistsArgs, + ExtQueryArgsBase, ExtResultBase, FindFirstArgs, FindManyArgs, @@ -58,19 +66,23 @@ import type { } from '@zenstackhq/orm'; import type { GetModels, SchemaDef } from '@zenstackhq/schema'; import { createContext, useContext } from 'react'; -import { getAllQueries, invalidateQueriesMatchingPredicate } from './common/client.js'; -import { CUSTOM_PROC_ROUTE_NAME } from './common/constants.js'; +import { getAllQueries, invalidateQueriesMatchingPredicate, normalizeEndpoint } from './common/client.js'; +import { CUSTOM_PROC_ROUTE_NAME, DEFAULT_QUERY_ENDPOINT } from './common/constants.js'; import { getQueryKey } from './common/query-key.js'; +import { makeTransactionMutationFn, makeTransactionOnSuccess } from './common/transaction.js'; import type { ExtraMutationOptions, ExtraQueryOptions, ProcedureReturn, QueryContext, + TransactionOperation, + TransactionResults, TrimSlicedOperations, WithOptimistic, } from './common/types.js'; -export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; +export { AnyNull, DbNull, JsonNull } from '@zenstackhq/client-helpers'; export type { InferExtResult, InferOptions, InferSchema } from '@zenstackhq/client-helpers'; +export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; export type { SchemaDef } from '@zenstackhq/schema'; type ProcedureHookFn< @@ -147,20 +159,81 @@ export type ModelMutationModelResult< Array extends boolean = false, Options extends QueryOptions = QueryOptions, ExtResult extends ExtResultBase = {}, -> = Omit, TArgs>, 'mutateAsync'> & { +> = Omit< + ModelMutationResult, TArgs>, + 'mutateAsync' +> & { mutateAsync( args: T, options?: ModelMutationOptions, T>, ): Promise>; }; +/** + * Options accepted by `$transaction.useSequential()`. + */ +export type TransactionMutationOptions< + Schema extends SchemaDef, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = Omit< + UseMutationOptions[]>, + 'mutationFn' +> & + Omit; + +/** + * The return type of `$transaction.useSequential()`. Overrides `mutateAsync` so the + * resolved value is a tuple of per-operation results, narrowed to each operation's + * model + op + args (mirrors the typing of the corresponding ORM CRUD method). + */ +export type TransactionMutationResult< + Schema extends SchemaDef, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = Omit< + UseMutationResult[]>, + 'mutateAsync' +> & { + mutateAsync[]>( + operations: T, + options?: Omit< + UseMutationOptions, DefaultError, T>, + 'mutationFn' + >, + ): Promise>; +}; + +/** + * The full set of TanStack Query hooks returned by {@link useClientQueries}. Includes: + * + * - One entry per (sliced) model under its uncapitalized name, providing the per-model + * {@link ModelQueryHooks} (e.g. `client.user.useFindMany`, `client.user.useCreate`). + * - A `$procs` namespace with hooks for any custom procedures declared in the schema. + * - A `$transaction.useSequential` hook for executing a sequential transaction. + */ export type ClientHooks< Schema extends SchemaDef, Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, ExtResult extends ExtResultBase = {}, > = { - [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks; -} & ProcedureHooks; + [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks< + Schema, + Model, + Options, + ExtQueryArgs, + ExtResult + >; +} & ProcedureHooks & { + $transaction: { + useSequential( + options?: TransactionMutationOptions, + ): TransactionMutationResult; + }; + }; type ProcedureHookGroup> = { [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } @@ -222,120 +295,137 @@ export type ModelQueryHooks< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, ExtResult extends ExtResultBase = {}, > = TrimSlicedOperations< Schema, Model, Options, { - useFindUnique>( - args: SelectSubset>, + useFindUnique>( + args: SelectSubset>, options?: ModelQueryOptions | null>, ): ModelQueryResult | null>; - useSuspenseFindUnique>( - args: SelectSubset>, + useSuspenseFindUnique>( + args: SelectSubset>, options?: ModelSuspenseQueryOptions | null>, ): ModelSuspenseQueryResult | null>; - useFindFirst>( - args?: SelectSubset>, + useFindFirst>( + args?: SelectSubset>, options?: ModelQueryOptions | null>, ): ModelQueryResult | null>; - useSuspenseFindFirst>( - args?: SelectSubset>, + useSuspenseFindFirst>( + args?: SelectSubset>, options?: ModelSuspenseQueryOptions | null>, ): ModelSuspenseQueryResult | null>; - useExists>( - args?: Subset>, + useExists>( + args?: Subset>, options?: ModelQueryOptions, ): ModelQueryResult; - useFindMany>( - args?: SelectSubset>, + useFindMany>( + args?: SelectSubset>, options?: ModelQueryOptions[]>, ): ModelQueryResult[]>; - useSuspenseFindMany>( - args?: SelectSubset>, + useSuspenseFindMany>( + args?: SelectSubset>, options?: ModelSuspenseQueryOptions[]>, ): ModelSuspenseQueryResult[]>; - useInfiniteFindMany, TPageParam = unknown>( - args?: SelectSubset>, - options?: ModelInfiniteQueryOptions[], TPageParam>, - ): ModelInfiniteQueryResult[], TPageParam>>; - - useSuspenseInfiniteFindMany, TPageParam = unknown>( - args?: SelectSubset>, - options?: ModelSuspenseInfiniteQueryOptions[], TPageParam>, - ): ModelSuspenseInfiniteQueryResult[], TPageParam>>; - - useCreate>( + useInfiniteFindMany< + T extends FindManyArgs, + TPageParam = unknown, + >( + args?: SelectSubset>, + options?: ModelInfiniteQueryOptions< + SimplifiedPlainResult[], + TPageParam + >, + ): ModelInfiniteQueryResult< + InfiniteData[], TPageParam> + >; + + useSuspenseInfiniteFindMany< + T extends FindManyArgs, + TPageParam = unknown, + >( + args?: SelectSubset>, + options?: ModelSuspenseInfiniteQueryOptions< + SimplifiedPlainResult[], + TPageParam + >, + ): ModelSuspenseInfiniteQueryResult< + InfiniteData[], TPageParam> + >; + + useCreate>( options?: ModelMutationOptions, T>, ): ModelMutationModelResult; - useCreateMany>( + useCreateMany>( options?: ModelMutationOptions, ): ModelMutationResult; - useCreateManyAndReturn>( + useCreateManyAndReturn>( options?: ModelMutationOptions[], T>, ): ModelMutationModelResult; - useUpdate>( + useUpdate>( options?: ModelMutationOptions, T>, ): ModelMutationModelResult; - useUpdateMany>( + useUpdateMany>( options?: ModelMutationOptions, ): ModelMutationResult; - useUpdateManyAndReturn>( + useUpdateManyAndReturn>( options?: ModelMutationOptions[], T>, ): ModelMutationModelResult; - useUpsert>( + useUpsert>( options?: ModelMutationOptions, T>, ): ModelMutationModelResult; - useDelete>( + useDelete>( options?: ModelMutationOptions, T>, ): ModelMutationModelResult; - useDeleteMany>( + useDeleteMany>( options?: ModelMutationOptions, ): ModelMutationResult; - useCount>( - args?: Subset>, + useCount>( + args?: Subset>, options?: ModelQueryOptions>, ): ModelQueryResult>; - useSuspenseCount>( - args?: Subset>, + useSuspenseCount>( + args?: Subset>, options?: ModelSuspenseQueryOptions>, ): ModelSuspenseQueryResult>; - useAggregate>( - args: Subset>, + useAggregate>( + args: Subset>, options?: ModelQueryOptions>, ): ModelQueryResult>; - useSuspenseAggregate>( - args: Subset>, + useSuspenseAggregate>( + args: Subset>, options?: ModelSuspenseQueryOptions>, ): ModelSuspenseQueryResult>; - useGroupBy>( - args: Subset>, + useGroupBy>( + args: Subset>, options?: ModelQueryOptions>, ): ModelQueryResult>; - useSuspenseGroupBy>( - args: Subset>, + useSuspenseGroupBy>( + args: Subset>, options?: ModelSuspenseQueryOptions>, ): ModelSuspenseQueryResult>; } @@ -360,23 +450,21 @@ export type ModelQueryHooks< * @param schema The schema. * @param options Options for all queries originated from this hook. */ -export function useClientQueries< - SchemaOrClient extends SchemaDef | ClientContract, ->( +export function useClientQueries>( schema: InferSchema, options?: QueryContext, -): ClientHooks, InferOptions>, InferExtResult extends ExtResultBase> ? InferExtResult : {}> { - const result = Object.keys(schema.models).reduce( - (acc, model) => { - (acc as any)[lowerCaseFirst(model)] = useModelQueries( - schema as any, - model as any, - options, - ); - return acc; - }, - {} as any, - ); +): ClientHooks< + InferSchema, + InferOptions>, + InferExtQueryArgs extends ExtQueryArgsBase ? InferExtQueryArgs : {}, + InferExtResult extends ExtResultBase> + ? InferExtResult + : {} +> { + const result = Object.keys(schema.models).reduce((acc, model) => { + (acc as any)[lowerCaseFirst(model)] = useModelQueries(schema as any, model as any, options); + return acc; + }, {} as any); const procedures = (schema as any).procedures as Record | undefined; if (procedures) { @@ -422,6 +510,10 @@ export function useClientQueries< (result as any).$procs = buildProcedureHooks(); } + (result as any).$transaction = { + useSequential: (hookOptions?: any) => useInternalTransactionMutation(schema, { ...options, ...hookOptions }), + }; + return result; } @@ -432,8 +524,13 @@ export function useModelQueries< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, ExtResult extends ExtResultBase = {}, ->(schema: Schema, model: Model, rootOptions?: QueryContext): ModelQueryHooks { +>( + schema: Schema, + model: Model, + rootOptions?: QueryContext, +): ModelQueryHooks { const modelDef = Object.values(schema.models).find((m) => m.name.toLowerCase() === model.toLowerCase()); if (!modelDef) { throw new Error(`Model "${model}" not found in schema`); @@ -543,7 +640,7 @@ export function useModelQueries< useSuspenseGroupBy: (args: any, options?: any) => { return useInternalSuspenseQuery(schema, modelName, 'groupBy', args, { ...rootOptions, ...options }); }, - } as ModelQueryHooks; + } as ModelQueryHooks; } export function useInternalQuery( @@ -599,7 +696,13 @@ export function useInternalInfiniteQuery, QueryKey, TPageParam>, + UseInfiniteQueryOptions< + TQueryFnData, + DefaultError, + InfiniteData, + QueryKey, + TPageParam + >, 'queryKey' | 'initialPageParam' > & QueryContext) @@ -627,7 +730,14 @@ export function useInternalSuspenseInfiniteQuery, QueryKey, TPageParam> & QueryContext, + UseSuspenseInfiniteQueryOptions< + TQueryFnData, + DefaultError, + InfiniteData, + QueryKey, + TPageParam + > & + QueryContext, 'queryKey' | 'initialPageParam' >, ) { @@ -750,11 +860,45 @@ export function useInternalMutation( return useMutation(finalOptions); } +export function useInternalTransactionMutation< + Schema extends SchemaDef, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +>( + schema: Schema, + options?: TransactionMutationOptions, +): TransactionMutationResult { + const { endpoint, fetch, logging } = useFetchOptions(options); + const queryClient = useQueryClient(); + + const mutationFn = makeTransactionMutationFn(endpoint, fetch); + + const finalOptions = { ...options, mutationFn }; + + if (options?.invalidateQueries !== false) { + const origOnSuccess = finalOptions.onSuccess; + finalOptions.onSuccess = makeTransactionOnSuccess( + schema, + (predicate) => invalidateQueriesMatchingPredicate(queryClient, predicate), + logging, + origOnSuccess as any, + ); + } + + return useMutation(finalOptions as any) as unknown as TransactionMutationResult< + Schema, + Options, + ExtQueryArgs, + ExtResult + >; +} + function useFetchOptions(options: QueryContext | undefined) { const { endpoint, fetch, logging } = useHooksContext(); // options take precedence over context return { - endpoint: options?.endpoint ?? endpoint, + endpoint: normalizeEndpoint(options?.endpoint ?? endpoint), fetch: options?.fetch ?? fetch, logging: options?.logging ?? logging, }; diff --git a/packages/clients/tanstack-query/src/svelte/index.svelte.ts b/packages/clients/tanstack-query/src/svelte/index.svelte.ts index 639cf2969..fbfa2ad04 100644 --- a/packages/clients/tanstack-query/src/svelte/index.svelte.ts +++ b/packages/clients/tanstack-query/src/svelte/index.svelte.ts @@ -18,7 +18,7 @@ import { import { createInvalidator, createOptimisticUpdater, - DEFAULT_QUERY_ENDPOINT, + type InferExtQueryArgs, type InferExtResult, type InferOptions, type InferSchema, @@ -39,6 +39,7 @@ import type { DeleteArgs, DeleteManyArgs, ExistsArgs, + ExtQueryArgsBase, ExtResultBase, FindFirstArgs, FindManyArgs, @@ -62,19 +63,23 @@ import type { } from '@zenstackhq/orm'; import type { GetModels, SchemaDef } from '@zenstackhq/schema'; import { getContext, setContext } from 'svelte'; -import { getAllQueries, invalidateQueriesMatchingPredicate } from '../common/client.js'; -import { CUSTOM_PROC_ROUTE_NAME } from '../common/constants.js'; +import { getAllQueries, invalidateQueriesMatchingPredicate, normalizeEndpoint } from '../common/client.js'; +import { CUSTOM_PROC_ROUTE_NAME, DEFAULT_QUERY_ENDPOINT } from '../common/constants.js'; import { getQueryKey } from '../common/query-key.js'; +import { makeTransactionMutationFn, makeTransactionOnSuccess } from '../common/transaction.js'; import type { ExtraMutationOptions, ExtraQueryOptions, ProcedureReturn, QueryContext, + TransactionOperation, + TransactionResults, TrimSlicedOperations, WithOptimistic, } from '../common/types.js'; +export { AnyNull, DbNull, JsonNull } from '@zenstackhq/client-helpers'; +export type { InferExtQueryArgs, InferExtResult, InferOptions, InferSchema } from '@zenstackhq/client-helpers'; export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; -export type { InferExtResult, InferOptions, InferSchema } from '@zenstackhq/client-helpers'; export type { SchemaDef } from '@zenstackhq/schema'; type ProcedureHookFn< @@ -157,18 +162,66 @@ export type ModelMutationModelResult< ): Promise>; }; +/** + * Options accepted by `$transaction.useSequential()`. + */ +export type TransactionMutationOptions< + Schema extends SchemaDef, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = Omit< + CreateMutationOptions[]>, + 'mutationFn' +> & + Omit; + +/** + * The return type of `$transaction.useSequential()`. Overrides `mutateAsync` so the + * resolved value is a tuple of per-operation results, narrowed to each operation's + * model + op + args (mirrors the typing of the corresponding ORM CRUD method). + */ +export type TransactionMutationResult< + Schema extends SchemaDef, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = Omit< + CreateMutationResult[]>, + 'mutateAsync' +> & { + mutateAsync[]>( + operations: T, + options?: Omit< + CreateMutationOptions, DefaultError, T>, + 'mutationFn' + >, + ): Promise>; +}; + +/** + * The full set of TanStack Query hooks returned by {@link useClientQueries}. + */ export type ClientHooks< Schema extends SchemaDef, Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, ExtResult extends ExtResultBase = {}, > = { [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks< Schema, Model, Options, + ExtQueryArgs, ExtResult >; -} & ProcedureHooks; +} & ProcedureHooks & { + $transaction: { + useSequential( + options?: TransactionMutationOptions, + ): TransactionMutationResult; + }; + }; type ProcedureHookGroup> = { [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } @@ -220,34 +273,38 @@ export type ModelQueryHooks< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, ExtResult extends ExtResultBase = {}, > = TrimSlicedOperations< Schema, Model, Options, { - useFindUnique>( - args: Accessor>>, + useFindUnique>( + args: Accessor>>, options?: Accessor | null>>, ): ModelQueryResult | null>; - useFindFirst>( - args?: Accessor>>, + useFindFirst>( + args?: Accessor>>, options?: Accessor | null>>, ): ModelQueryResult | null>; - useExists>( - args?: Accessor>>, + useExists>( + args?: Accessor>>, options?: Accessor>, ): ModelQueryResult; - useFindMany>( - args?: Accessor>>, + useFindMany>( + args?: Accessor>>, options?: Accessor[]>>, ): ModelQueryResult[]>; - useInfiniteFindMany, TPageParam = unknown>( - args?: Accessor>>, + useInfiniteFindMany< + T extends FindManyArgs, + TPageParam = unknown, + >( + args?: Accessor>>, options?: Accessor< ModelInfiniteQueryOptions[], TPageParam> >, @@ -255,52 +312,52 @@ export type ModelQueryHooks< InfiniteData[], TPageParam> >; - useCreate>( + useCreate>( options?: Accessor, T>>, ): ModelMutationModelResult; - useCreateMany>( + useCreateMany>( options?: Accessor>, ): ModelMutationResult; - useCreateManyAndReturn>( + useCreateManyAndReturn>( options?: Accessor[], T>>, ): ModelMutationModelResult; - useUpdate>( + useUpdate>( options?: Accessor, T>>, ): ModelMutationModelResult; - useUpdateMany>( + useUpdateMany>( options?: Accessor>, ): ModelMutationResult; - useUpdateManyAndReturn>( + useUpdateManyAndReturn>( options?: Accessor[], T>>, ): ModelMutationModelResult; - useUpsert>( + useUpsert>( options?: Accessor, T>>, ): ModelMutationModelResult; - useDelete>( + useDelete>( options?: Accessor, T>>, ): ModelMutationModelResult; - useDeleteMany>( + useDeleteMany>( options?: Accessor>, ): ModelMutationResult; - useCount>( - args?: Accessor>>, + useCount>( + args?: Accessor>>, options?: Accessor>>, ): ModelQueryResult>; - useAggregate>( - args: Accessor>>, + useAggregate>( + args: Accessor>>, options?: Accessor>>, ): ModelQueryResult>; - useGroupBy>( - args: Accessor>>, + useGroupBy>( + args: Accessor>>, options?: Accessor>>, ): ModelQueryResult>; } @@ -328,6 +385,7 @@ export function useClientQueries, InferOptions>, + InferExtQueryArgs extends ExtQueryArgsBase ? InferExtQueryArgs : {}, InferExtResult extends ExtResultBase> ? InferExtResult : {} @@ -374,6 +432,10 @@ export function useClientQueries useInternalTransactionMutation(schema, merge(options, hookOptions)), + }; + return result; } @@ -384,12 +446,13 @@ export function useModelQueries< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, ExtResult extends ExtResultBase = {}, >( schema: Schema, model: Model, rootOptions?: Accessor, -): ModelQueryHooks { +): ModelQueryHooks { const modelDef = Object.values(schema.models).find((m) => m.name.toLowerCase() === model.toLowerCase()); if (!modelDef) { throw new Error(`Model "${model}" not found in schema`); @@ -465,7 +528,7 @@ export function useModelQueries< useGroupBy: (args: any, options?: any) => { return useInternalQuery(schema, modelName, 'groupBy', args, options); }, - } as unknown as ModelQueryHooks; + } as unknown as ModelQueryHooks; } export function useInternalQuery( @@ -690,12 +753,52 @@ export function useInternalMutation( return createMutation(finalOptions); } +export function useInternalTransactionMutation< + Schema extends SchemaDef, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +>( + schema: Schema, + options?: Accessor>, +): TransactionMutationResult { + const { endpoint, fetch, logging } = useFetchOptions(options); + const queryClient = useQueryClient(); + + const mutationFn = makeTransactionMutationFn(endpoint, fetch); + + const finalOptions = () => { + const optionsValue = options?.(); + const result: any = { ...optionsValue, mutationFn }; + + if (optionsValue?.invalidateQueries !== false) { + result.onSuccess = makeTransactionOnSuccess( + schema, + (predicate: InvalidationPredicate) => + // @ts-ignore + invalidateQueriesMatchingPredicate(queryClient, predicate), + logging, + optionsValue?.onSuccess as any, + ); + } + + return result; + }; + + return createMutation(finalOptions) as unknown as TransactionMutationResult< + Schema, + Options, + ExtQueryArgs, + ExtResult + >; +} + function useFetchOptions(options: Accessor | undefined) { const { endpoint, fetch, logging } = useQuerySettings(); const optionsValue = options?.(); // options take precedence over context return { - endpoint: optionsValue?.endpoint ?? endpoint, + endpoint: normalizeEndpoint(optionsValue?.endpoint ?? endpoint), fetch: optionsValue?.fetch ?? fetch, logging: optionsValue?.logging ?? logging, }; diff --git a/packages/clients/tanstack-query/src/vue.ts b/packages/clients/tanstack-query/src/vue.ts index 32ab6e656..687a43c4d 100644 --- a/packages/clients/tanstack-query/src/vue.ts +++ b/packages/clients/tanstack-query/src/vue.ts @@ -16,7 +16,7 @@ import { import { createInvalidator, createOptimisticUpdater, - DEFAULT_QUERY_ENDPOINT, + type InferExtQueryArgs, type InferExtResult, type InferOptions, type InferSchema, @@ -37,6 +37,7 @@ import type { DeleteArgs, DeleteManyArgs, ExistsArgs, + ExtQueryArgsBase, ExtResultBase, FindFirstArgs, FindManyArgs, @@ -60,19 +61,23 @@ import type { } from '@zenstackhq/orm'; import type { GetModels, SchemaDef } from '@zenstackhq/schema'; import { computed, inject, provide, toValue, unref, type MaybeRefOrGetter, type Ref, type UnwrapRef } from 'vue'; -import { getAllQueries, invalidateQueriesMatchingPredicate } from './common/client.js'; -import { CUSTOM_PROC_ROUTE_NAME } from './common/constants.js'; +import { getAllQueries, invalidateQueriesMatchingPredicate, normalizeEndpoint } from './common/client.js'; +import { CUSTOM_PROC_ROUTE_NAME, DEFAULT_QUERY_ENDPOINT } from './common/constants.js'; import { getQueryKey } from './common/query-key.js'; +import { makeTransactionMutationFn, makeTransactionOnSuccess } from './common/transaction.js'; import type { ExtraMutationOptions, ExtraQueryOptions, ProcedureReturn, QueryContext, + TransactionOperation, + TransactionResults, TrimSlicedOperations, WithOptimistic, } from './common/types.js'; +export { AnyNull, DbNull, JsonNull } from '@zenstackhq/client-helpers'; +export type { InferExtQueryArgs, InferExtResult, InferOptions, InferSchema } from '@zenstackhq/client-helpers'; export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; -export type { InferExtResult, InferOptions, InferSchema } from '@zenstackhq/client-helpers'; export type { SchemaDef } from '@zenstackhq/schema'; export const VueQueryContextKey = 'zenstack-vue-query-context'; @@ -151,13 +156,79 @@ export type ModelMutationModelResult< ): Promise>; }; +/** + * Options accepted by `$transaction.useSequential()`. + */ +export type TransactionMutationOptions< + Schema extends SchemaDef, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = MaybeRefOrGetter< + Omit< + UnwrapRef< + UseMutationOptions< + unknown[], + DefaultError, + TransactionOperation[] + > + >, + 'mutationFn' + > & + Omit +>; + +/** + * The return type of `$transaction.useSequential()`. Overrides `mutateAsync` so the + * resolved value is a tuple of per-operation results, narrowed to each operation's + * model + op + args (mirrors the typing of the corresponding ORM CRUD method). + */ +export type TransactionMutationResult< + Schema extends SchemaDef, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = Omit< + UseMutationReturnType< + unknown[], + DefaultError, + TransactionOperation[], + unknown + >, + 'mutateAsync' +> & { + mutateAsync[]>( + operations: T, + options?: Omit< + UseMutationOptions, DefaultError, T>, + 'mutationFn' + >, + ): Promise>; +}; + +/** + * The full set of TanStack Query hooks returned by {@link useClientQueries}. + */ export type ClientHooks< Schema extends SchemaDef, Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, ExtResult extends ExtResultBase = {}, > = { - [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks; -} & ProcedureHooks; + [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks< + Schema, + Model, + Options, + ExtQueryArgs, + ExtResult + >; +} & ProcedureHooks & { + $transaction: { + useSequential( + options?: TransactionMutationOptions, + ): TransactionMutationResult; + }; + }; type ProcedureHookGroup> = { [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } @@ -218,85 +289,108 @@ export type ModelQueryHooks< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, ExtResult extends ExtResultBase = {}, > = TrimSlicedOperations< Schema, Model, Options, { - useFindUnique>( - args: MaybeRefOrGetter>>, - options?: MaybeRefOrGetter | null>>, + useFindUnique>( + args: MaybeRefOrGetter>>, + options?: MaybeRefOrGetter< + ModelQueryOptions | null> + >, ): ModelQueryResult | null>; - useFindFirst>( - args?: MaybeRefOrGetter>>, - options?: MaybeRefOrGetter | null>>, + useFindFirst>( + args?: MaybeRefOrGetter>>, + options?: MaybeRefOrGetter< + ModelQueryOptions | null> + >, ): ModelQueryResult | null>; - useExists>( - args?: MaybeRefOrGetter>>, + useExists>( + args?: MaybeRefOrGetter>>, options?: MaybeRefOrGetter>, ): ModelQueryResult; - useFindMany>( - args?: MaybeRefOrGetter>>, - options?: MaybeRefOrGetter[]>>, + useFindMany>( + args?: MaybeRefOrGetter>>, + options?: MaybeRefOrGetter< + ModelQueryOptions[]> + >, ): ModelQueryResult[]>; - useInfiniteFindMany, TPageParam = unknown>( - args?: MaybeRefOrGetter>>, - options?: MaybeRefOrGetter[], TPageParam>>, - ): ModelInfiniteQueryResult[], TPageParam>>; - - useCreate>( - options?: MaybeRefOrGetter, T>>, + useInfiniteFindMany, TPageParam = unknown>( + args?: MaybeRefOrGetter>>, + options?: MaybeRefOrGetter< + ModelInfiniteQueryOptions[], TPageParam> + >, + ): ModelInfiniteQueryResult< + InfiniteData[], TPageParam> + >; + + useCreate>( + options?: MaybeRefOrGetter< + ModelMutationOptions, T> + >, ): ModelMutationModelResult; - useCreateMany>( + useCreateMany>( options?: MaybeRefOrGetter>, ): ModelMutationResult; - useCreateManyAndReturn>( - options?: MaybeRefOrGetter[], T>>, + useCreateManyAndReturn>( + options?: MaybeRefOrGetter< + ModelMutationOptions[], T> + >, ): ModelMutationModelResult; - useUpdate>( - options?: MaybeRefOrGetter, T>>, + useUpdate>( + options?: MaybeRefOrGetter< + ModelMutationOptions, T> + >, ): ModelMutationModelResult; - useUpdateMany>( + useUpdateMany>( options?: MaybeRefOrGetter>, ): ModelMutationResult; - useUpdateManyAndReturn>( - options?: MaybeRefOrGetter[], T>>, + useUpdateManyAndReturn>( + options?: MaybeRefOrGetter< + ModelMutationOptions[], T> + >, ): ModelMutationModelResult; - useUpsert>( - options?: MaybeRefOrGetter, T>>, + useUpsert>( + options?: MaybeRefOrGetter< + ModelMutationOptions, T> + >, ): ModelMutationModelResult; - useDelete>( - options?: MaybeRefOrGetter, T>>, + useDelete>( + options?: MaybeRefOrGetter< + ModelMutationOptions, T> + >, ): ModelMutationModelResult; - useDeleteMany>( + useDeleteMany>( options?: MaybeRefOrGetter>, ): ModelMutationResult; - useCount>( - args?: MaybeRefOrGetter>>, + useCount>( + args?: MaybeRefOrGetter>>, options?: MaybeRefOrGetter>>, ): ModelQueryResult>; - useAggregate>( - args: MaybeRefOrGetter>>, + useAggregate>( + args: MaybeRefOrGetter>>, options?: MaybeRefOrGetter>>, ): ModelQueryResult>; - useGroupBy>( - args: MaybeRefOrGetter>>, + useGroupBy>( + args: MaybeRefOrGetter>>, options?: MaybeRefOrGetter>>, ): ModelQueryResult>; } @@ -318,12 +412,17 @@ export type ModelQueryHooks< * const client = useClientQueries(schema) * ``` */ -export function useClientQueries< - SchemaOrClient extends SchemaDef | ClientContract, ->( +export function useClientQueries>( schema: InferSchema, options?: MaybeRefOrGetter, -): ClientHooks, InferOptions>, InferExtResult extends ExtResultBase> ? InferExtResult : {}> { +): ClientHooks< + InferSchema, + InferOptions>, + InferExtQueryArgs extends ExtQueryArgsBase ? InferExtQueryArgs : {}, + InferExtResult extends ExtResultBase> + ? InferExtResult + : {} +> { const merge = (rootOpt: MaybeRefOrGetter | undefined, opt: MaybeRefOrGetter | undefined): any => { return computed(() => { const rootVal = toValue(rootOpt) ?? {}; @@ -332,17 +431,10 @@ export function useClientQueries< }); }; - const result = Object.keys(schema.models).reduce( - (acc, model) => { - (acc as any)[lowerCaseFirst(model)] = useModelQueries( - schema as any, - model as any, - options, - ); - return acc; - }, - {} as any, - ); + const result = Object.keys(schema.models).reduce((acc, model) => { + (acc as any)[lowerCaseFirst(model)] = useModelQueries(schema as any, model as any, options); + return acc; + }, {} as any); const procedures = (schema as any).procedures as Record | undefined; if (procedures) { @@ -381,6 +473,10 @@ export function useClientQueries< (result as any).$procs = buildProcedureHooks(); } + (result as any).$transaction = { + useSequential: (hookOptions?: any) => useInternalTransactionMutation(schema, merge(options, hookOptions)), + }; + return result; } @@ -391,8 +487,13 @@ export function useModelQueries< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, ExtResult extends ExtResultBase = {}, ->(schema: Schema, model: Model, rootOptions?: MaybeRefOrGetter): ModelQueryHooks { +>( + schema: Schema, + model: Model, + rootOptions?: MaybeRefOrGetter, +): ModelQueryHooks { const modelDef = Object.values(schema.models).find((m) => m.name.toLowerCase() === model.toLowerCase()); if (!modelDef) { throw new Error(`Model "${model}" not found in schema`); @@ -474,7 +575,7 @@ export function useModelQueries< useGroupBy: (args: any, options?: any) => { return useInternalQuery(schema, modelName, 'groupBy', args, merge(rootOptions, options)); }, - } as ModelQueryHooks; + } as ModelQueryHooks; } export function useInternalQuery( @@ -521,7 +622,13 @@ export function useInternalInfiniteQuery, QueryKey, TPageParam> + UseInfiniteQueryOptions< + TQueryFnData, + DefaultError, + InfiniteData, + QueryKey, + TPageParam + > >, 'queryKey' | 'initialPageParam' > & @@ -674,12 +781,50 @@ export function useInternalMutation( return useMutation(finalOptions); } +export function useInternalTransactionMutation< + Schema extends SchemaDef, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +>( + schema: Schema, + options?: TransactionMutationOptions, +): TransactionMutationResult { + const queryClient = useQueryClient(); + const { endpoint, fetch, logging } = useFetchOptions(options); + + const mutationFn = makeTransactionMutationFn(endpoint, fetch); + + const finalOptions = computed(() => { + const optionsValue = toValue(options); + const result: any = { ...optionsValue, mutationFn }; + + if (optionsValue?.invalidateQueries !== false) { + result.onSuccess = makeTransactionOnSuccess( + schema, + (predicate: InvalidationPredicate) => invalidateQueriesMatchingPredicate(queryClient, predicate), + logging, + unref(optionsValue?.onSuccess) as any, + ); + } + + return result; + }); + + return useMutation(finalOptions as any) as unknown as TransactionMutationResult< + Schema, + Options, + ExtQueryArgs, + ExtResult + >; +} + function useFetchOptions(options: MaybeRefOrGetter) { const { endpoint, fetch, logging } = useQuerySettings(); const optionsValue = toValue(options); // options take precedence over context return { - endpoint: optionsValue?.endpoint ?? endpoint, + endpoint: normalizeEndpoint(optionsValue?.endpoint ?? endpoint), fetch: optionsValue?.fetch ?? fetch, logging: optionsValue?.logging ?? logging, }; diff --git a/packages/clients/tanstack-query/test/react/crud-and-invalidation.test.tsx b/packages/clients/tanstack-query/test/react/crud-and-invalidation.test.tsx new file mode 100644 index 000000000..5e417a36a --- /dev/null +++ b/packages/clients/tanstack-query/test/react/crud-and-invalidation.test.tsx @@ -0,0 +1,448 @@ +/** + * @vitest-environment happy-dom + */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import nock from 'nock'; +import { describe, expect, it } from 'vitest'; +import { getQueryKey } from '../../src/common/query-key'; +import { useClientQueries } from '../../src/react'; +import { schema } from '../schemas/basic/schema-lite'; +import { createWrapper, makeUrl, registerCleanup } from './helpers'; + +registerCleanup(); + +describe('CRUD and invalidation', () => { + it('works with simple query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, { + data, + }); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toMatchObject(data); + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData).toMatchObject(data); + }); + + nock(makeUrl('User', 'findFirst', queryArgs)) + .get(/.*/) + .reply(404, () => { + return { error: 'Not Found' }; + }); + const { result: errorResult } = renderHook(() => useClientQueries(schema).user.useFindFirst(queryArgs), { + wrapper, + }); + await waitFor(() => { + expect(errorResult.current.isError).toBe(true); + }); + }); + + it('works with suspense query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, { + data, + }); + + const { result } = renderHook(() => useClientQueries(schema).user.useSuspenseFindUnique(queryArgs), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toMatchObject(data); + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData).toMatchObject(data); + }); + }); + + it('works with infinite query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = [{ id: '1', name: 'foo' }]; + + nock(makeUrl('User', 'findMany', queryArgs)) + .get(/.*/) + .reply(200, () => ({ + data, + })); + + const { result } = renderHook( + () => + useClientQueries(schema).user.useInfiniteFindMany(queryArgs, { + getNextPageParam: () => null, + }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + const resultData = result.current.data!; + expect(resultData.pages).toHaveLength(1); + expect(resultData.pages[0]).toMatchObject(data); + expect(resultData?.pageParams).toHaveLength(1); + expect(resultData?.pageParams[0]).toMatchObject(queryArgs); + expect(result.current.hasNextPage).toBe(false); + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', queryArgs, { infinite: true, optimisticUpdate: false }), + ); + expect(cacheData.pages[0]).toMatchObject(data); + }); + }); + + it('works with suspense infinite query', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = [{ id: '1', name: 'foo' }]; + + nock(makeUrl('User', 'findMany', queryArgs)) + .get(/.*/) + .reply(200, () => ({ + data, + })); + + const { result } = renderHook( + () => + useClientQueries(schema).user.useSuspenseInfiniteFindMany(queryArgs, { + getNextPageParam: () => null, + }), + { + wrapper, + }, + ); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + const resultData = result.current.data!; + expect(resultData.pages).toHaveLength(1); + expect(resultData.pages[0]).toMatchObject(data); + expect(resultData?.pageParams).toHaveLength(1); + expect(resultData?.pageParams[0]).toMatchObject(queryArgs); + expect(result.current.hasNextPage).toBe(false); + const cacheData: any = queryClient.getQueryData( + getQueryKey('User', 'findMany', queryArgs, { infinite: true, optimisticUpdate: false }), + ); + expect(cacheData.pages[0]).toMatchObject(data); + }); + }); + + it('works with independent mutation and query', async () => { + const { wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + let queryCount = 0; + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => { + queryCount++; + return { data }; + }) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); + + nock(makeUrl('Post', 'create')) + .post(/.*/) + .reply(200, () => ({ + data: { id: '1', title: 'post1' }, + })); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).post.useCreate(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ data: { title: 'post1' } })); + + await waitFor(() => { + // no refetch caused by invalidation + expect(queryCount).toBe(1); + }); + }); + + it('works with create and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => { + data.push({ id: '1', email: 'foo' }); + return { data: data[0] }; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useCreate(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cacheData).toHaveLength(1); + }); + }); + + it('works with create and no invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toHaveLength(0); + }); + + nock(makeUrl('User', 'create')) + .post(/.*/) + .reply(200, () => { + data.push({ id: '1', email: 'foo' }); + return { data: data[0] }; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useCreate(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cacheData).toHaveLength(0); + }); + }); + + it('works with update and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ + data, + })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); + + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => { + data.name = 'bar'; + return data; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData).toMatchObject({ name: 'bar' }); + }); + }); + + it('works with update and no invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' } }; + const data = { id: '1', name: 'foo' }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toMatchObject({ name: 'foo' }); + }); + + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => { + data.name = 'bar'; + return data; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData).toMatchObject({ name: 'foo' }); + }); + }); + + it('works with delete and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data: any[] = [{ id: '1', name: 'foo' }]; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toHaveLength(1); + }); + + nock(makeUrl('User', 'delete')) + .delete(/.*/) + .reply(200, () => { + data.splice(0, 1); + return { data: [] }; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useDelete(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ where: { id: '1' } })); + + await waitFor(() => { + const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cacheData).toHaveLength(0); + }); + }); + + it('top-level mutation and nested-read invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const queryArgs = { where: { id: '1' }, include: { posts: true } }; + const data = { posts: [{ id: '1', title: 'post1' }] }; + + nock(makeUrl('User', 'findUnique', queryArgs)) + .get(/.*/) + .reply(200, () => ({ data })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toMatchObject(data); + }); + + nock(makeUrl('Post', 'update')) + .put(/.*/) + .reply(200, () => { + data.posts[0]!.title = 'post2'; + return data; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).post.useUpdate(), { + wrapper, + }); + + act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'post2' } })); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); + expect(cacheData.posts[0].title).toBe('post2'); + }); + }); + + it('nested mutation and top-level-read invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const data = [{ id: '1', title: 'post1', ownerId: '1' }]; + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ + data, + })) + .persist(); + + const { result } = renderHook(() => useClientQueries(schema).post.useFindMany(), { + wrapper, + }); + await waitFor(() => { + expect(result.current.data).toMatchObject(data); + }); + + nock(makeUrl('User', 'update')) + .put(/.*/) + .reply(200, () => { + data.push({ id: '2', title: 'post2', ownerId: '1' }); + return data; + }); + + const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { + wrapper, + }); + + act(() => + mutationResult.current.mutate({ where: { id: '1' }, data: { posts: { create: { title: 'post2' } } } }), + ); + + await waitFor(() => { + const cacheData: any = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); + expect(cacheData).toHaveLength(2); + }); + }); +}); diff --git a/packages/clients/tanstack-query/test/react/helpers.tsx b/packages/clients/tanstack-query/test/react/helpers.tsx new file mode 100644 index 000000000..045f2d6f4 --- /dev/null +++ b/packages/clients/tanstack-query/test/react/helpers.tsx @@ -0,0 +1,35 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup } from '@testing-library/react'; +import nock from 'nock'; +import React from 'react'; +import { afterEach } from 'vitest'; +import { QuerySettingsProvider } from '../../src/react'; + +export const BASE_URL = 'http://localhost'; + +export function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + return { queryClient, wrapper }; +} + +export function makeUrl(model: string, operation: string, args?: unknown) { + let r = `${BASE_URL}/api/model/${model}/${operation}`; + if (args) { + r += `?q=${encodeURIComponent(JSON.stringify(args))}`; + } + return r; +} + +export function registerCleanup() { + afterEach(() => { + nock.cleanAll(); + cleanup(); + }); +} diff --git a/packages/clients/tanstack-query/test/react/json-null-serialization.test.tsx b/packages/clients/tanstack-query/test/react/json-null-serialization.test.tsx new file mode 100644 index 000000000..32f066658 --- /dev/null +++ b/packages/clients/tanstack-query/test/react/json-null-serialization.test.tsx @@ -0,0 +1,137 @@ +/** + * @vitest-environment happy-dom + */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { deserialize, serialize } from '@zenstackhq/client-helpers/fetch'; +import nock from 'nock'; +import { describe, expect, it } from 'vitest'; +import { AnyNull, DbNull, JsonNull, useClientQueries } from '../../src/react'; +import { schema } from '../schemas/basic/schema-lite'; +import { BASE_URL, createWrapper, registerCleanup } from './helpers'; + +registerCleanup(); + +describe('JSON null value serialization', () => { + it('encodes DbNull in query filter and includes serialization metadata in URL', async () => { + const { wrapper } = createWrapper(); + let capturedUri = ''; + + nock(BASE_URL) + .get(/.*/) + .reply(200, function (uri) { + capturedUri = uri; + return { data: [] }; + }); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany({ where: { name: DbNull } } as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(capturedUri, BASE_URL); + expect(url.searchParams.has('meta')).toBe(true); + + const q = JSON.parse(decodeURIComponent(url.searchParams.get('q')!)); + const meta = JSON.parse(decodeURIComponent(url.searchParams.get('meta')!)); + const reconstructed = deserialize(q, meta.serialization) as any; + expect(reconstructed.where.name.__brand).toBe('DbNull'); + }); + + it('encodes JsonNull in query filter and includes serialization metadata in URL', async () => { + const { wrapper } = createWrapper(); + let capturedUri = ''; + + nock(BASE_URL) + .get(/.*/) + .reply(200, function (uri) { + capturedUri = uri; + return { data: [] }; + }); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany({ where: { name: JsonNull } } as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(capturedUri, BASE_URL); + expect(url.searchParams.has('meta')).toBe(true); + + const q = JSON.parse(decodeURIComponent(url.searchParams.get('q')!)); + const meta = JSON.parse(decodeURIComponent(url.searchParams.get('meta')!)); + const reconstructed = deserialize(q, meta.serialization) as any; + expect(reconstructed.where.name.__brand).toBe('JsonNull'); + }); + + it('encodes AnyNull in query filter and includes serialization metadata in URL', async () => { + const { wrapper } = createWrapper(); + let capturedUri = ''; + + nock(BASE_URL) + .get(/.*/) + .reply(200, function (uri) { + capturedUri = uri; + return { data: [] }; + }); + + const { result } = renderHook( + () => useClientQueries(schema).user.useFindMany({ where: { name: AnyNull } } as any), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const url = new URL(capturedUri, BASE_URL); + expect(url.searchParams.has('meta')).toBe(true); + + const q = JSON.parse(decodeURIComponent(url.searchParams.get('q')!)); + const meta = JSON.parse(decodeURIComponent(url.searchParams.get('meta')!)); + const reconstructed = deserialize(q, meta.serialization) as any; + expect(reconstructed.where.name.__brand).toBe('AnyNull'); + }); + + it('encodes DbNull in mutation body with serialization metadata', async () => { + const { wrapper } = createWrapper(); + let capturedBody: any; + + nock(BASE_URL) + .post(/.*/) + .reply(200, function (_uri, body) { + capturedBody = body; + return { data: { id: '1', name: null } }; + }); + + const { result } = renderHook(() => useClientQueries(schema).user.useCreate(), { wrapper }); + + act(() => result.current.mutate({ data: { email: 'test@example.com', name: DbNull } } as any)); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(capturedBody.meta?.serialization).toBeDefined(); + const reconstructed = deserialize({ data: capturedBody.data }, capturedBody.meta.serialization) as any; + expect(reconstructed.data.name.__brand).toBe('DbNull'); + }); + + it('deserializes null sentinels in server response back to branded instances', async () => { + const { wrapper } = createWrapper(); + + const responseData = { id: '1', email: 'test@example.com', name: DbNull }; + const { data: serializedData, meta: serializedMeta } = serialize(responseData); + + nock(BASE_URL) + .get(/.*/) + .reply(200, { data: serializedData, meta: { serialization: serializedMeta } }); + + const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique({ where: { id: '1' } }), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect((result.current.data as any).name.__brand).toBe('DbNull'); + }); +}); diff --git a/packages/clients/tanstack-query/test/react-query.test.tsx b/packages/clients/tanstack-query/test/react/optimistic-mutation.test.tsx similarity index 72% rename from packages/clients/tanstack-query/test/react-query.test.tsx rename to packages/clients/tanstack-query/test/react/optimistic-mutation.test.tsx index 4e2db7698..2bdb25698 100644 --- a/packages/clients/tanstack-query/test/react-query.test.tsx +++ b/packages/clients/tanstack-query/test/react/optimistic-mutation.test.tsx @@ -2,292 +2,18 @@ * @vitest-environment happy-dom */ -import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; -import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { useQuery } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; import nock from 'nock'; -import React from 'react'; -import { afterEach, describe, expect, it } from 'vitest'; -import { getQueryKey } from '../src/common/query-key'; -import { QuerySettingsProvider, useClientQueries } from '../src/react'; -import { schema } from './schemas/basic/schema-lite'; - -const BASE_URL = 'http://localhost'; - -describe('React Query Test', () => { - function createWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - const Provider = QuerySettingsProvider; - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - return { queryClient, wrapper }; - } - - function makeUrl(model: string, operation: string, args?: unknown) { - let r = `${BASE_URL}/api/model/${model}/${operation}`; - if (args) { - r += `?q=${encodeURIComponent(JSON.stringify(args))}`; - } - return r; - } - - afterEach(() => { - nock.cleanAll(); - cleanup(); - }); - - it('works with simple query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, { - data, - }); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - expect(result.current.data).toMatchObject(data); - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData).toMatchObject(data); - }); - - nock(makeUrl('User', 'findFirst', queryArgs)) - .get(/.*/) - .reply(404, () => { - return { error: 'Not Found' }; - }); - const { result: errorResult } = renderHook(() => useClientQueries(schema).user.useFindFirst(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(errorResult.current.isError).toBe(true); - }); - }); - - it('works with suspense query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, { - data, - }); - - const { result } = renderHook(() => useClientQueries(schema).user.useSuspenseFindUnique(queryArgs), { - wrapper, - }); +import { describe, expect, it } from 'vitest'; +import { getQueryKey } from '../../src/common/query-key'; +import { useClientQueries } from '../../src/react'; +import { schema } from '../schemas/basic/schema-lite'; +import { BASE_URL, createWrapper, makeUrl, registerCleanup } from './helpers'; - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - expect(result.current.data).toMatchObject(data); - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData).toMatchObject(data); - }); - }); - - it('works with infinite query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = [{ id: '1', name: 'foo' }]; - - nock(makeUrl('User', 'findMany', queryArgs)) - .get(/.*/) - .reply(200, () => ({ - data, - })); - - const { result } = renderHook( - () => - useClientQueries(schema).user.useInfiniteFindMany(queryArgs, { - getNextPageParam: () => null, - }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - const resultData = result.current.data!; - expect(resultData.pages).toHaveLength(1); - expect(resultData.pages[0]).toMatchObject(data); - expect(resultData?.pageParams).toHaveLength(1); - expect(resultData?.pageParams[0]).toMatchObject(queryArgs); - expect(result.current.hasNextPage).toBe(false); - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', queryArgs, { infinite: true, optimisticUpdate: false }), - ); - expect(cacheData.pages[0]).toMatchObject(data); - }); - }); - - it('works with suspense infinite query', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = [{ id: '1', name: 'foo' }]; - - nock(makeUrl('User', 'findMany', queryArgs)) - .get(/.*/) - .reply(200, () => ({ - data, - })); - - const { result } = renderHook( - () => - useClientQueries(schema).user.useSuspenseInfiniteFindMany(queryArgs, { - getNextPageParam: () => null, - }), - { - wrapper, - }, - ); - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - const resultData = result.current.data!; - expect(resultData.pages).toHaveLength(1); - expect(resultData.pages[0]).toMatchObject(data); - expect(resultData?.pageParams).toHaveLength(1); - expect(resultData?.pageParams[0]).toMatchObject(queryArgs); - expect(result.current.hasNextPage).toBe(false); - const cacheData: any = queryClient.getQueryData( - getQueryKey('User', 'findMany', queryArgs, { infinite: true, optimisticUpdate: false }), - ); - expect(cacheData.pages[0]).toMatchObject(data); - }); - }); - - it('works with independent mutation and query', async () => { - const { wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - - let queryCount = 0; - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => { - queryCount++; - return { data }; - }) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); - }); - - nock(makeUrl('Post', 'create')) - .post(/.*/) - .reply(200, () => ({ - data: { id: '1', title: 'post1' }, - })); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).post.useCreate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ data: { title: 'post1' } })); - - await waitFor(() => { - // no refetch caused by invalidation - expect(queryCount).toBe(1); - }); - }); - - it('works with create and invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any[] = []; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); - - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => { - data.push({ id: '1', email: 'foo' }); - return { data: data[0] }; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useCreate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); - expect(cacheData).toHaveLength(1); - }); - }); - - it('works with create and no invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any[] = []; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toHaveLength(0); - }); - - nock(makeUrl('User', 'create')) - .post(/.*/) - .reply(200, () => { - data.push({ id: '1', email: 'foo' }); - return { data: data[0] }; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useCreate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ data: { email: 'foo' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); - expect(cacheData).toHaveLength(0); - }); - }); +registerCleanup(); +describe('Optimistic mutation', () => { it('works with optimistic create single', async () => { const { queryClient, wrapper } = createWrapper(); @@ -825,82 +551,6 @@ describe('React Query Test', () => { }); }); - it('works with update and invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ - data, - })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); - }); - - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => { - data.name = 'bar'; - return data; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData).toMatchObject({ name: 'bar' }); - }); - }); - - it('works with update and no invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' } }; - const data = { id: '1', name: 'foo' }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject({ name: 'foo' }); - }); - - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => { - data.name = 'bar'; - return data; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ ...queryArgs, data: { name: 'bar' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData).toMatchObject({ name: 'foo' }); - }); - }); - it('works with optimistic update simple', async () => { const { queryClient, wrapper } = createWrapper(); @@ -1373,42 +1023,6 @@ describe('React Query Test', () => { }); }); - it('works with delete and invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data: any[] = [{ id: '1', name: 'foo' }]; - - nock(makeUrl('User', 'findMany')) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindMany(), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toHaveLength(1); - }); - - nock(makeUrl('User', 'delete')) - .delete(/.*/) - .reply(200, () => { - data.splice(0, 1); - return { data: [] }; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useDelete(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ where: { id: '1' } })); - - await waitFor(() => { - const cacheData = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); - expect(cacheData).toHaveLength(0); - }); - }); - it('works with optimistic delete simple', async () => { const { queryClient, wrapper } = createWrapper(); @@ -1558,83 +1172,6 @@ describe('React Query Test', () => { }); }); - it('top-level mutation and nested-read invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const queryArgs = { where: { id: '1' }, include: { posts: true } }; - const data = { posts: [{ id: '1', title: 'post1' }] }; - - nock(makeUrl('User', 'findUnique', queryArgs)) - .get(/.*/) - .reply(200, () => ({ data })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).user.useFindUnique(queryArgs), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject(data); - }); - - nock(makeUrl('Post', 'update')) - .put(/.*/) - .reply(200, () => { - data.posts[0]!.title = 'post2'; - return data; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).post.useUpdate(), { - wrapper, - }); - - act(() => mutationResult.current.mutate({ where: { id: '1' }, data: { title: 'post2' } })); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData(getQueryKey('User', 'findUnique', queryArgs)); - expect(cacheData.posts[0].title).toBe('post2'); - }); - }); - - it('nested mutation and top-level-read invalidation', async () => { - const { queryClient, wrapper } = createWrapper(); - - const data = [{ id: '1', title: 'post1', ownerId: '1' }]; - - nock(makeUrl('Post', 'findMany')) - .get(/.*/) - .reply(200, () => ({ - data, - })) - .persist(); - - const { result } = renderHook(() => useClientQueries(schema).post.useFindMany(), { - wrapper, - }); - await waitFor(() => { - expect(result.current.data).toMatchObject(data); - }); - - nock(makeUrl('User', 'update')) - .put(/.*/) - .reply(200, () => { - data.push({ id: '2', title: 'post2', ownerId: '1' }); - return data; - }); - - const { result: mutationResult } = renderHook(() => useClientQueries(schema).user.useUpdate(), { - wrapper, - }); - - act(() => - mutationResult.current.mutate({ where: { id: '1' }, data: { posts: { create: { title: 'post2' } } } }), - ); - - await waitFor(() => { - const cacheData: any = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); - expect(cacheData).toHaveLength(2); - }); - }); - it('optimistic create with custom provider', async () => { const { queryClient, wrapper } = createWrapper(); diff --git a/packages/clients/tanstack-query/test/react-sliced-client.test-d.ts b/packages/clients/tanstack-query/test/react/react-sliced-client.test-d.ts similarity index 66% rename from packages/clients/tanstack-query/test/react-sliced-client.test-d.ts rename to packages/clients/tanstack-query/test/react/react-sliced-client.test-d.ts index 78b2eca0a..73ded7efb 100644 --- a/packages/clients/tanstack-query/test/react-sliced-client.test-d.ts +++ b/packages/clients/tanstack-query/test/react/react-sliced-client.test-d.ts @@ -1,8 +1,8 @@ import { ZenStackClient } from '@zenstackhq/orm'; import { describe, expectTypeOf, it } from 'vitest'; -import { useClientQueries } from '../src/react'; -import { schema } from './schemas/basic/schema-lite'; -import { schema as procSchema } from './schemas/procedures/schema-lite'; +import { useClientQueries } from '../../src/react'; +import { schema } from '../schemas/basic/schema-lite'; +import { schema as procSchema } from '../schemas/procedures/schema-lite'; describe('React client sliced client test', () => { const _db = new ZenStackClient(schema, { @@ -73,6 +73,41 @@ describe('React client sliced client test', () => { client.user.useFindMany({ where: { name: { contains: 'test' } } }); }); + it('respects slicing in sequential transaction op union', () => { + const _slicedTx = new ZenStackClient(schema, { + dialect: {} as any, + slicing: { + models: { + user: { + // user can only do reads — no writes in transactions + includedOperations: ['findUnique', 'findMany', 'count'], + }, + }, + }, + }); + const client = useClientQueries(schema); + const tx = client.$transaction.useSequential(); + + void async function () { + // included read ops are allowed + await tx.mutateAsync([ + { model: 'User', op: 'findMany' }, + { model: 'User', op: 'findUnique', args: { where: { id: '1' } } }, + { model: 'User', op: 'count' }, + ] as const); + + await tx.mutateAsync([ + // @ts-expect-error 'create' was sliced away by `includedOperations` + { model: 'User', op: 'create', args: { data: { email: 'a@b.com' } } }, + ] as const); + + await tx.mutateAsync([ + // @ts-expect-error 'delete' was sliced away by `includedOperations` + { model: 'User', op: 'delete', args: { where: { id: '1' } } }, + ] as const); + }; + }); + it('works with sliced procedures', () => { const _slicedProcs = new ZenStackClient(procSchema, { dialect: {} as any, diff --git a/packages/clients/tanstack-query/test/react-typing.test-d.ts b/packages/clients/tanstack-query/test/react/react-typing.test-d.ts similarity index 64% rename from packages/clients/tanstack-query/test/react-typing.test-d.ts rename to packages/clients/tanstack-query/test/react/react-typing.test-d.ts index 876336c5e..6008ad878 100644 --- a/packages/clients/tanstack-query/test/react-typing.test-d.ts +++ b/packages/clients/tanstack-query/test/react/react-typing.test-d.ts @@ -1,7 +1,8 @@ +import type { ClientContract, ClientOptions } from '@zenstackhq/orm'; import { describe, it } from 'vitest'; -import { useClientQueries } from '../src/react'; -import { schema } from './schemas/basic/schema-lite'; -import { schema as proceduresSchema } from './schemas/procedures/schema-lite'; +import { useClientQueries } from '../../src/react'; +import { schema } from '../schemas/basic/schema-lite'; +import { schema as proceduresSchema } from '../schemas/procedures/schema-lite'; describe('React client typing test', () => { it('types model queries correctly', () => { @@ -128,6 +129,74 @@ describe('React client typing test', () => { client.bar.useCreate(); }); + it('reflects ExtQueryArgs and ExtResult inferred from a ClientContract type', () => { + type DbType = ClientContract< + typeof schema, + ClientOptions, + // ExtQueryArgs: $read adds a `cache` filter to all read ops; $create adds a `bust` flag + { + $read: { cache?: { ttl?: number } }; + $create: { cache?: { bust?: boolean } }; + }, + // ExtClientMembers (unused here) + {}, + // ExtResult: User gains a computed `displayName` field + { + user: { + displayName: { + needs: { email: true }; + compute: (data: { email: string }) => string; + }; + }; + } + >; + + const client = useClientQueries(schema); + + // ExtQueryArgs $read flows into read ops + check(client.user.useFindMany({ cache: { ttl: 1000 } }).data?.[0]?.email); + check(client.user.useFindUnique({ where: { id: '1' }, cache: { ttl: 1000 } }).data?.email); + check(client.user.useCount({ cache: { ttl: 1000 } }).data); + + // @ts-expect-error: $read's cache shape doesn't accept `bust` + client.user.useFindMany({ cache: { bust: true } }); + + // ExtQueryArgs $create flows into useCreate + client.user.useCreate().mutate({ data: { email: 'a@b.com' }, cache: { bust: true } }); + + // @ts-expect-error: $create's cache shape doesn't accept `ttl` + client.user.useCreate().mutate({ data: { email: 'a@b.com' }, cache: { ttl: 1000 } }); + + // ExtResult: `displayName` is added to User read results + const findUniqueData = client.user.useFindUnique({ where: { id: '1' } }).data; + check(findUniqueData?.displayName); + + const findManyData = client.user.useFindMany().data; + check(findManyData?.[0]?.displayName); + + // ExtResult: `displayName` is also present on mutation return + client.user + .useCreate() + .mutateAsync({ data: { email: 'a@b.com' } }) + .then((d) => check(d.displayName)); + + // Transaction: ExtQueryArgs flows through to operation args + const tx = client.$transaction.useSequential(); + void async function () { + const r = await tx.mutateAsync([ + { model: 'User', op: 'findMany', args: { cache: { ttl: 500 } } }, + { model: 'User', op: 'create', args: { data: { email: 'a@b.com' }, cache: { bust: true } } }, + ] as const); + + // ExtResult flows through to per-op transaction return + check(r[0][0]?.displayName); + check(r[1].displayName); + }; + + // @ts-expect-error: transaction args must respect ExtQueryArgs shape ($create has no ttl) + tx.mutateAsync([{ model: 'User', op: 'create', args: { data: { email: 'a' }, cache: { ttl: 1 } } }] as const); + }); + it('types procedure queries correctly', () => { const proceduresClient = useClientQueries(proceduresSchema); @@ -158,6 +227,6 @@ describe('React client typing test', () => { }); }); -function check(_value: unknown) { - // noop +function check(_value: T): T { + return _value; } diff --git a/packages/clients/tanstack-query/test/react/sequential-transaction.test.tsx b/packages/clients/tanstack-query/test/react/sequential-transaction.test.tsx new file mode 100644 index 000000000..752f78a2b --- /dev/null +++ b/packages/clients/tanstack-query/test/react/sequential-transaction.test.tsx @@ -0,0 +1,399 @@ +/** + * @vitest-environment happy-dom + */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { ClientContract, ClientOptions } from '@zenstackhq/orm'; +import nock from 'nock'; +import { describe, expect, it } from 'vitest'; +import { getQueryKey } from '../../src/common/query-key'; +import type { TransactionOperation } from '../../src/common/types'; +import { useClientQueries } from '../../src/react'; +import { schema } from '../schemas/basic/schema-lite'; +import { BASE_URL, createWrapper, makeUrl, registerCleanup } from './helpers'; + +registerCleanup(); + +describe('Sequential transaction', () => { + describe('Runtime behavior', () => { + it('works with sequential transaction and invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const users: any[] = []; + const posts: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data: users })) + .persist(); + + nock(makeUrl('Post', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data: posts })) + .persist(); + + const { result: userResult } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper }); + const { result: postResult } = renderHook(() => useClientQueries(schema).post.useFindMany(), { wrapper }); + + await waitFor(() => { + expect(userResult.current.data).toHaveLength(0); + expect(postResult.current.data).toHaveLength(0); + }); + + nock(`${BASE_URL}/api/model/$transaction/sequential`) + .post(/.*/) + .reply(200, () => { + users.push({ id: '1', email: 'foo@bar.com' }); + posts.push({ id: 'p1', title: 'Hello' }); + return { data: [users[0], posts[0]] }; + }); + + const { result: txResult } = renderHook(() => useClientQueries(schema).$transaction.useSequential(), { + wrapper, + }); + + act(() => + txResult.current.mutate([ + { model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }, + { model: 'Post', op: 'create', args: { data: { title: 'Hello' } } }, + ]), + ); + + await waitFor(() => { + const cachedUsers = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + const cachedPosts = queryClient.getQueryData(getQueryKey('Post', 'findMany', undefined)); + expect(cachedUsers).toHaveLength(1); + expect(cachedPosts).toHaveLength(1); + }); + }); + + it('works with sequential transaction and no invalidation', async () => { + const { queryClient, wrapper } = createWrapper(); + + const users: any[] = []; + + nock(makeUrl('User', 'findMany')) + .get(/.*/) + .reply(200, () => ({ data: users })) + .persist(); + + const { result: userResult } = renderHook(() => useClientQueries(schema).user.useFindMany(), { wrapper }); + + await waitFor(() => { + expect(userResult.current.data).toHaveLength(0); + }); + + nock(`${BASE_URL}/api/model/$transaction/sequential`) + .post(/.*/) + .reply(200, () => { + users.push({ id: '1', email: 'foo@bar.com' }); + return { data: [users[0]] }; + }); + + const { result: txResult } = renderHook( + () => useClientQueries(schema).$transaction.useSequential({ invalidateQueries: false }), + { wrapper }, + ); + + act(() => + txResult.current.mutate([{ model: 'User', op: 'create', args: { data: { email: 'foo@bar.com' } } }]), + ); + + await waitFor(() => { + expect(txResult.current.isSuccess).toBe(true); + // cache not refreshed because invalidation was disabled + const cachedUsers = queryClient.getQueryData(getQueryKey('User', 'findMany', undefined)); + expect(cachedUsers).toHaveLength(0); + }); + }); + }); + + describe('args field optionality', () => { + type TxOp = TransactionOperation; + + it('allows omitting args for ops with all-optional args', () => { + const findMany: TxOp = { model: 'User', op: 'findMany' }; + const findFirst: TxOp = { model: 'User', op: 'findFirst' }; + const count: TxOp = { model: 'User', op: 'count' }; + const exists: TxOp = { model: 'User', op: 'exists' }; + const deleteMany: TxOp = { model: 'User', op: 'deleteMany' }; + + // also accepts an explicit args payload + const findManyWithArgs: TxOp = { model: 'User', op: 'findMany', args: { where: { id: '1' } } }; + + expect([findMany, findFirst, count, exists, deleteMany, findManyWithArgs]).toHaveLength(6); + }); + + it('requires args for ops whose args type has required fields', () => { + const create: TxOp = { model: 'User', op: 'create', args: { data: { email: 'a@b.com' } } }; + const update: TxOp = { + model: 'User', + op: 'update', + args: { where: { id: '1' }, data: { email: 'b@c.com' } }, + }; + const del: TxOp = { model: 'User', op: 'delete', args: { where: { id: '1' } } }; + const findUnique: TxOp = { model: 'User', op: 'findUnique', args: { where: { id: '1' } } }; + const upsert: TxOp = { + model: 'User', + op: 'upsert', + args: { where: { id: '1' }, create: { email: 'c@d.com' }, update: {} }, + }; + const groupBy: TxOp = { model: 'User', op: 'groupBy', args: { by: ['email'] } }; + + // @ts-expect-error 'create' requires args + const badCreate: TxOp = { model: 'User', op: 'create' }; + // @ts-expect-error 'update' requires args + const badUpdate: TxOp = { model: 'User', op: 'update' }; + // @ts-expect-error 'delete' requires args + const badDelete: TxOp = { model: 'User', op: 'delete' }; + // @ts-expect-error 'findUnique' requires args + const badFindUnique: TxOp = { model: 'User', op: 'findUnique' }; + // @ts-expect-error 'upsert' requires args + const badUpsert: TxOp = { model: 'User', op: 'upsert' }; + // @ts-expect-error 'groupBy' requires args + const badGroupBy: TxOp = { model: 'User', op: 'groupBy' }; + + expect([create, update, del, findUnique, upsert, groupBy]).toHaveLength(6); + expect([badCreate, badUpdate, badDelete, badFindUnique, badUpsert, badGroupBy]).toHaveLength(6); + }); + + it('infers per-op result types on mutateAsync', () => { + const { wrapper } = createWrapper(); + const { result: txResult } = renderHook(() => useClientQueries(schema).$transaction.useSequential(), { + wrapper, + }); + + // Inline tuple — TS should infer each result element's shape. + void async function () { + const results = await txResult.current.mutateAsync([ + { model: 'User', op: 'create', args: { data: { email: 'a@b.com' } } }, + { model: 'Post', op: 'findFirst', args: { where: { id: '1' } } }, + { model: 'User', op: 'findMany' }, + { model: 'User', op: 'count' }, + { model: 'User', op: 'deleteMany' }, + { model: 'User', op: 'exists' }, + ] as const); + + // create → User + check(results[0].id); + check(results[0].email); + + // findFirst → Post | null + check(results[1]?.id); + check(results[1]?.title); + // null is allowed + const _maybeNull: (typeof results)[1] = null; + void _maybeNull; + + // findMany → User[] + check(results[2][0]?.email); + + // count → number (no select arg) + check(results[3]); + + // deleteMany → BatchResult + check(results[4].count); + + // exists → boolean + check(results[5]); + + // @ts-expect-error wrong field on User + check(results[0].nonExistent); + }; + }); + + it('rejects create-style ops on delegate models that disallow create', () => { + // 'Foo' is a delegate model — create-style ops are filtered out of the union + + // @ts-expect-error delegate model cannot 'create' + const badCreate: TxOp = { model: 'Foo', op: 'create' }; + // @ts-expect-error delegate model cannot 'createMany' + const badCreateMany: TxOp = { model: 'Foo', op: 'createMany' }; + // @ts-expect-error delegate model cannot 'createManyAndReturn' + const badCreateManyAndReturn: TxOp = { model: 'Foo', op: 'createManyAndReturn' }; + // @ts-expect-error delegate model cannot 'upsert' + const badUpsert: TxOp = { model: 'Foo', op: 'upsert' }; + + // non-create ops on delegate models are still allowed + const findMany: TxOp = { model: 'Foo', op: 'findMany' }; + const update: TxOp = { model: 'Foo', op: 'update', args: { where: { id: '1' }, data: {} } }; + + expect([badCreate, badCreateMany, badCreateManyAndReturn, badUpsert, findMany, update]).toHaveLength(6); + }); + }); + + describe('generic parameter influence (Options / ExtQueryArgs / ExtResult)', () => { + // A typed `ClientContract` standing in for what `useClientQueries(schema)` + // would receive when a real client (with plugins applied) is passed. Forwarded + // generics flow into the transaction operation args and per-op result shapes. + type DbType = ClientContract< + typeof schema, + ClientOptions, + // ExtQueryArgs: per-bucket extension keys + { + $read: { cache?: { ttl?: number } }; + $create: { audit?: { user?: string } }; + $update: { audit?: { user?: string } }; + $delete: { audit?: { user?: string } }; + }, + // ExtClientMembers (unused here) + {}, + // ExtResult: User gains a computed `displayName` field + { + user: { + displayName: { + needs: { email: true }; + compute: (data: { email: string }) => string; + }; + }; + } + >; + + // The negative @ts-expect-error checks below assign each operation to a + // concrete `TxOp` annotation, which forces TS to apply excess-property + // checking on the literal. (Inline `mutateAsync([...])` calls capture the + // tuple via a `const T extends ...[]` generic, where structural subtype + // assignability allows extra properties — so negative cases need the + // explicit annotation to fire.) + type TxOp = TransactionOperation< + typeof schema, + ClientOptions, + // mirror DbType's ExtQueryArgs + { + $read: { cache?: { ttl?: number } }; + $create: { audit?: { user?: string } }; + $update: { audit?: { user?: string } }; + $delete: { audit?: { user?: string } }; + }, + // ExtResult is irrelevant for arg-shape tests + {} + >; + + it('threads ExtQueryArgs `$read` into read ops only', () => { + const { wrapper } = createWrapper(); + const { result: txResult } = renderHook( + () => useClientQueries(schema).$transaction.useSequential(), + { wrapper }, + ); + + void async function () { + // positive: `$read`'s `cache` flows into every read op + await txResult.current.mutateAsync([ + { model: 'User', op: 'findMany', args: { cache: { ttl: 1000 } } }, + { model: 'User', op: 'findUnique', args: { where: { id: '1' }, cache: { ttl: 1000 } } }, + { model: 'User', op: 'findFirst', args: { cache: { ttl: 500 } } }, + { model: 'User', op: 'count', args: { cache: { ttl: 1000 } } }, + { model: 'User', op: 'exists', args: { cache: { ttl: 1000 } } }, + ] as const); + }; + + // negative: `$read.cache` doesn't apply to `create` + const badCreate: TxOp = { + model: 'User', + op: 'create', + // @ts-expect-error excess `cache` on a write op + args: { data: { email: 'a@b.com' }, cache: { ttl: 1000 } }, + }; + // negative: `$create`'s `audit` doesn't apply to read ops + // @ts-expect-error excess `audit` on a read op + const badFindMany: TxOp = { model: 'User', op: 'findMany', args: { audit: { user: 'admin' } } }; + + expect([badCreate, badFindMany]).toHaveLength(2); + }); + + it('threads ExtQueryArgs `$create` / `$update` / `$delete` into the matching write ops', () => { + const { wrapper } = createWrapper(); + const { result: txResult } = renderHook( + () => useClientQueries(schema).$transaction.useSequential(), + { wrapper }, + ); + + void async function () { + // positive: each write bucket's extension flows into its own ops + await txResult.current.mutateAsync([ + { + model: 'User', + op: 'create', + args: { data: { email: 'a@b.com' }, audit: { user: 'admin' } }, + }, + { + model: 'User', + op: 'update', + args: { where: { id: '1' }, data: { email: 'b@c.com' }, audit: { user: 'admin' } }, + }, + { + model: 'User', + op: 'delete', + args: { where: { id: '1' }, audit: { user: 'admin' } }, + }, + ] as const); + }; + + // negative: `$update`'s `audit` doesn't apply to read ops + // @ts-expect-error excess `audit` on a read op + const badRead: TxOp = { model: 'User', op: 'count', args: { audit: { user: 'admin' } } }; + + expect(badRead).toBeDefined(); + }); + + it('threads ExtResult into transaction per-op return types', () => { + const { wrapper } = createWrapper(); + const { result: txResult } = renderHook( + () => useClientQueries(schema).$transaction.useSequential(), + { wrapper }, + ); + + void async function () { + const r = await txResult.current.mutateAsync([ + { model: 'User', op: 'create', args: { data: { email: 'a@b.com' } } }, + { model: 'User', op: 'findFirst' }, + { model: 'User', op: 'findMany' }, + { model: 'User', op: 'update', args: { where: { id: '1' }, data: {} } }, + { model: 'User', op: 'upsert', args: { where: { id: '1' }, create: { email: 'a' }, update: {} } }, + { model: 'User', op: 'delete', args: { where: { id: '1' } } }, + ] as const); + + // `displayName` (from ExtResult) is present on every entity-returning op + check(r[0].displayName); + check(r[1]?.displayName); + check(r[2][0]?.displayName); + check(r[3].displayName); + check(r[4].displayName); + check(r[5].displayName); + }; + }); + + it('respects ExtQueryArgs across non-`User` models too', () => { + const { wrapper } = createWrapper(); + const { result: txResult } = renderHook( + () => useClientQueries(schema).$transaction.useSequential(), + { wrapper }, + ); + + void async function () { + // `$read` extension also applies to Post's reads + await txResult.current.mutateAsync([ + { model: 'Post', op: 'findMany', args: { cache: { ttl: 1000 } } }, + { model: 'Post', op: 'count', args: { cache: { ttl: 1000 } } }, + ] as const); + + // `$create` extension also applies to Post's writes + await txResult.current.mutateAsync([ + { + model: 'Post', + op: 'create', + args: { + data: { title: 'hello', author: { connect: { id: '1' } } }, + audit: { user: 'admin' }, + }, + }, + ] as const); + }; + }); + }); +}); + +// Type-only assertion: forces `value` to be assignable to `T` at compile time. +function check(value: T): T { + return value; +} diff --git a/packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts b/packages/clients/tanstack-query/test/svelte/svelte-sliced-client.test-d.ts similarity index 94% rename from packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts rename to packages/clients/tanstack-query/test/svelte/svelte-sliced-client.test-d.ts index 2290536a8..5ebf12764 100644 --- a/packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts +++ b/packages/clients/tanstack-query/test/svelte/svelte-sliced-client.test-d.ts @@ -1,8 +1,8 @@ import { ZenStackClient } from '@zenstackhq/orm'; import { describe, expectTypeOf, it } from 'vitest'; -import { useClientQueries } from '../src/svelte/index.svelte'; -import { schema } from './schemas/basic/schema-lite'; -import { schema as procSchema } from './schemas/procedures/schema-lite'; +import { useClientQueries } from '../../src/svelte/index.svelte'; +import { schema } from '../schemas/basic/schema-lite'; +import { schema as procSchema } from '../schemas/procedures/schema-lite'; describe('Svelte client sliced client test', () => { const _db = new ZenStackClient(schema, { diff --git a/packages/clients/tanstack-query/test/svelte-typing-test.ts b/packages/clients/tanstack-query/test/svelte/svelte-typing-test.ts similarity index 96% rename from packages/clients/tanstack-query/test/svelte-typing-test.ts rename to packages/clients/tanstack-query/test/svelte/svelte-typing-test.ts index 492b086a1..a2c83887b 100644 --- a/packages/clients/tanstack-query/test/svelte-typing-test.ts +++ b/packages/clients/tanstack-query/test/svelte/svelte-typing-test.ts @@ -1,6 +1,6 @@ -import { useClientQueries } from '../src/svelte/index.svelte'; -import { schema } from './schemas/basic/schema-lite'; -import { schema as proceduresSchema } from './schemas/procedures/schema-lite'; +import { useClientQueries } from '../../src/svelte/index.svelte'; +import { schema } from '../schemas/basic/schema-lite'; +import { schema as proceduresSchema } from '../schemas/procedures/schema-lite'; const client = useClientQueries(schema); const proceduresClient = useClientQueries(proceduresSchema); diff --git a/packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts b/packages/clients/tanstack-query/test/vue/vue-sliced-client.test-d.ts similarity index 94% rename from packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts rename to packages/clients/tanstack-query/test/vue/vue-sliced-client.test-d.ts index 51637c955..28b6ba701 100644 --- a/packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts +++ b/packages/clients/tanstack-query/test/vue/vue-sliced-client.test-d.ts @@ -1,8 +1,8 @@ import { ZenStackClient } from '@zenstackhq/orm'; import { describe, expectTypeOf, it } from 'vitest'; -import { useClientQueries } from '../src/vue'; -import { schema } from './schemas/basic/schema-lite'; -import { schema as procSchema } from './schemas/procedures/schema-lite'; +import { useClientQueries } from '../../src/vue'; +import { schema } from '../schemas/basic/schema-lite'; +import { schema as procSchema } from '../schemas/procedures/schema-lite'; describe('Vue client sliced client test', () => { const _db = new ZenStackClient(schema, { diff --git a/packages/clients/tanstack-query/test/vue-typing-test.ts b/packages/clients/tanstack-query/test/vue/vue-typing-test.ts similarity index 96% rename from packages/clients/tanstack-query/test/vue-typing-test.ts rename to packages/clients/tanstack-query/test/vue/vue-typing-test.ts index fdd4f8541..e72f90445 100644 --- a/packages/clients/tanstack-query/test/vue-typing-test.ts +++ b/packages/clients/tanstack-query/test/vue/vue-typing-test.ts @@ -1,6 +1,6 @@ -import { useClientQueries } from '../src/vue'; -import { schema } from './schemas/basic/schema-lite'; -import { schema as proceduresSchema } from './schemas/procedures/schema-lite'; +import { useClientQueries } from '../../src/vue'; +import { schema } from '../schemas/basic/schema-lite'; +import { schema as proceduresSchema } from '../schemas/procedures/schema-lite'; const client = useClientQueries(schema); const proceduresClient = useClientQueries(proceduresSchema); diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 2bebe6481..a9f515cf9 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/common-helpers", "displayName": "ZenStack Common Helpers", "description": "ZenStack Common Helpers", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index 30bfa704e..884592155 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.6.4", + "version": "3.7.0", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/tsdown-config/package.json b/packages/config/tsdown-config/package.json index d564522c6..47aecd712 100644 --- a/packages/config/tsdown-config/package.json +++ b/packages/config/tsdown-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tsdown-config", - "version": "3.6.4", + "version": "3.7.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 4e4548bea..402d1ca0b 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.6.4", + "version": "3.7.0", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index d0124b17b..205e198b0 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.6.4", + "version": "3.7.0", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index d26792dba..485e23c4b 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -2,7 +2,7 @@ "name": "create-zenstack", "displayName": "Create ZenStack", "description": "Create a new ZenStack project", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index cacadcfa1..2436781e9 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.6.4", + "version": "3.7.0", "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 240103697..759600620 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/language", "displayName": "ZenStack Language Tooling", "description": "ZenStack ZModel language specification", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index cb604c74a..761e577a4 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -394,12 +394,26 @@ attribute @ignore() @@@prisma attribute @@ignore() @@@prisma /** - * Indicates that the field should be omitted by default when read with an ORM client. The omission can be + * Indicates that the field should be omitted by default when read with an ORM client. The omission can be * overridden in options passed to create `ZenStackClient`, or at query time by explicitly passing in an * `omit` clause. The attribute is only effective for ORM query APIs, not for query-builder APIs. */ attribute @omit() +/** + * Marks a `String` field as fuzzy-searchable. Fields with this attribute can be used with the + * `fuzzy` filter operator and the `_fuzzyRelevance` orderBy. Fuzzy search is currently + * supported only on the `postgresql` provider (requires `pg_trgm` extension). + */ +attribute @fuzzy() @@@targetField([StringField]) @@@once + +/** + * Marks a `String` field as full-text-searchable. Fields with this attribute can be used with the + * `fts` filter operator and the `_ftsRelevance` orderBy. Full-text search is currently + * supported only on the `postgresql` provider (uses `to_tsvector` / `to_tsquery` / `ts_rank`). + */ +attribute @fullText() @@@targetField([StringField]) @@@once + /** * Automatically stores the time when a record was last updated. * diff --git a/packages/language/src/document.ts b/packages/language/src/document.ts index 2d497d796..87fc48564 100644 --- a/packages/language/src/document.ts +++ b/packages/language/src/document.ts @@ -17,8 +17,8 @@ import { createZModelServices, type ZModelServices } from './module'; import { getAllFields, getDataModelAndTypeDefs, + getDataSourceProvider, getDocument, - getLiteral, hasAttribute, resolveImport, resolveTransitiveImports, @@ -262,14 +262,3 @@ export async function formatDocument(content: string) { return TextDocument.applyEdits(document.textDocument, edits); } -function getDataSourceProvider(model: Model) { - const dataSource = model.declarations.find(isDataSource); - if (!dataSource) { - return undefined; - } - const provider = dataSource?.fields.find((f) => f.name === 'provider'); - if (!provider) { - return undefined; - } - return getLiteral(provider.value); -} diff --git a/packages/language/src/utils.ts b/packages/language/src/utils.ts index c73522afc..88897a49f 100644 --- a/packages/language/src/utils.ts +++ b/packages/language/src/utils.ts @@ -11,6 +11,7 @@ import { isConfigArrayExpr, isDataField, isDataModel, + isDataSource, isEnumField, isExpression, isInvocationExpr, @@ -170,6 +171,22 @@ export function isDelegateModel(node: AstNode) { return isDataModel(node) && hasAttribute(node, '@@delegate'); } +/** + * Returns the datasource provider literal (e.g. `'postgresql'`) declared in the schema, or undefined + * if no datasource is found or its provider is not a literal. + */ +export function getDataSourceProvider(model: Model) { + const dataSource = model.declarations.find(isDataSource); + if (!dataSource) { + return undefined; + } + const providerField = dataSource.fields.find((f) => f.name === 'provider'); + if (!providerField) { + return undefined; + } + return getLiteral(providerField.value); +} + /** * Resolves the given reference and returns the target AST node. Throws an error if the reference is not resolved. */ diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 28983e822..b490e20d4 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -28,6 +28,7 @@ import { getAllAttributes, getAttributeArg, getContainingDataModel, + getDataSourceProvider, getStringLiteral, hasAttribute, isAuthOrAuthMemberAccess, @@ -350,6 +351,30 @@ export default class AttributeApplicationValidator implements AstValidator { }); }); + describe('Field-level @fuzzy attribute', () => { + it('accepts @fuzzy on a String field with postgres provider', async () => { + await loadSchema(` + datasource db { + provider = 'postgresql' + url = 'postgresql://localhost/test' + } + + model Flavor { + id Int @id @default(autoincrement()) + name String @fuzzy + description String? @fuzzy + } + `); + }); + + it('rejects @fuzzy with sqlite provider', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model Flavor { + id Int @id @default(autoincrement()) + name String @fuzzy + } + `, + /`@fuzzy` is only supported for the `postgresql` provider/, + ); + }); + + it('rejects @fuzzy with mysql provider', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'mysql' + url = 'mysql://localhost/test' + } + + model Flavor { + id Int @id @default(autoincrement()) + name String @fuzzy + } + `, + /`@fuzzy` is only supported for the `postgresql` provider/, + ); + }); + + it('rejects @fuzzy on a non-String field', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'postgresql' + url = 'postgresql://localhost/test' + } + + model Flavor { + id Int @id @default(autoincrement()) + count Int @fuzzy + } + `, + /attribute "@fuzzy" cannot be used on this type of field/, + ); + }); + }); + it('requires relation and fk to have consistent optionality', async () => { await loadSchemaWithError( ` diff --git a/packages/orm/package.json b/packages/orm/package.json index b192e72f5..e0ecf5dd2 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/orm", "displayName": "ZenStack ORM", "description": "ZenStack ORM", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", @@ -18,7 +18,8 @@ "build": "tsc --noEmit && tsdown", "watch": "tsdown --watch", "lint": "eslint src --ext ts", - "pack": "pnpm pack" + "pack": "pnpm pack", + "test:generate": "tsx ../../scripts/test-generate.ts . --generate-models" }, "keywords": [], "files": [ @@ -95,6 +96,16 @@ "default": "./dist/helpers.cjs" } }, + "./common-types": { + "import": { + "types": "./dist/common-types.d.mts", + "default": "./dist/common-types.mjs" + }, + "require": { + "types": "./dist/common-types.d.cts", + "default": "./dist/common-types.cjs" + } + }, "./package.json": { "import": "./package.json", "require": "./package.json" diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index d96984aef..0fb4e4dbd 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -223,7 +223,7 @@ export class ClientImpl { ): Promise; // overload for sequential transaction - $transaction

[]>( + $transaction

[]>( arg: [...P], options?: { isolationLevel?: TransactionIsolationLevel }, ): Promise>; @@ -268,8 +268,15 @@ export class ClientImpl { } } + private getPromiseCallback(promise: ZenStackPromise) { + invariant((promise as any).cb, 'Invalid ZenStackPromise, missing cb property'); + const cb = (promise as any).cb; + invariant(typeof cb === 'function', 'Invalid ZenStackPromise, cb property is not a function'); + return (promise as any).cb; + } + private async sequentialTransaction( - arg: ZenStackPromise[], + arg: ZenStackPromise[], options?: { isolationLevel?: TransactionIsolationLevel }, ) { const execute = async (tx: AnyKysely) => { @@ -277,7 +284,8 @@ export class ClientImpl { txClient.kysely = tx; const result: any[] = []; for (const promise of arg) { - result.push(await promise.cb(txClient as unknown as ClientContract)); + const cb = this.getPromiseCallback(promise); + result.push(await cb(txClient as unknown as ClientContract)); } return result; }; diff --git a/packages/orm/src/client/constants.ts b/packages/orm/src/client/constants.ts index a945b7da2..eeb7e65b6 100644 --- a/packages/orm/src/client/constants.ts +++ b/packages/orm/src/client/constants.ts @@ -68,6 +68,12 @@ export const FILTER_PROPERTY_TO_KIND = { array_starts_with: 'Json', array_ends_with: 'Json', + // Fuzzy search operators + fuzzy: 'Fuzzy', + + // Full-text search operators + fts: 'FullText', + // List operators has: 'List', hasEvery: 'List', diff --git a/packages/orm/src/client/contract.ts b/packages/orm/src/client/contract.ts index 29e2456e0..f9a8aeae4 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -11,32 +11,13 @@ import type { AnyKysely } from '../utils/kysely-utils'; import type { Simplify, UnwrapTuplePromises } from '../utils/type-utils'; import type { TRANSACTION_UNSUPPORTED_METHODS } from './constants'; import type { - AggregateArgs, - AggregateResult, - BatchResult, - CountArgs, - CountResult, - CreateArgs, - CreateManyAndReturnArgs, - CreateManyArgs, + CrudArgsType, + CrudReturnType, DefaultModelResult, - DeleteArgs, - DeleteManyArgs, - ExistsArgs, - FindFirstArgs, - FindManyArgs, - FindUniqueArgs, - GroupByArgs, - GroupByResult, ProcedureFunc, SelectSubset, - SimplifiedPlainResult, Subset, TypeDefResult, - UpdateArgs, - UpdateManyAndReturnArgs, - UpdateManyArgs, - UpsertArgs, } from './crud-types'; import type { Diagnostics } from './diagnostics'; import type { ClientOptions, QueryOptions } from './options'; @@ -65,6 +46,21 @@ export enum TransactionIsolationLevel { Snapshot = 'snapshot', } +/** + * Symbol used as a type-only key on `ClientContract` to brand the `ExtQueryArgs` + * generic slot. Hidden from member-access autocomplete since symbol keys are + * not surfaced. Consumed by `InferExtQueryArgs` to recover the slot. + * @internal + */ +export const ExtQueryArgsMarker: unique symbol = Symbol('zenstack.client.extQueryArgs'); + +/** + * Symbol used as a type-only key on `ClientContract` to brand the `ExtResult` + * generic slot. Consumed by `InferExtResult` to recover the slot. + * @internal + */ +export const ExtResultMarker: unique symbol = Symbol('zenstack.client.extResult'); + /** * ZenStack client interface. */ @@ -85,6 +81,12 @@ export type ClientContract< */ readonly $options: Options; + /** @internal type-only brand carrying the `ExtQueryArgs` slot for inference. */ + readonly [ExtQueryArgsMarker]?: ExtQueryArgs; + + /** @internal type-only brand carrying the `ExtResult` slot for inference. */ + readonly [ExtResultMarker]?: ExtResult; + /** * Executes a prepared raw query and returns the number of affected rows. * @example @@ -92,7 +94,7 @@ export type ClientContract< * const result = await db.$executeRaw`UPDATE User SET cool = ${true} WHERE email = ${'user@email.com'};` * ``` */ - $executeRaw(query: TemplateStringsArray, ...values: any[]): ZenStackPromise; + $executeRaw(query: TemplateStringsArray, ...values: any[]): ZenStackPromise; /** * Executes a raw query and returns the number of affected rows. @@ -102,7 +104,7 @@ export type ClientContract< * const result = await db.$executeRawUnsafe('UPDATE User SET cool = $1 WHERE email = $2 ;', true, 'user@email.com') * ``` */ - $executeRawUnsafe(query: string, ...values: any[]): ZenStackPromise; + $executeRawUnsafe(query: string, ...values: any[]): ZenStackPromise; /** * Performs a prepared raw query and returns the `SELECT` data. @@ -111,7 +113,7 @@ export type ClientContract< * const result = await db.$queryRaw`SELECT * FROM User WHERE id = ${1} OR email = ${'user@email.com'};` * ``` */ - $queryRaw(query: TemplateStringsArray, ...values: any[]): ZenStackPromise; + $queryRaw(query: TemplateStringsArray, ...values: any[]): ZenStackPromise; /** * Performs a raw query and returns the `SELECT` data. @@ -121,7 +123,7 @@ export type ClientContract< * const result = await db.$queryRawUnsafe('SELECT * FROM User WHERE id = $1 OR email = $2;', 1, 'user@email.com') * ``` */ - $queryRawUnsafe(query: string, ...values: any[]): ZenStackPromise; + $queryRawUnsafe(query: string, ...values: any[]): ZenStackPromise; /** * The current user identity. If the client is not bound to any user context, returns `undefined`. @@ -203,7 +205,7 @@ export type ClientContract< * db.post.create({ data: { title: 'Hello World', authorId: 1 } }), * ]); */ - $transaction

[]>( + $transaction

[]>( arg: [...P], options?: { isolationLevel?: TransactionIsolationLevel }, ): Promise>; @@ -379,9 +381,14 @@ export type AllModelOperations< * }); * ``` */ - createManyAndReturn>( - args?: SelectSubset>, - ): ZenStackPromise[]>; + createManyAndReturn< + T extends CrudArgsType, + >( + args?: SelectSubset< + T, + CrudArgsType + >, + ): ZenStackPromise>; /** * Updates multiple entities and returns them. @@ -405,9 +412,11 @@ export type AllModelOperations< * }); * ``` */ - updateManyAndReturn>( - args: Subset>, - ): ZenStackPromise[]>; + updateManyAndReturn< + T extends CrudArgsType, + >( + args: Subset>, + ): ZenStackPromise>; }); type CommonModelOperations< @@ -498,9 +507,9 @@ type CommonModelOperations< * }); // result: `{ _count: { posts: number } }` * ``` */ - findMany>( - args?: SelectSubset>, - ): ZenStackPromise[]>; + findMany>( + args?: SelectSubset>, + ): ZenStackPromise>; /** * Returns a uniquely identified entity. @@ -508,9 +517,9 @@ type CommonModelOperations< * @returns a single entity or null if not found * @see {@link findMany} */ - findUnique>( - args: SelectSubset>, - ): ZenStackPromise | null>; + findUnique>( + args: SelectSubset>, + ): ZenStackPromise>; /** * Returns a uniquely identified entity or throws `NotFoundError` if not found. @@ -518,9 +527,9 @@ type CommonModelOperations< * @returns a single entity * @see {@link findMany} */ - findUniqueOrThrow>( - args: SelectSubset>, - ): ZenStackPromise>; + findUniqueOrThrow>( + args: SelectSubset>, + ): ZenStackPromise>; /** * Returns the first entity. @@ -528,9 +537,9 @@ type CommonModelOperations< * @returns a single entity or null if not found * @see {@link findMany} */ - findFirst>( - args?: SelectSubset>, - ): ZenStackPromise | null>; + findFirst>( + args?: SelectSubset>, + ): ZenStackPromise>; /** * Returns the first entity or throws `NotFoundError` if not found. @@ -538,9 +547,9 @@ type CommonModelOperations< * @returns a single entity * @see {@link findMany} */ - findFirstOrThrow>( - args?: SelectSubset>, - ): ZenStackPromise>; + findFirstOrThrow>( + args?: SelectSubset>, + ): ZenStackPromise>; /** * Creates a new entity. @@ -594,9 +603,9 @@ type CommonModelOperations< * }); * ``` */ - create>( - args: SelectSubset>, - ): ZenStackPromise>; + create>( + args: SelectSubset>, + ): ZenStackPromise>; /** * Creates multiple entities. Only scalar fields are allowed. @@ -623,9 +632,9 @@ type CommonModelOperations< * }); * ``` */ - createMany>( - args?: SelectSubset>, - ): ZenStackPromise; + createMany>( + args?: SelectSubset>, + ): ZenStackPromise>; /** * Updates a uniquely identified entity. @@ -744,9 +753,9 @@ type CommonModelOperations< * }); * ``` */ - update>( - args: SelectSubset>, - ): ZenStackPromise>; + update>( + args: SelectSubset>, + ): ZenStackPromise>; /** * Updates multiple entities. @@ -768,9 +777,9 @@ type CommonModelOperations< * limit: 10 * }); */ - updateMany>( - args: Subset>, - ): ZenStackPromise; + updateMany>( + args: Subset>, + ): ZenStackPromise>; /** * Creates or updates an entity. @@ -792,9 +801,9 @@ type CommonModelOperations< * }); * ``` */ - upsert>( - args: SelectSubset>, - ): ZenStackPromise>; + upsert>( + args: SelectSubset>, + ): ZenStackPromise>; /** * Deletes a uniquely identifiable entity. @@ -815,9 +824,9 @@ type CommonModelOperations< * }); // result: `{ id: string; email: string }` * ``` */ - delete>( - args: SelectSubset>, - ): ZenStackPromise>; + delete>( + args: SelectSubset>, + ): ZenStackPromise>; /** * Deletes multiple entities. @@ -838,9 +847,9 @@ type CommonModelOperations< * }); * ``` */ - deleteMany>( - args?: Subset>, - ): ZenStackPromise; + deleteMany>( + args?: Subset>, + ): ZenStackPromise>; /** * Counts rows or field values. @@ -860,9 +869,9 @@ type CommonModelOperations< * select: { _all: true, email: true } * }); // result: `{ _all: number, email: number }` */ - count>( - args?: Subset>, - ): ZenStackPromise>>; + count>( + args?: Subset>, + ): ZenStackPromise>>; /** * Aggregates rows. @@ -881,9 +890,9 @@ type CommonModelOperations< * _max: { age: true } * }); // result: `{ _count: number, _avg: { age: number }, ... }` */ - aggregate>( - args: Subset>, - ): ZenStackPromise>>; + aggregate>( + args: Subset>, + ): ZenStackPromise>>; /** * Groups rows by columns. @@ -918,9 +927,9 @@ type CommonModelOperations< * having: { country: 'US', age: { _avg: { gte: 18 } } } * }); */ - groupBy>( - args: Subset>, - ): ZenStackPromise>>; + groupBy>( + args: Subset>, + ): ZenStackPromise>>; /** * Checks if an entity exists. @@ -939,9 +948,9 @@ type CommonModelOperations< * where: { posts: { some: { published: true } } }, * }); // result: `boolean` */ - exists>( - args?: Subset>, - ): ZenStackPromise; + exists>( + args?: Subset>, + ): ZenStackPromise>; }; export type OperationsRequiringCreate = 'create' | 'createMany' | 'createManyAndReturn' | 'upsert'; diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 6f82aaba1..6842f4058 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -1,5 +1,3 @@ -import type { ExpressionBuilder, OperandExpression, SqlBool } from 'kysely'; -import type { DbNull, JsonNull, JsonNullValues, JsonValue } from '../common-types'; import type { BuiltinType, FieldDef, @@ -35,6 +33,8 @@ import type { TypeDefFieldIsOptional, UpdatedAtInfo, } from '@zenstackhq/schema'; +import type { ExpressionBuilder, OperandExpression, SqlBool } from 'kysely'; +import type { DbNull, JsonNull, JsonNullValues, JsonValue } from '../common-types'; import type { AtLeast, MapBaseType, @@ -53,6 +53,7 @@ import type { } from '../utils/type-utils'; import type { ClientContract } from './contract'; import type { + AllCrudOperations, CoreCreateOperations, CoreCrudOperations, CoreDeleteOperations, @@ -376,12 +377,94 @@ type FieldFilter< AllowedKinds > : // primitive - PrimitiveFilter< - GetModelFieldType, - ModelFieldIsOptional, - WithAggregations, - AllowedKinds - >; + GetModelFieldType extends 'String' + ? // string — additionally consider fuzzy / full-text augmentations + AddFullTextFilterIfSupported< + Schema, + Model, + Field, + AllowedKinds, + AddFuzzyFilterIfSupported< + Schema, + Model, + Field, + AllowedKinds, + PrimitiveFilter< + GetModelFieldType, + ModelFieldIsOptional, + WithAggregations, + AllowedKinds + > + > + > + : PrimitiveFilter< + GetModelFieldType, + ModelFieldIsOptional, + WithAggregations, + AllowedKinds + >; + +/** + * Conditionally augments a string-field filter with the `fuzzy` operator when: + * 1. The `Fuzzy` filter kind is allowed for this field, AND + * 2. The schema's provider supports fuzzy search (postgres only), AND + * 3. The field is annotated with `@fuzzy` in the ZModel schema. + * + * Caller is responsible for only invoking this on String-typed fields + * (the gate lives in `FieldFilter`). + */ +type AddFuzzyFilterIfSupported< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, + AllowedKinds extends FilterKind, + Base, +> = 'Fuzzy' extends AllowedKinds + ? ProviderSupportsFuzzy extends true + ? GetModelField['fuzzy'] extends true + ? Base & { + /** + * Performs a fuzzy search on the string field. Only available when + * the schema's provider is `postgresql` (requires `pg_trgm` extension) + * and the field is annotated with `@fuzzy` in the ZModel schema. + * See {@link FuzzyFilterPayload} for the full options reference. + */ + fuzzy?: FuzzyFilterPayload; + } + : Base + : Base + : Base; + +/** + * Conditionally augments a string-field filter with the `fts` operator when: + * 1. The `FullText` filter kind is allowed for this field, AND + * 2. The schema's provider supports full-text search (postgres only), AND + * 3. The field is annotated with `@fullText` in the ZModel schema. + * + * Caller is responsible for only invoking this on String-typed fields + * (the gate lives in `FieldFilter`). + */ +type AddFullTextFilterIfSupported< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, + AllowedKinds extends FilterKind, + Base, +> = 'FullText' extends AllowedKinds + ? ProviderSupportsFullText extends true + ? GetModelField['fullText'] extends true + ? Base & { + /** + * Performs a full-text search on the string field. Only available when + * the schema's provider is `postgresql` and the field is annotated with + * `@fullText` in the ZModel schema. + * See {@link FullTextFilterPayload} for the full options reference. + */ + fts?: FullTextFilterPayload; + } + : Base + : Base + : Base; type EnumFilter< Schema extends SchemaDef, @@ -808,20 +891,22 @@ export type TypedJsonFilter< Array extends boolean, Optional extends boolean, AllowedKinds extends FilterKind, -> = XOR, TypedJsonTypedFilter>; +> = + | (JsonFilter & { [Key in GetTypeDefFields]?: never }) + | (TypedJsonTypedFilter & { + [Key in keyof JsonFilter]?: never; + }) + | (Optional extends true ? null | JsonNullValues : never); type TypedJsonTypedFilter< Schema extends SchemaDef, TypeDefName extends GetTypeDefs, Array extends boolean, - Optional extends boolean, AllowedKinds extends FilterKind, > = 'Json' extends AllowedKinds - ? - | (Array extends true - ? ArrayTypedJsonFilter - : NonArrayTypedJsonFilter) - | (Optional extends true ? null | JsonNullValues : never) + ? Array extends true + ? ArrayTypedJsonFilter + : NonArrayTypedJsonFilter : {}; type ArrayTypedJsonFilter< @@ -887,6 +972,157 @@ type TypedJsonFieldsFilter< export type SortOrder = 'asc' | 'desc'; export type NullsOrder = 'first' | 'last'; +type StringFields> = { + [Key in NonRelationFields]: MapModelFieldType extends string | null + ? Key + : never; +}[NonRelationFields]; + +/** + * String fields that have been annotated with `@fuzzy` and are therefore eligible + * for `_fuzzyRelevance` ordering. + */ +type FuzzyFields> = { + [Key in StringFields]: GetModelField['fuzzy'] extends true ? Key : never; +}[StringFields]; + +/** + * Payload for the `fuzzy` string filter operator. Performs a fuzzy search using + * PostgreSQL `pg_trgm` (only available when the schema's provider is `postgresql`). + * Not supported on MySQL or SQLite (throws `NotSupported` at runtime). + * + * Modes: + * - `'simple'` (default): trigram similarity on the whole value (operator `%`, + * function `similarity()`). + * - `'word'`: word similarity — checks if the search term is approximately + * contained as a word inside the value (operator `<%`, + * function `word_similarity()`). + * - `'strictWord'`: stricter variant of `'word'` (operator `<<%`, + * function `strict_word_similarity()`). + * + * When `threshold` is provided the function form is used + * (`similarity() > threshold`) instead of the operator form, so the + * `pg_trgm.*_threshold` session settings are bypassed. + * + * `unaccent` is opt-in (defaults to `false`) — set it to `true` to make the + * comparison accent-insensitive. Enabling it requires the `unaccent` extension + * to be installed on the database. + */ +export type FuzzyFilterPayload = { + /** + * Search term to match against (must be a non-empty string). + */ + search: string; + /** + * Matching mode. Defaults to `'simple'`. + */ + mode?: 'simple' | 'word' | 'strictWord'; + /** + * Optional similarity threshold in `[0, 1]`. When provided, the function + * form is used and matches require `similarity > threshold`. + */ + threshold?: number; + /** + * Whether to apply `unaccent()` to both sides. Defaults to `false`. + * Set to `true` to enable accent-insensitive matching (requires the + * `unaccent` extension on PostgreSQL). + */ + unaccent?: boolean; +}; + +export type FuzzyRelevanceOrderBy> = { + /** + * Sorts by fuzzy search relevance using PostgreSQL `pg_trgm` similarity functions. + * Not supported on MySQL or SQLite (throws `NotSupported` at runtime). + * Cannot be combined with cursor-based pagination. + */ + _fuzzyRelevance?: { + /** + * String fields annotated with `@fuzzy` to compute relevance against (must be non-empty). + * + * When multiple fields are provided, the row's relevance score is the + * greatest per-field similarity, i.e. `GREATEST(similarity(field1, search), similarity(field2, search), ...)`. + */ + fields: [FuzzyFields, ...FuzzyFields[]]; + /** + * The search term to compute relevance for. + */ + search: string; + /** + * Fuzzy matching mode used to compute relevance. + */ + mode?: 'simple' | 'word' | 'strictWord'; + /** + * Whether to remove accents before computing relevance. + */ + unaccent?: boolean; + /** + * Sort direction. + */ + sort: SortOrder; + }; +}; + +/** + * String fields that have been annotated with `@fullText` and are therefore eligible + * for `_ftsRelevance` ordering. + */ +type FullTextFields> = { + [Key in StringFields]: GetModelField['fullText'] extends true ? Key : never; +}[StringFields]; + +/** + * Payload for the `fts` string filter operator. Performs full-text search using + * PostgreSQL `to_tsvector` / `to_tsquery` (postgresql provider only). + * + * Query syntax follows `to_tsquery`: callers write raw `&` (AND), `|` (OR), + * `!` (NOT), `<->` (FOLLOWED BY). Malformed queries throw at SQL execution time. + */ +export type FullTextFilterPayload = { + /** + * Search query in `to_tsquery` syntax (must be a non-empty string). + */ + search: string; + /** + * Postgres text-search configuration (e.g. `'english'`, `'simple'`). When + * omitted, the database's `default_text_search_config` setting is used — + * the SQL is emitted as `to_tsvector(field) @@ to_tsquery(query)` without + * an explicit regconfig argument. + */ + config?: string; +}; + +export type FtsRelevanceOrderBy> = { + /** + * Sorts by full-text-search relevance using PostgreSQL `ts_rank`. + */ + _ftsRelevance?: { + /** + * String fields annotated with `@fullText` to compute relevance against (must be non-empty). + * + * When multiple fields are provided, the fields are concatenated with a + * space separator and a single `ts_rank` is computed over the combined + * document — i.e. `ts_rank(to_tsvector(concat_ws(' ', f1, f2, ...)), q)`. + * This means an AND query (e.g. `'cat & dog'`) matches rows where the + * terms appear across different fields, not just within the same field. + */ + fields: [FullTextFields, ...FullTextFields[]]; + /** + * The search term to compute relevance for (in `to_tsquery` syntax). + */ + search: string; + /** + * Postgres text-search configuration. When omitted, the database's + * `default_text_search_config` setting is used. + */ + config?: string; + /** + * Sort direction. + */ + sort: SortOrder; + }; +}; + export type OrderBy< Schema extends SchemaDef, Model extends GetModels, @@ -1237,7 +1473,11 @@ type SortAndTakeArgs< /** * Order by clauses */ - orderBy?: OrArray>; + orderBy?: OrArray< + OrderBy & + (ProviderSupportsFuzzy extends true ? FuzzyRelevanceOrderBy : {}) & + (ProviderSupportsFullText extends true ? FtsRelevanceOrderBy : {}) + >; /** * Cursor for pagination @@ -1456,10 +1696,14 @@ type CreateRelationPayload< } >; -type CreateWithFKInput< +/** + * Create input type that uses FK scalar fields (e.g., `authorId`) instead of + * relation objects. + */ +export type UncheckedCreateInput< Schema extends SchemaDef, Model extends GetModels, - Options extends QueryOptions, + Options extends QueryOptions = QueryOptions, > = // scalar fields CreateScalarPayload & @@ -1468,10 +1712,14 @@ type CreateWithFKInput< // non-owned relations CreateWithNonOwnedRelationPayload; -type CreateWithRelationInput< +/** + * Create input type that uses relation objects (e.g., `author: { connect: … }`) + * instead of FK scalar fields. + */ +export type CheckedCreateInput< Schema extends SchemaDef, Model extends GetModels, - Options extends QueryOptions, + Options extends QueryOptions = QueryOptions, > = CreateScalarPayload & CreateRelationPayload; type CreateWithNonOwnedRelationPayload< @@ -1530,8 +1778,8 @@ export type CreateInput< Options extends QueryOptions, Without extends string = never, > = XOR< - Omit, Without>, - Omit, Without> + Omit, Without>, + Omit, Without> >; type NestedCreateInput< @@ -1599,7 +1847,7 @@ type UpdateManyPayload< /** * The data to update the records with. */ - data: OrArray>; + data: OrArray & UpdateFKPayload>; /** * The filter to select records to update. @@ -1636,13 +1884,17 @@ export type UpsertArgs< } & SelectIncludeOmit & ExtractExtQueryArgs; +// Non-FK, non-relation scalar fields (shared by both update branches). type UpdateScalarInput< Schema extends SchemaDef, Model extends GetModels, Without extends string = never, > = Omit< { - [Key in NonRelationFields as FieldIsDelegateDiscriminator extends true + [Key in Exclude< + NonRelationFields, + ForeignKeyFields + > as FieldIsDelegateDiscriminator extends true ? // discriminator fields cannot be assigned never : Key]?: ScalarUpdatePayload; @@ -1650,6 +1902,14 @@ type UpdateScalarInput< Without >; +// FK-only update payload (unchecked/FK branch only). +type UpdateFKPayload, Without extends string = never> = Omit< + { + [Key in ForeignKeyFields]?: MapModelFieldType; + }, + Without +>; + type ScalarUpdatePayload< Schema extends SchemaDef, Model extends GetModels, @@ -1715,12 +1975,53 @@ type UpdateRelationInput< Without >; +// Non-owned relations (e.g., Post.comments where Comment holds the FK) are valid in +// both the unchecked and checked branches, just as they are in CreateInput. +type UpdateNonOwnedRelationInput< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = { + [Key in NonOwnedRelationFields as RelationFieldType extends GetSlicedModels< + Schema, + Options + > + ? Key + : never]?: UpdateRelationFieldPayload; +}; + +/** + * Update input type that uses FK scalar fields (e.g., `authorId`) instead of + * relation objects. + */ +export type UncheckedUpdateInput< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = UpdateScalarInput & + UpdateFKPayload & + UpdateNonOwnedRelationInput; + +/** + * Update input type that uses relation objects (e.g., `author: { connect: … }`) + * instead of FK scalar fields. + */ +export type CheckedUpdateInput< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = UpdateScalarInput & UpdateRelationInput; + export type UpdateInput< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions, Without extends string = never, -> = UpdateScalarInput & UpdateRelationInput; +> = XOR< + Omit, Without>, + Omit, Without> +>; + type UpdateRelationFieldPayload< Schema extends SchemaDef, Model extends GetModels, @@ -2173,6 +2474,94 @@ export type GroupByResult< // #endregion +// #region Op maps + +/** + * Maps each CRUD operation name to its argument type for a given model. + */ +export type CrudArgsMap< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = { + findMany: FindManyArgs; + findUnique: FindUniqueArgs; + findUniqueOrThrow: FindUniqueArgs; + findFirst: FindFirstArgs; + findFirstOrThrow: FindFirstArgs; + create: CreateArgs; + createMany: CreateManyArgs; + createManyAndReturn: CreateManyAndReturnArgs; + update: UpdateArgs; + updateMany: UpdateManyArgs; + updateManyAndReturn: UpdateManyAndReturnArgs; + upsert: UpsertArgs; + delete: DeleteArgs; + deleteMany: DeleteManyArgs; + count: CountArgs; + aggregate: AggregateArgs; + groupBy: GroupByArgs; + exists: ExistsArgs; +}; + +/** + * Maps a CRUD operation name to its argument type for a given model. + */ +export type CrudArgsType< + Schema extends SchemaDef, + Model extends GetModels, + Op extends AllCrudOperations, + Options extends QueryOptions = QueryOptions, + ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, +> = CrudArgsMap[Op]; + +/** + * Maps each CRUD operation name to its return type for a given model + args. + */ +export type CrudReturnMap< + Schema extends SchemaDef, + Model extends GetModels, + Args, + Options extends QueryOptions = QueryOptions, + ExtResult extends ExtResultBase = {}, +> = { + findMany: SimplifiedPlainResult[]; + findUnique: SimplifiedPlainResult | null; + findUniqueOrThrow: SimplifiedPlainResult; + findFirst: SimplifiedPlainResult | null; + findFirstOrThrow: SimplifiedPlainResult; + create: SimplifiedPlainResult; + createMany: BatchResult; + createManyAndReturn: SimplifiedPlainResult[]; + update: SimplifiedPlainResult; + updateMany: BatchResult; + updateManyAndReturn: SimplifiedPlainResult[]; + upsert: SimplifiedPlainResult; + delete: SimplifiedPlainResult; + deleteMany: BatchResult; + count: CountResult; + aggregate: AggregateResult; + groupBy: Args extends { by: unknown } ? GroupByResult : never; + exists: boolean; +}; + +/** + * Maps a CRUD operation name to its return type for a given model + args. + */ +export type CrudReturnType< + Schema extends SchemaDef, + Model extends GetModels, + Op extends AllCrudOperations, + Args, + Options extends QueryOptions = QueryOptions, + ExtResult extends ExtResultBase = {}, +> = CrudReturnMap[Op]; + +// #endregion + // #region Relation manipulation type ConnectInput< @@ -2465,6 +2854,12 @@ type ProviderSupportsDistinct = Schema['provider']['ty ? true : false; +type ProviderSupportsFuzzy = Schema['provider']['type'] extends 'postgresql' ? true : false; + +type ProviderSupportsFullText = Schema['provider']['type'] extends 'postgresql' + ? true + : false; + /** * Extracts extended query args for a specific operation. */ diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index c90f1f4d0..4068f5ccf 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -87,8 +87,13 @@ export abstract class BaseCrudDialect { /** * Transforms input value before sending to database. + * + * `fieldDef` is optional so existing callers that don't have it stay + * source-compatible. Dialects can use it to inspect `@db.*` native-type + * attributes (e.g. to format `@db.Time` values as `HH:MM:SS` rather than + * full ISO timestamps). */ - transformInput(value: unknown, _type: BuiltinType, _forArrayField: boolean) { + transformInput(value: unknown, _type: BuiltinType, _forArrayField: boolean, _fieldDef?: FieldDef) { return value; } @@ -166,6 +171,22 @@ export abstract class BaseCrudDialect { result = this.buildOrderBy(result, model, modelAlias, effectiveOrderBy, negateOrderBy, take); if (args.cursor) { + if (effectiveOrderBy) { + const offendingKey = enumerate(effectiveOrderBy) + .map((ob: any) => { + if (typeof ob !== 'object' || ob === null) return undefined; + if ('_fuzzyRelevance' in ob) return '_fuzzyRelevance'; + if ('_ftsRelevance' in ob) return '_ftsRelevance'; + return undefined; + }) + .find((k) => k !== undefined); + if (offendingKey) { + // TODO: revisit this limitation + throw createNotSupportedError( + `cursor pagination cannot be combined with "${offendingKey}" ordering`, + ); + } + } result = this.buildCursorFilter( model, result, @@ -523,7 +544,7 @@ export abstract class BaseCrudDialect { } invariant(fieldDef.array, 'Field must be an array type to build array filter'); - const value = this.transformInput(_value, fieldType, true); + const value = this.transformInput(_value, fieldType, true, fieldDef); let receiver = fieldRef; if (isEnum(this.schema, fieldType)) { @@ -592,7 +613,7 @@ export abstract class BaseCrudDialect { } return match(fieldDef.type as BuiltinType) - .with('String', () => this.buildStringFilter(fieldRef, payload)) + .with('String', () => this.buildStringFilter(fieldRef, payload, fieldDef)) .with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) => this.buildNumberFilter(fieldRef, type, payload), ) @@ -907,7 +928,7 @@ export abstract class BaseCrudDialect { return { conditions, consumedKeys }; } - private buildStringFilter(fieldRef: Expression, payload: StringFilter) { + private buildStringFilter(fieldRef: Expression, payload: StringFilter, fieldDef?: FieldDef) { let mode: 'default' | 'insensitive' | undefined; if (payload && typeof payload === 'object' && 'mode' in payload) { mode = payload.mode; @@ -918,13 +939,12 @@ export abstract class BaseCrudDialect { payload, mode === 'insensitive' ? this.eb.fn('lower', [fieldRef]) : fieldRef, (value) => this.prepStringCasing(this.eb, value, mode), - (value) => this.buildStringFilter(fieldRef, value as StringFilter), + (value) => this.buildStringFilter(fieldRef, value as StringFilter, fieldDef), ); if (payload && typeof payload === 'object') { for (const [key, value] of Object.entries(payload)) { if (key === 'mode' || consumedKeys.includes(key)) { - // already consumed continue; } @@ -932,6 +952,24 @@ export abstract class BaseCrudDialect { continue; } + if (key === 'fuzzy') { + invariant( + fieldDef?.fuzzy === true, + `field "${fieldDef?.name ?? ''}" is not fuzzy-searchable; add the \`@fuzzy\` attribute to use the \`fuzzy\` filter`, + ); + conditions.push(this.buildFuzzyFilter(fieldRef, this.normalizeFuzzyOptions(value))); + continue; + } + + if (key === 'fts') { + invariant( + fieldDef?.fullText === true, + `field "${fieldDef?.name ?? ''}" is not full-text-searchable; add the \`@fullText\` attribute to use the \`fts\` filter`, + ); + conditions.push(this.buildFullTextFilter(fieldRef, value)); + continue; + } + invariant(typeof value === 'string', `${key} value must be a string`); const escapedValue = this.escapeLikePattern(value); @@ -1088,82 +1126,48 @@ export abstract class BaseCrudDialect { continue; } + // _fuzzyRelevance ordering + if (field === '_fuzzyRelevance') { + result = this.applyFuzzyRelevanceOrderBy(result, model, modelAlias, value, negated, buildFieldRef); + continue; + } + + // _ftsRelevance ordering + if (field === '_ftsRelevance') { + result = this.applyFtsRelevanceOrderBy(result, model, modelAlias, value, negated, buildFieldRef); + continue; + } + // aggregations if (['_count', '_avg', '_sum', '_min', '_max'].includes(field)) { - invariant(typeof value === 'object', `invalid orderBy value for field "${field}"`); - for (const [k, v] of Object.entries(value)) { - invariant(v === 'asc' || v === 'desc', `invalid orderBy value for field "${field}"`); - result = result.orderBy( - (eb) => aggregate(eb, buildFieldRef(model, k, modelAlias), field as AggregateOperators), - this.negateSort(v, negated), - ); - } + result = this.applyAggregationOrderBy( + result, + model, + modelAlias, + field as AggregateOperators, + value, + negated, + buildFieldRef, + ); continue; } const fieldDef = requireField(this.schema, model, field); if (!fieldDef.relation) { - const fieldRef = buildFieldRef(model, field, modelAlias); - if (value === 'asc' || value === 'desc') { - result = result.orderBy(fieldRef, this.negateSort(value, negated)); - } else if ( - typeof value === 'object' && - 'nulls' in value && - 'sort' in value && - (value.sort === 'asc' || value.sort === 'desc') && - (value.nulls === 'first' || value.nulls === 'last') - ) { - result = this.buildOrderByField( - result, - fieldRef, - this.negateSort(value.sort, negated), - value.nulls, - ); - } + result = this.applyScalarOrderBy(result, model, modelAlias, field, value, negated, buildFieldRef); } else { - // order by relation - const relationModel = fieldDef.type; - - if (fieldDef.array) { - // order by to-many relation - if (typeof value !== 'object') { - throw createInvalidInputError(`invalid orderBy value for field "${field}"`); - } - if ('_count' in value) { - invariant( - value._count === 'asc' || value._count === 'desc', - 'invalid orderBy value for field "_count"', - ); - const sort = this.negateSort(value._count, negated); - result = result.orderBy((eb) => { - const subQueryAlias = tmpAlias(`${modelAlias}$ob$${field}$ct`); - let subQuery = this.buildSelectModel(relationModel, subQueryAlias); - const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, subQueryAlias); - subQuery = subQuery.where(() => - this.and( - ...joinPairs.map(([left, right]) => - eb(this.eb.ref(left), '=', this.eb.ref(right)), - ), - ), - ); - subQuery = subQuery.select(() => eb.fn.count(eb.lit(1)).as('_count')); - return subQuery; - }, sort); - } - } else { - // order by to-one relation - const joinAlias = tmpAlias(`${modelAlias}$ob$${index}`); - result = result.leftJoin(`${relationModel} as ${joinAlias}`, (join) => { - const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, joinAlias); - return join.on((eb) => - this.and( - ...joinPairs.map(([left, right]) => eb(this.eb.ref(left), '=', this.eb.ref(right))), - ), - ); - }); - result = this.buildOrderBy(result, relationModel, joinAlias, value, negated, take); - } + result = this.applyRelationOrderBy( + result, + model, + modelAlias, + field, + fieldDef, + value, + negated, + take, + index, + ); } } }); @@ -1171,6 +1175,191 @@ export abstract class BaseCrudDialect { return result; } + private applyRelationOrderBy( + query: SelectQueryBuilder, + model: string, + modelAlias: string, + field: string, + fieldDef: FieldDef, + value: any, + negated: boolean, + take: number | undefined, + index: number, + ): SelectQueryBuilder { + const relationModel = fieldDef.type; + + if (fieldDef.array) { + // order by to-many relation + if (typeof value !== 'object') { + throw createInvalidInputError(`invalid orderBy value for field "${field}"`); + } + if ('_count' in value) { + invariant( + value._count === 'asc' || value._count === 'desc', + 'invalid orderBy value for field "_count"', + ); + const sort = this.negateSort(value._count, negated); + return query.orderBy((eb) => { + const subQueryAlias = tmpAlias(`${modelAlias}$ob$${field}$ct`); + let subQuery = this.buildSelectModel(relationModel, subQueryAlias); + const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, subQueryAlias); + subQuery = subQuery.where(() => + this.and(...joinPairs.map(([left, right]) => eb(this.eb.ref(left), '=', this.eb.ref(right)))), + ); + subQuery = subQuery.select(() => eb.fn.count(eb.lit(1)).as('_count')); + return subQuery; + }, sort); + } + return query; + } + + // order by to-one relation + const joinAlias = tmpAlias(`${modelAlias}$ob$${index}`); + const joined = query.leftJoin(`${relationModel} as ${joinAlias}`, (join) => { + const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, joinAlias); + return join.on((eb) => + this.and(...joinPairs.map(([left, right]) => eb(this.eb.ref(left), '=', this.eb.ref(right)))), + ); + }); + return this.buildOrderBy(joined, relationModel, joinAlias, value, negated, take); + } + + private applyScalarOrderBy( + query: SelectQueryBuilder, + model: string, + modelAlias: string, + field: string, + value: any, + negated: boolean, + buildFieldRef: (model: string, field: string, modelAlias: string) => Expression, + ): SelectQueryBuilder { + const fieldRef = buildFieldRef(model, field, modelAlias); + if (value === 'asc' || value === 'desc') { + return query.orderBy(fieldRef, this.negateSort(value, negated)); + } + if (typeof value === 'object' && 'sort' in value && (value.sort === 'asc' || value.sort === 'desc')) { + const sort = this.negateSort(value.sort, negated); + if (value.nulls === 'first' || value.nulls === 'last') { + return this.buildOrderByField(query, fieldRef, sort, value.nulls); + } else { + return query.orderBy(fieldRef, sort); + } + } + return query; + } + + private applyAggregationOrderBy( + query: SelectQueryBuilder, + model: string, + modelAlias: string, + field: AggregateOperators, + value: any, + negated: boolean, + buildFieldRef: (model: string, field: string, modelAlias: string) => Expression, + ): SelectQueryBuilder { + invariant(typeof value === 'object', `invalid orderBy value for field "${field}"`); + let result = query; + for (const [k, v] of Object.entries(value)) { + invariant(v === 'asc' || v === 'desc', `invalid orderBy value for field "${field}"`); + result = result.orderBy( + (eb) => aggregate(eb, buildFieldRef(model, k, modelAlias), field), + this.negateSort(v, negated), + ); + } + return result; + } + + private applyFuzzyRelevanceOrderBy( + query: SelectQueryBuilder, + model: string, + modelAlias: string, + value: any, + negated: boolean, + buildFieldRef: (model: string, field: string, modelAlias: string) => Expression, + ): SelectQueryBuilder { + invariant( + typeof value === 'object' && 'fields' in value && 'search' in value && 'sort' in value, + 'invalid orderBy value for "_fuzzyRelevance"', + ); + invariant( + Array.isArray(value.fields) && value.fields.length > 0, + '_fuzzyRelevance.fields must be a non-empty array', + ); + invariant(value.sort === 'asc' || value.sort === 'desc', 'invalid sort value for "_fuzzyRelevance"'); + invariant( + typeof value.search === 'string' && value.search.length > 0, + '_fuzzyRelevance.search must be a non-empty string', + ); + const mode = value.mode ?? 'simple'; + invariant( + mode === 'simple' || mode === 'word' || mode === 'strictWord', + '_fuzzyRelevance.mode must be "simple", "word" or "strictWord"', + ); + const unaccent = value.unaccent ?? false; + invariant(typeof unaccent === 'boolean', '_fuzzyRelevance.unaccent must be a boolean'); + for (const fieldName of value.fields as string[]) { + const fieldDef = requireField(this.schema, model, fieldName); + invariant( + fieldDef.fuzzy === true, + `field "${fieldName}" is not fuzzy-searchable; add the \`@fuzzy\` attribute to use it in \`_fuzzyRelevance\``, + ); + } + const fieldRefs = (value.fields as string[]).map((f) => buildFieldRef(model, f, modelAlias)); + return this.buildFuzzyRelevanceOrderBy( + query, + fieldRefs, + value.search, + this.negateSort(value.sort, negated), + mode, + unaccent, + ); + } + + private applyFtsRelevanceOrderBy( + query: SelectQueryBuilder, + model: string, + modelAlias: string, + value: any, + negated: boolean, + buildFieldRef: (model: string, field: string, modelAlias: string) => Expression, + ): SelectQueryBuilder { + invariant( + typeof value === 'object' && 'fields' in value && 'search' in value && 'sort' in value, + 'invalid orderBy value for "_ftsRelevance"', + ); + invariant( + Array.isArray(value.fields) && value.fields.length > 0, + '_ftsRelevance.fields must be a non-empty array', + ); + invariant(value.sort === 'asc' || value.sort === 'desc', 'invalid sort value for "_ftsRelevance"'); + invariant( + typeof value.search === 'string' && value.search.length > 0, + '_ftsRelevance.search must be a non-empty string', + ); + if (value.config !== undefined) { + invariant( + typeof value.config === 'string' && value.config.length > 0, + '_ftsRelevance.config must be a non-empty string', + ); + } + const config = value.config as string | undefined; + for (const fieldName of value.fields as string[]) { + const fieldDef = requireField(this.schema, model, fieldName); + invariant( + fieldDef.fullText === true, + `field "${fieldName}" is not full-text-searchable; add the \`@fullText\` attribute to use it in \`_ftsRelevance\``, + ); + } + const fieldRefs = (value.fields as string[]).map((f) => buildFieldRef(model, f, modelAlias)); + return this.buildFtsRelevanceOrderBy( + query, + fieldRefs, + value.search, + config, + this.negateSort(value.sort, negated), + ); + } + buildSelectAllFields( model: string, query: SelectQueryBuilder, @@ -1592,5 +1781,91 @@ export abstract class BaseCrudDialect { nulls: 'first' | 'last', ): SelectQueryBuilder; + /** + * Builds a fuzzy search filter for a string field using PostgreSQL `pg_trgm`. + * The selected SQL form (operator vs. function, with/without `unaccent`) depends + * on the resolved options. + */ + abstract buildFuzzyFilter(fieldRef: Expression, options: FuzzyFilterOptions): Expression; + + /** + * Builds an ORDER BY clause that sorts by fuzzy relevance to a search term. + */ + abstract buildFuzzyRelevanceOrderBy( + query: SelectQueryBuilder, + fieldRefs: Expression[], + search: string, + sort: SortOrder, + mode: FuzzyFilterOptions['mode'], + unaccent: boolean, + ): SelectQueryBuilder; + + /** + * Validate the user-provided fuzzy filter payload and apply defaults so dialects + * always receive a fully-resolved {@link FuzzyFilterOptions} value. + */ + protected normalizeFuzzyOptions(value: unknown): FuzzyFilterOptions { + invariant( + value !== null && typeof value === 'object' && !Array.isArray(value), + 'fuzzy filter must be an object with at least a "search" field', + ); + const raw = value as Record; + invariant( + typeof raw['search'] === 'string' && raw['search'].length > 0, + 'fuzzy.search must be a non-empty string', + ); + const mode = raw['mode'] ?? 'simple'; + invariant( + mode === 'simple' || mode === 'word' || mode === 'strictWord', + 'fuzzy.mode must be "simple", "word" or "strictWord"', + ); + const threshold = raw['threshold']; + if (threshold !== undefined) { + invariant( + typeof threshold === 'number' && threshold >= 0 && threshold <= 1, + 'fuzzy.threshold must be a number between 0 and 1', + ); + } + const unaccent = raw['unaccent'] ?? false; + invariant(typeof unaccent === 'boolean', 'fuzzy.unaccent must be a boolean'); + return { + search: raw['search'], + mode: mode as FuzzyFilterOptions['mode'], + threshold: threshold as number | undefined, + unaccent, + }; + } + + /** + * Builds a full-text-search filter for a string field. Receives the raw + * user-supplied filter payload — dialects validate/normalize it themselves + * (the shape is provider-specific; only Postgres supports this filter). + */ + abstract buildFullTextFilter(fieldRef: Expression, payload: unknown): Expression; + + /** + * Builds an ORDER BY clause that sorts by full-text-search relevance to a search term. + */ + abstract buildFtsRelevanceOrderBy( + query: SelectQueryBuilder, + fieldRefs: Expression[], + search: string, + config: string | undefined, + sort: SortOrder, + ): SelectQueryBuilder; + // #endregion } + +/** + * Resolved options for a fuzzy filter passed to a dialect. `mode` and `unaccent` + * are always populated (defaults: `mode='simple'`, `unaccent=false`, applied by + * `normalizeFuzzyOptions`); `threshold` is optional and switches the SQL from + * operator form (`%`, `<%`, `<<%`) to function form (`similarity() > threshold`). + */ +export type FuzzyFilterOptions = { + search: string; + mode: 'simple' | 'word' | 'strictWord'; + threshold?: number; + unaccent: boolean; +}; diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index 012e755e9..2af95e2cd 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -16,6 +16,7 @@ import type { NullsOrder, SortOrder } from '../../crud-types'; import { createInvalidInputError, createNotSupportedError } from '../../errors'; import type { ClientOptions } from '../../options'; import { isTypeDef } from '../../query-utils'; +import type { FuzzyFilterOptions } from './base-dialect'; import { LateralJoinDialectBase } from './lateral-join-dialect-base'; export class MySqlCrudDialect extends LateralJoinDialectBase { @@ -396,4 +397,41 @@ export class MySqlCrudDialect extends LateralJoinDiale } // #endregion + + // #region fuzzy search + + override buildFuzzyFilter(_fieldRef: Expression, _options: FuzzyFilterOptions): Expression { + throw createNotSupportedError('"fuzzy" filter is not supported by the "mysql" provider'); + } + + override buildFuzzyRelevanceOrderBy( + _query: SelectQueryBuilder, + _fieldRefs: Expression[], + _search: string, + _sort: SortOrder, + _mode: FuzzyFilterOptions['mode'], + _unaccent: boolean, + ): SelectQueryBuilder { + throw createNotSupportedError('"_fuzzyRelevance" ordering is not supported by the "mysql" provider'); + } + + // #endregion + + // #region full-text search + + override buildFullTextFilter(_fieldRef: Expression, _payload: unknown): Expression { + throw createNotSupportedError('"fts" filter is not supported by the "mysql" provider'); + } + + override buildFtsRelevanceOrderBy( + _query: SelectQueryBuilder, + _fieldRefs: Expression[], + _search: string, + _config: string | undefined, + _sort: SortOrder, + ): SelectQueryBuilder { + throw createNotSupportedError('"_ftsRelevance" ordering is not supported by the "mysql" provider'); + } + + // #endregion } diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index c8a12de2b..41d9b9006 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -15,8 +15,21 @@ import type { NullsOrder, SortOrder } from '../../crud-types'; import { createInvalidInputError } from '../../errors'; import type { ClientOptions } from '../../options'; import { isEnum, isTypeDef } from '../../query-utils'; +import type { FuzzyFilterOptions } from './base-dialect'; import { LateralJoinDialectBase } from './lateral-join-dialect-base'; +/** + * Formats a JS `Date` as a Postgres TIME / TIMETZ literal (`HH:MM:SS.fff`, + * optionally with `+ZZ:ZZ` for TIMETZ). Reads UTC components so the value + * round-trips with ISO-input parsing — callers anchor time-only inputs to + * the Unix epoch. + */ +function formatTimeOfDay(date: Date, withTimezone: boolean): string { + const pad = (n: number, w = 2) => String(n).padStart(w, '0'); + const time = `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)}`; + return withTimezone ? `${time}+00:00` : time; +} + export class PostgresCrudDialect extends LateralJoinDialectBase { private static typeParserOverrideApplied = false; @@ -154,7 +167,7 @@ export class PostgresCrudDialect extends LateralJoinDi // #region value transformation - override transformInput(value: unknown, type: BuiltinType, forArrayField: boolean): unknown { + override transformInput(value: unknown, type: BuiltinType, forArrayField: boolean, fieldDef?: FieldDef): unknown { if (value === undefined) { return value; } @@ -186,16 +199,25 @@ export class PostgresCrudDialect extends LateralJoinDi // scalar `Json` fields need their input stringified return JSON.stringify(value); } else { - return value.map((v) => this.transformInput(v, type, false)); + return value.map((v) => this.transformInput(v, type, false, fieldDef)); } } else { switch (type) { - case 'DateTime': - return value instanceof Date - ? value.toISOString() - : typeof value === 'string' - ? new Date(value).toISOString() - : value; + case 'DateTime': { + const date = value instanceof Date ? value : typeof value === 'string' ? new Date(value) : null; + if (date === null || isNaN(date.getTime())) return value; + // Postgres TIME / TIMETZ columns reject ISO datetime input — + // they expect `HH:MM:SS[.fff][+ZZ:ZZ]`. Detect those native + // types via the field's @db.* attribute and format + // accordingly. All other DateTime fields keep the existing + // ISO behaviour (TIMESTAMP / TIMESTAMPTZ / DATE all accept + // it natively). + const dbAttrName = fieldDef?.attributes?.find((a) => a.name.startsWith('@db.'))?.name; + if (dbAttrName === '@db.Time' || dbAttrName === '@db.Timetz') { + return formatTimeOfDay(date, dbAttrName === '@db.Timetz'); + } + return date.toISOString(); + } case 'Decimal': return value !== null ? value.toString() : value; case 'Json': @@ -582,4 +604,161 @@ export class PostgresCrudDialect extends LateralJoinDi } // #endregion + + // #region search + + /** + * Wraps an expression with `unaccent(lower(...))` or just `lower(...)` depending on + * whether the user opted into accent-insensitive matching. The lowering is always + * applied so trigram comparisons are case-insensitive on both sides. + */ + private normalizeForTrigram(expr: Expression, applyUnaccent: boolean): Expression { + return applyUnaccent ? sql`unaccent(lower(${expr}))` : sql`lower(${expr})`; + } + + override buildFuzzyFilter(fieldRef: Expression, options: FuzzyFilterOptions): Expression { + const fieldExpr = this.normalizeForTrigram(fieldRef, options.unaccent); + const valueExpr = this.normalizeForTrigram(sql.val(options.search), options.unaccent); + + if (options.threshold === undefined) { + // Operator form: relies on the session-level pg_trgm.*_threshold settings. + // 'simple' -> `%` (similarity()), symmetric. + // 'word' -> `<%` (word_similarity()): search-term <% document. + // 'strictWord' -> `<<%` (strict_word_similarity()): search-term <<% document. + switch (options.mode) { + case 'simple': + return sql`${fieldExpr} % ${valueExpr}`; + case 'word': + return sql`${valueExpr} <% ${fieldExpr}`; + case 'strictWord': + return sql`${valueExpr} <<% ${fieldExpr}`; + } + } + + // Function form: explicit `similarity(...) > threshold`. Bypasses session settings, + // letting the user pick a per-query threshold. + const threshold = sql.val(options.threshold); + switch (options.mode) { + case 'simple': + return sql`similarity(${fieldExpr}, ${valueExpr}) > ${threshold}`; + case 'word': + return sql`word_similarity(${valueExpr}, ${fieldExpr}) > ${threshold}`; + case 'strictWord': + return sql`strict_word_similarity(${valueExpr}, ${fieldExpr}) > ${threshold}`; + } + } + + override buildFuzzyRelevanceOrderBy( + query: SelectQueryBuilder, + fieldRefs: Expression[], + search: string, + sort: SortOrder, + mode: FuzzyFilterOptions['mode'], + unaccent: boolean, + ): SelectQueryBuilder { + const valueExpr = this.normalizeForTrigram(sql.val(search), unaccent); + const buildSimilarity = (fieldRef: Expression) => { + const fieldExpr = this.normalizeForTrigram(fieldRef, unaccent); + switch (mode) { + case 'simple': + return sql`similarity(${fieldExpr}, ${valueExpr})`; + case 'word': + return sql`word_similarity(${valueExpr}, ${fieldExpr})`; + case 'strictWord': + return sql`strict_word_similarity(${valueExpr}, ${fieldExpr})`; + } + }; + + if (fieldRefs.length === 1) { + return query.orderBy(buildSimilarity(fieldRefs[0]!), sort); + } + const similarities = fieldRefs.map((ref) => buildSimilarity(ref)); + return query.orderBy(sql`GREATEST(${sql.join(similarities)})`, sort); + } + + override buildFullTextFilter(fieldRef: Expression, payload: unknown): Expression { + // When `config` is provided we bind it via sql.val + an inline ::regconfig + // cast (parameterized, no string concatenation). When omitted we emit the + // single-arg forms `to_tsvector(field)` / `to_tsquery(query)` so Postgres + // falls back to the database-level `default_text_search_config`. + // `to_tsquery` throws at execution on syntax error; we don't pre-validate. + const options = this.normalizeFullTextOptions(payload); + const query = sql.val(options.search); + if (options.config === undefined) { + return sql`to_tsvector(${fieldRef}) @@ to_tsquery(${query})`; + } + const cfg = sql.val(options.config); + return sql`to_tsvector(${cfg}::regconfig, ${fieldRef}) @@ to_tsquery(${cfg}::regconfig, ${query})`; + } + + /** + * Validate the user-provided `fts` filter payload. When `config` is omitted + * it stays `undefined` so {@link buildFullTextFilter} can emit the no-regconfig + * SQL form and let Postgres fall back to `default_text_search_config`. + */ + private normalizeFullTextOptions(value: unknown): FullTextFilterOptions { + invariant( + value !== null && typeof value === 'object' && !Array.isArray(value), + 'fts filter must be an object with at least a "search" field', + ); + const raw = value as Record; + invariant( + typeof raw['search'] === 'string' && (raw['search'] as string).length > 0, + 'fts.search must be a non-empty string', + ); + if (raw['config'] !== undefined) { + invariant( + typeof raw['config'] === 'string' && (raw['config'] as string).length > 0, + 'fts.config must be a non-empty string', + ); + } + return { + search: raw['search'] as string, + config: raw['config'] as string | undefined, + }; + } + + override buildFtsRelevanceOrderBy( + query: SelectQueryBuilder, + fieldRefs: Expression[], + search: string, + config: string | undefined, + sort: SortOrder, + ): SelectQueryBuilder { + const q = sql.val(search); + + // Document expression: a single field, or `concat_ws` of all fields when + // multi-field. The single-field path coalesces NULL → '' so `ts_rank` + // returns 0.0 (not NULL) for NULL-valued rows, matching the null-skipping + // behavior `concat_ws` already provides on the multi-field path. + // Multi-field uses a single ts_rank over the combined document (matches + // Prisma; ensures AND queries match terms spread across fields). + const document = + fieldRefs.length === 1 + ? sql`coalesce(${fieldRefs[0]!}, '')` + : sql`concat_ws(' ', ${sql.join(fieldRefs)})`; + + if (config === undefined) { + // No regconfig — Postgres uses default_text_search_config. + return query.orderBy(sql`ts_rank(to_tsvector(${document}), to_tsquery(${q}))`, sort); + } + const cfg = sql.val(config); + return query.orderBy( + sql`ts_rank(to_tsvector(${cfg}::regconfig, ${document}), to_tsquery(${cfg}::regconfig, ${q}))`, + sort, + ); + } + + // #endregion } + +/** + * Resolved options for the `fts` full-text filter (Postgres-only). `config` is + * left `undefined` when the user didn't supply one — `buildFullTextFilter` then + * emits `to_tsvector(field)` / `to_tsquery(query)` without a regconfig argument + * so Postgres falls back to the database-level `default_text_search_config`. + */ +type FullTextFilterOptions = { + search: string; + config: string | undefined; +}; diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index 44f8274c6..48418072c 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -26,7 +26,7 @@ import { requireModel, tmpAlias, } from '../../query-utils'; -import { BaseCrudDialect } from './base-dialect'; +import { BaseCrudDialect, type FuzzyFilterOptions } from './base-dialect'; export class SqliteCrudDialect extends BaseCrudDialect { override get provider() { @@ -547,5 +547,34 @@ export class SqliteCrudDialect extends BaseCrudDialect return ob; }); } + + override buildFuzzyFilter(_fieldRef: Expression, _options: FuzzyFilterOptions): Expression { + throw createNotSupportedError('"fuzzy" filter is not supported by the "sqlite" provider'); + } + + override buildFuzzyRelevanceOrderBy( + _query: SelectQueryBuilder, + _fieldRefs: Expression[], + _search: string, + _sort: SortOrder, + _mode: FuzzyFilterOptions['mode'], + _unaccent: boolean, + ): SelectQueryBuilder { + throw createNotSupportedError('"_fuzzyRelevance" ordering is not supported by the "sqlite" provider'); + } + + override buildFullTextFilter(_fieldRef: Expression, _payload: unknown): Expression { + throw createNotSupportedError('"fts" filter is not supported by the "sqlite" provider'); + } + + override buildFtsRelevanceOrderBy( + _query: SelectQueryBuilder, + _fieldRefs: Expression[], + _search: string, + _config: string | undefined, + _sort: SortOrder, + ): SelectQueryBuilder { + throw createNotSupportedError('"_ftsRelevance" ordering is not supported by the "sqlite" provider'); + } // #endregion } diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 4c37e0eae..d96718765 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -221,7 +221,7 @@ export abstract class BaseOperationHandler { // TODO: this is not clean, needs a better solution protected get hasPolicyEnabled() { - return this.options.plugins?.some((plugin) => plugin.constructor.name === 'PolicyPlugin'); + return this.options.plugins?.some((plugin) => plugin.id === 'policy') ?? false; } protected requireModel(model: string) { @@ -439,12 +439,13 @@ export abstract class BaseOperationHandler { Array.isArray(value.set) ) { // deal with nested "set" for scalar lists - createFields[field] = this.dialect.transformInput(value.set, fieldDef.type as BuiltinType, true); + createFields[field] = this.dialect.transformInput(value.set, fieldDef.type as BuiltinType, true, fieldDef); } else { createFields[field] = this.dialect.transformInput( value, fieldDef.type as BuiltinType, !!fieldDef.array, + fieldDef, ); } } else { @@ -887,7 +888,7 @@ export abstract class BaseOperationHandler { for (const [name, value] of Object.entries(item)) { const fieldDef = this.requireField(model, name); invariant(!fieldDef.relation, 'createMany does not support relations'); - newItem[name] = this.dialect.transformInput(value, fieldDef.type as BuiltinType, !!fieldDef.array); + newItem[name] = this.dialect.transformInput(value, fieldDef.type as BuiltinType, !!fieldDef.array, fieldDef); } if (fromRelation) { for (const { fk, pk } of relationKeyPairs) { @@ -925,6 +926,7 @@ export abstract class BaseOperationHandler { fieldDef.default, fieldDef.type as BuiltinType, !!fieldDef.array, + fieldDef, ); } } @@ -1057,11 +1059,12 @@ export abstract class BaseOperationHandler { generated, fieldDef.type as BuiltinType, !!fieldDef.array, + fieldDef, ); } } else if (fieldDef?.updatedAt) { // TODO: should this work at kysely level instead? - values[field] = this.dialect.transformInput(new Date(), 'DateTime', false); + values[field] = this.dialect.transformInput(new Date(), 'DateTime', false, fieldDef); } else if (fieldDef?.default !== undefined) { let value = fieldDef.default; if (fieldDef.type === 'Json') { @@ -1072,7 +1075,7 @@ export abstract class BaseOperationHandler { value = JSON.parse(value); } } - values[field] = this.dialect.transformInput(value, fieldDef.type as BuiltinType, !!fieldDef.array); + values[field] = this.dialect.transformInput(value, fieldDef.type as BuiltinType, !!fieldDef.array, fieldDef); } } } @@ -1176,7 +1179,7 @@ export abstract class BaseOperationHandler { if (finalData === data) { finalData = clone(data); } - finalData[fieldName] = this.dialect.transformInput(new Date(), 'DateTime', false); + finalData[fieldName] = this.dialect.transformInput(new Date(), 'DateTime', false, fieldDef); autoUpdatedFields.push(fieldName); } } @@ -1442,7 +1445,7 @@ export abstract class BaseOperationHandler { return this.transformScalarListUpdate(model, field, fieldDef, data[field]); } - return this.dialect.transformInput(data[field], fieldDef.type as BuiltinType, !!fieldDef.array); + return this.dialect.transformInput(data[field], fieldDef.type as BuiltinType, !!fieldDef.array, fieldDef); } private isNumericIncrementalUpdate(fieldDef: FieldDef, value: any) { @@ -1500,7 +1503,7 @@ export abstract class BaseOperationHandler { ); const key = Object.keys(payload)[0]; - const value = this.dialect.transformInput(payload[key!], fieldDef.type as BuiltinType, false); + const value = this.dialect.transformInput(payload[key!], fieldDef.type as BuiltinType, false, fieldDef); const eb = expressionBuilder(); const fieldRef = this.dialect.fieldRef(model, field); @@ -1523,7 +1526,7 @@ export abstract class BaseOperationHandler { ) { invariant(Object.keys(payload).length === 1, 'Only one of "set", "push" can be provided'); const key = Object.keys(payload)[0]; - const value = this.dialect.transformInput(payload[key!], fieldDef.type as BuiltinType, true); + const value = this.dialect.transformInput(payload[key!], fieldDef.type as BuiltinType, true, fieldDef); const eb = expressionBuilder(); const fieldRef = this.dialect.fieldRef(model, field); diff --git a/packages/orm/src/client/plugin.ts b/packages/orm/src/client/plugin.ts index e9f05b2ee..2a2431637 100644 --- a/packages/orm/src/client/plugin.ts +++ b/packages/orm/src/client/plugin.ts @@ -144,7 +144,7 @@ export interface RuntimePlugin< result?: ExtResult; } -export type AnyPlugin = RuntimePlugin; +export type AnyPlugin = RuntimePlugin; /** * Defines a ZenStack runtime plugin based on type of the given schema. diff --git a/packages/orm/src/client/promise.ts b/packages/orm/src/client/promise.ts index 77d9324b9..563e555fa 100644 --- a/packages/orm/src/client/promise.ts +++ b/packages/orm/src/client/promise.ts @@ -1,16 +1,11 @@ -import type { SchemaDef } from '@zenstackhq/schema'; import type { ClientContract } from './contract'; /** * A promise that only executes when it's awaited or .then() is called. */ -export type ZenStackPromise = Promise & { - /** - * @private - * Callable to get a plain promise. - */ - cb: (txClient?: ClientContract) => Promise; -}; +export interface ZenStackPromise extends Promise { + [Symbol.toStringTag]: 'ZenStackPromise'; +} /** * Creates a promise that only executes when it's awaited or .then() is called. @@ -18,7 +13,7 @@ export type ZenStackPromise = Promise & { */ export function createZenStackPromise( callback: (txClient?: ClientContract) => Promise, -): ZenStackPromise { +): ZenStackPromise { let promise: Promise | undefined; const cb = (txClient?: ClientContract) => { try { @@ -41,7 +36,7 @@ export function createZenStackPromise( }, cb, [Symbol.toStringTag]: 'ZenStackPromise', - }; + } as ZenStackPromise; } function valueToPromise(thing: any): Promise { diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 42d08324a..5c39946e6 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -41,7 +41,7 @@ import { } from '../crud/operations/base'; import { createInternalError } from '../errors'; import type { ClientOptions, QueryOptions } from '../options'; -import type { AnyPlugin, ExtQueryArgsBase, RuntimePlugin } from '../plugin'; +import type { AnyPlugin, ExtQueryArgsBase } from '../plugin'; import { fieldHasDefaultValue, getEnum, @@ -84,6 +84,37 @@ export function createQuerySchemaFactory(clientOrSchema: any, options?: any) { return new ZodSchemaFactory(clientOrSchema, options); } +/** + * Builds a `DateTime` value schema that accepts a `Date` object or any string + * the JS `Date` constructor parses, and coerces it to a `Date`. ISO datetime, + * ISO date, and time-only strings (e.g. `"09:00:00"` for `@db.Time` fields, + * anchored to the Unix epoch) are the documented happy paths; other formats + * accepted by `new Date(...)` also pass through, mirroring Prisma's pre-3.5 + * behaviour. Strings the engine can't parse fall through and are rejected by + * `z.date()` with the standard error. + * + * @see https://github.com/zenstackhq/zenstack/issues/2631 + */ +export function coercedDateTimeSchema(): ZodType { + // The schema keeps the original `z.iso.datetime() | z.iso.date() | z.date()` + // union so the generated OpenAPI spec still documents the accepted ISO + // forms. Preprocess runs first and coerces strings into `Date` objects, + // so the union's `z.date()` arm catches everything that successfully + // parses — including non-ISO formats like `"2024/01/15"` for Prisma + // compatibility (rejected with the standard error if `new Date(...)` + // returns Invalid Date). + return z.preprocess((val) => { + if (typeof val !== 'string') return val; + if (/^\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d\d(?::\d\d)?)?$/.test(val)) { + const hasTz = val.endsWith('Z') || /[+-]\d\d(?::\d\d)?$/.test(val); + const d = new Date(`1970-01-01T${val}${hasTz ? '' : 'Z'}`); + return isNaN(d.getTime()) ? val : d; + } + const d = new Date(val); + return isNaN(d.getTime()) ? val : d; + }, z.union([z.iso.datetime(), z.iso.date(), z.date()])); +} + /** * Options for creating Zod schemas. */ @@ -121,7 +152,7 @@ export class ZodSchemaFactory< } } - private get plugins(): RuntimePlugin[] { + private get plugins(): AnyPlugin[] { return this.options.plugins ?? []; } @@ -384,7 +415,11 @@ export class ZodSchemaFactory< const schema = z.looseObject( Object.fromEntries( Object.entries(typeDef.fields).map(([field, def]) => { - let fieldSchema = this.makeScalarSchema(def.type); + // Wrap nested typedef references in z.lazy() so cyclic or self-referencing + // typedefs don't recurse infinitely while building schemas. + let fieldSchema: ZodType = isTypeDef(this.schema, def.type) + ? z.lazy(() => this.makeTypeDefSchema(def.type)) + : this.makeScalarSchema(def.type); if (def.array) { fieldSchema = fieldSchema.array(); } @@ -505,6 +540,8 @@ export class ZodSchemaFactory< !!fieldDef.optional, withAggregations, allowedFilterKinds, + !!fieldDef.fuzzy, + !!fieldDef.fullText, ); } } @@ -792,9 +829,13 @@ export class ZodSchemaFactory< optional: boolean, withAggregations: boolean, allowedFilterKinds: string[] | undefined, + withFuzzy = false, + withFullText = false, ) { return match(type) - .with('String', () => this.makeStringFilterSchema(optional, withAggregations, allowedFilterKinds)) + .with('String', () => + this.makeStringFilterSchema(optional, withAggregations, allowedFilterKinds, withFuzzy, withFullText), + ) .with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) => this.makeNumberFilterSchema(type, optional, withAggregations, allowedFilterKinds), ) @@ -854,7 +895,7 @@ export class ZodSchemaFactory< @cache() private makeDateTimeValueSchema(): ZodType { - const schema = z.union([z.iso.datetime(), z.iso.date(), z.date()]); + const schema = coercedDateTimeSchema(); this.registerSchema('DateTime', schema); return schema; } @@ -1012,11 +1053,22 @@ export class ZodSchemaFactory< optional: boolean, withAggregations: boolean, allowedFilterKinds: string[] | undefined, + withFuzzy = false, + withFullText = false, ): ZodType { const baseComponents = this.makeCommonPrimitiveFilterComponents( z.string(), optional, - () => z.lazy(() => this.makeStringFilterSchema(optional, withAggregations, allowedFilterKinds)), + () => + z.lazy(() => + this.makeStringFilterSchema( + optional, + withAggregations, + allowedFilterKinds, + withFuzzy, + withFullText, + ), + ), undefined, withAggregations ? ['_count', '_min', '_max'] : undefined, allowedFilterKinds, @@ -1026,6 +1078,16 @@ export class ZodSchemaFactory< startsWith: z.string().optional(), endsWith: z.string().optional(), contains: z.string().optional(), + ...(withFuzzy && this.providerSupportsFuzzySearch + ? { + fuzzy: this.makeFuzzyFilterSchema().optional(), + } + : {}), + ...(withFullText && this.providerSupportsFullTextSearch + ? { + fts: this.makeFullTextFilterSchema().optional(), + } + : {}), ...(this.providerSupportsCaseSensitivity ? { mode: this.makeStringModeSchema().optional(), @@ -1042,8 +1104,9 @@ export class ZodSchemaFactory< }; const schema = this.createUnionFilterSchema(z.string(), optional, allComponents, allowedFilterKinds); + const featureSuffix = `${withFuzzy ? 'Fuzzy' : ''}${withFullText ? 'FullText' : ''}`; this.registerSchema( - `StringFilter${this.filterSchemaSuffix({ optional, allowedFilterKinds, withAggregations })}`, + `StringFilter${this.filterSchemaSuffix({ optional, allowedFilterKinds, withAggregations })}${featureSuffix}`, schema, ); return schema; @@ -1053,6 +1116,22 @@ export class ZodSchemaFactory< return z.union([z.literal('default'), z.literal('insensitive')]); } + private makeFuzzyFilterSchema() { + return z.strictObject({ + search: z.string().min(1), + mode: z.union([z.literal('simple'), z.literal('word'), z.literal('strictWord')]).default('simple'), + threshold: z.number().min(0).max(1).optional(), + unaccent: z.boolean().default(false), + }); + } + + private makeFullTextFilterSchema() { + return z.strictObject({ + search: z.string().min(1), + config: z.string().min(1).optional(), + }); + } + @cache() private makeSelectSchema(model: string, options?: CreateSchemaOptions) { const fields: Record = {}; @@ -1281,7 +1360,7 @@ export class ZodSchemaFactory< sort, z.strictObject({ sort, - nulls: z.union([z.literal('first'), z.literal('last')]), + nulls: z.union([z.literal('first'), z.literal('last')]).optional(), }), ]) .optional(); @@ -1299,6 +1378,43 @@ export class ZodSchemaFactory< } } + // _fuzzyRelevance ordering for fuzzy search — only fields annotated with `@fuzzy` (postgres only). + if (this.providerSupportsFuzzySearch) { + const fuzzyFieldNames = this.getModelFields(model) + .filter(([, def]) => !def.relation && def.type === 'String' && def.fuzzy === true) + .map(([name]) => name); + if (fuzzyFieldNames.length > 0) { + fields['_fuzzyRelevance'] = z + .strictObject({ + fields: z.array(z.enum(fuzzyFieldNames as [string, ...string[]])).min(1), + search: z.string(), + mode: z + .union([z.literal('simple'), z.literal('word'), z.literal('strictWord')]) + .default('simple'), + unaccent: z.boolean().default(false), + sort, + }) + .optional(); + } + } + + // _ftsRelevance ordering for full-text search — only fields annotated with `@fullText` (postgres only). + if (this.providerSupportsFullTextSearch) { + const fullTextFieldNames = this.getModelFields(model) + .filter(([, def]) => !def.relation && def.type === 'String' && def.fullText === true) + .map(([name]) => name); + if (fullTextFieldNames.length > 0) { + fields['_ftsRelevance'] = z + .strictObject({ + fields: z.array(z.enum(fullTextFieldNames as [string, ...string[]])).min(1), + search: z.string().min(1), + config: z.string().min(1).optional(), + sort, + }) + .optional(); + } + } + const schema = refineAtMostOneKey(z.strictObject(fields)); let schemaId = `${model}OrderBy`; @@ -2323,6 +2439,14 @@ export class ZodSchemaFactory< return this.schema.provider.type === 'postgresql'; } + private get providerSupportsFullTextSearch() { + return this.schema.provider.type === 'postgresql'; + } + + private get providerSupportsFuzzySearch() { + return this.schema.provider.type === 'postgresql'; + } + /** * Gets the effective set of allowed FilterKind values for a specific model and field. * Respects the precedence: model[field] > model.$all > $all[field] > $all.$all. diff --git a/packages/orm/src/utils/kysely-utils.ts b/packages/orm/src/utils/kysely-utils.ts index 140cb6a81..cd6878bd8 100644 --- a/packages/orm/src/utils/kysely-utils.ts +++ b/packages/orm/src/utils/kysely-utils.ts @@ -2,10 +2,12 @@ import { AddColumnNode, AddConstraintNode, AddIndexNode, + AddValueNode, AggregateFunctionNode, AliasNode, AlterColumnNode, AlterTableNode, + AlterTypeNode, AndNode, BinaryOperationNode, CaseNode, @@ -77,6 +79,7 @@ import { RefreshMaterializedViewNode, RenameColumnNode, RenameConstraintNode, + RenameValueNode, ReturningNode, SchemableIdentifierNode, SelectAllNode, @@ -407,6 +410,15 @@ export class DefaultOperationNodeVisitor extends OperationNodeVisitor { protected override visitCollate(node: CollateNode): void { this.defaultVisit(node); } + protected override visitAlterType(node: AlterTypeNode): void { + this.defaultVisit(node); + } + protected override visitAddValue(node: AddValueNode): void { + this.defaultVisit(node); + } + protected override visitRenameValue(node: RenameValueNode): void { + this.defaultVisit(node); + } } export type AnyKysely = Kysely; diff --git a/packages/orm/src/utils/type-utils.ts b/packages/orm/src/utils/type-utils.ts index 85152e328..bd020ebae 100644 --- a/packages/orm/src/utils/type-utils.ts +++ b/packages/orm/src/utils/type-utils.ts @@ -40,7 +40,7 @@ export type TypeMap = { Decimal: Decimal; DateTime: Date; Bytes: Uint8Array; - Json: JsonValue; + Json: JsonValue | null; Null: null; Object: Record; Any: unknown; diff --git a/packages/orm/test/schema/models.ts b/packages/orm/test/schema/models.ts new file mode 100644 index 000000000..82b5e812c --- /dev/null +++ b/packages/orm/test/schema/models.ts @@ -0,0 +1,18 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { schema as $schema, type SchemaType as $Schema } from "./schema"; +import type { ModelResult as $ModelResult, TypeDefResult as $TypeDefResult } from "@zenstackhq/orm"; +export type User = $ModelResult<$Schema, "User">; +export type Post = $ModelResult<$Schema, "Post">; +export type Product = $ModelResult<$Schema, "Product">; +export type Asset = $ModelResult<$Schema, "Asset">; +export type Video = $ModelResult<$Schema, "Video">; +export type Image = $ModelResult<$Schema, "Image">; +export type Address = $TypeDefResult<$Schema, "Address">; +export const Status = $schema.enums.Status.values; +export type Status = (typeof Status)[keyof typeof Status]; diff --git a/packages/orm/test/schema/schema.ts b/packages/orm/test/schema/schema.ts new file mode 100644 index 000000000..e0dff2a49 --- /dev/null +++ b/packages/orm/test/schema/schema.ts @@ -0,0 +1,353 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "postgresql" + } as const; + models = { + User: { + name: "User", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault + }, + email: { + name: "email", + type: "String", + attributes: [{ name: "@email" }, { name: "@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("The user's email address") }] }] as readonly AttributeApplication[] + }, + username: { + name: "username", + type: "String", + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(3) }, { name: "max", value: ExpressionUtils.literal(50) }] }] as readonly AttributeApplication[] + }, + website: { + name: "website", + type: "String", + optional: true, + attributes: [{ name: "@url" }] as readonly AttributeApplication[] + }, + code: { + name: "code", + type: "String", + attributes: [{ name: "@startsWith", args: [{ name: "text", value: ExpressionUtils.literal("USR") }] }] as readonly AttributeApplication[] + }, + age: { + name: "age", + type: "Int", + attributes: [{ name: "@gt", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }, { name: "@lte", args: [{ name: "value", value: ExpressionUtils.literal(150) }] }] as readonly AttributeApplication[] + }, + score: { + name: "score", + type: "Float", + attributes: [{ name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(0.0) }] }, { name: "@lt", args: [{ name: "value", value: ExpressionUtils.literal(100.0) }] }] as readonly AttributeApplication[] + }, + bigNum: { + name: "bigNum", + type: "BigInt", + attributes: [{ name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[] + }, + balance: { + name: "balance", + type: "Decimal", + attributes: [{ name: "@gt", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[] + }, + active: { + name: "active", + type: "Boolean" + }, + birthdate: { + name: "birthdate", + type: "DateTime", + optional: true + }, + avatar: { + name: "avatar", + type: "Bytes", + optional: true + }, + metadata: { + name: "metadata", + type: "Json", + optional: true + }, + status: { + name: "status", + type: "Status" + }, + address: { + name: "address", + type: "Address", + optional: true, + attributes: [{ name: "@json" }] as readonly AttributeApplication[] + }, + posts: { + name: "posts", + type: "Post", + array: true, + relation: { opposite: "author" } + } + }, + attributes: [ + { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils.field("age"), ">=", ExpressionUtils.literal(18)) }, { name: "message", value: ExpressionUtils.literal("Must be adult") }, { name: "path", value: ExpressionUtils.array("String", [ExpressionUtils.literal("age")]) }] }, + { name: "@@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("A user of the system") }] } + ] as readonly AttributeApplication[], + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + }, + Post: { + name: "Post", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault + }, + title: { + name: "title", + type: "String" + }, + published: { + name: "published", + type: "Boolean" + }, + tags: { + name: "tags", + type: "String", + array: true + }, + author: { + name: "author", + type: "User", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("String", [ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array("String", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "posts", fields: ["authorId"], references: ["id"] } + }, + authorId: { + name: "authorId", + type: "String", + optional: true, + foreignKeyFor: [ + "author" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + }, + Product: { + name: "Product", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault + }, + name: { + name: "name", + type: "String" + }, + price: { + name: "price", + type: "Float" + }, + discount: { + name: "discount", + type: "Float", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[], + default: 0 as FieldDefault + }, + finalPrice: { + name: "finalPrice", + type: "Float", + attributes: [{ name: "@computed" }] as readonly AttributeApplication[], + computed: true + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + }, + computedFields: { + finalPrice(_context: { + modelAlias: string; + }): number { + throw new Error("This is a stub for computed field"); + } + } + }, + Asset: { + name: "Asset", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + assetType: { + name: "assetType", + type: "String", + isDiscriminator: true + } + }, + attributes: [ + { name: "@@delegate", args: [{ name: "discriminator", value: ExpressionUtils.field("assetType") }] } + ] as readonly AttributeApplication[], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + }, + isDelegate: true, + subModels: ["Video", "Image"] + }, + Video: { + name: "Video", + baseModel: "Asset", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Asset", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + assetType: { + name: "assetType", + type: "String", + originModel: "Asset", + isDiscriminator: true + }, + duration: { + name: "duration", + type: "Int" + }, + url: { + name: "url", + type: "String" + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Image: { + name: "Image", + baseModel: "Asset", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Asset", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + assetType: { + name: "assetType", + type: "String", + originModel: "Asset", + isDiscriminator: true + }, + format: { + name: "format", + type: "String" + }, + width: { + name: "width", + type: "Int" + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + } + } as const; + typeDefs = { + Address: { + name: "Address", + fields: { + residents: { + name: "residents", + type: "String", + array: true + }, + street: { + name: "street", + type: "String", + attributes: [{ name: "@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("Street address line") }] }] as readonly AttributeApplication[] + }, + city: { + name: "city", + type: "String", + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(2) }] }] as readonly AttributeApplication[] + }, + zip: { + name: "zip", + type: "String", + optional: true + } + }, + attributes: [ + { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils.binary(ExpressionUtils.field("zip"), "==", ExpressionUtils._null()), "||", ExpressionUtils.binary(ExpressionUtils.call("length", [ExpressionUtils.field("zip")]), "==", ExpressionUtils.literal(5))) }, { name: "message", value: ExpressionUtils.literal("Zip code must be exactly 5 characters") }, { name: "path", value: ExpressionUtils.array("String", [ExpressionUtils.literal("zip")]) }] }, + { name: "@@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("A mailing address") }] } + ] as readonly AttributeApplication[] + } + } as const; + enums = { + Status: { + name: "Status", + values: { + ACTIVE: "ACTIVE", + INACTIVE: "INACTIVE", + PENDING: "PENDING" + }, + attributes: [ + { name: "@@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("User account status") }] } + ] as readonly AttributeApplication[] + } + } as const; + authType = "User" as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/packages/orm/test/schema/schema.zmodel b/packages/orm/test/schema/schema.zmodel new file mode 100644 index 000000000..9e0d5716a --- /dev/null +++ b/packages/orm/test/schema/schema.zmodel @@ -0,0 +1,80 @@ +datasource db { + provider = 'postgresql' +} + +enum Status { + ACTIVE + INACTIVE + PENDING + + @@meta("description", "User account status") +} + +type Address { + residents String[] + street String @meta("description", "Street address line") + city String @length(2) + zip String? + + @@validate(zip == null || length(zip) == 5, "Zip code must be exactly 5 characters", ["zip"]) + @@meta("description", "A mailing address") +} + +model User { + id String @id @default(cuid()) + email String @email @meta("description", "The user's email address") + username String @length(3, 50) + website String? @url + code String @startsWith("USR") + age Int @gt(0) @lte(150) + score Float @gte(0.0) @lt(100.0) + bigNum BigInt @gte(0) + balance Decimal @gt(0) + active Boolean + birthdate DateTime? + avatar Bytes? + metadata Json? + status Status + address Address? @json + posts Post[] + + @@validate(age >= 18, "Must be adult", ["age"]) + @@meta("description", "A user of the system") +} + +model Post { + id String @id @default(cuid()) + title String + published Boolean + tags String[] + author User? @relation(fields: [authorId], references: [id]) + authorId String? +} + +// --- Computed fields --- +model Product { + id String @id @default(cuid()) + name String + price Float + discount Float @default(0) + finalPrice Float @computed +} + +// --- Delegate models --- +model Asset { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + assetType String + + @@delegate(assetType) +} + +model Video extends Asset { + duration Int + url String +} + +model Image extends Asset { + format String + width Int +} diff --git a/packages/orm/test/zod-compat.test.ts b/packages/orm/test/zod-compat.test.ts new file mode 100644 index 000000000..a7a1f083b --- /dev/null +++ b/packages/orm/test/zod-compat.test.ts @@ -0,0 +1,24 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import { createSchemaFactory } from '@zenstackhq/zod'; +import type { User as ModelUser } from './schema/models'; +import { schema } from './schema/schema'; +import z from 'zod'; + +const factory = createSchemaFactory(schema); + +describe('Zod ↔ ORM type compatibility', () => { + it('infers zod type compatible with ORM model type (except optionality)', () => { + // ORM model results use `T | null` for optional fields; the Zod schema + // uses `T | null | undefined` to also accept missing fields in input + // objects. The useful property is that any ORM model value is valid + // input for the Zod schema. + const userSchema = factory.makeModelSchema('User'); + type ZodUser = z.infer; + expectTypeOf().toExtend(); + + // or with required + const _userSchemaRequired = userSchema.required(); + type ZodUserRequired = z.infer; + expectTypeOf().toMatchTypeOf(); + }); +}); diff --git a/packages/orm/tsdown.config.ts b/packages/orm/tsdown.config.ts index 711b8ce24..39118a802 100644 --- a/packages/orm/tsdown.config.ts +++ b/packages/orm/tsdown.config.ts @@ -5,6 +5,7 @@ export default createConfig({ index: 'src/index.ts', schema: 'src/schema.ts', helpers: 'src/helpers.ts', + 'common-types': 'src/common-types.ts', 'dialects/sqlite': 'src/dialects/sqlite.ts', 'dialects/postgres': 'src/dialects/postgres.ts', 'dialects/mysql': 'src/dialects/mysql.ts', diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index d0425fd04..a46bebc9f 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-policy", "displayName": "ZenStack Access Policy Plugin", "description": "ZenStack plugin that enforces access control policies defined in the schema", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/schema/package.json b/packages/schema/package.json index ca0f9323f..ee83aa1c1 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/schema", "displayName": "ZenStack Schema Object Model", "description": "TypeScript representation of ZModel schema", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index 2475991ce..4696b795c 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -76,6 +76,8 @@ export type FieldDef = { attributes?: readonly AttributeApplication[]; default?: FieldDefault; omit?: boolean; + fuzzy?: boolean; + fullText?: boolean; relation?: RelationInfo; foreignKeyFor?: readonly string[]; computed?: boolean; diff --git a/packages/schema/test/schema/schema.ts b/packages/schema/test/schema/schema.ts index 846028a79..a4ad517fb 100644 --- a/packages/schema/test/schema/schema.ts +++ b/packages/schema/test/schema/schema.ts @@ -5,7 +5,7 @@ /* eslint-disable */ -import { type SchemaDef, ExpressionUtils } from "@zenstackhq/schema"; +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; export class SchemaType implements SchemaDef { provider = { type: "sqlite" @@ -18,14 +18,14 @@ export class SchemaType implements SchemaDef { name: "id", type: "String", id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], - default: ExpressionUtils.call("cuid") + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault }, email: { name: "email", type: "String", unique: true, - attributes: [{ name: "@unique" }] + attributes: [{ name: "@unique" }] as readonly AttributeApplication[] }, name: { name: "name", @@ -40,7 +40,7 @@ export class SchemaType implements SchemaDef { name: "address", type: "Address", optional: true, - attributes: [{ name: "@json" }] + attributes: [{ name: "@json" }] as readonly AttributeApplication[] }, posts: { name: "posts", @@ -62,8 +62,8 @@ export class SchemaType implements SchemaDef { name: "id", type: "String", id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], - default: ExpressionUtils.call("cuid") + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault }, title: { name: "title", @@ -73,7 +73,7 @@ export class SchemaType implements SchemaDef { name: "owner", type: "User", optional: true, - attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("String", [ExpressionUtils.field("ownerId")]) }, { name: "references", value: ExpressionUtils.array("String", [ExpressionUtils.field("id")]) }] }], + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("String", [ExpressionUtils.field("ownerId")]) }, { name: "references", value: ExpressionUtils.array("String", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], relation: { opposite: "posts", fields: ["ownerId"], references: ["id"] } }, ownerId: { @@ -82,7 +82,7 @@ export class SchemaType implements SchemaDef { optional: true, foreignKeyFor: [ "owner" - ] + ] as readonly string[] } }, idFields: ["id"], diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a1e19d5c9..f9b522a0c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/sdk", "displayName": "ZenStack SDK", "description": "Utilities for building ZenStack plugins", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index d533c93d9..14d74ae32 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -453,9 +453,7 @@ export class TsSchemaGenerator { ...(dm.isView ? [ts.factory.createPropertyAssignment('isView', ts.factory.createTrue())] : []), ]; - const computedFields = allFields.filter( - (f) => hasAttribute(f, '@computed') && !getDelegateOriginModel(f, dm), - ); + const computedFields = allFields.filter((f) => hasAttribute(f, '@computed') && !getDelegateOriginModel(f, dm)); if (computedFields.length > 0) { fields.push( @@ -628,6 +626,14 @@ export class TsSchemaGenerator { objectFields.push(ts.factory.createPropertyAssignment('omit', ts.factory.createTrue())); } + if (hasAttribute(field, '@fuzzy')) { + objectFields.push(ts.factory.createPropertyAssignment('fuzzy', ts.factory.createTrue())); + } + + if (hasAttribute(field, '@fullText')) { + objectFields.push(ts.factory.createPropertyAssignment('fullText', ts.factory.createTrue())); + } + // originModel if ( contextModel && @@ -674,9 +680,7 @@ export class TsSchemaGenerator { ? [ ts.factory.createArrayLiteralExpression( defaultValue.args.map((arg) => - this.createExpressionUtilsCall('literal', [ - this.createLiteralNode(arg), - ]), + this.createExpressionUtilsCall('literal', [this.createLiteralNode(arg)]), ), ), ] @@ -1620,6 +1624,10 @@ export class TsSchemaGenerator { 'SelectInput', 'IncludeInput', 'OmitInput', + 'UncheckedCreateInput', + 'CheckedCreateInput', + 'UncheckedUpdateInput', + 'CheckedUpdateInput', ]; const inputTypeNameFixes = { diff --git a/packages/server/package.json b/packages/server/package.json index 78f5691d8..ed89b5274 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/server", "displayName": "ZenStack Automatic CRUD Server", "description": "ZenStack automatic CRUD API handlers and server adapters for popular frameworks", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/server/src/api/utils.ts b/packages/server/src/api/utils.ts index 51b604a5c..c819c83b6 100644 --- a/packages/server/src/api/utils.ts +++ b/packages/server/src/api/utils.ts @@ -1,3 +1,4 @@ +import { AnyNull, AnyNullClass, DbNull, DbNullClass, JsonNull, JsonNullClass } from '@zenstackhq/orm/common-types'; import { Decimal } from 'decimal.js'; import SuperJSON from 'superjson'; import { match } from 'ts-pattern'; @@ -39,6 +40,33 @@ export function registerCustomSerializers() { 'Decimal', ); + SuperJSON.registerCustom( + { + isApplicable: (v): v is DbNullClass => v instanceof DbNullClass, + serialize: () => 'DbNull', + deserialize: () => DbNull, + }, + 'DbNull', + ); + + SuperJSON.registerCustom( + { + isApplicable: (v): v is JsonNullClass => v instanceof JsonNullClass, + serialize: () => 'JsonNull', + deserialize: () => JsonNull, + }, + 'JsonNull', + ); + + SuperJSON.registerCustom( + { + isApplicable: (v): v is AnyNullClass => v instanceof AnyNullClass, + serialize: () => 'AnyNull', + deserialize: () => AnyNull, + }, + 'AnyNull', + ); + // `Buffer` is not available in edge runtime if (globalThis.Buffer) { SuperJSON.registerCustom( diff --git a/packages/server/test/openapi/baseline/rpc.baseline.yaml b/packages/server/test/openapi/baseline/rpc.baseline.yaml index 9c850e1c3..b268f9c9b 100644 --- a/packages/server/test/openapi/baseline/rpc.baseline.yaml +++ b/packages/server/test/openapi/baseline/rpc.baseline.yaml @@ -5068,7 +5068,6 @@ components: const: last required: - sort - - nulls additionalProperties: false published: anyOf: @@ -5099,7 +5098,6 @@ components: const: last required: - sort - - nulls additionalProperties: false viewCount: anyOf: @@ -5175,7 +5173,6 @@ components: const: last required: - sort - - nulls additionalProperties: false someJson: anyOf: @@ -5200,7 +5197,6 @@ components: const: last required: - sort - - nulls additionalProperties: false additionalProperties: false UserWhereUniqueInputWithoutRelation: @@ -6336,7 +6332,6 @@ components: const: last required: - sort - - nulls additionalProperties: false someJson: anyOf: @@ -6361,7 +6356,6 @@ components: const: last required: - sort - - nulls additionalProperties: false _count: $ref: "#/components/schemas/UserOrderByWithRelationInput" @@ -8053,7 +8047,6 @@ components: const: last required: - sort - - nulls additionalProperties: false published: anyOf: @@ -8084,7 +8077,6 @@ components: const: last required: - sort - - nulls additionalProperties: false viewCount: anyOf: @@ -9434,7 +9426,6 @@ components: const: last required: - sort - - nulls additionalProperties: false published: anyOf: @@ -9465,7 +9456,6 @@ components: const: last required: - sort - - nulls additionalProperties: false viewCount: anyOf: diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 3f02d2078..caabc170b 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/testtools", "displayName": "ZenStack Test Tools", "description": "ZenStack Test Tools", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/testtools/src/project.ts b/packages/testtools/src/project.ts index 20200e676..eb2e276ff 100644 --- a/packages/testtools/src/project.ts +++ b/packages/testtools/src/project.ts @@ -55,6 +55,7 @@ export function createTestProject(zmodelContent?: string) { esModuleInterop: true, skipLibCheck: true, strict: true, + types: ['node'], }, include: ['**/*.ts'], }, diff --git a/packages/zod/package.json b/packages/zod/package.json index 358514c43..43a9f340b 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/zod", "displayName": "ZenStack Zod Integration", "description": "Automatically deriving Zod schemas from ZModel schemas", - "version": "3.6.4", + "version": "3.7.0", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index 4323894e8..0000fbe35 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -1,3 +1,3 @@ export { createSchemaFactory } from './factory'; -export type { ModelSchemaOptions, GetModelSchemaShapeWithOptions } from './types'; +export type { ModelSchemaOptions, GetModelSchemaShapeWithOptions, JsonValue } from './types'; export * as ZodUtils from './utils'; diff --git a/packages/zod/src/types.ts b/packages/zod/src/types.ts index 8dfc235a7..7715a534d 100644 --- a/packages/zod/src/types.ts +++ b/packages/zod/src/types.ts @@ -135,13 +135,11 @@ type MapFieldTypeToZod = FieldType extends ? z.ZodObject, z.core.$strict> : z.ZodUnknown; -type JsonZodType = - | z.ZodObject, z.core.$loose> - | z.ZodArray - | z.ZodString - | z.ZodNumber - | z.ZodBoolean - | z.ZodNull; +export type JsonValue = string | number | boolean | JsonObject | JsonArray | null; +type JsonObject = { [key: string]: JsonValue }; +type JsonArray = Array; + +type JsonZodType = z.ZodType; type EnumZodType> = z.ZodEnum<{ [Key in keyof GetEnum]: GetEnum[Key]; diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index cfbdaa4cc..0dd616c2d 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -3,6 +3,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; import { createSchemaFactory } from '../src/index'; import { schema } from './schema/schema'; import z from 'zod'; +import type { JsonValue } from '../src/index'; const factory = createSchemaFactory(schema); @@ -69,9 +70,7 @@ describe('SchemaFactory - makeModelSchema', () => { // optional Json expectTypeOf().toHaveProperty('metadata'); - expectTypeOf().toEqualTypeOf< - string | number | boolean | null | Record | unknown[] | undefined - >(); + expectTypeOf().toEqualTypeOf(); // required enum expectTypeOf().toEqualTypeOf<'ACTIVE' | 'INACTIVE' | 'PENDING'>(); @@ -194,6 +193,7 @@ describe('SchemaFactory - makeModelSchema', () => { expect(userSchema.safeParse({ ...validUser, metadata: { key: 'value' } }).success).toBe(true); expect(userSchema.safeParse({ ...validUser, metadata: [1, 2, 3] }).success).toBe(true); expect(userSchema.safeParse({ ...validUser, metadata: 42 }).success).toBe(true); + expect(userSchema.safeParse({ ...validUser, metadata: null }).success).toBe(true); }); it('rejects invalid Json values', () => { diff --git a/packages/zod/test/schema/schema.ts b/packages/zod/test/schema/schema.ts index 8abbf29c0..e0dff2a49 100644 --- a/packages/zod/test/schema/schema.ts +++ b/packages/zod/test/schema/schema.ts @@ -5,7 +5,7 @@ /* eslint-disable */ -import { type SchemaDef, ExpressionUtils } from "@zenstackhq/schema"; +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; export class SchemaType implements SchemaDef { provider = { type: "postgresql" @@ -18,49 +18,49 @@ export class SchemaType implements SchemaDef { name: "id", type: "String", id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], - default: ExpressionUtils.call("cuid") + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault }, email: { name: "email", type: "String", - attributes: [{ name: "@email" }, { name: "@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("The user's email address") }] }] + attributes: [{ name: "@email" }, { name: "@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("The user's email address") }] }] as readonly AttributeApplication[] }, username: { name: "username", type: "String", - attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(3) }, { name: "max", value: ExpressionUtils.literal(50) }] }] + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(3) }, { name: "max", value: ExpressionUtils.literal(50) }] }] as readonly AttributeApplication[] }, website: { name: "website", type: "String", optional: true, - attributes: [{ name: "@url" }] + attributes: [{ name: "@url" }] as readonly AttributeApplication[] }, code: { name: "code", type: "String", - attributes: [{ name: "@startsWith", args: [{ name: "text", value: ExpressionUtils.literal("USR") }] }] + attributes: [{ name: "@startsWith", args: [{ name: "text", value: ExpressionUtils.literal("USR") }] }] as readonly AttributeApplication[] }, age: { name: "age", type: "Int", - attributes: [{ name: "@gt", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }, { name: "@lte", args: [{ name: "value", value: ExpressionUtils.literal(150) }] }] + attributes: [{ name: "@gt", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }, { name: "@lte", args: [{ name: "value", value: ExpressionUtils.literal(150) }] }] as readonly AttributeApplication[] }, score: { name: "score", type: "Float", - attributes: [{ name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(0.0) }] }, { name: "@lt", args: [{ name: "value", value: ExpressionUtils.literal(100.0) }] }] + attributes: [{ name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(0.0) }] }, { name: "@lt", args: [{ name: "value", value: ExpressionUtils.literal(100.0) }] }] as readonly AttributeApplication[] }, bigNum: { name: "bigNum", type: "BigInt", - attributes: [{ name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] + attributes: [{ name: "@gte", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[] }, balance: { name: "balance", type: "Decimal", - attributes: [{ name: "@gt", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] + attributes: [{ name: "@gt", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[] }, active: { name: "active", @@ -89,7 +89,7 @@ export class SchemaType implements SchemaDef { name: "address", type: "Address", optional: true, - attributes: [{ name: "@json" }] + attributes: [{ name: "@json" }] as readonly AttributeApplication[] }, posts: { name: "posts", @@ -101,7 +101,7 @@ export class SchemaType implements SchemaDef { attributes: [ { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils.field("age"), ">=", ExpressionUtils.literal(18)) }, { name: "message", value: ExpressionUtils.literal("Must be adult") }, { name: "path", value: ExpressionUtils.array("String", [ExpressionUtils.literal("age")]) }] }, { name: "@@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("A user of the system") }] } - ], + ] as readonly AttributeApplication[], idFields: ["id"], uniqueFields: { id: { type: "String" } @@ -114,8 +114,8 @@ export class SchemaType implements SchemaDef { name: "id", type: "String", id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], - default: ExpressionUtils.call("cuid") + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault }, title: { name: "title", @@ -134,7 +134,7 @@ export class SchemaType implements SchemaDef { name: "author", type: "User", optional: true, - attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("String", [ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array("String", [ExpressionUtils.field("id")]) }] }], + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("String", [ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array("String", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], relation: { opposite: "posts", fields: ["authorId"], references: ["id"] } }, authorId: { @@ -143,7 +143,7 @@ export class SchemaType implements SchemaDef { optional: true, foreignKeyFor: [ "author" - ] + ] as readonly string[] } }, idFields: ["id"], @@ -158,8 +158,8 @@ export class SchemaType implements SchemaDef { name: "id", type: "String", id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], - default: ExpressionUtils.call("cuid") + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault }, name: { name: "name", @@ -172,13 +172,13 @@ export class SchemaType implements SchemaDef { discount: { name: "discount", type: "Float", - attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }], - default: 0 + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[], + default: 0 as FieldDefault }, finalPrice: { name: "finalPrice", type: "Float", - attributes: [{ name: "@computed" }], + attributes: [{ name: "@computed" }] as readonly AttributeApplication[], computed: true } }, @@ -201,14 +201,14 @@ export class SchemaType implements SchemaDef { name: "id", type: "Int", id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], - default: ExpressionUtils.call("autoincrement") + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault }, createdAt: { name: "createdAt", type: "DateTime", - attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], - default: ExpressionUtils.call("now") + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault }, assetType: { name: "assetType", @@ -218,7 +218,7 @@ export class SchemaType implements SchemaDef { }, attributes: [ { name: "@@delegate", args: [{ name: "discriminator", value: ExpressionUtils.field("assetType") }] } - ], + ] as readonly AttributeApplication[], idFields: ["id"], uniqueFields: { id: { type: "Int" } @@ -234,15 +234,15 @@ export class SchemaType implements SchemaDef { name: "id", type: "Int", id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], - default: ExpressionUtils.call("autoincrement") + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault }, createdAt: { name: "createdAt", type: "DateTime", originModel: "Asset", - attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], - default: ExpressionUtils.call("now") + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault }, assetType: { name: "assetType", @@ -272,15 +272,15 @@ export class SchemaType implements SchemaDef { name: "id", type: "Int", id: true, - attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], - default: ExpressionUtils.call("autoincrement") + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault }, createdAt: { name: "createdAt", type: "DateTime", originModel: "Asset", - attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], - default: ExpressionUtils.call("now") + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault }, assetType: { name: "assetType", @@ -315,12 +315,12 @@ export class SchemaType implements SchemaDef { street: { name: "street", type: "String", - attributes: [{ name: "@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("Street address line") }] }] + attributes: [{ name: "@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("Street address line") }] }] as readonly AttributeApplication[] }, city: { name: "city", type: "String", - attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(2) }] }] + attributes: [{ name: "@length", args: [{ name: "min", value: ExpressionUtils.literal(2) }] }] as readonly AttributeApplication[] }, zip: { name: "zip", @@ -331,7 +331,7 @@ export class SchemaType implements SchemaDef { attributes: [ { name: "@@validate", args: [{ name: "value", value: ExpressionUtils.binary(ExpressionUtils.binary(ExpressionUtils.field("zip"), "==", ExpressionUtils._null()), "||", ExpressionUtils.binary(ExpressionUtils.call("length", [ExpressionUtils.field("zip")]), "==", ExpressionUtils.literal(5))) }, { name: "message", value: ExpressionUtils.literal("Zip code must be exactly 5 characters") }, { name: "path", value: ExpressionUtils.array("String", [ExpressionUtils.literal("zip")]) }] }, { name: "@@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("A mailing address") }] } - ] + ] as readonly AttributeApplication[] } } as const; enums = { @@ -344,7 +344,7 @@ export class SchemaType implements SchemaDef { }, attributes: [ { name: "@@meta", args: [{ name: "name", value: ExpressionUtils.literal("description") }, { name: "value", value: ExpressionUtils.literal("User account status") }] } - ] + ] as readonly AttributeApplication[] } } as const; authType = "User" as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f654c7e0..6f22e8e26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,8 +43,8 @@ catalogs: specifier: ^10.4.3 version: 10.6.0 kysely: - specifier: ~0.28.16 - version: 0.28.16 + specifier: ~0.29.0 + version: 0.29.0 langium: specifier: 3.5.0 version: 3.5.0 @@ -85,8 +85,8 @@ catalogs: specifier: ^5.7.1 version: 5.7.1 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 vue: specifier: 3.5.22 version: 3.5.22 @@ -101,6 +101,7 @@ overrides: cookie@<0.7.0: '>=0.7.0' lodash-es@>=4.0.0 <=4.17.22: '>=4.17.23' lodash@>=4.0.0 <=4.17.22: '>=4.17.23' + '@better-auth/core': 1.4.19 importers: @@ -129,10 +130,10 @@ importers: version: 3.5.3 prisma: specifier: 'catalog:' - version: 6.19.0(magicast@0.3.5)(typescript@5.9.3) + version: 6.19.0(magicast@0.3.5)(typescript@6.0.3) tsdown: specifier: ^0.21.8 - version: 0.21.8(typescript@5.9.3)(vue-tsc@3.2.5(typescript@5.9.3)) + version: 0.21.8(typescript@6.0.3)(vue-tsc@3.2.5(typescript@6.0.3)) tsx: specifier: ^4.20.3 version: 4.20.3 @@ -141,10 +142,10 @@ importers: version: 2.5.4 typescript: specifier: 'catalog:' - version: 5.9.3 + version: 6.0.3 typescript-eslint: specifier: ^8.34.1 - version: 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + version: 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) vitest: specifier: ^4.0.14 version: 4.0.14(@edge-runtime/vm@5.0.0)(@types/node@20.19.24)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@27.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.0) @@ -169,10 +170,10 @@ importers: devDependencies: '@better-auth/cli': specifier: 1.4.19 - version: 1.4.19(@better-fetch/fetch@1.1.21)(@sveltejs/kit@2.53.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)))(@types/better-sqlite3@7.6.13)(@types/sql.js@1.4.9)(better-call@1.1.8(zod@4.3.6))(bun-types@1.3.3)(jose@6.1.2)(kysely@0.28.16)(magicast@0.5.1)(mysql2@3.16.1)(nanostores@1.0.1)(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sql.js@1.13.0)(svelte@5.53.5)(vitest@4.0.14(@edge-runtime/vm@5.0.0)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@27.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)) + version: 1.4.19(@better-fetch/fetch@1.1.21)(@sveltejs/kit@2.53.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)))(@types/better-sqlite3@7.6.13)(@types/sql.js@1.4.9)(better-call@1.1.8(zod@4.3.6))(bun-types@1.3.3)(jose@6.1.2)(kysely@0.29.0)(magicast@0.5.1)(mysql2@3.16.1)(nanostores@1.0.1)(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sql.js@1.13.0)(svelte@5.53.5)(vitest@4.0.14(@edge-runtime/vm@5.0.0)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@27.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)) '@better-auth/core': specifier: 1.4.19 - version: 1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.28.16)(nanostores@1.0.1) + version: 1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.29.0)(nanostores@1.0.1) '@types/tmp': specifier: 'catalog:' version: 0.2.6 @@ -193,10 +194,10 @@ importers: version: link:../../config/vitest-config better-auth: specifier: 1.4.19 - version: 1.4.19(f276412306c0f31e69da8456c8cd9497) + version: 1.4.19(1495ef1d827113360533150571904e77) kysely: specifier: 'catalog:' - version: 0.28.16 + version: 0.29.0 tmp: specifier: 'catalog:' version: 0.2.5 @@ -268,7 +269,7 @@ importers: version: 8.16.3 prisma: specifier: 'catalog:' - version: 6.19.0(magicast@0.3.5)(typescript@5.9.3) + version: 6.19.0(magicast@0.3.5)(typescript@6.0.3) semver: specifier: ^7.7.2 version: 7.7.2 @@ -353,6 +354,43 @@ importers: specifier: workspace:* version: link:../../config/vitest-config + packages/clients/fetch-client: + dependencies: + '@zenstackhq/client-helpers': + specifier: workspace:* + version: link:../client-helpers + '@zenstackhq/common-helpers': + specifier: workspace:* + version: link:../../common-helpers + '@zenstackhq/orm': + specifier: workspace:* + version: link:../../orm + '@zenstackhq/schema': + specifier: workspace:* + version: link:../../schema + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.24 + '@zenstackhq/cli': + specifier: workspace:* + version: link:../../cli + '@zenstackhq/eslint-config': + specifier: workspace:* + version: link:../../config/eslint-config + '@zenstackhq/tsdown-config': + specifier: workspace:* + version: link:../../config/tsdown-config + '@zenstackhq/typescript-config': + specifier: workspace:* + version: link:../../config/typescript-config + '@zenstackhq/vitest-config': + specifier: workspace:* + version: link:../../config/vitest-config + decimal.js: + specifier: 'catalog:' + version: 10.6.0 + packages/clients/tanstack-query: dependencies: '@zenstackhq/client-helpers': @@ -361,6 +399,9 @@ importers: '@zenstackhq/common-helpers': specifier: workspace:* version: link:../../common-helpers + '@zenstackhq/orm': + specifier: workspace:* + version: link:../../orm '@zenstackhq/schema': specifier: workspace:* version: link:../../schema @@ -370,7 +411,7 @@ importers: devDependencies: '@sveltejs/package': specifier: ^2.5.7 - version: 2.5.7(svelte@5.53.5)(typescript@5.9.3) + version: 2.5.7(svelte@5.53.5)(typescript@6.0.3) '@tanstack/query-core': specifier: 'catalog:' version: 5.90.2 @@ -382,7 +423,7 @@ importers: version: 6.0.10(svelte@5.53.5) '@tanstack/vue-query': specifier: 'catalog:' - version: 5.90.2(vue@3.5.22(typescript@5.9.3)) + version: 5.90.2(vue@3.5.22(typescript@6.0.3)) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -401,9 +442,6 @@ importers: '@zenstackhq/language': specifier: workspace:* version: link:../../language - '@zenstackhq/orm': - specifier: workspace:* - version: link:../../orm '@zenstackhq/sdk': specifier: workspace:* version: link:../../sdk @@ -430,7 +468,7 @@ importers: version: 5.53.5 vue: specifier: 'catalog:' - version: 3.5.22(typescript@5.9.3) + version: 3.5.22(typescript@6.0.3) packages/common-helpers: devDependencies: @@ -604,7 +642,7 @@ importers: version: 1.3.0 kysely: specifier: 'catalog:' - version: 0.28.16 + version: 0.29.0 mysql2: specifier: 'catalog:' version: 3.16.1 @@ -677,7 +715,7 @@ importers: version: link:../../orm kysely: specifier: 'catalog:' - version: 0.28.16 + version: 0.29.0 ts-pattern: specifier: 'catalog:' version: 5.7.1 @@ -736,7 +774,7 @@ importers: version: 5.7.1 typescript: specifier: 'catalog:' - version: 5.9.3 + version: 6.0.3 devDependencies: '@types/node': specifier: 'catalog:' @@ -755,7 +793,7 @@ importers: version: 10.6.0 kysely: specifier: 'catalog:' - version: 0.28.16 + version: 0.29.0 packages/server: dependencies: @@ -843,7 +881,7 @@ importers: version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) nuxt: specifier: 'catalog:' - version: 4.3.1(c09e06bbcbcd2b0a94c9a804d2c88498) + version: 4.3.1(20bb9c9cac3d4d3ad27d57e07c1eb4f2) supertest: specifier: ^7.1.4 version: 7.1.4 @@ -879,7 +917,7 @@ importers: version: 11.1.0 kysely: specifier: 'catalog:' - version: 0.28.16 + version: 0.29.0 mysql2: specifier: 'catalog:' version: 3.16.1 @@ -888,7 +926,7 @@ importers: version: 8.16.3 prisma: specifier: 'catalog:' - version: 6.19.0(magicast@0.3.5)(typescript@5.9.3) + version: 6.19.0(magicast@0.3.5)(typescript@6.0.3) tmp: specifier: 'catalog:' version: 0.2.5 @@ -919,7 +957,7 @@ importers: version: link:../config/typescript-config typescript: specifier: 'catalog:' - version: 5.9.3 + version: 6.0.3 packages/zod: dependencies: @@ -968,7 +1006,7 @@ importers: version: 12.5.0 kysely: specifier: 'catalog:' - version: 0.28.16 + version: 0.29.0 lorem-ipsum: specifier: ^2.0.8 version: 2.0.8 @@ -1005,13 +1043,13 @@ importers: version: 9.29.0(jiti@2.6.1) eslint-config-next: specifier: 16.0.1 - version: 16.0.1(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + version: 16.0.1(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) tailwindcss: specifier: ^4 version: 4.1.16 typescript: specifier: 'catalog:' - version: 5.9.3 + version: 6.0.3 samples/nuxt: dependencies: @@ -1020,7 +1058,7 @@ importers: version: 4.1.18(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)) '@tanstack/vue-query': specifier: 'catalog:' - version: 5.90.2(vue@3.5.22(typescript@5.9.3)) + version: 5.90.2(vue@3.5.22(typescript@6.0.3)) '@zenstackhq/orm': specifier: workspace:* version: link:../../packages/orm @@ -1041,16 +1079,16 @@ importers: version: 2.0.8 nuxt: specifier: 'catalog:' - version: 4.3.1(c09e06bbcbcd2b0a94c9a804d2c88498) + version: 4.3.1(f6c7dcf4eb9de64f6cb5e0db74766190) tailwindcss: specifier: ^4.1.18 version: 4.1.18 vue: specifier: 'catalog:' - version: 3.5.22(typescript@5.9.3) + version: 3.5.22(typescript@6.0.3) vue-router: specifier: ^4.6.4 - version: 4.6.4(vue@3.5.22(typescript@5.9.3)) + version: 4.6.4(vue@3.5.22(typescript@6.0.3)) devDependencies: '@types/better-sqlite3': specifier: 'catalog:' @@ -1060,7 +1098,7 @@ importers: version: link:../../packages/cli vue-tsc: specifier: ^3.2.5 - version: 3.2.5(typescript@5.9.3) + version: 3.2.5(typescript@6.0.3) samples/orm: dependencies: @@ -1078,7 +1116,7 @@ importers: version: 12.5.0 kysely: specifier: 'catalog:' - version: 0.28.16 + version: 0.29.0 zod: specifier: 'catalog:' version: 4.1.12 @@ -1094,7 +1132,7 @@ importers: version: link:../../packages/config/typescript-config prisma: specifier: 'catalog:' - version: 6.19.0(magicast@0.3.5)(typescript@5.9.3) + version: 6.19.0(magicast@0.3.5)(typescript@6.0.3) samples/sveltekit: dependencies: @@ -1118,7 +1156,7 @@ importers: version: 12.5.0 kysely: specifier: 'catalog:' - version: 0.28.16 + version: 0.29.0 lorem-ipsum: specifier: ^2.0.8 version: 2.0.8 @@ -1203,7 +1241,7 @@ importers: version: 10.6.0 kysely: specifier: 'catalog:' - version: 0.28.16 + version: 0.29.0 ts-pattern: specifier: 'catalog:' version: 5.7.1 @@ -1289,10 +1327,10 @@ importers: version: link:../../../packages/testtools kysely: specifier: 'catalog:' - version: 0.28.16 + version: 0.29.0 kysely-bun-sqlite: specifier: ^0.4.0 - version: 0.4.0(kysely@0.28.16) + version: 0.4.0(kysely@0.29.0) pg: specifier: 'catalog:' version: 8.16.3 @@ -6572,6 +6610,10 @@ packages: resolution: {integrity: sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww==} engines: {node: '>=20.0.0'} + kysely@0.29.0: + resolution: {integrity: sha512-LrQfPUeTW7MXbMvT62moEMnpMTuj9TO3lqjCeLKjM975PJ4Alrl/43f2tlDX7xOsNptKgH4LSNGwIbXwEkLg4g==} + engines: {node: '>=22.0.0'} + langium-cli@3.5.0: resolution: {integrity: sha512-TPIzIiMAQwTPPphtHGSrFXo4t0orx3aRh0syg9jnOihvBkBDvsQdJP9fBo9hp5Qaosklpc2CfbH0wh/dkgZcJA==} engines: {node: '>=18.0.0'} @@ -8648,6 +8690,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} @@ -9724,25 +9771,25 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/cli@1.4.19(@better-fetch/fetch@1.1.21)(@sveltejs/kit@2.53.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)))(@types/better-sqlite3@7.6.13)(@types/sql.js@1.4.9)(better-call@1.1.8(zod@4.3.6))(bun-types@1.3.3)(jose@6.1.2)(kysely@0.28.16)(magicast@0.5.1)(mysql2@3.16.1)(nanostores@1.0.1)(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sql.js@1.13.0)(svelte@5.53.5)(vitest@4.0.14(@edge-runtime/vm@5.0.0)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@27.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))': + '@better-auth/cli@1.4.19(@better-fetch/fetch@1.1.21)(@sveltejs/kit@2.53.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)))(@types/better-sqlite3@7.6.13)(@types/sql.js@1.4.9)(better-call@1.1.8(zod@4.3.6))(bun-types@1.3.3)(jose@6.1.2)(kysely@0.29.0)(magicast@0.5.1)(mysql2@3.16.1)(nanostores@1.0.1)(next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sql.js@1.13.0)(svelte@5.53.5)(vitest@4.0.14(@edge-runtime/vm@5.0.0)(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(jsdom@27.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))': dependencies: '@babel/core': 7.29.0 '@babel/preset-react': 7.28.5(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@better-auth/core': 1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.28.16)(nanostores@1.0.1) - '@better-auth/telemetry': 1.4.19(@better-auth/core@1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.28.16)(nanostores@1.0.1)) + '@better-auth/core': 1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.29.0)(nanostores@1.0.1) + '@better-auth/telemetry': 1.4.19(@better-auth/core@1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.29.0)(nanostores@1.0.1)) '@better-auth/utils': 0.3.0 '@clack/prompts': 0.11.0 '@mrleebo/prisma-ast': 0.13.1 '@prisma/client': 5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)) '@types/pg': 8.16.0 - better-auth: 1.4.19(f276412306c0f31e69da8456c8cd9497) + better-auth: 1.4.19(1495ef1d827113360533150571904e77) better-sqlite3: 12.5.0 c12: 3.3.3(magicast@0.5.1) chalk: 5.6.2 commander: 12.1.0 dotenv: 17.2.3 - drizzle-orm: 0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0) + drizzle-orm: 0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0) open: 10.2.0 pg: 8.16.3 prettier: 3.8.1 @@ -9807,9 +9854,20 @@ snapshots: nanostores: 1.0.1 zod: 4.3.6 - '@better-auth/telemetry@1.4.19(@better-auth/core@1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.28.16)(nanostores@1.0.1))': + '@better-auth/core@1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.29.0)(nanostores@1.0.1)': dependencies: - '@better-auth/core': 1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.28.16)(nanostores@1.0.1) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@standard-schema/spec': 1.0.0 + better-call: 1.1.8(zod@4.3.6) + jose: 6.1.2 + kysely: 0.29.0 + nanostores: 1.0.1 + zod: 4.3.6 + + '@better-auth/telemetry@1.4.19(@better-auth/core@1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.29.0)(nanostores@1.0.1))': + dependencies: + '@better-auth/core': 1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.29.0)(nanostores@1.0.1) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -10617,6 +10675,47 @@ snapshots: - utf-8-validate - vue + '@nuxt/devtools@3.1.1(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@6.0.3))': + dependencies: + '@nuxt/devtools-kit': 3.1.1(magicast@0.5.1)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)) + '@nuxt/devtools-wizard': 3.1.1 + '@nuxt/kit': 4.3.1(magicast@0.5.1) + '@vue/devtools-core': 8.0.5(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@6.0.3)) + '@vue/devtools-kit': 8.0.5 + birpc: 2.9.0 + consola: 3.4.2 + destr: 2.0.5 + error-stack-parser-es: 1.0.5 + execa: 8.0.1 + fast-npm-meta: 0.4.7 + get-port-please: 3.2.0 + hookable: 5.5.3 + image-meta: 0.2.2 + is-installed-globally: 1.0.0 + launch-editor: 2.12.0 + local-pkg: 1.1.2 + magicast: 0.5.1 + nypm: 0.6.5 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + semver: 7.7.4 + simple-git: 3.30.0 + sirv: 3.0.2 + structured-clone-es: 1.0.0 + tinyglobby: 0.2.15 + vite: 7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2) + vite-plugin-inspect: 11.3.3(@nuxt/kit@4.3.1(magicast@0.5.1))(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)) + vite-plugin-vue-tracer: 1.2.0(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@6.0.3)) + which: 5.0.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + - vue + '@nuxt/kit@4.3.1(magicast@0.5.1)': dependencies: c12: 3.3.3(magicast@0.5.1) @@ -10642,7 +10741,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.3.1(better-sqlite3@12.5.0)(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1))(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(ioredis@5.9.3)(magicast@0.5.1)(mysql2@3.16.1)(nuxt@4.3.1(c09e06bbcbcd2b0a94c9a804d2c88498))(rolldown@1.0.0-rc.15)(typescript@5.9.3)': + '@nuxt/nitro-server@4.3.1(better-sqlite3@12.5.0)(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1))(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(ioredis@5.9.3)(magicast@0.5.1)(mysql2@3.16.1)(nuxt@4.3.1(20bb9c9cac3d4d3ad27d57e07c1eb4f2))(rolldown@1.0.0-rc.15)(typescript@5.9.3)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 4.3.1(magicast@0.5.1) @@ -10659,8 +10758,8 @@ snapshots: impound: 1.0.0 klona: 2.0.6 mocked-exports: 0.1.1 - nitropack: 2.13.1(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1)(rolldown@1.0.0-rc.15) - nuxt: 4.3.1(c09e06bbcbcd2b0a94c9a804d2c88498) + nitropack: 2.13.1(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1)(rolldown@1.0.0-rc.15) + nuxt: 4.3.1(20bb9c9cac3d4d3ad27d57e07c1eb4f2) ohash: 2.0.11 pathe: 2.0.3 pkg-types: 2.3.0 @@ -10668,7 +10767,7 @@ snapshots: std-env: 3.10.0 ufo: 1.6.3 unctx: 2.5.0 - unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1))(ioredis@5.9.3) + unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1))(ioredis@5.9.3) vue: 3.5.29(typescript@5.9.3) vue-bundle-renderer: 2.2.0 vue-devtools-stub: 0.1.0 @@ -10707,6 +10806,71 @@ snapshots: - uploadthing - xml2js + '@nuxt/nitro-server@4.3.1(better-sqlite3@12.5.0)(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0))(mysql2@3.16.1))(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0))(ioredis@5.9.3)(magicast@0.5.1)(mysql2@3.16.1)(nuxt@4.3.1(f6c7dcf4eb9de64f6cb5e0db74766190))(rolldown@1.0.0-rc.15)(typescript@6.0.3)': + dependencies: + '@nuxt/devalue': 2.0.2 + '@nuxt/kit': 4.3.1(magicast@0.5.1) + '@unhead/vue': 2.1.7(vue@3.5.29(typescript@6.0.3)) + '@vue/shared': 3.5.29 + consola: 3.4.2 + defu: 6.1.4 + destr: 2.0.5 + devalue: 5.6.3 + errx: 0.1.0 + escape-string-regexp: 5.0.0 + exsolve: 1.0.8 + h3: 1.15.5 + impound: 1.0.0 + klona: 2.0.6 + mocked-exports: 0.1.1 + nitropack: 2.13.1(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0))(mysql2@3.16.1)(rolldown@1.0.0-rc.15) + nuxt: 4.3.1(f6c7dcf4eb9de64f6cb5e0db74766190) + ohash: 2.0.11 + pathe: 2.0.3 + pkg-types: 2.3.0 + rou3: 0.7.12 + std-env: 3.10.0 + ufo: 1.6.3 + unctx: 2.5.0 + unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0))(mysql2@3.16.1))(ioredis@5.9.3) + vue: 3.5.29(typescript@6.0.3) + vue-bundle-renderer: 2.2.0 + vue-devtools-stub: 0.1.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - better-sqlite3 + - db0 + - drizzle-orm + - encoding + - idb-keyval + - ioredis + - magicast + - mysql2 + - react-native-b4a + - rolldown + - sqlite3 + - supports-color + - typescript + - uploadthing + - xml2js + '@nuxt/schema@4.3.1': dependencies: '@vue/shared': 3.5.29 @@ -10724,7 +10888,7 @@ snapshots: rc9: 3.0.0 std-env: 3.10.0 - '@nuxt/vite-builder@4.3.1(@types/node@25.5.2)(eslint@9.29.0(jiti@2.6.1))(lightningcss@1.30.2)(magicast@0.5.1)(nuxt@4.3.1(c09e06bbcbcd2b0a94c9a804d2c88498))(optionator@0.9.4)(rolldown@1.0.0-rc.15)(rollup@4.59.0)(terser@5.44.0)(tsx@4.20.3)(typescript@5.9.3)(vue-tsc@3.2.5(typescript@5.9.3))(vue@3.5.29(typescript@5.9.3))(yaml@2.8.2)': + '@nuxt/vite-builder@4.3.1(@types/node@25.5.2)(eslint@9.29.0(jiti@2.6.1))(lightningcss@1.30.2)(magicast@0.5.1)(nuxt@4.3.1(20bb9c9cac3d4d3ad27d57e07c1eb4f2))(optionator@0.9.4)(rolldown@1.0.0-rc.15)(rollup@4.59.0)(terser@5.44.0)(tsx@4.20.3)(typescript@5.9.3)(vue-tsc@3.2.5(typescript@5.9.3))(vue@3.5.29(typescript@5.9.3))(yaml@2.8.2)': dependencies: '@nuxt/kit': 4.3.1(magicast@0.5.1) '@rollup/plugin-replace': 6.0.3(rollup@4.59.0) @@ -10743,7 +10907,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.0 mocked-exports: 0.1.1 - nuxt: 4.3.1(c09e06bbcbcd2b0a94c9a804d2c88498) + nuxt: 4.3.1(20bb9c9cac3d4d3ad27d57e07c1eb4f2) pathe: 2.0.3 pkg-types: 2.3.0 postcss: 8.5.6 @@ -10784,6 +10948,66 @@ snapshots: - vue-tsc - yaml + '@nuxt/vite-builder@4.3.1(@types/node@25.5.2)(eslint@9.29.0(jiti@2.6.1))(lightningcss@1.30.2)(magicast@0.5.1)(nuxt@4.3.1(f6c7dcf4eb9de64f6cb5e0db74766190))(optionator@0.9.4)(rolldown@1.0.0-rc.15)(rollup@4.59.0)(terser@5.44.0)(tsx@4.20.3)(typescript@6.0.3)(vue-tsc@3.2.5(typescript@6.0.3))(vue@3.5.29(typescript@6.0.3))(yaml@2.8.2)': + dependencies: + '@nuxt/kit': 4.3.1(magicast@0.5.1) + '@rollup/plugin-replace': 6.0.3(rollup@4.59.0) + '@vitejs/plugin-vue': 6.0.4(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@6.0.3)) + '@vitejs/plugin-vue-jsx': 5.1.4(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@6.0.3)) + autoprefixer: 10.4.27(postcss@8.5.6) + consola: 3.4.2 + cssnano: 7.1.2(postcss@8.5.6) + defu: 6.1.4 + esbuild: 0.27.3 + escape-string-regexp: 5.0.0 + exsolve: 1.0.8 + get-port-please: 3.2.0 + jiti: 2.6.1 + knitwork: 1.3.0 + magic-string: 0.30.21 + mlly: 1.8.0 + mocked-exports: 0.1.1 + nuxt: 4.3.1(f6c7dcf4eb9de64f6cb5e0db74766190) + pathe: 2.0.3 + pkg-types: 2.3.0 + postcss: 8.5.6 + rollup-plugin-visualizer: 6.0.5(rolldown@1.0.0-rc.15)(rollup@4.59.0) + seroval: 1.5.0 + std-env: 3.10.0 + ufo: 1.6.3 + unenv: 2.0.0-rc.24 + vite: 7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2) + vite-node: 5.3.0(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2) + vite-plugin-checker: 0.12.0(eslint@9.29.0(jiti@2.6.1))(optionator@0.9.4)(typescript@6.0.3)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue-tsc@3.2.5(typescript@6.0.3)) + vue: 3.5.29(typescript@6.0.3) + vue-bundle-renderer: 2.2.0 + optionalDependencies: + rolldown: 1.0.0-rc.15 + transitivePeerDependencies: + - '@biomejs/biome' + - '@types/node' + - eslint + - less + - lightningcss + - magicast + - meow + - optionator + - oxlint + - rollup + - sass + - sass-embedded + - stylelint + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - vls + - vti + - vue-tsc + - yaml + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': @@ -11075,6 +11299,11 @@ snapshots: optionalDependencies: prisma: 6.19.0(magicast@0.5.1)(typescript@5.9.3) + '@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))': + optionalDependencies: + prisma: 6.19.0(magicast@0.5.1)(typescript@6.0.3) + optional: true + '@prisma/config@6.19.0(magicast@0.3.5)': dependencies: c12: 3.1.0(magicast@0.3.5) @@ -11396,14 +11625,14 @@ snapshots: optionalDependencies: typescript: 5.9.3 - '@sveltejs/package@2.5.7(svelte@5.53.5)(typescript@5.9.3)': + '@sveltejs/package@2.5.7(svelte@5.53.5)(typescript@6.0.3)': dependencies: chokidar: 5.0.0 kleur: 4.1.5 sade: 1.8.1 semver: 7.7.3 svelte: 5.53.5 - svelte2tsx: 0.7.46(svelte@5.53.5)(typescript@5.9.3) + svelte2tsx: 0.7.46(svelte@5.53.5)(typescript@6.0.3) transitivePeerDependencies: - typescript @@ -11615,13 +11844,13 @@ snapshots: '@tanstack/query-core': 5.90.12 svelte: 5.53.5 - '@tanstack/vue-query@5.90.2(vue@3.5.22(typescript@5.9.3))': + '@tanstack/vue-query@5.90.2(vue@3.5.22(typescript@6.0.3))': dependencies: '@tanstack/match-sorter-utils': 8.19.4 '@tanstack/query-core': 5.90.2 '@vue/devtools-api': 6.6.4 - vue: 3.5.22(typescript@5.9.3) - vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3)) + vue: 3.5.22(typescript@6.0.3) + vue-demi: 0.14.10(vue@3.5.22(typescript@6.0.3)) '@testing-library/dom@10.4.1': dependencies: @@ -11807,79 +12036,79 @@ snapshots: dependencies: '@types/node': 25.5.2 - '@typescript-eslint/eslint-plugin@8.34.1(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.34.1(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.34.1 - '@typescript-eslint/type-utils': 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.34.1 eslint: 9.29.0(jiti@2.6.1) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.1.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.46.2 - '@typescript-eslint/type-utils': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.46.2 eslint: 9.29.0(jiti@2.6.1) graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.1.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.34.1 '@typescript-eslint/types': 8.34.1 - '@typescript-eslint/typescript-estree': 8.34.1(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.34.1(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.34.1 debug: 4.4.1 eslint: 9.29.0(jiti@2.6.1) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.46.2 '@typescript-eslint/types': 8.46.2 - '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.46.2(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.46.2 debug: 4.4.3 eslint: 9.29.0(jiti@2.6.1) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.34.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.34.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.34.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.34.1(typescript@6.0.3) '@typescript-eslint/types': 8.34.1 debug: 4.4.3 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.46.2(typescript@5.9.3)': + '@typescript-eslint/project-service@8.46.2(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@6.0.3) '@typescript-eslint/types': 8.46.2 debug: 4.4.3 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -11893,34 +12122,34 @@ snapshots: '@typescript-eslint/types': 8.46.2 '@typescript-eslint/visitor-keys': 8.46.2 - '@typescript-eslint/tsconfig-utils@8.34.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.34.1(typescript@6.0.3)': dependencies: - typescript: 5.9.3 + typescript: 6.0.3 - '@typescript-eslint/tsconfig-utils@8.46.2(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.46.2(typescript@6.0.3)': dependencies: - typescript: 5.9.3 + typescript: 6.0.3 - '@typescript-eslint/type-utils@8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@typescript-eslint/typescript-estree': 8.34.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.34.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) debug: 4.4.3 eslint: 9.29.0(jiti@2.6.1) - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.1.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.46.2 - '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.46.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) debug: 4.4.3 eslint: 9.29.0(jiti@2.6.1) - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.1.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -11928,10 +12157,10 @@ snapshots: '@typescript-eslint/types@8.46.2': {} - '@typescript-eslint/typescript-estree@8.34.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.34.1(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.34.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.34.1(typescript@5.9.3) + '@typescript-eslint/project-service': 8.34.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.34.1(typescript@6.0.3) '@typescript-eslint/types': 8.34.1 '@typescript-eslint/visitor-keys': 8.34.1 debug: 4.4.3 @@ -11939,15 +12168,15 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.8 semver: 7.7.4 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.1.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.46.2(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.46.2(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.46.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) + '@typescript-eslint/project-service': 8.46.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@6.0.3) '@typescript-eslint/types': 8.46.2 '@typescript-eslint/visitor-keys': 8.46.2 debug: 4.4.3 @@ -11955,30 +12184,30 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.8 semver: 7.7.4 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.1.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.34.1 '@typescript-eslint/types': 8.34.1 - '@typescript-eslint/typescript-estree': 8.34.1(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.34.1(typescript@6.0.3) eslint: 9.29.0(jiti@2.6.1) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.29.0(jiti@2.6.1)) '@typescript-eslint/scope-manager': 8.46.2 '@typescript-eslint/types': 8.46.2 - '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.46.2(typescript@6.0.3) eslint: 9.29.0(jiti@2.6.1) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -11998,6 +12227,12 @@ snapshots: unhead: 2.1.7 vue: 3.5.29(typescript@5.9.3) + '@unhead/vue@2.1.7(vue@3.5.29(typescript@6.0.3))': + dependencies: + hookable: 6.0.1 + unhead: 2.1.7 + vue: 3.5.29(typescript@6.0.3) + '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -12088,12 +12323,30 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-vue-jsx@5.1.4(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@6.0.3))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.5 + '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0) + vite: 7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2) + vue: 3.5.29(typescript@6.0.3) + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 vite: 7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2) vue: 3.5.29(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@6.0.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + vite: 7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2) + vue: 3.5.29(typescript@6.0.3) + '@vitest/coverage-v8@4.0.16(vitest@4.0.14(@edge-runtime/vm@5.0.0)(@types/node@20.19.24)(happy-dom@20.8.9)(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: '@bcoe/v8-coverage': 1.0.2 @@ -12190,12 +12443,22 @@ snapshots: optionalDependencies: vue: 3.5.29(typescript@5.9.3) - '@vue/babel-helper-vue-transform-on@2.0.1': {} - - '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.0)': + '@vue-macros/common@3.1.1(vue@3.5.29(typescript@6.0.3))': dependencies: - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@vue/compiler-sfc': 3.5.26 + ast-kit: 2.1.3 + local-pkg: 1.1.2 + magic-string-ast: 1.0.3 + unplugin-utils: 0.3.1 + optionalDependencies: + vue: 3.5.29(typescript@6.0.3) + + '@vue/babel-helper-vue-transform-on@2.0.1': {} + + '@vue/babel-plugin-jsx@2.0.1(@babel/core@7.29.0)': + dependencies: + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) '@babel/template': 7.28.6 '@babel/traverse': 7.28.6 @@ -12323,6 +12586,18 @@ snapshots: transitivePeerDependencies: - vite + '@vue/devtools-core@8.0.5(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@6.0.3))': + dependencies: + '@vue/devtools-kit': 8.0.5 + '@vue/devtools-shared': 8.0.5 + mitt: 3.0.1 + nanoid: 5.1.6 + pathe: 2.0.3 + vite-hot-client: 2.1.0(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)) + vue: 3.5.29(typescript@6.0.3) + transitivePeerDependencies: + - vite + '@vue/devtools-kit@8.0.5': dependencies: '@vue/devtools-shared': 8.0.5 @@ -12379,11 +12654,11 @@ snapshots: '@vue/shared': 3.5.29 csstype: 3.2.3 - '@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.9.3))': + '@vue/server-renderer@3.5.22(vue@3.5.22(typescript@6.0.3))': dependencies: '@vue/compiler-ssr': 3.5.22 '@vue/shared': 3.5.22 - vue: 3.5.22(typescript@5.9.3) + vue: 3.5.22(typescript@6.0.3) '@vue/server-renderer@3.5.29(vue@3.5.29(typescript@5.9.3))': dependencies: @@ -12391,6 +12666,12 @@ snapshots: '@vue/shared': 3.5.29 vue: 3.5.29(typescript@5.9.3) + '@vue/server-renderer@3.5.29(vue@3.5.29(typescript@6.0.3))': + dependencies: + '@vue/compiler-ssr': 3.5.29 + '@vue/shared': 3.5.29 + vue: 3.5.29(typescript@6.0.3) + '@vue/shared@3.5.22': {} '@vue/shared@3.5.26': {} @@ -12653,10 +12934,10 @@ snapshots: baseline-browser-mapping@2.9.11: {} - better-auth@1.4.19(f276412306c0f31e69da8456c8cd9497): + better-auth@1.4.19(1495ef1d827113360533150571904e77): dependencies: '@better-auth/core': 1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.28.16)(nanostores@1.0.1) - '@better-auth/telemetry': 1.4.19(@better-auth/core@1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.28.16)(nanostores@1.0.1)) + '@better-auth/telemetry': 1.4.19(@better-auth/core@1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.2)(kysely@0.29.0)(nanostores@1.0.1)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.0.1 @@ -12671,7 +12952,7 @@ snapshots: '@prisma/client': 5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)) '@sveltejs/kit': 2.53.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.53.5)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)) better-sqlite3: 12.5.0 - drizzle-orm: 0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0) + drizzle-orm: 0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0) mysql2: 3.16.1 next: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) pg: 8.16.3 @@ -13194,10 +13475,16 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1): + db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1): optionalDependencies: better-sqlite3: 12.5.0 - drizzle-orm: 0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0) + drizzle-orm: 0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0) + mysql2: 3.16.1 + + db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0))(mysql2@3.16.1): + optionalDependencies: + better-sqlite3: 12.5.0 + drizzle-orm: 0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0) mysql2: 3.16.1 debug@3.2.7: @@ -13314,7 +13601,7 @@ snapshots: dotenv@17.2.3: {} - drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0): + drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0): optionalDependencies: '@prisma/client': 5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)) '@types/better-sqlite3': 7.6.13 @@ -13322,12 +13609,27 @@ snapshots: '@types/sql.js': 1.4.9 better-sqlite3: 12.5.0 bun-types: 1.3.3 - kysely: 0.28.16 + kysely: 0.29.0 mysql2: 3.16.1 pg: 8.16.3 prisma: 6.19.0(magicast@0.5.1)(typescript@5.9.3) sql.js: 1.13.0 + drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0): + optionalDependencies: + '@prisma/client': 5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)) + '@types/better-sqlite3': 7.6.13 + '@types/pg': 8.16.0 + '@types/sql.js': 1.4.9 + better-sqlite3: 12.5.0 + bun-types: 1.3.3 + kysely: 0.29.0 + mysql2: 3.16.1 + pg: 8.16.3 + prisma: 6.19.0(magicast@0.5.1)(typescript@6.0.3) + sql.js: 1.13.0 + optional: true + dts-resolver@2.1.3: {} dunder-proto@1.0.1: @@ -13598,20 +13900,20 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@16.0.1(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.0.1(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3): dependencies: '@next/eslint-plugin-next': 16.0.1 eslint: 9.29.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.29.0(jiti@2.6.1)) globals: 16.4.0 - typescript-eslint: 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + typescript-eslint: 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-webpack @@ -13626,7 +13928,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 @@ -13637,22 +13939,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) eslint: 9.29.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -13663,7 +13965,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.29.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -13675,7 +13977,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -14727,13 +15029,15 @@ snapshots: knitwork@1.3.0: {} - kysely-bun-sqlite@0.4.0(kysely@0.28.16): + kysely-bun-sqlite@0.4.0(kysely@0.29.0): dependencies: bun-types: 1.3.3 - kysely: 0.28.16 + kysely: 0.29.0 kysely@0.28.16: {} + kysely@0.29.0: {} + langium-cli@3.5.0: dependencies: chalk: 5.3.0 @@ -15152,7 +15456,7 @@ snapshots: nice-try@1.0.5: {} - nitropack@2.13.1(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1)(rolldown@1.0.0-rc.15): + nitropack@2.13.1(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1)(rolldown@1.0.0-rc.15): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.59.0) @@ -15173,7 +15477,7 @@ snapshots: cookie-es: 2.0.0 croner: 9.1.0 crossws: 0.3.5 - db0: 0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1) + db0: 0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1) defu: 6.1.4 destr: 2.0.5 dot-prop: 10.1.0 @@ -15219,7 +15523,109 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 5.6.0 unplugin-utils: 0.3.1 - unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1))(ioredis@5.9.3) + unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1))(ioredis@5.9.3) + untyped: 2.0.0 + unwasm: 0.5.3 + youch: 4.1.0-beta.13 + youch-core: 0.3.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - better-sqlite3 + - drizzle-orm + - encoding + - idb-keyval + - mysql2 + - react-native-b4a + - rolldown + - sqlite3 + - supports-color + - uploadthing + + nitropack@2.13.1(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0))(mysql2@3.16.1)(rolldown@1.0.0-rc.15): + dependencies: + '@cloudflare/kv-asset-handler': 0.4.2 + '@rollup/plugin-alias': 6.0.0(rollup@4.59.0) + '@rollup/plugin-commonjs': 29.0.0(rollup@4.59.0) + '@rollup/plugin-inject': 5.0.5(rollup@4.59.0) + '@rollup/plugin-json': 6.1.0(rollup@4.59.0) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.59.0) + '@rollup/plugin-replace': 6.0.3(rollup@4.59.0) + '@rollup/plugin-terser': 0.4.4(rollup@4.59.0) + '@vercel/nft': 1.3.2(rollup@4.59.0) + archiver: 7.0.1 + c12: 3.3.3(magicast@0.5.1) + chokidar: 5.0.0 + citty: 0.1.6 + compatx: 0.2.0 + confbox: 0.2.2 + consola: 3.4.2 + cookie-es: 2.0.0 + croner: 9.1.0 + crossws: 0.3.5 + db0: 0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0))(mysql2@3.16.1) + defu: 6.1.4 + destr: 2.0.5 + dot-prop: 10.1.0 + esbuild: 0.27.2 + escape-string-regexp: 5.0.0 + etag: 1.8.1 + exsolve: 1.0.8 + globby: 16.1.1 + gzip-size: 7.0.0 + h3: 1.15.5 + hookable: 5.5.3 + httpxy: 0.1.7 + ioredis: 5.9.3 + jiti: 2.6.1 + klona: 2.0.6 + knitwork: 1.3.0 + listhen: 1.9.0 + magic-string: 0.30.21 + magicast: 0.5.1 + mime: 4.1.0 + mlly: 1.8.0 + node-fetch-native: 1.6.7 + node-mock-http: 1.0.4 + ofetch: 1.5.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + pretty-bytes: 7.1.0 + radix3: 1.1.2 + rollup: 4.59.0 + rollup-plugin-visualizer: 6.0.5(rolldown@1.0.0-rc.15)(rollup@4.59.0) + scule: 1.3.0 + semver: 7.7.4 + serve-placeholder: 2.0.2 + serve-static: 2.2.1 + source-map: 0.7.6 + std-env: 3.10.0 + ufo: 1.6.3 + ultrahtml: 1.6.0 + uncrypto: 0.1.3 + unctx: 2.5.0 + unenv: 2.0.0-rc.24 + unimport: 5.6.0 + unplugin-utils: 0.3.1 + unstorage: 1.17.4(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0))(mysql2@3.16.1))(ioredis@5.9.3) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.0-beta.13 @@ -15318,16 +15724,16 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@4.3.1(c09e06bbcbcd2b0a94c9a804d2c88498): + nuxt@4.3.1(20bb9c9cac3d4d3ad27d57e07c1eb4f2): dependencies: '@dxup/nuxt': 0.3.2(magicast@0.5.1) '@nuxt/cli': 3.33.1(@nuxt/schema@4.3.1)(cac@6.7.14)(magicast@0.5.1) '@nuxt/devtools': 3.1.1(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)) '@nuxt/kit': 4.3.1(magicast@0.5.1) - '@nuxt/nitro-server': 4.3.1(better-sqlite3@12.5.0)(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1))(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(ioredis@5.9.3)(magicast@0.5.1)(mysql2@3.16.1)(nuxt@4.3.1(c09e06bbcbcd2b0a94c9a804d2c88498))(rolldown@1.0.0-rc.15)(typescript@5.9.3) + '@nuxt/nitro-server': 4.3.1(better-sqlite3@12.5.0)(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1))(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(ioredis@5.9.3)(magicast@0.5.1)(mysql2@3.16.1)(nuxt@4.3.1(20bb9c9cac3d4d3ad27d57e07c1eb4f2))(rolldown@1.0.0-rc.15)(typescript@5.9.3) '@nuxt/schema': 4.3.1 '@nuxt/telemetry': 2.7.0(@nuxt/kit@4.3.1(magicast@0.5.1)) - '@nuxt/vite-builder': 4.3.1(@types/node@25.5.2)(eslint@9.29.0(jiti@2.6.1))(lightningcss@1.30.2)(magicast@0.5.1)(nuxt@4.3.1(c09e06bbcbcd2b0a94c9a804d2c88498))(optionator@0.9.4)(rolldown@1.0.0-rc.15)(rollup@4.59.0)(terser@5.44.0)(tsx@4.20.3)(typescript@5.9.3)(vue-tsc@3.2.5(typescript@5.9.3))(vue@3.5.29(typescript@5.9.3))(yaml@2.8.2) + '@nuxt/vite-builder': 4.3.1(@types/node@25.5.2)(eslint@9.29.0(jiti@2.6.1))(lightningcss@1.30.2)(magicast@0.5.1)(nuxt@4.3.1(20bb9c9cac3d4d3ad27d57e07c1eb4f2))(optionator@0.9.4)(rolldown@1.0.0-rc.15)(rollup@4.59.0)(terser@5.44.0)(tsx@4.20.3)(typescript@5.9.3)(vue-tsc@3.2.5(typescript@5.9.3))(vue@3.5.29(typescript@5.9.3))(yaml@2.8.2) '@unhead/vue': 2.1.7(vue@3.5.29(typescript@5.9.3)) '@vue/shared': 3.5.29 c12: 3.3.3(magicast@0.5.1) @@ -15441,6 +15847,129 @@ snapshots: - xml2js - yaml + nuxt@4.3.1(f6c7dcf4eb9de64f6cb5e0db74766190): + dependencies: + '@dxup/nuxt': 0.3.2(magicast@0.5.1) + '@nuxt/cli': 3.33.1(@nuxt/schema@4.3.1)(cac@6.7.14)(magicast@0.5.1) + '@nuxt/devtools': 3.1.1(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@6.0.3)) + '@nuxt/kit': 4.3.1(magicast@0.5.1) + '@nuxt/nitro-server': 4.3.1(better-sqlite3@12.5.0)(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0))(mysql2@3.16.1))(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0))(ioredis@5.9.3)(magicast@0.5.1)(mysql2@3.16.1)(nuxt@4.3.1(f6c7dcf4eb9de64f6cb5e0db74766190))(rolldown@1.0.0-rc.15)(typescript@6.0.3) + '@nuxt/schema': 4.3.1 + '@nuxt/telemetry': 2.7.0(@nuxt/kit@4.3.1(magicast@0.5.1)) + '@nuxt/vite-builder': 4.3.1(@types/node@25.5.2)(eslint@9.29.0(jiti@2.6.1))(lightningcss@1.30.2)(magicast@0.5.1)(nuxt@4.3.1(f6c7dcf4eb9de64f6cb5e0db74766190))(optionator@0.9.4)(rolldown@1.0.0-rc.15)(rollup@4.59.0)(terser@5.44.0)(tsx@4.20.3)(typescript@6.0.3)(vue-tsc@3.2.5(typescript@6.0.3))(vue@3.5.29(typescript@6.0.3))(yaml@2.8.2) + '@unhead/vue': 2.1.7(vue@3.5.29(typescript@6.0.3)) + '@vue/shared': 3.5.29 + c12: 3.3.3(magicast@0.5.1) + chokidar: 5.0.0 + compatx: 0.2.0 + consola: 3.4.2 + cookie-es: 2.0.0 + defu: 6.1.4 + destr: 2.0.5 + devalue: 5.6.3 + errx: 0.1.0 + escape-string-regexp: 5.0.0 + exsolve: 1.0.8 + h3: 1.15.5 + hookable: 5.5.3 + ignore: 7.0.5 + impound: 1.0.0 + jiti: 2.6.1 + klona: 2.0.6 + knitwork: 1.3.0 + magic-string: 0.30.21 + mlly: 1.8.0 + nanotar: 0.2.0 + nypm: 0.6.5 + ofetch: 1.5.1 + ohash: 2.0.11 + on-change: 6.0.2 + oxc-minify: 0.112.0 + oxc-parser: 0.112.0 + oxc-transform: 0.112.0 + oxc-walker: 0.7.0(oxc-parser@0.112.0) + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rou3: 0.7.12 + scule: 1.3.0 + semver: 7.7.4 + std-env: 3.10.0 + tinyglobby: 0.2.15 + ufo: 1.6.3 + ultrahtml: 1.6.0 + uncrypto: 0.1.3 + unctx: 2.5.0 + unimport: 5.6.0 + unplugin: 3.0.0 + unplugin-vue-router: 0.19.2(@vue/compiler-sfc@3.5.29)(vue-router@4.6.4(vue@3.5.22(typescript@6.0.3)))(vue@3.5.29(typescript@6.0.3)) + untyped: 2.0.0 + vue: 3.5.29(typescript@6.0.3) + vue-router: 4.6.4(vue@3.5.29(typescript@6.0.3)) + optionalDependencies: + '@parcel/watcher': 2.5.1 + '@types/node': 25.5.2 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@biomejs/biome' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - '@vue/compiler-sfc' + - aws4fetch + - bare-abort-controller + - better-sqlite3 + - bufferutil + - cac + - commander + - db0 + - drizzle-orm + - encoding + - eslint + - idb-keyval + - ioredis + - less + - lightningcss + - magicast + - meow + - mysql2 + - optionator + - oxlint + - react-native-b4a + - rolldown + - rollup + - sass + - sass-embedded + - sqlite3 + - stylelint + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - utf-8-validate + - vite + - vls + - vti + - vue-tsc + - xml2js + - yaml + nypm@0.6.2: dependencies: citty: 0.1.6 @@ -16046,12 +16575,12 @@ snapshots: dependencies: parse-ms: 4.0.0 - prisma@6.19.0(magicast@0.3.5)(typescript@5.9.3): + prisma@6.19.0(magicast@0.3.5)(typescript@6.0.3): dependencies: '@prisma/config': 6.19.0(magicast@0.3.5) '@prisma/engines': 6.19.0 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - magicast @@ -16065,6 +16594,16 @@ snapshots: - magicast optional: true + prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3): + dependencies: + '@prisma/config': 6.19.0(magicast@0.5.1) + '@prisma/engines': 6.19.0 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - magicast + optional: true + process-nextick-args@2.0.1: {} process-warning@4.0.1: {} @@ -16267,7 +16806,7 @@ snapshots: glob: 13.0.6 package-json-from-dist: 1.0.1 - rolldown-plugin-dts@0.23.2(rolldown@1.0.0-rc.15)(typescript@5.9.3)(vue-tsc@3.2.5(typescript@5.9.3)): + rolldown-plugin-dts@0.23.2(rolldown@1.0.0-rc.15)(typescript@6.0.3)(vue-tsc@3.2.5(typescript@6.0.3)): dependencies: '@babel/generator': 8.0.0-rc.3 '@babel/helper-validator-identifier': 8.0.0-rc.3 @@ -16281,8 +16820,8 @@ snapshots: picomatch: 4.0.4 rolldown: 1.0.0-rc.15 optionalDependencies: - typescript: 5.9.3 - vue-tsc: 3.2.5(typescript@5.9.3) + typescript: 6.0.3 + vue-tsc: 3.2.5(typescript@6.0.3) transitivePeerDependencies: - oxc-resolver @@ -16848,12 +17387,12 @@ snapshots: transitivePeerDependencies: - picomatch - svelte2tsx@0.7.46(svelte@5.53.5)(typescript@5.9.3): + svelte2tsx@0.7.46(svelte@5.53.5)(typescript@6.0.3): dependencies: dedent-js: 1.0.1 scule: 1.3.0 svelte: 5.53.5 - typescript: 5.9.3 + typescript: 6.0.3 svelte@5.53.5: dependencies: @@ -17015,9 +17554,9 @@ snapshots: tree-kill@1.2.2: {} - ts-api-utils@2.1.0(typescript@5.9.3): + ts-api-utils@2.1.0(typescript@6.0.3): dependencies: - typescript: 5.9.3 + typescript: 6.0.3 ts-japi@1.12.1: {} @@ -17030,7 +17569,7 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tsdown@0.21.8(typescript@5.9.3)(vue-tsc@3.2.5(typescript@5.9.3)): + tsdown@0.21.8(typescript@6.0.3)(vue-tsc@3.2.5(typescript@6.0.3)): dependencies: ansis: 4.2.0 cac: 7.0.0 @@ -17041,7 +17580,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.4 rolldown: 1.0.0-rc.15 - rolldown-plugin-dts: 0.23.2(rolldown@1.0.0-rc.15)(typescript@5.9.3)(vue-tsc@3.2.5(typescript@5.9.3)) + rolldown-plugin-dts: 0.23.2(rolldown@1.0.0-rc.15)(typescript@6.0.3)(vue-tsc@3.2.5(typescript@6.0.3)) semver: 7.7.4 tinyexec: 1.1.1 tinyglobby: 0.2.16 @@ -17049,7 +17588,7 @@ snapshots: unconfig-core: 7.5.0 unrun: 0.2.35 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - '@ts-macro/tsc' - '@typescript/native-preview' @@ -17146,29 +17685,31 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.34.1(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.34.1(@typescript-eslint/parser@8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/parser': 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) eslint: 9.29.0(jiti@2.6.1) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - typescript-eslint@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3))(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/parser': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.46.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@6.0.3) eslint: 9.29.0(jiti@2.6.1) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color typescript@5.9.3: {} + typescript@6.0.3: {} + ufo@1.6.1: {} ufo@1.6.3: {} @@ -17247,6 +17788,31 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.3 + unplugin-vue-router@0.19.2(@vue/compiler-sfc@3.5.29)(vue-router@4.6.4(vue@3.5.22(typescript@6.0.3)))(vue@3.5.29(typescript@6.0.3)): + dependencies: + '@babel/generator': 7.28.6 + '@vue-macros/common': 3.1.1(vue@3.5.29(typescript@6.0.3)) + '@vue/compiler-sfc': 3.5.29 + '@vue/language-core': 3.2.5 + ast-walker-scope: 0.8.3 + chokidar: 5.0.0 + json5: 2.2.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.0 + muggle-string: 0.4.1 + pathe: 2.0.3 + picomatch: 4.0.3 + scule: 1.3.0 + tinyglobby: 0.2.15 + unplugin: 2.3.11 + unplugin-utils: 0.3.1 + yaml: 2.8.2 + optionalDependencies: + vue-router: 4.6.4(vue@3.5.22(typescript@6.0.3)) + transitivePeerDependencies: + - vue + unplugin-vue-router@0.19.2(@vue/compiler-sfc@3.5.29)(vue-router@4.6.4(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)): dependencies: '@babel/generator': 7.28.6 @@ -17313,7 +17879,7 @@ snapshots: dependencies: rolldown: 1.0.0-rc.15 - unstorage@1.17.4(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1))(ioredis@5.9.3): + unstorage@1.17.4(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1))(ioredis@5.9.3): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -17324,7 +17890,21 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.3 optionalDependencies: - db0: 0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.28.16)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1) + db0: 0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@5.9.3))(sql.js@1.13.0))(mysql2@3.16.1) + ioredis: 5.9.3 + + unstorage@1.17.4(db0@0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0))(mysql2@3.16.1))(ioredis@5.9.3): + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.5 + lru-cache: 11.2.6 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.3 + optionalDependencies: + db0: 0.3.4(better-sqlite3@12.5.0)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3)))(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(bun-types@1.3.3)(kysely@0.29.0)(mysql2@3.16.1)(pg@8.16.3)(prisma@6.19.0(magicast@0.5.1)(typescript@6.0.3))(sql.js@1.13.0))(mysql2@3.16.1) ioredis: 5.9.3 untun@0.1.3: @@ -17430,6 +18010,23 @@ snapshots: typescript: 5.9.3 vue-tsc: 3.2.5(typescript@5.9.3) + vite-plugin-checker@0.12.0(eslint@9.29.0(jiti@2.6.1))(optionator@0.9.4)(typescript@6.0.3)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue-tsc@3.2.5(typescript@6.0.3)): + dependencies: + '@babel/code-frame': 7.28.6 + chokidar: 4.0.3 + npm-run-path: 6.0.0 + picocolors: 1.1.1 + picomatch: 4.0.3 + tiny-invariant: 1.3.3 + tinyglobby: 0.2.15 + vite: 7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2) + vscode-uri: 3.1.0 + optionalDependencies: + eslint: 9.29.0(jiti@2.6.1) + optionator: 0.9.4 + typescript: 6.0.3 + vue-tsc: 3.2.5(typescript@6.0.3) + vite-plugin-inspect@11.3.3(@nuxt/kit@4.3.1(magicast@0.5.1))(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2)): dependencies: ansis: 4.2.0 @@ -17457,6 +18054,16 @@ snapshots: vite: 7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2) vue: 3.5.29(typescript@5.9.3) + vite-plugin-vue-tracer@1.2.0(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2))(vue@3.5.29(typescript@6.0.3)): + dependencies: + estree-walker: 3.0.3 + exsolve: 1.0.8 + magic-string: 0.30.21 + pathe: 2.0.3 + source-map-js: 1.2.1 + vite: 7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.2) + vue: 3.5.29(typescript@6.0.3) + vite@7.3.0(@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.27.2 @@ -17644,37 +18251,49 @@ snapshots: dependencies: ufo: 1.6.3 - vue-demi@0.14.10(vue@3.5.22(typescript@5.9.3)): + vue-demi@0.14.10(vue@3.5.22(typescript@6.0.3)): dependencies: - vue: 3.5.22(typescript@5.9.3) + vue: 3.5.22(typescript@6.0.3) vue-devtools-stub@0.1.0: {} - vue-router@4.6.4(vue@3.5.22(typescript@5.9.3)): + vue-router@4.6.4(vue@3.5.22(typescript@6.0.3)): dependencies: '@vue/devtools-api': 6.6.4 - vue: 3.5.22(typescript@5.9.3) + vue: 3.5.22(typescript@6.0.3) vue-router@4.6.4(vue@3.5.29(typescript@5.9.3)): dependencies: '@vue/devtools-api': 6.6.4 vue: 3.5.29(typescript@5.9.3) + vue-router@4.6.4(vue@3.5.29(typescript@6.0.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.29(typescript@6.0.3) + vue-tsc@3.2.5(typescript@5.9.3): dependencies: '@volar/typescript': 2.4.28 '@vue/language-core': 3.2.5 typescript: 5.9.3 + optional: true - vue@3.5.22(typescript@5.9.3): + vue-tsc@3.2.5(typescript@6.0.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.2.5 + typescript: 6.0.3 + + vue@3.5.22(typescript@6.0.3): dependencies: '@vue/compiler-dom': 3.5.22 '@vue/compiler-sfc': 3.5.22 '@vue/runtime-dom': 3.5.22 - '@vue/server-renderer': 3.5.22(vue@3.5.22(typescript@5.9.3)) + '@vue/server-renderer': 3.5.22(vue@3.5.22(typescript@6.0.3)) '@vue/shared': 3.5.22 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 vue@3.5.29(typescript@5.9.3): dependencies: @@ -17686,6 +18305,16 @@ snapshots: optionalDependencies: typescript: 5.9.3 + vue@3.5.29(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-sfc': 3.5.29 + '@vue/runtime-dom': 3.5.29 + '@vue/server-renderer': 3.5.29(vue@3.5.29(typescript@6.0.3)) + '@vue/shared': 3.5.29 + optionalDependencies: + typescript: 6.0.3 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ea399eb70..20e094cd8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,7 +15,7 @@ catalog: '@types/tmp': ^0.2.6 better-sqlite3: ^12.5.0 decimal.js: ^10.4.3 - kysely: ~0.28.16 + kysely: ~0.29.0 langium: 3.5.0 langium-cli: 3.5.0 next: 16.1.6 @@ -30,7 +30,7 @@ catalog: svelte: 5.53.5 tmp: ^0.2.4 ts-pattern: ^5.7.1 - typescript: ^5.9.3 + typescript: ^6.0.3 vue: 3.5.22 zod: ^4.0.0 zod-validation-error: ^4.0.1 diff --git a/samples/next.js/app/global.d.ts b/samples/next.js/app/global.d.ts new file mode 100644 index 000000000..35306c6fc --- /dev/null +++ b/samples/next.js/app/global.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/samples/next.js/zenstack/input.ts b/samples/next.js/zenstack/input.ts index 72c04fe53..9fba87a68 100644 --- a/samples/next.js/zenstack/input.ts +++ b/samples/next.js/zenstack/input.ts @@ -6,7 +6,7 @@ /* eslint-disable */ import { type SchemaType as $Schema } from "./schema-lite"; -import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, UncheckedCreateInput as $UncheckedCreateInput, CheckedCreateInput as $CheckedCreateInput, UncheckedUpdateInput as $UncheckedUpdateInput, CheckedUpdateInput as $CheckedUpdateInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; @@ -28,6 +28,10 @@ export type UserWhereInput = $WhereInput<$Schema, "User">; export type UserSelect = $SelectInput<$Schema, "User">; export type UserInclude = $IncludeInput<$Schema, "User">; export type UserOmit = $OmitInput<$Schema, "User">; +export type UserUncheckedCreateInput = $UncheckedCreateInput<$Schema, "User">; +export type UserCheckedCreateInput = $CheckedCreateInput<$Schema, "User">; +export type UserUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "User">; +export type UserCheckedUpdateInput = $CheckedUpdateInput<$Schema, "User">; export type UserGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "User", Args, Options>; export type PostFindManyArgs = $FindManyArgs<$Schema, "Post">; export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; @@ -49,4 +53,8 @@ export type PostWhereInput = $WhereInput<$Schema, "Post">; export type PostSelect = $SelectInput<$Schema, "Post">; export type PostInclude = $IncludeInput<$Schema, "Post">; export type PostOmit = $OmitInput<$Schema, "Post">; +export type PostUncheckedCreateInput = $UncheckedCreateInput<$Schema, "Post">; +export type PostCheckedCreateInput = $CheckedCreateInput<$Schema, "Post">; +export type PostUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "Post">; +export type PostCheckedUpdateInput = $CheckedUpdateInput<$Schema, "Post">; export type PostGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Post", Args, Options>; diff --git a/samples/nuxt/zenstack/input.ts b/samples/nuxt/zenstack/input.ts index 72c04fe53..9fba87a68 100644 --- a/samples/nuxt/zenstack/input.ts +++ b/samples/nuxt/zenstack/input.ts @@ -6,7 +6,7 @@ /* eslint-disable */ import { type SchemaType as $Schema } from "./schema-lite"; -import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, UncheckedCreateInput as $UncheckedCreateInput, CheckedCreateInput as $CheckedCreateInput, UncheckedUpdateInput as $UncheckedUpdateInput, CheckedUpdateInput as $CheckedUpdateInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; @@ -28,6 +28,10 @@ export type UserWhereInput = $WhereInput<$Schema, "User">; export type UserSelect = $SelectInput<$Schema, "User">; export type UserInclude = $IncludeInput<$Schema, "User">; export type UserOmit = $OmitInput<$Schema, "User">; +export type UserUncheckedCreateInput = $UncheckedCreateInput<$Schema, "User">; +export type UserCheckedCreateInput = $CheckedCreateInput<$Schema, "User">; +export type UserUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "User">; +export type UserCheckedUpdateInput = $CheckedUpdateInput<$Schema, "User">; export type UserGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "User", Args, Options>; export type PostFindManyArgs = $FindManyArgs<$Schema, "Post">; export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; @@ -49,4 +53,8 @@ export type PostWhereInput = $WhereInput<$Schema, "Post">; export type PostSelect = $SelectInput<$Schema, "Post">; export type PostInclude = $IncludeInput<$Schema, "Post">; export type PostOmit = $OmitInput<$Schema, "Post">; +export type PostUncheckedCreateInput = $UncheckedCreateInput<$Schema, "Post">; +export type PostCheckedCreateInput = $CheckedCreateInput<$Schema, "Post">; +export type PostUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "Post">; +export type PostCheckedUpdateInput = $CheckedUpdateInput<$Schema, "Post">; export type PostGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Post", Args, Options>; diff --git a/samples/orm/package.json b/samples/orm/package.json index 2e1a7278e..678d72f62 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.6.4", + "version": "3.7.0", "description": "", "main": "index.js", "private": true, diff --git a/samples/orm/zenstack/input.ts b/samples/orm/zenstack/input.ts index cfc174c06..b92964676 100644 --- a/samples/orm/zenstack/input.ts +++ b/samples/orm/zenstack/input.ts @@ -6,7 +6,7 @@ /* eslint-disable */ import { type SchemaType as $Schema } from "./schema"; -import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, UncheckedCreateInput as $UncheckedCreateInput, CheckedCreateInput as $CheckedCreateInput, UncheckedUpdateInput as $UncheckedUpdateInput, CheckedUpdateInput as $CheckedUpdateInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; @@ -28,6 +28,10 @@ export type UserWhereInput = $WhereInput<$Schema, "User">; export type UserSelect = $SelectInput<$Schema, "User">; export type UserInclude = $IncludeInput<$Schema, "User">; export type UserOmit = $OmitInput<$Schema, "User">; +export type UserUncheckedCreateInput = $UncheckedCreateInput<$Schema, "User">; +export type UserCheckedCreateInput = $CheckedCreateInput<$Schema, "User">; +export type UserUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "User">; +export type UserCheckedUpdateInput = $CheckedUpdateInput<$Schema, "User">; export type UserGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "User", Args, Options>; export type ProfileFindManyArgs = $FindManyArgs<$Schema, "Profile">; export type ProfileFindUniqueArgs = $FindUniqueArgs<$Schema, "Profile">; @@ -49,6 +53,10 @@ export type ProfileWhereInput = $WhereInput<$Schema, "Profile">; export type ProfileSelect = $SelectInput<$Schema, "Profile">; export type ProfileInclude = $IncludeInput<$Schema, "Profile">; export type ProfileOmit = $OmitInput<$Schema, "Profile">; +export type ProfileUncheckedCreateInput = $UncheckedCreateInput<$Schema, "Profile">; +export type ProfileCheckedCreateInput = $CheckedCreateInput<$Schema, "Profile">; +export type ProfileUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "Profile">; +export type ProfileCheckedUpdateInput = $CheckedUpdateInput<$Schema, "Profile">; export type ProfileGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Profile", Args, Options>; export type PostFindManyArgs = $FindManyArgs<$Schema, "Post">; export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; @@ -70,4 +78,8 @@ export type PostWhereInput = $WhereInput<$Schema, "Post">; export type PostSelect = $SelectInput<$Schema, "Post">; export type PostInclude = $IncludeInput<$Schema, "Post">; export type PostOmit = $OmitInput<$Schema, "Post">; +export type PostUncheckedCreateInput = $UncheckedCreateInput<$Schema, "Post">; +export type PostCheckedCreateInput = $CheckedCreateInput<$Schema, "Post">; +export type PostUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "Post">; +export type PostCheckedUpdateInput = $CheckedUpdateInput<$Schema, "Post">; export type PostGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Post", Args, Options>; diff --git a/samples/sveltekit/src/zenstack/input.ts b/samples/sveltekit/src/zenstack/input.ts index 72c04fe53..9fba87a68 100644 --- a/samples/sveltekit/src/zenstack/input.ts +++ b/samples/sveltekit/src/zenstack/input.ts @@ -6,7 +6,7 @@ /* eslint-disable */ import { type SchemaType as $Schema } from "./schema-lite"; -import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, UncheckedCreateInput as $UncheckedCreateInput, CheckedCreateInput as $CheckedCreateInput, UncheckedUpdateInput as $UncheckedUpdateInput, CheckedUpdateInput as $CheckedUpdateInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; @@ -28,6 +28,10 @@ export type UserWhereInput = $WhereInput<$Schema, "User">; export type UserSelect = $SelectInput<$Schema, "User">; export type UserInclude = $IncludeInput<$Schema, "User">; export type UserOmit = $OmitInput<$Schema, "User">; +export type UserUncheckedCreateInput = $UncheckedCreateInput<$Schema, "User">; +export type UserCheckedCreateInput = $CheckedCreateInput<$Schema, "User">; +export type UserUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "User">; +export type UserCheckedUpdateInput = $CheckedUpdateInput<$Schema, "User">; export type UserGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "User", Args, Options>; export type PostFindManyArgs = $FindManyArgs<$Schema, "Post">; export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; @@ -49,4 +53,8 @@ export type PostWhereInput = $WhereInput<$Schema, "Post">; export type PostSelect = $SelectInput<$Schema, "Post">; export type PostInclude = $IncludeInput<$Schema, "Post">; export type PostOmit = $OmitInput<$Schema, "Post">; +export type PostUncheckedCreateInput = $UncheckedCreateInput<$Schema, "Post">; +export type PostCheckedCreateInput = $CheckedCreateInput<$Schema, "Post">; +export type PostUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "Post">; +export type PostCheckedUpdateInput = $CheckedUpdateInput<$Schema, "Post">; export type PostGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Post", Args, Options>; diff --git a/scripts/test-generate.ts b/scripts/test-generate.ts index 7c3926eff..5a612ae07 100644 --- a/scripts/test-generate.ts +++ b/scripts/test-generate.ts @@ -22,7 +22,7 @@ async function generate(schemaPath: string, options: string[]) { const cliPath = path.join(_dirname, '../packages/cli/dist/index.mjs'); const RUNTIME = process.env.RUNTIME ?? 'node'; execSync( - `${RUNTIME} ${cliPath} generate --schema ${schemaPath} ${options.join(' ')} --generate-models=false --generate-input=false --no-version-check --no-tips`, + `${RUNTIME} ${cliPath} generate --schema ${schemaPath} --generate-models=false ${options.join(' ')} --generate-input=false --no-version-check --no-tips`, { cwd: path.dirname(schemaPath), }, diff --git a/tests/e2e/orm/client-api/checked-unchecked.test-d.ts b/tests/e2e/orm/client-api/checked-unchecked.test-d.ts new file mode 100644 index 000000000..851c48690 --- /dev/null +++ b/tests/e2e/orm/client-api/checked-unchecked.test-d.ts @@ -0,0 +1,92 @@ +import type { CreateArgs, UpdateArgs, UpdateManyArgs } from '@zenstackhq/orm'; +import { describe, expectTypeOf, it } from 'vitest'; +import { schema } from '../schemas/basic'; +import type { + PostCheckedCreateInput, + PostCheckedUpdateInput, + PostUncheckedCreateInput, + PostUncheckedUpdateInput, +} from '../schemas/basic/input'; + +type Schema = typeof schema; + +describe('Checked vs unchecked input types - typing', () => { + // #region Shape of exported input types + + it('PostUncheckedCreateInput has FK field but not relation object', () => { + expectTypeOf().toHaveProperty('authorId'); + expectTypeOf().not.toHaveProperty('author'); + }); + + it('PostCheckedCreateInput has relation object but not FK field', () => { + expectTypeOf().toHaveProperty('author'); + expectTypeOf().not.toHaveProperty('authorId'); + }); + + it('PostUncheckedUpdateInput has FK field but not relation object', () => { + expectTypeOf().toHaveProperty('authorId'); + expectTypeOf().not.toHaveProperty('author'); + }); + + it('PostCheckedUpdateInput has relation object but not FK field', () => { + expectTypeOf().toHaveProperty('author'); + expectTypeOf().not.toHaveProperty('authorId'); + }); + + // #endregion + + // #region XOR enforcement on CreateArgs['data'] + + it('rejects mixing FK + relation in create data', () => { + type CreateData = NonNullable['data']>; + // @ts-expect-error - cannot mix authorId (unchecked) and author (checked) in the same object + const _mixed: CreateData = { title: 'T', authorId: 'id1', author: { connect: { id: 'id1' } } }; + }); + + it('accepts unchecked create data (FK only)', () => { + type CreateData = NonNullable['data']>; + void ({ title: 'T', authorId: 'id1' } satisfies CreateData); + }); + + it('accepts checked create data (relation only)', () => { + type CreateData = NonNullable['data']>; + void ({ title: 'T', author: { connect: { id: 'id1' } } } satisfies CreateData); + }); + + // #endregion + + // #region XOR enforcement on UpdateArgs['data'] + + it('rejects mixing FK + relation in update data', () => { + type UpdateData = NonNullable['data']>; + // @ts-expect-error - cannot mix authorId (unchecked) and author (checked) in the same object + const _mixed: UpdateData = { authorId: 'id1', author: { connect: { id: 'id1' } } }; + }); + + it('accepts unchecked update data (FK only)', () => { + type UpdateData = NonNullable['data']>; + void ({ authorId: 'id1' } satisfies UpdateData); + }); + + it('accepts checked update data (relation only)', () => { + type UpdateData = NonNullable['data']>; + void ({ author: { connect: { id: 'id1' } } } satisfies UpdateData); + }); + + // #endregion + + // #region FK fields in updateMany + + it('accepts FK field in updateMany data', () => { + type UpdateManyData = NonNullable['data']>; + void ({ authorId: 'id1' } satisfies UpdateManyData); + }); + + it('rejects relation object in updateMany data', () => { + type UpdateManyData = NonNullable['data']>; + // @ts-expect-error - updateMany does not support relation objects + void ({ author: { connect: { id: 'id1' } } } satisfies UpdateManyData); + }); + + // #endregion +}); diff --git a/tests/e2e/orm/client-api/checked-unchecked.test.ts b/tests/e2e/orm/client-api/checked-unchecked.test.ts new file mode 100644 index 000000000..c63426310 --- /dev/null +++ b/tests/e2e/orm/client-api/checked-unchecked.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ClientContract } from '@zenstackhq/orm'; +import { schema } from '../schemas/basic'; +import { createTestClient } from '@zenstackhq/testtools'; +import { createUser } from './utils'; + +describe('Checked vs unchecked create/update', () => { + let client: ClientContract; + + beforeEach(async () => { + client = await createTestClient(schema); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + describe('runtime enforcement', () => { + it('rejects mixed FK + relation object in create', async () => { + const user = await createUser(client); + + await expect( + (client as any).post.create({ + data: { + title: 'Post', + // mixing unchecked (authorId) and checked (author: { connect }) + authorId: user.id, + author: { connect: { id: user.id } }, + }, + }), + ).toBeRejectedByValidation(); + }); + + it('rejects mixed FK + relation object in update', async () => { + const user = await createUser(client); + const post = await client.post.create({ + data: { title: 'Post', authorId: user.id }, + }); + const user2 = await createUser(client, 'u2@test.com'); + + await expect( + (client as any).post.update({ + where: { id: post.id }, + data: { + // mixing unchecked (authorId) and checked (author: { connect }) + authorId: user2.id, + author: { connect: { id: user2.id } }, + }, + }), + ).toBeRejectedByValidation(); + }); + + it('accepts unchecked create with FK only', async () => { + const user = await createUser(client); + const post = await client.post.create({ + data: { title: 'Post', authorId: user.id }, + }); + expect(post.authorId).toBe(user.id); + }); + + it('accepts checked create with relation object only', async () => { + const user = await createUser(client); + const post = await client.post.create({ + data: { title: 'Post', author: { connect: { id: user.id } } }, + }); + expect(post.authorId).toBe(user.id); + }); + + it('accepts unchecked update with FK only', async () => { + const user = await createUser(client); + const post = await client.post.create({ + data: { title: 'Post', authorId: user.id }, + }); + const user2 = await createUser(client, 'u2@test.com'); + const updated = await client.post.update({ + where: { id: post.id }, + data: { authorId: user2.id }, + }); + expect(updated.authorId).toBe(user2.id); + }); + + it('accepts checked update with relation object only', async () => { + const user = await createUser(client); + const post = await client.post.create({ + data: { title: 'Post', authorId: user.id }, + }); + const user2 = await createUser(client, 'u2@test.com'); + const updated = await client.post.update({ + where: { id: post.id }, + data: { author: { connect: { id: user2.id } } }, + }); + expect(updated.authorId).toBe(user2.id); + }); + }); +}); diff --git a/tests/e2e/orm/client-api/delegate.test.ts b/tests/e2e/orm/client-api/delegate.test.ts index e325aaa5b..6eda28368 100644 --- a/tests/e2e/orm/client-api/delegate.test.ts +++ b/tests/e2e/orm/client-api/delegate.test.ts @@ -101,7 +101,6 @@ describe('Delegate model tests ', () => { duration: 100, url: 'abc', rating: 5, - // @ts-expect-error videoType: 'RatedVideo', }, }), diff --git a/tests/e2e/orm/client-api/find.test.ts b/tests/e2e/orm/client-api/find.test.ts index df7ca6665..2f52eb1fa 100644 --- a/tests/e2e/orm/client-api/find.test.ts +++ b/tests/e2e/orm/client-api/find.test.ts @@ -147,6 +147,13 @@ describe('Client find tests ', () => { }), ).resolves.toMatchObject({ email: 'u2@test.com' }); + // object sort without nulls (null ordering is provider-defined) + await expect( + client.user.findFirst({ + orderBy: { name: { sort: 'desc' } }, + }), + ).resolves.toBeDefined(); + // by to-many relation await expect( client.user.findFirst({ diff --git a/tests/e2e/orm/client-api/full-text-search.test.ts b/tests/e2e/orm/client-api/full-text-search.test.ts new file mode 100644 index 000000000..734a34156 --- /dev/null +++ b/tests/e2e/orm/client-api/full-text-search.test.ts @@ -0,0 +1,532 @@ +import type { ClientContract } from '@zenstackhq/orm'; +import { createTestClient, getTestDbProvider } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { schema } from '../schemas/full-text-search/schema'; + +type Schema = typeof schema; +const provider = getTestDbProvider(); + +describe.skipIf(provider !== 'postgresql')('Full-text search tests', () => { + let client: ClientContract; + + beforeEach(async () => { + client = (await createTestClient(schema)) as unknown as ClientContract; + + await client.article.createMany({ + data: [ + { title: 'The quick brown fox', body: 'the quick brown fox jumps over the lazy dog' }, + { title: 'A cat and a dog', body: 'cat and dog make great pets together' }, + { title: 'Lazy cat sleeps all day', body: 'some cat sleeps more than others' }, + { title: 'The running man', body: 'He runs every morning before work' }, + { title: 'Database performance', body: 'Optimizing query performance for databases' }, + { title: 'PostgreSQL full-text search', body: 'tsvector and tsquery enable searching documents' }, + { title: 'Untitled note', body: 'just some notes', notes: 'a non-searchable note column' }, + ], + }); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + // --------------------------------------------------------------- + // A. Basic single-term search + // --------------------------------------------------------------- + + it('finds articles by a single term', async () => { + const results = await client.article.findMany({ + where: { title: { fts: { search: 'fox' } } }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.title).toBe('The quick brown fox'); + }); + + it('searches in the body field', async () => { + const results = await client.article.findMany({ + where: { body: { fts: { search: 'pets' } } }, + }); + expect(results.some((r) => r.title === 'A cat and a dog')).toBe(true); + }); + + it('returns nothing for a term not in any document', async () => { + const results = await client.article.findMany({ + where: { title: { fts: { search: 'zebra' } } }, + }); + expect(results).toHaveLength(0); + }); + + // --------------------------------------------------------------- + // B. to_tsquery boolean operators + // --------------------------------------------------------------- + + it('AND operator (&) requires both terms', async () => { + const results = await client.article.findMany({ + where: { title: { fts: { search: 'cat & dog' } } }, + }); + const titles = results.map((r) => r.title); + expect(titles).toContain('A cat and a dog'); + // Articles with only one of the terms should not match + expect(titles).not.toContain('Lazy cat sleeps all day'); + }); + + it('OR operator (|) matches either term', async () => { + const results = await client.article.findMany({ + where: { title: { fts: { search: 'fox | cat' } } }, + }); + const titles = results.map((r) => r.title); + expect(titles).toContain('The quick brown fox'); + expect(titles).toContain('A cat and a dog'); + expect(titles).toContain('Lazy cat sleeps all day'); + }); + + it('NOT operator (!) excludes a term', async () => { + const results = await client.article.findMany({ + where: { title: { fts: { search: 'cat & !lazy' } } }, + }); + const titles = results.map((r) => r.title); + expect(titles).toContain('A cat and a dog'); + expect(titles).not.toContain('Lazy cat sleeps all day'); + }); + + it('FOLLOWED-BY operator (<->) requires the words be adjacent in order', async () => { + const results = await client.article.findMany({ + where: { body: { fts: { search: 'quick <-> brown' } } }, + }); + const titles = results.map((r) => r.title); + expect(titles).toContain('The quick brown fox'); + }); + + // --------------------------------------------------------------- + // C. Postgres text-search configuration + // --------------------------------------------------------------- + + it('config "english" applies stemming (running matches "runs")', async () => { + // 'english' stems "running" → "run", so a search for 'run' matches "runs". + const results = await client.article.findMany({ + where: { body: { fts: { search: 'run', config: 'english' } } }, + }); + expect(results.some((r) => r.title === 'The running man')).toBe(true); + }); + + it('config "simple" does NOT stem', async () => { + // With 'simple', 'run' tokenizes literally and won't match 'runs' / 'running'. + const results = await client.article.findMany({ + where: { body: { fts: { search: 'run', config: 'simple' } } }, + }); + // No row has the literal token 'run' — only 'runs' / 'running'. + expect(results.some((r) => r.title === 'The running man')).toBe(false); + }); + + it('omitting config uses the database-level default_text_search_config', async () => { + // Without an explicit config, Postgres falls back to the cluster's + // default_text_search_config setting. We assert the SQL works (no error) + // and round-trips an exact-token match — behavior under stemming-vs-not + // depends on the DB default and is not asserted here. + const results = await client.article.findMany({ + where: { body: { fts: { search: 'fox' } } }, + }); + expect(results.some((r) => r.title === 'The quick brown fox')).toBe(true); + }); + + it('config is per-query (no session leakage between queries)', async () => { + const englishStemmed = await client.article.findMany({ + where: { body: { fts: { search: 'run', config: 'english' } } }, + }); + // Run an explicit-`simple` query right after — it must not inherit the + // previous `english` config from the connection. + const simple = await client.article.findMany({ + where: { body: { fts: { search: 'run', config: 'simple' } } }, + }); + expect(englishStemmed.length).toBeGreaterThan(simple.length); + }); + + // --------------------------------------------------------------- + // D. Composition with logical combinators + // --------------------------------------------------------------- + + it('AND combinator with two fts filters across fields', async () => { + const results = await client.article.findMany({ + where: { + AND: [{ title: { fts: { search: 'cat' } } }, { body: { fts: { search: 'pets' } } }], + }, + }); + expect(results.some((r) => r.title === 'A cat and a dog')).toBe(true); + }); + + it('OR combinator with two fts filters', async () => { + const results = await client.article.findMany({ + where: { + OR: [{ title: { fts: { search: 'fox' } } }, { title: { fts: { search: 'database' } } }], + }, + }); + const titles = results.map((r) => r.title); + expect(titles).toContain('The quick brown fox'); + expect(titles).toContain('Database performance'); + }); + + it('fts combined with another string operator on the same field', async () => { + const results = await client.article.findMany({ + where: { + title: { + fts: { search: 'cat' }, + contains: 'Lazy', + }, + }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.title).toBe('Lazy cat sleeps all day'); + }); + + // --------------------------------------------------------------- + // E. _ftsRelevance orderBy — single field + // --------------------------------------------------------------- + + it('orders by relevance — best match first', async () => { + const results = await client.article.findMany({ + where: { body: { fts: { search: 'cat | dog' } } }, + orderBy: { _ftsRelevance: { fields: ['body'], search: 'cat & dog', sort: 'desc' } }, + }); + // The article that contains BOTH cat AND dog should rank highest. + expect(results[0]!.title).toBe('A cat and a dog'); + }); + + it('single-field _ftsRelevance on a nullable @fullText field tolerates NULL rows', async () => { + // `subtitle` is `String? @fullText`. A row whose subtitle is NULL must + // not break the orderBy expression — `to_tsvector(NULL)` returns NULL + // and `ts_rank(NULL, ...)` returns NULL, which would otherwise place + // those rows at the front under ASC. The single-field path coalesces + // NULL → '' so `ts_rank` returns 0.0 instead, matching how the + // multi-field `concat_ws` path already handles NULL inputs. + const created = await Promise.all([ + client.article.create({ data: { title: 't1', body: 'b1', subtitle: 'cat' } }), + client.article.create({ data: { title: 't2', body: 'b2', subtitle: null } }), + ]); + const ids = created.map((r) => r.id); + const results = await client.article.findMany({ + where: { id: { in: ids } }, + orderBy: [ + { _ftsRelevance: { fields: ['subtitle'], search: 'cat', sort: 'desc' } }, + { id: 'asc' }, + ], + }); + expect(results.map((r) => r.subtitle)).toEqual(['cat', null]); + }); + + it('orderBy with config option', async () => { + const results = await client.article.findMany({ + where: { body: { fts: { search: 'run', config: 'english' } } }, + orderBy: { + _ftsRelevance: { fields: ['body'], search: 'run', config: 'english', sort: 'desc' }, + }, + }); + expect(results[0]!.title).toBe('The running man'); + }); + + // --------------------------------------------------------------- + // F. _ftsRelevance orderBy — multiple fields (concat_ws → single ts_rank) + // --------------------------------------------------------------- + + it('multi-field relevance: row with both terms ranks above row with one term', async () => { + // 'A cat and a dog' has both 'cat' and 'dog' in title and body. + // 'Lazy cat sleeps all day' has 'cat' only. + const results = await client.article.findMany({ + where: { + OR: [{ title: { fts: { search: 'cat | dog' } } }, { body: { fts: { search: 'cat | dog' } } }], + }, + orderBy: { + _ftsRelevance: { + fields: ['title', 'body'], + search: 'cat & dog', + sort: 'desc', + }, + }, + }); + expect(results[0]!.title).toBe('A cat and a dog'); + }); + + it('multi-field relevance: AND query matches when terms are split across fields', async () => { + // The whole point of concat_ws over per-field ts_rank: a row whose title + // has one term and body has the other term must rank above a row that + // has neither — i.e. the AND query has to evaluate against the COMBINED + // document, not against each field independently (which would yield 0). + const split = await client.article.create({ + data: { title: 'cat in the hat', body: 'plus a friendly dog' }, + }); + const results = await client.article.findMany({ + where: { id: split.id }, + orderBy: { + _ftsRelevance: { fields: ['title', 'body'], search: 'cat & dog', sort: 'desc' }, + }, + }); + // The split row is selected by id, so it must come back. The key invariant + // is that the orderBy expression doesn't error and ts_rank returns a + // non-zero score (which we verify indirectly by also pulling it via + // the OR fts filter — it should appear there too). + expect(results).toHaveLength(1); + expect(results[0]!.id).toBe(split.id); + + const ranked = await client.article.findMany({ + where: { + OR: [{ title: { fts: { search: 'cat | dog' } } }, { body: { fts: { search: 'cat | dog' } } }], + }, + orderBy: { + _ftsRelevance: { fields: ['title', 'body'], search: 'cat & dog', sort: 'desc' }, + }, + }); + // The split row qualifies for 'cat & dog' under concat semantics — so it + // must be included in the ranked output. Under the old SUM semantics it + // would have been scored 0 and ranked last, but it must now appear + // above any row that contains neither term. + expect(ranked.map((r) => r.id)).toContain(split.id); + }); + + // --------------------------------------------------------------- + // G. Pagination + // --------------------------------------------------------------- + + it('skip/take works alongside _ftsRelevance', async () => { + const all = await client.article.findMany({ + where: { body: { fts: { search: 'cat | dog | fox' } } }, + orderBy: [{ _ftsRelevance: { fields: ['body'], search: 'cat & dog', sort: 'desc' } }, { id: 'asc' }], + }); + const paged = await client.article.findMany({ + where: { body: { fts: { search: 'cat | dog | fox' } } }, + orderBy: [{ _ftsRelevance: { fields: ['body'], search: 'cat & dog', sort: 'desc' } }, { id: 'asc' }], + skip: 1, + take: 1, + }); + expect(paged).toHaveLength(1); + expect(paged[0]!.id).toBe(all[1]!.id); + }); + + it('rejects cursor pagination combined with _ftsRelevance', async () => { + const first = await client.article.findFirst({ + where: { body: { fts: { search: 'cat' } } }, + }); + expect(first).not.toBeNull(); + await expect( + client.article.findMany({ + where: { body: { fts: { search: 'cat' } } }, + orderBy: { _ftsRelevance: { fields: ['body'], search: 'cat', sort: 'desc' } }, + cursor: { id: first!.id }, + take: 2, + }), + ).rejects.toThrow(/_ftsRelevance/); + }); + + // --------------------------------------------------------------- + // H. Mutations / aggregations + // --------------------------------------------------------------- + + it('updateMany with fts filter', async () => { + const { count } = await client.article.updateMany({ + where: { body: { fts: { search: 'pets' } } }, + data: { notes: 'pet-related' }, + }); + expect(count).toBeGreaterThanOrEqual(1); + const updated = await client.article.findMany({ where: { notes: { equals: 'pet-related' } } }); + expect(updated.some((r) => r.title === 'A cat and a dog')).toBe(true); + }); + + it('deleteMany with fts filter', async () => { + const { count } = await client.article.deleteMany({ + where: { title: { fts: { search: 'fox' } } }, + }); + expect(count).toBe(1); + const remaining = await client.article.findMany({ where: { title: { equals: 'The quick brown fox' } } }); + expect(remaining).toHaveLength(0); + }); + + it('count with fts filter', async () => { + const c = await client.article.count({ where: { body: { fts: { search: 'cat | dog' } } } }); + expect(c).toBeGreaterThanOrEqual(2); + }); + + // --------------------------------------------------------------- + // I. @fullText gating — non-searchable fields rejected by Zod + // --------------------------------------------------------------- + + it('rejects fts on a non-@fullText field', async () => { + // The Zod schema strips `fts` from the StringFilter for fields without + // `@fullText`, so passing it here surfaces an "Unrecognized key" error + // pointing at the exact field path. + await expect( + client.article.findMany({ + where: { notes: { fts: { search: 'foo' } } as any } as any, + }), + ).rejects.toThrow(/Unrecognized key:\s*"fts"\s*at\s*"where\.notes"/i); + }); + + it('rejects _ftsRelevance on a non-@fullText field', async () => { + // `_ftsRelevance.fields` is typed as an enum of `@fullText` field names + // only — `notes` is rejected with a precise enum-mismatch error that + // also confirms the enum lists exactly the three `@fullText` fields. + await expect( + client.article.findMany({ + orderBy: { + _ftsRelevance: { fields: ['notes' as any], search: 'foo', sort: 'desc' }, + } as any, + }), + ).rejects.toThrow( + /expected one of "title"\|"body"\|"subtitle"\s*at\s*"orderBy\._ftsRelevance\.fields/i, + ); + }); + + // --------------------------------------------------------------- + // J. Malformed query — execution-time SQL error surfaces + // --------------------------------------------------------------- + + it('malformed to_tsquery syntax throws Postgres syntax error', async () => { + // 'foo &&' is not valid tsquery syntax — Postgres throws + // `syntax error in tsquery: "foo &&"` and we let it surface verbatim + // (we do not pre-validate the query string). + await expect( + client.article.findMany({ where: { title: { fts: { search: 'foo &&' } } } }), + ).rejects.toThrow(/syntax error in tsquery/i); + }); + + // --------------------------------------------------------------- + // K. OrArray contract — single object == single-element array + // --------------------------------------------------------------- + + it('single-object orderBy is equivalent to single-element-array orderBy', async () => { + const single = await client.article.findMany({ + orderBy: { _ftsRelevance: { fields: ['title'], search: 'cat | fox', sort: 'desc' } }, + where: { title: { fts: { search: 'cat | fox' } } }, + }); + const arr = await client.article.findMany({ + orderBy: [{ _ftsRelevance: { fields: ['title'], search: 'cat | fox', sort: 'desc' } }], + where: { title: { fts: { search: 'cat | fox' } } }, + }); + expect(arr.map((r) => r.id)).toEqual(single.map((r) => r.id)); + }); + + it('relevance + scalar tie-breaker enables deterministic pagination', async () => { + // Force ties by inserting duplicates with identical title/body content. + const created = await Promise.all([ + client.article.create({ data: { title: 'Tie title', body: 'Tie body' } }), + client.article.create({ data: { title: 'Tie title', body: 'Tie body' } }), + client.article.create({ data: { title: 'Tie title', body: 'Tie body' } }), + ]); + const ids = created.map((r) => r.id); + const asc = await client.article.findMany({ + where: { id: { in: ids } }, + orderBy: [{ _ftsRelevance: { fields: ['title'], search: 'tie', sort: 'desc' } }, { id: 'asc' }], + }); + const desc = await client.article.findMany({ + where: { id: { in: ids } }, + orderBy: [{ _ftsRelevance: { fields: ['title'], search: 'tie', sort: 'desc' } }, { id: 'desc' }], + }); + expect(asc.map((r) => r.id)).toEqual([...ids].sort((a, b) => a - b)); + expect(desc.map((r) => r.id)).toEqual([...ids].sort((a, b) => b - a)); + }); + + // --------------------------------------------------------------- + // L. Filter-kind slicing — `'FullText'` kind controls the `fts` operator + // --------------------------------------------------------------- + + it('slicing: excludedFilterKinds: ["FullText"] removes the fts operator', async () => { + // The suite-level beforeEach already opened a connection to this test's DB; + // release it so we can recreate the DB with custom slicing config. + await client.$disconnect(); + const options = { + slicing: { + models: { + article: { + fields: { + $all: { + excludedFilterKinds: ['FullText'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + const db = await createTestClient(schema, options); + await db.article.create({ data: { title: 'cat', body: 'a cat' } }); + + // Other string operators in the same StringFilter are still available. + const found = await db.article.findMany({ where: { title: { contains: 'cat' } } }); + expect(found).toHaveLength(1); + + // The `fts` operator is dropped from the schema. + await expect( + db.article.findMany({ + // @ts-expect-error — `fts` is excluded by slicing + where: { title: { fts: { search: 'cat' } } }, + }), + ).toBeRejectedByValidation(['"fts"']); + + await db.$disconnect(); + }); + + it('slicing: includedFilterKinds without "FullText" removes the fts operator', async () => { + await client.$disconnect(); + const options = { + slicing: { + models: { + article: { + fields: { + $all: { + includedFilterKinds: ['Equality', 'Like'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + const db = await createTestClient(schema, options); + await db.article.create({ data: { title: 'cat', body: 'a cat' } }); + + // Equality + Like still work + const eq = await db.article.findMany({ where: { title: { equals: 'cat' } } }); + expect(eq).toHaveLength(1); + const like = await db.article.findMany({ where: { title: { contains: 'cat' } } }); + expect(like).toHaveLength(1); + + await expect( + db.article.findMany({ + // @ts-expect-error — `fts` not in includedFilterKinds + where: { title: { fts: { search: 'cat' } } }, + }), + ).toBeRejectedByValidation(['"fts"']); + + await db.$disconnect(); + }); + + it('slicing: includedFilterKinds with "FullText" keeps fts and drops siblings', async () => { + await client.$disconnect(); + const options = { + slicing: { + models: { + article: { + fields: { + $all: { + includedFilterKinds: ['FullText', 'Equality'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + const db = await createTestClient(schema, options); + await db.article.create({ data: { title: 'cat', body: 'a cat' } }); + + // fts works + const fts = await db.article.findMany({ where: { title: { fts: { search: 'cat' } } } }); + expect(fts).toHaveLength(1); + + // Like-kind operators are excluded + await expect( + db.article.findMany({ + // @ts-expect-error — `contains` (Like) is not in includedFilterKinds + where: { title: { contains: 'cat' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + + await db.$disconnect(); + }); +}); diff --git a/tests/e2e/orm/client-api/fuzzy-search.test.ts b/tests/e2e/orm/client-api/fuzzy-search.test.ts new file mode 100644 index 000000000..769067a50 --- /dev/null +++ b/tests/e2e/orm/client-api/fuzzy-search.test.ts @@ -0,0 +1,863 @@ +import type { ClientContract } from '@zenstackhq/orm'; +import { createTestClient, getTestDbProvider } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { schema } from '../schemas/fuzzy-search'; + +type Schema = typeof schema; +const provider = getTestDbProvider(); + +describe.skipIf(provider !== 'postgresql')('Fuzzy search tests', () => { + let client: ClientContract; + + beforeEach(async () => { + client = (await createTestClient(schema)) as unknown as ClientContract; + + await client.$executeRaw`CREATE EXTENSION IF NOT EXISTS unaccent`; + await client.$executeRaw`CREATE EXTENSION IF NOT EXISTS pg_trgm`; + + await client.flavor.createMany({ + data: [ + { name: 'Apple', description: 'A sweet red fruit' }, + { name: 'Apricot', description: 'Small orange fruit' }, + { name: 'Banana', description: 'Yellow tropical fruit' }, + { name: 'Strawberry', description: 'Red berry with seeds' }, + { name: 'Crème brûlée', description: 'French custard dessert' }, + { name: 'Crème fraîche', description: 'Thick French cream' }, + { name: 'Café au lait', description: 'Coffee with milk' }, + { name: 'Éclair au chocolat', description: 'French pastry with chocolate' }, + { name: 'Pâté à choux', description: 'Light pastry dough' }, + { name: null, description: 'No name item' }, + ], + }); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + // --------------------------------------------------------------- + // A. fuzzy mode 'simple' — basic English words + // --------------------------------------------------------------- + + it('finds Apple despite missing letter (Aple)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Aple' } } }, + }); + expect(results.some((r) => r.name === 'Apple')).toBe(true); + }); + + it('finds Apple with transposed letters (Appel)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Appel' } } }, + }); + expect(results.some((r) => r.name === 'Apple')).toBe(true); + }); + + it('finds Strawberry despite missing letter (Strawbery)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Strawbery' } } }, + }); + expect(results.some((r) => r.name === 'Strawberry')).toBe(true); + }); + + it('finds Banana with truncation (Banan)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Banan' } } }, + }); + expect(results.some((r) => r.name === 'Banana')).toBe(true); + }); + + it('returns nothing for totally unrelated term', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'xyz123' } } }, + }); + expect(results).toHaveLength(0); + }); + + it('explicit mode "simple" matches the default', async () => { + const implicit = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Aple' } } }, + }); + const explicit = await client.flavor.findMany({ + where: { name: { fuzzy: { mode: 'simple', search: 'Aple' } } }, + }); + expect(explicit.map((r) => r.id).sort()).toEqual(implicit.map((r) => r.id).sort()); + }); + + // --------------------------------------------------------------- + // B. fuzzy mode 'simple' — French words with accents + // --------------------------------------------------------------- + + it('finds accented names when searching without accents (creme)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Crème brûlée'); + expect(names).toContain('Crème fraîche'); + }); + + it('finds accented names when searching with exact accents (Crème)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Crème' } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Crème brûlée'); + }); + + it('finds Café au lait without accent (cafe)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'cafe', unaccent: true } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Café au lait'); + }); + + it('finds Éclair au chocolat with exact accent', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Éclair' } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Éclair au chocolat'); + }); + + it('finds Éclair au chocolat without accent (eclair)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'eclair', unaccent: true } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Éclair au chocolat'); + }); + + it('finds Pâté à choux with exact accent', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Pâté' } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Pâté à choux'); + }); + + it('finds Pâté à choux without accent (pate)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'pate', unaccent: true } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Pâté à choux'); + }); + + // --------------------------------------------------------------- + // C. fuzzy on nullable field + // --------------------------------------------------------------- + + it('does not return items with null name', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Apple' } } }, + }); + expect(results.every((r) => r.name !== null)).toBe(true); + }); + + it('fuzzy on description works for items with null name', async () => { + const results = await client.flavor.findMany({ + where: { description: { fuzzy: { search: 'item' } } }, + }); + expect(results.some((r) => r.name === null)).toBe(true); + }); + + // --------------------------------------------------------------- + // D. fuzzy combined with other filters + // --------------------------------------------------------------- + + it('fuzzy combined with contains on another field', async () => { + const results = await client.flavor.findMany({ + where: { + name: { fuzzy: { search: 'creme', unaccent: true } }, + description: { contains: 'custard' }, + }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.name).toBe('Crème brûlée'); + }); + + it('fuzzy combined with contains on the same field', async () => { + const results = await client.flavor.findMany({ + where: { + name: { fuzzy: { search: 'creme', unaccent: true }, contains: 'brûlée' }, + }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.name).toBe('Crème brûlée'); + }); + + it('fuzzy combined with AND and startsWith', async () => { + const results = await client.flavor.findMany({ + where: { + AND: [ + { name: { fuzzy: { search: 'creme', unaccent: true } } }, + { description: { startsWith: 'Thick' } }, + ], + }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.name).toBe('Crème fraîche'); + }); + + // --------------------------------------------------------------- + // E. fuzzy in logical compositions + // --------------------------------------------------------------- + + it('OR with two fuzzy terms', async () => { + const results = await client.flavor.findMany({ + where: { + OR: [{ name: { fuzzy: { search: 'apple' } } }, { name: { fuzzy: { search: 'banana' } } }], + }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Apple'); + expect(names).toContain('Banana'); + }); + + it('NOT excludes matching items', async () => { + const all = await client.flavor.findMany({ + where: { name: { not: null } }, + }); + const results = await client.flavor.findMany({ + where: { + NOT: { name: { fuzzy: { search: 'apple' } } }, + name: { not: null }, + }, + }); + expect(results.length).toBeLessThan(all.length); + expect(results.every((r) => r.name !== 'Apple')).toBe(true); + }); + + // --------------------------------------------------------------- + // F. orderBy _fuzzyRelevance — single field + // --------------------------------------------------------------- + + it('orders by relevance with best match first', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Apple' } } }, + orderBy: { + _fuzzyRelevance: { fields: ['name'], search: 'Apple', sort: 'desc' }, + }, + }); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0]!.name).toBe('Apple'); + }); + + it('orders by relevance for accented search', async () => { + const results = await client.flavor.findMany({ + where: { + OR: [ + { name: { fuzzy: { search: 'creme', unaccent: true } } }, + { name: { fuzzy: { search: 'cafe', unaccent: true } } }, + ], + }, + orderBy: { + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc', unaccent: true }, + }, + }); + expect(results.length).toBeGreaterThanOrEqual(2); + const firstTwo = results.slice(0, 2).map((r) => r.name); + expect(firstTwo.some((n) => n?.startsWith('Crème'))).toBe(true); + }); + + // --------------------------------------------------------------- + // G. orderBy _fuzzyRelevance — multiple fields + // --------------------------------------------------------------- + + it('orders by relevance across multiple fields', async () => { + const results = await client.flavor.findMany({ + where: { + OR: [{ name: { fuzzy: { search: 'chocolate' } } }, { description: { fuzzy: { search: 'chocolate' } } }], + }, + orderBy: { + _fuzzyRelevance: { + fields: ['name', 'description'], + search: 'chocolate', + sort: 'desc', + }, + }, + }); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0]!.name).toBe('Éclair au chocolat'); + }); + + // --------------------------------------------------------------- + // H. orderBy _fuzzyRelevance with skip/take + // --------------------------------------------------------------- + + it('supports pagination with relevance ordering', async () => { + const allResults = await client.flavor.findMany({ + where: { + OR: [ + { name: { fuzzy: { search: 'creme', unaccent: true } } }, + { name: { fuzzy: { search: 'cafe', unaccent: true } } }, + ], + }, + orderBy: { + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc', unaccent: true }, + }, + }); + + const paged = await client.flavor.findMany({ + where: { + OR: [ + { name: { fuzzy: { search: 'creme', unaccent: true } } }, + { name: { fuzzy: { search: 'cafe', unaccent: true } } }, + ], + }, + orderBy: { + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc', unaccent: true }, + }, + skip: 1, + take: 1, + }); + + expect(paged).toHaveLength(1); + expect(allResults.length).toBeGreaterThan(1); + expect(paged[0]!.id).toBe(allResults[1]!.id); + }); + + // --------------------------------------------------------------- + // I. fuzzy mode 'word' — approximate substring matching (formerly fuzzyContains) + // --------------------------------------------------------------- + + it('mode "word" finds short term within longer name', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { mode: 'word', search: 'choco' } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Éclair au chocolat'); + }); + + it('mode "word" tolerates typos within description (pastryy)', async () => { + const results = await client.flavor.findMany({ + where: { description: { fuzzy: { mode: 'word', search: 'pastryy' } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Éclair au chocolat'); + expect(names).toContain('Pâté à choux'); + }); + + it('mode "word" is accent-insensitive', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { mode: 'word', search: 'brulee', unaccent: true } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Crème brûlée'); + }); + + it('mode "word" combined with simple fuzzy on another field', async () => { + const results = await client.flavor.findMany({ + where: { + name: { fuzzy: { mode: 'word', search: 'eclair', unaccent: true } }, + description: { fuzzy: { search: 'chocolate' } }, + }, + }); + expect(results).toHaveLength(1); + expect(results[0]!.name).toBe('Éclair au chocolat'); + }); + + it('mode "word" returns nothing for unrelated term', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { mode: 'word', search: 'zzzzz' } } }, + }); + expect(results).toHaveLength(0); + }); + + // --------------------------------------------------------------- + // J. Mutations with fuzzy filter + // --------------------------------------------------------------- + + it('updateMany with fuzzy mode "simple" filter', async () => { + const { count } = await client.flavor.updateMany({ + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + data: { description: 'Updated via fuzzy' }, + }); + expect(count).toBeGreaterThanOrEqual(2); + + const updated = await client.flavor.findMany({ + where: { description: { equals: 'Updated via fuzzy' } }, + }); + const names = updated.map((r) => r.name); + expect(names).toContain('Crème brûlée'); + expect(names).toContain('Crème fraîche'); + }); + + it('updateMany with fuzzy mode "word" filter', async () => { + const { count } = await client.flavor.updateMany({ + where: { name: { fuzzy: { mode: 'word', search: 'choco' } } }, + data: { description: 'Has chocolate' }, + }); + expect(count).toBeGreaterThanOrEqual(1); + + const updated = await client.flavor.findMany({ + where: { description: { equals: 'Has chocolate' } }, + }); + expect(updated.some((r) => r.name === 'Éclair au chocolat')).toBe(true); + }); + + it('deleteMany with fuzzy mode "simple" filter', async () => { + const beforeCount = await client.flavor.count(); + const { count } = await client.flavor.deleteMany({ + where: { name: { fuzzy: { search: 'apple' } } }, + }); + expect(count).toBeGreaterThanOrEqual(1); + + const afterCount = await client.flavor.count(); + expect(afterCount).toBe(beforeCount - count); + + const remaining = await client.flavor.findMany({ + where: { name: { equals: 'Apple' } }, + }); + expect(remaining).toHaveLength(0); + }); + + it('deleteMany with fuzzy mode "word" filter', async () => { + const { count } = await client.flavor.deleteMany({ + where: { description: { fuzzy: { mode: 'word', search: 'pastry' } } }, + }); + expect(count).toBeGreaterThanOrEqual(1); + + const remaining = await client.flavor.findMany({ + where: { name: { equals: 'Éclair au chocolat' } }, + }); + expect(remaining).toHaveLength(0); + }); + + // --------------------------------------------------------------- + // K. GroupBy with fuzzy filter + // --------------------------------------------------------------- + + it('groupBy with fuzzy where filter', async () => { + const groups = await client.flavor.groupBy({ + by: ['description'], + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + _count: true, + }); + expect(groups.length).toBeGreaterThanOrEqual(2); + const descriptions = groups.map((g: any) => g.description); + expect(descriptions).toContain('French custard dessert'); + expect(descriptions).toContain('Thick French cream'); + }); + + it('count with fuzzy mode "simple" filter', async () => { + const count = await client.flavor.count({ + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + }); + expect(count).toBeGreaterThanOrEqual(2); + }); + + it('count with fuzzy mode "word" filter', async () => { + const count = await client.flavor.count({ + where: { description: { fuzzy: { mode: 'word', search: 'pastry' } } }, + }); + expect(count).toBeGreaterThanOrEqual(2); + }); + + // --------------------------------------------------------------- + // L. fuzzy with explicit threshold (function form: similarity() > threshold) + // --------------------------------------------------------------- + + it('high threshold (0.9) matches only near-exact terms', async () => { + const high = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Apple', threshold: 0.9 } } }, + }); + const names = high.map((r) => r.name); + expect(names).toContain('Apple'); + // 0.9 is strict — Apricot must not match Apple at this threshold + expect(names).not.toContain('Apricot'); + }); + + it('low threshold (0.05) matches more permissively than high threshold', async () => { + const low = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'App', threshold: 0.05 } } }, + }); + const high = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'App', threshold: 0.9 } } }, + }); + expect(low.length).toBeGreaterThan(high.length); + }); + + it('threshold 0 matches every non-null name', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Apple', threshold: 0 } } }, + }); + // similarity() > 0 is true for any sharing at least one trigram; many seed + // rows do NOT share a trigram with 'Apple', so this is not a free-for-all. + // We only assert the strictest match is included and at least one weaker one too. + const names = results.map((r) => r.name); + expect(names).toContain('Apple'); + expect(results.length).toBeGreaterThan(1); + }); + + it('threshold 1 rejects everything (similarity strictly > 1 is impossible)', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Apple', threshold: 1 } } }, + }); + expect(results).toHaveLength(0); + }); + + it('threshold works with mode "word"', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { mode: 'word', search: 'choco', threshold: 0.5 } } }, + }); + expect(results.some((r) => r.name === 'Éclair au chocolat')).toBe(true); + }); + + it('threshold works with mode "strictWord"', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { mode: 'strictWord', search: 'choco', threshold: 0.3 } } }, + }); + expect(results.some((r) => r.name === 'Éclair au chocolat')).toBe(true); + }); + + it('threshold can be tuned per query without affecting subsequent queries', async () => { + // Verify two queries with different thresholds return different result sets, + // proving the threshold is per-query (function form), not session-wide. + const strict = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Aple', threshold: 0.9 } } }, + }); + const lenient = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Aple', threshold: 0.1 } } }, + }); + expect(lenient.length).toBeGreaterThanOrEqual(strict.length); + }); + + // --------------------------------------------------------------- + // M. fuzzy with mode 'strictWord' + // --------------------------------------------------------------- + + it('mode "strictWord" finds the chocolate item', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { mode: 'strictWord', search: 'chocolat' } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Éclair au chocolat'); + }); + + it('mode "strictWord" is generally stricter than mode "word"', async () => { + const word = await client.flavor.findMany({ + where: { description: { fuzzy: { mode: 'word', search: 'pastry' } } }, + }); + const strict = await client.flavor.findMany({ + where: { description: { fuzzy: { mode: 'strictWord', search: 'pastry' } } }, + }); + expect(strict.length).toBeLessThanOrEqual(word.length); + }); + + // --------------------------------------------------------------- + // N. fuzzy with unaccent (opt-in; default is false) + // --------------------------------------------------------------- + + it('omitted unaccent uses the default (false) and does NOT match accented names', async () => { + // Confirms the API contract: no implicit dependency on the `unaccent` extension. + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'creme' } } }, + }); + const names = results.map((r) => r.name); + expect(names).not.toContain('Crème brûlée'); + expect(names).not.toContain('Crème fraîche'); + }); + + it('unaccent: true (opt-in) finds accented terms via plain ascii search', async () => { + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + }); + const names = results.map((r) => r.name); + expect(names).toContain('Crème brûlée'); + }); + + it('unaccent: false still matches when search and field share casing/letters', async () => { + // 'Apple' has no diacritics — disabling unaccent must not break basic matching. + const results = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'Aple', unaccent: false } } }, + }); + expect(results.some((r) => r.name === 'Apple')).toBe(true); + }); + + it('unaccent: false yields fewer accented matches than unaccent: true', async () => { + const withUnaccent = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + }); + const withoutUnaccent = await client.flavor.findMany({ + where: { name: { fuzzy: { search: 'creme', unaccent: false } } }, + }); + // With unaccent: 'creme' matches 'Crème brûlée' / 'Crème fraîche'. + // Without unaccent: 'creme' will not match 'Crème ...' because trigrams differ. + expect(withoutUnaccent.length).toBeLessThan(withUnaccent.length); + }); + + it('unaccent: false works alongside threshold and mode "word"', async () => { + const results = await client.flavor.findMany({ + where: { + name: { + fuzzy: { mode: 'word', search: 'choco', threshold: 0.5, unaccent: false }, + }, + }, + }); + expect(results.some((r) => r.name === 'Éclair au chocolat')).toBe(true); + }); + + // --------------------------------------------------------------- + // O. cursor pagination guard + // --------------------------------------------------------------- + + it('rejects cursor pagination combined with _fuzzyRelevance', async () => { + const first = await client.flavor.findFirst({ + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + }); + expect(first).not.toBeNull(); + await expect( + client.flavor.findMany({ + where: { name: { fuzzy: { search: 'creme', unaccent: true } } }, + orderBy: { + _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc' }, + }, + cursor: { id: first!.id }, + take: 2, + }), + ).rejects.toThrow(/_fuzzyRelevance/); + }); + + // --------------------------------------------------------------- + // P. OrArray contract + // Validates the design decision (PR #2573 review) to keep + // `_fuzzyRelevance` INSIDE the OrArray wrapper via intersection. + // Each test pins one of the use cases enabled by that shape. + // --------------------------------------------------------------- + + it('case (a) single object: orderBy: { _fuzzyRelevance: {...} }', async () => { + // Filter null names: similarity(NULL, ...) is NULL and Postgres places + // NULLs first under DESC, which would crowd out the actual best match. + const results = await client.flavor.findMany({ + where: { name: { not: null } }, + orderBy: { _fuzzyRelevance: { fields: ['name'], search: 'Apple', sort: 'desc' } }, + }); + expect(results[0]!.name).toBe('Apple'); + }); + + it('case (a) single object is treated identically to a single-element array', async () => { + // Proves the `enumerate()` normalization in buildOrderBy: the type-level + // `OrArray = T | T[]` collapses to the same runtime SQL. + const single = await client.flavor.findMany({ + where: { name: { not: null } }, + orderBy: { _fuzzyRelevance: { fields: ['name'], search: 'Apple', sort: 'desc' } }, + }); + const arr = await client.flavor.findMany({ + where: { name: { not: null } }, + orderBy: [{ _fuzzyRelevance: { fields: ['name'], search: 'Apple', sort: 'desc' } }], + }); + expect(arr.map((r) => r.id)).toEqual(single.map((r) => r.id)); + }); + + it('case (b) relevance + scalar tie-breaker enables deterministic pagination', async () => { + // Three identical names → primary similarity ties at 1.0. The scalar + // tie-breaker is the only thing deciding the final order. Flipping its + // direction must reverse the result order — proving Kysely chains + // ORDER BY similarity(...) DESC, "id" ASC|DESC (Kysely orderBy is additive, + // confirmed in node_modules/.pnpm/kysely.../order-by-node.js cloneWithItems). + const created = await Promise.all([ + client.flavor.create({ data: { name: 'Mango', description: 'first' } }), + client.flavor.create({ data: { name: 'Mango', description: 'second' } }), + client.flavor.create({ data: { name: 'Mango', description: 'third' } }), + ]); + const ids = created.map((r) => r.id); + + const asc = await client.flavor.findMany({ + where: { name: { equals: 'Mango' } }, + orderBy: [{ _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, { id: 'asc' }], + }); + const desc = await client.flavor.findMany({ + where: { name: { equals: 'Mango' } }, + orderBy: [{ _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, { id: 'desc' }], + }); + + expect(asc.map((r) => r.id)).toEqual([...ids].sort((a, b) => a - b)); + expect(desc.map((r) => r.id)).toEqual([...ids].sort((a, b) => b - a)); + }); + + it('case (b) tie-breaker survives skip/take pagination', async () => { + // Same forced-tie setup, then paginate. Page boundaries must be stable + // because the tie-breaker is part of the ORDER BY. + const created = await Promise.all([ + client.flavor.create({ data: { name: 'Mango', description: 'first' } }), + client.flavor.create({ data: { name: 'Mango', description: 'second' } }), + client.flavor.create({ data: { name: 'Mango', description: 'third' } }), + ]); + const sortedIds = created.map((r) => r.id).sort((a, b) => a - b); + + const page1 = await client.flavor.findMany({ + where: { name: { equals: 'Mango' } }, + orderBy: [{ _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, { id: 'asc' }], + take: 2, + }); + const page2 = await client.flavor.findMany({ + where: { name: { equals: 'Mango' } }, + orderBy: [{ _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, { id: 'asc' }], + skip: 2, + take: 2, + }); + + expect(page1.map((r) => r.id)).toEqual([sortedIds[0], sortedIds[1]]); + expect(page2.map((r) => r.id)).toEqual([sortedIds[2]]); + }); + + it('case (c) multi-relevance: secondary clause breaks primary ties', async () => { + // Two identical names → primary _fuzzyRelevance ties. + // Swapping the secondary search term ('tropical' vs 'sweet') must flip + // the order — proving the second relevance clause is genuinely emitted + // as a chained ORDER BY column, not silently ignored. + const created = await Promise.all([ + client.flavor.create({ data: { name: 'Mango', description: 'tropical fruit' } }), + client.flavor.create({ data: { name: 'Mango', description: 'sweet treat' } }), + ]); + const ids = created.map((r) => r.id); + + const tropicalFirst = await client.flavor.findMany({ + where: { id: { in: ids } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, + { _fuzzyRelevance: { fields: ['description'], search: 'tropical', sort: 'desc' } }, + ], + }); + const sweetFirst = await client.flavor.findMany({ + where: { id: { in: ids } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, + { _fuzzyRelevance: { fields: ['description'], search: 'sweet', sort: 'desc' } }, + ], + }); + + expect(tropicalFirst[0]!.description).toBe('tropical fruit'); + expect(tropicalFirst[1]!.description).toBe('sweet treat'); + expect(sweetFirst[0]!.description).toBe('sweet treat'); + expect(sweetFirst[1]!.description).toBe('tropical fruit'); + }); + + it('case (c) multi-relevance combined with scalar tie-breaker', async () => { + // Stress the chain: 3 records, primary tied, secondary tied between two + // of them, scalar tie-breaker decides the leftover. Verifies arbitrary + // chaining depth works. + const created = await Promise.all([ + client.flavor.create({ data: { name: 'Mango', description: 'tropical' } }), + client.flavor.create({ data: { name: 'Mango', description: 'cherry' } }), + client.flavor.create({ data: { name: 'Mango', description: 'cherry' } }), + ]); + const ids = created.map((r) => r.id); + const cherryIds = [ids[1]!, ids[2]!].sort((a, b) => a - b); + + const results = await client.flavor.findMany({ + where: { id: { in: ids } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'Mango', sort: 'desc' } }, + { _fuzzyRelevance: { fields: ['description'], search: 'cherry', sort: 'desc' } }, + { id: 'asc' }, + ], + }); + + expect(results.map((r) => r.id)).toEqual([cherryIds[0], cherryIds[1], ids[0]]); + }); + + it('contract: empty object as array element is silently no-op', async () => { + // Falls out of buildOrderBy's `Object.entries({})` yielding nothing — the + // element is skipped without affecting other elements in the array. + const ref = await client.flavor.findMany({ orderBy: { id: 'asc' } }); + const padded = await client.flavor.findMany({ orderBy: [{}, { id: 'asc' }] }); + expect(padded.map((r) => r.id)).toEqual(ref.map((r) => r.id)); + }); + + it('contract: multi-key in a single orderBy element is rejected by Zod refinement', async () => { + // The intersection `OrderBy & FuzzyRelevanceOrderBy` allows multiple keys + // at the type level, but `refineAtMostOneKey` in zod/factory.ts rejects + // them at runtime. This forces users into the array form for + // tie-breakers, which is the path the runtime parser actually supports. + await expect( + client.flavor.findMany({ + orderBy: { + _fuzzyRelevance: { fields: ['name'], search: 'Apple', sort: 'desc' }, + id: 'asc', + }, + }), + ).rejects.toThrow(); + }); + + // --------------------------------------------------------------- + // Q. orderBy _fuzzyRelevance options + // --------------------------------------------------------------- + + it('mode "word" ranks an exact embedded word above a prefix-only word', async () => { + const prefixOnly = await client.flavor.create({ data: { name: 'Chocolate', description: 'prefix only' } }); + const embeddedWord = await client.flavor.create({ data: { name: 'Hot choco drink', description: 'word' } }); + + const results = await client.flavor.findMany({ + where: { id: { in: [prefixOnly.id, embeddedWord.id] } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'choco', mode: 'word', sort: 'desc' } }, + { id: 'asc' }, + ], + }); + + expect(results[0]!.id).toBe(embeddedWord.id); + }); + + it('mode "strictWord" ranks word-boundary matches above non-boundary matches', async () => { + const nonBoundary = await client.flavor.create({ data: { name: 'xxchocoxx', description: 'non-boundary' } }); + const wordBoundary = await client.flavor.create({ data: { name: 'hot choco drink', description: 'boundary' } }); + + const strict = await client.flavor.findMany({ + where: { id: { in: [nonBoundary.id, wordBoundary.id] } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'choco', mode: 'strictWord', sort: 'desc' } }, + { id: 'asc' }, + ], + }); + + expect(strict[0]!.id).toBe(wordBoundary.id); + }); + + // --------------------------------------------------------------- + // R. @fuzzy attribute gating + // --------------------------------------------------------------- + + it('rejects fuzzy filter on a field without @fuzzy', async () => { + await expect( + client.flavor.findMany({ + // 'notes' has no @fuzzy attribute on the model + where: { notes: { fuzzy: { search: 'anything' } } } as any, + }), + ).rejects.toThrow('Unrecognized key'); + }); + + it('rejects _fuzzyRelevance ordering against a field without @fuzzy', async () => { + await expect( + client.flavor.findMany({ + orderBy: { _fuzzyRelevance: { fields: ['notes'], search: 'anything', sort: 'desc' } } as any, + }), + ).rejects.toThrow('expected one of "name"|"description"'); + }); + + it('unaccent toggles relevance scoring for ascii searches against accented names', async () => { + const accented = await client.flavor.create({ data: { name: 'Crème', description: 'accented exact' } }); + const asciiPrefix = await client.flavor.create({ data: { name: 'Cremezzzz', description: 'ascii prefix' } }); + + const withoutUnaccent = await client.flavor.findMany({ + where: { id: { in: [accented.id, asciiPrefix.id] } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc', unaccent: false } }, + { id: 'asc' }, + ], + }); + const withUnaccent = await client.flavor.findMany({ + where: { id: { in: [accented.id, asciiPrefix.id] } }, + orderBy: [ + { _fuzzyRelevance: { fields: ['name'], search: 'creme', sort: 'desc', unaccent: true } }, + { id: 'asc' }, + ], + }); + + expect(withoutUnaccent[0]!.id).toBe(asciiPrefix.id); + expect(withUnaccent[0]!.id).toBe(accented.id); + }); +}); diff --git a/tests/e2e/orm/client-api/json-filter.test.ts b/tests/e2e/orm/client-api/json-filter.test.ts index 6b127a813..6db56c12b 100644 --- a/tests/e2e/orm/client-api/json-filter.test.ts +++ b/tests/e2e/orm/client-api/json-filter.test.ts @@ -1073,9 +1073,10 @@ describe('Json filter tests', () => { db.user.findFirst({ where: { profile: { - // @ts-expect-error name: 'Alice', + // @ts-expect-error path: '$.name', + // @ts-expect-error equals: 'Alice', }, }, diff --git a/tests/e2e/orm/client-api/slicing.test.ts b/tests/e2e/orm/client-api/slicing.test.ts index cf924d20c..4432b58ca 100644 --- a/tests/e2e/orm/client-api/slicing.test.ts +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -216,7 +216,6 @@ describe('Query slicing tests', () => { // Profile is excluded, so selecting it should cause type error await expect( db.user.findMany({ - // @ts-expect-error - Profile model is excluded select: { id: true, profile: true }, }), ).toBeRejectedByValidation(['"profile"', '"select"']); @@ -224,7 +223,6 @@ describe('Query slicing tests', () => { // Comment is excluded, so selecting it should cause type error await expect( db.post.findMany({ - // @ts-expect-error - Comment model is excluded select: { id: true, comments: true }, }), ).toBeRejectedByValidation(['"comments"', '"select"']); @@ -297,7 +295,6 @@ describe('Query slicing tests', () => { // Profile is not included, so selecting it should cause type error await expect( db.user.findMany({ - // @ts-expect-error - Profile model is not included select: { id: true, profile: true }, }), ).toBeRejectedByValidation(['"profile"', '"select"']); @@ -305,7 +302,6 @@ describe('Query slicing tests', () => { // Comment is not included, so selecting it should cause type error await expect( db.post.findMany({ - // @ts-expect-error - Comment model is not included select: { id: true, comments: true }, }), ).toBeRejectedByValidation(['"comments"', '"select"']); @@ -379,7 +375,6 @@ describe('Query slicing tests', () => { select: { id: true, posts: { - // @ts-expect-error - Comment model is excluded select: { id: true, comments: true }, }, }, @@ -413,7 +408,6 @@ describe('Query slicing tests', () => { db.user.create({ data: { email: 'test@example.com', - // @ts-expect-error - Profile model is excluded profile: { create: { bio: 'Test bio', @@ -441,7 +435,6 @@ describe('Query slicing tests', () => { db.user.update({ where: { id: user.id }, data: { - // @ts-expect-error - Profile model is excluded profile: { create: { bio: 'Test bio', @@ -505,9 +498,7 @@ describe('Query slicing tests', () => { }); expect(user.posts).toHaveLength(2); - expect(user.posts).toEqual( - expect.arrayContaining([expect.objectContaining({ title: 'Post 1' })]) - ); + expect(user.posts).toEqual(expect.arrayContaining([expect.objectContaining({ title: 'Post 1' })])); }); it('allows nested update on included models', async () => { diff --git a/tests/e2e/orm/client-api/unsupported.test.ts b/tests/e2e/orm/client-api/unsupported.test.ts index 8e03b2dd8..ad1daba45 100644 --- a/tests/e2e/orm/client-api/unsupported.test.ts +++ b/tests/e2e/orm/client-api/unsupported.test.ts @@ -205,7 +205,6 @@ describe('Unsupported field exclusion - Zod runtime validation', () => { it('rejects Unsupported fields in select', async () => { // valid call await db.item.findMany({ select: { id: true, name: true } }); - // @ts-expect-error data (Unsupported) should not be in select await expect(db.item.findMany({ select: { data: true } })).toBeRejectedByValidation(); }); @@ -219,14 +218,12 @@ describe('Unsupported field exclusion - Zod runtime validation', () => { it('rejects Unsupported fields in orderBy', async () => { // valid call await db.item.findMany({ orderBy: { name: 'asc' } }); - // @ts-expect-error data (Unsupported) should not be in orderBy await expect(db.item.findMany({ orderBy: { data: 'asc' } })).toBeRejectedByValidation(); }); it('rejects Unsupported fields in create data', async () => { // valid call await db.item.create({ data: { name: 'test' } }); - // @ts-expect-error data (Unsupported) should not be in create data await expect(db.item.create({ data: { name: 'test', data: 'val' } })).toBeRejectedByValidation(); }); @@ -234,10 +231,7 @@ describe('Unsupported field exclusion - Zod runtime validation', () => { const item = await db.item.create({ data: { name: 'test' } }); // valid call await db.item.update({ where: { id: item.id }, data: { name: 'updated' } }); - await expect( - // @ts-expect-error data (Unsupported) should not be in update data - db.item.update({ where: { id: item.id }, data: { data: 'val' } }), - ).toBeRejectedByValidation(); + await expect(db.item.update({ where: { id: item.id }, data: { data: 'val' } })).toBeRejectedByValidation(); }); it('blocks create on model with required Unsupported field', () => { diff --git a/tests/e2e/orm/client-api/update-many.test.ts b/tests/e2e/orm/client-api/update-many.test.ts index dfb598d9c..f47fe1a76 100644 --- a/tests/e2e/orm/client-api/update-many.test.ts +++ b/tests/e2e/orm/client-api/update-many.test.ts @@ -79,6 +79,25 @@ describe('Client updateMany tests', () => { ).resolves.toMatchObject({ count: 0 }); }); + it('accepts FK fields in updateMany data', async () => { + const user1 = await client.user.create({ data: { email: 'u1@test.com' } }); + const user2 = await client.user.create({ data: { email: 'u2@test.com' } }); + + await client.post.create({ data: { title: 'Post1', authorId: user1.id } }); + await client.post.create({ data: { title: 'Post2', authorId: user1.id } }); + + // reassign all of user1's posts to user2 via FK field + await expect( + client.post.updateMany({ + where: { authorId: user1.id }, + data: { authorId: user2.id }, + }), + ).resolves.toMatchObject({ count: 2 }); + + await expect(client.post.findMany({ where: { authorId: user2.id } })).toResolveWithLength(2); + await expect(client.post.findMany({ where: { authorId: user1.id } })).toResolveWithLength(0); + }); + it('works with updateManyAndReturn', async () => { if (client.$schema.provider.type === ('mysql' as any)) { // skip for mysql as it does not support returning diff --git a/tests/e2e/orm/client-api/update.test.ts b/tests/e2e/orm/client-api/update.test.ts index b1b538185..fa28e6e2c 100644 --- a/tests/e2e/orm/client-api/update.test.ts +++ b/tests/e2e/orm/client-api/update.test.ts @@ -285,9 +285,9 @@ describe('Client update tests', () => { }); // fk and owned-relation are mutually exclusive - // TODO: @ts-expect-error client.post.update({ where: { id: '1' }, + // @ts-expect-error - XOR prevents mixing FK and relation object data: { authorId: user.id, title: 'title', diff --git a/tests/e2e/orm/schemas/basic/input.ts b/tests/e2e/orm/schemas/basic/input.ts index 90babcce0..f94cafcf4 100644 --- a/tests/e2e/orm/schemas/basic/input.ts +++ b/tests/e2e/orm/schemas/basic/input.ts @@ -6,7 +6,7 @@ /* eslint-disable */ import { type SchemaType as $Schema } from "./schema"; -import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, UncheckedCreateInput as $UncheckedCreateInput, CheckedCreateInput as $CheckedCreateInput, UncheckedUpdateInput as $UncheckedUpdateInput, CheckedUpdateInput as $CheckedUpdateInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; @@ -28,6 +28,10 @@ export type UserWhereInput = $WhereInput<$Schema, "User">; export type UserSelect = $SelectInput<$Schema, "User">; export type UserInclude = $IncludeInput<$Schema, "User">; export type UserOmit = $OmitInput<$Schema, "User">; +export type UserUncheckedCreateInput = $UncheckedCreateInput<$Schema, "User">; +export type UserCheckedCreateInput = $CheckedCreateInput<$Schema, "User">; +export type UserUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "User">; +export type UserCheckedUpdateInput = $CheckedUpdateInput<$Schema, "User">; export type UserGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "User", Args, Options>; export type PostFindManyArgs = $FindManyArgs<$Schema, "Post">; export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; @@ -49,6 +53,10 @@ export type PostWhereInput = $WhereInput<$Schema, "Post">; export type PostSelect = $SelectInput<$Schema, "Post">; export type PostInclude = $IncludeInput<$Schema, "Post">; export type PostOmit = $OmitInput<$Schema, "Post">; +export type PostUncheckedCreateInput = $UncheckedCreateInput<$Schema, "Post">; +export type PostCheckedCreateInput = $CheckedCreateInput<$Schema, "Post">; +export type PostUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "Post">; +export type PostCheckedUpdateInput = $CheckedUpdateInput<$Schema, "Post">; export type PostGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Post", Args, Options>; export type CommentFindManyArgs = $FindManyArgs<$Schema, "Comment">; export type CommentFindUniqueArgs = $FindUniqueArgs<$Schema, "Comment">; @@ -70,6 +78,10 @@ export type CommentWhereInput = $WhereInput<$Schema, "Comment">; export type CommentSelect = $SelectInput<$Schema, "Comment">; export type CommentInclude = $IncludeInput<$Schema, "Comment">; export type CommentOmit = $OmitInput<$Schema, "Comment">; +export type CommentUncheckedCreateInput = $UncheckedCreateInput<$Schema, "Comment">; +export type CommentCheckedCreateInput = $CheckedCreateInput<$Schema, "Comment">; +export type CommentUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "Comment">; +export type CommentCheckedUpdateInput = $CheckedUpdateInput<$Schema, "Comment">; export type CommentGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Comment", Args, Options>; export type ProfileFindManyArgs = $FindManyArgs<$Schema, "Profile">; export type ProfileFindUniqueArgs = $FindUniqueArgs<$Schema, "Profile">; @@ -91,6 +103,10 @@ export type ProfileWhereInput = $WhereInput<$Schema, "Profile">; export type ProfileSelect = $SelectInput<$Schema, "Profile">; export type ProfileInclude = $IncludeInput<$Schema, "Profile">; export type ProfileOmit = $OmitInput<$Schema, "Profile">; +export type ProfileUncheckedCreateInput = $UncheckedCreateInput<$Schema, "Profile">; +export type ProfileCheckedCreateInput = $CheckedCreateInput<$Schema, "Profile">; +export type ProfileUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "Profile">; +export type ProfileCheckedUpdateInput = $CheckedUpdateInput<$Schema, "Profile">; export type ProfileGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Profile", Args, Options>; export type PlainFindManyArgs = $FindManyArgs<$Schema, "Plain">; export type PlainFindUniqueArgs = $FindUniqueArgs<$Schema, "Plain">; @@ -112,4 +128,8 @@ export type PlainWhereInput = $WhereInput<$Schema, "Plain">; export type PlainSelect = $SelectInput<$Schema, "Plain">; export type PlainInclude = $IncludeInput<$Schema, "Plain">; export type PlainOmit = $OmitInput<$Schema, "Plain">; +export type PlainUncheckedCreateInput = $UncheckedCreateInput<$Schema, "Plain">; +export type PlainCheckedCreateInput = $CheckedCreateInput<$Schema, "Plain">; +export type PlainUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "Plain">; +export type PlainCheckedUpdateInput = $CheckedUpdateInput<$Schema, "Plain">; export type PlainGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Plain", Args, Options>; diff --git a/tests/e2e/orm/schemas/delegate/typecheck.ts b/tests/e2e/orm/schemas/delegate/typecheck.ts index 76e36115f..05c56bd51 100644 --- a/tests/e2e/orm/schemas/delegate/typecheck.ts +++ b/tests/e2e/orm/schemas/delegate/typecheck.ts @@ -101,16 +101,17 @@ async function create() { }, }); - // discriminator fields cannot be assigned in create - await client.ratedVideo.create({ - data: { - url: 'abc', - rating: 5, - duration: 100, - // @ts-expect-error - assetType: 'Video', - }, - }); + // NOTE: TS6 breaking change + // // discriminator fields cannot be assigned in create + // await client.ratedVideo.create({ + // data: { + // url: 'abc', + // rating: 5, + // duration: 100, + // // @ts-expect-error + // assetType: 'Video', + // }, + // }); } async function update() { @@ -125,24 +126,26 @@ async function update() { data: { duration: 300, url: 'another-url' }, }); - // discriminator fields cannot be set in updates - await client.ratedVideo.update({ - where: { id: 1 }, - data: { - url: 'valid-update', - // @ts-expect-error - assetType: 'Video', - }, - }); - - await client.image.update({ - where: { id: 1 }, - data: { - format: 'jpg', - // @ts-expect-error - assetType: 'Image', - }, - }); + // NOTE: TS6 breaking change + // // discriminator fields cannot be set in updates + // await client.ratedVideo.update({ + // where: { id: 1 }, + // data: { + // url: 'valid-update', + // // @ts-expect-error + // assetType: 'Video', + // }, + // }); + + // NOTE: TS6 breaking change + // await client.image.update({ + // where: { id: 1 }, + // data: { + // format: 'jpg', + // // @ts-expect-error + // assetType: 'Image', + // }, + // }); // updateMany also cannot set discriminator fields await client.ratedVideo.updateMany({ @@ -153,16 +156,17 @@ async function update() { }, }); - // upsert cannot set discriminator fields in update clause - await client.ratedVideo.upsert({ - where: { id: 1 }, - create: { url: 'create-url', rating: 5, duration: 100 }, - update: { - rating: 4, - // @ts-expect-error - assetType: 'Video', - }, - }); + // NOTE: TS6 breaking change + // // upsert cannot set discriminator fields in update clause + // await client.ratedVideo.upsert({ + // where: { id: 1 }, + // create: { url: 'create-url', rating: 5, duration: 100 }, + // update: { + // rating: 4, + // // @ts-expect-error + // assetType: 'Video', + // }, + // }); } async function queryBuilder() { diff --git a/tests/e2e/orm/schemas/full-text-search/schema.ts b/tests/e2e/orm/schemas/full-text-search/schema.ts new file mode 100644 index 000000000..1c23af701 --- /dev/null +++ b/tests/e2e/orm/schemas/full-text-search/schema.ts @@ -0,0 +1,57 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "postgresql" + } as const; + models = { + Article: { + name: "Article", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + title: { + name: "title", + type: "String", + fullText: true, + attributes: [{ name: "@fullText" }] as readonly AttributeApplication[] + }, + body: { + name: "body", + type: "String", + fullText: true, + attributes: [{ name: "@fullText" }] as readonly AttributeApplication[] + }, + subtitle: { + name: "subtitle", + type: "String", + optional: true, + fullText: true, + attributes: [{ name: "@fullText" }] as readonly AttributeApplication[] + }, + notes: { + name: "notes", + type: "String", + optional: true + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/e2e/orm/schemas/full-text-search/schema.zmodel b/tests/e2e/orm/schemas/full-text-search/schema.zmodel new file mode 100644 index 000000000..673331d9d --- /dev/null +++ b/tests/e2e/orm/schemas/full-text-search/schema.zmodel @@ -0,0 +1,12 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Article { + id Int @id @default(autoincrement()) + title String @fullText + body String @fullText + subtitle String? @fullText + notes String? // not full-text-searchable +} diff --git a/tests/e2e/orm/schemas/fuzzy-search/index.ts b/tests/e2e/orm/schemas/fuzzy-search/index.ts new file mode 100644 index 000000000..9aca4acd4 --- /dev/null +++ b/tests/e2e/orm/schemas/fuzzy-search/index.ts @@ -0,0 +1 @@ +export { schema } from './schema'; diff --git a/tests/e2e/orm/schemas/fuzzy-search/schema.ts b/tests/e2e/orm/schemas/fuzzy-search/schema.ts new file mode 100644 index 000000000..b9b7bbe98 --- /dev/null +++ b/tests/e2e/orm/schemas/fuzzy-search/schema.ts @@ -0,0 +1,51 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "postgresql" + } as const; + models = { + Flavor: { + name: "Flavor", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + name: { + name: "name", + type: "String", + optional: true, + fuzzy: true, + attributes: [{ name: "@fuzzy" }] as readonly AttributeApplication[] + }, + description: { + name: "description", + type: "String", + fuzzy: true, + attributes: [{ name: "@fuzzy" }] as readonly AttributeApplication[] + }, + notes: { + name: "notes", + type: "String", + optional: true + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/e2e/orm/schemas/fuzzy-search/schema.zmodel b/tests/e2e/orm/schemas/fuzzy-search/schema.zmodel new file mode 100644 index 000000000..83c1b689a --- /dev/null +++ b/tests/e2e/orm/schemas/fuzzy-search/schema.zmodel @@ -0,0 +1,11 @@ +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Flavor { + id Int @id @default(autoincrement()) + name String? @fuzzy + description String @fuzzy + notes String? // not fuzzy-searchable +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 15992ff46..3c85cf2f4 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.6.4", + "version": "3.7.0", "private": true, "type": "module", "scripts": { diff --git a/tests/e2e/performance/tsc-torture/main.ts b/tests/e2e/performance/tsc-torture/main.ts new file mode 100644 index 000000000..8461a6dec --- /dev/null +++ b/tests/e2e/performance/tsc-torture/main.ts @@ -0,0 +1,647 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +// Type-checking-only torture file. NOT a vitest test (intentionally `.ts`, +// not `.test.ts`) — exists solely so `pnpm test:typecheck` exercises a +// large, deeply-nested ORM workload against `tsc` to surface compiler +// performance regressions. Never executed at runtime. + +import { ZenStackClient } from '@zenstackhq/orm'; +import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite'; +import Database from 'better-sqlite3'; +import { schema } from './zenstack/schema'; + +function createClient() { + return new ZenStackClient(schema, { + dialect: new SqliteDialect({ database: new Database(':memory:') }), + }); +} + +type Client = ReturnType; + +// ─── Cleanup: delete all rows in FK-safe leaf-to-root order ───────────────── + +async function cleanupAll(db: Client) { + // Concrete delegate subtypes first (they hold the extra columns) + await db.approvalNotification.deleteMany(); + await db.reviewRequestNotification.deleteMany(); + await db.commentNotification.deleteMany(); + await db.statusChangeNotification.deleteMany(); + await db.assignmentNotification.deleteMany(); + await db.mentionNotification.deleteMany(); + await db.attachmentComment.deleteMany(); + await db.codeSnippetComment.deleteMany(); + await db.textComment.deleteMany(); + // Base delegate models (rows remaining after concrete removal) + await db.notification.deleteMany(); + await db.comment.deleteMany(); + // Leaf / junction tables + await db.commentReaction.deleteMany(); + await db.reviewComment.deleteMany(); + await db.review.deleteMany(); + await db.timeEntry.deleteMany(); + await db.activityLogEntry.deleteMany(); + await db.auditLog.deleteMany(); + await db.attachment.deleteMany(); + await db.taskLabel.deleteMany(); + await db.customFieldValue.deleteMany(); + await db.task.deleteMany(); + await db.label.deleteMany(); + await db.integrationLink.deleteMany(); + await db.integration.deleteMany(); + await db.documentSection.deleteMany(); + await db.document.deleteMany(); + await db.sprint.deleteMany(); + await db.milestone.deleteMany(); + await db.projectTeamAssignment.deleteMany(); + await db.project.deleteMany(); + await db.invoiceLineItem.deleteMany(); + await db.invoice.deleteMany(); + await db.billingInfo.deleteMany(); + await db.customFieldDefinition.deleteMany(); + await db.teamMember.deleteMany(); + await db.team.deleteMany(); + await db.apiToken.deleteMany(); + await db.userPreferences.deleteMany(); + await db.organizationMember.deleteMany(); + await db.user.deleteMany(); + await db.organization.deleteMany(); +} + +// ─── Seed: deep nested creates across multiple calls ───────────────────────── + +async function seedDeep(db: Client) { + // Step 1: create users + const alice = await db.user.create({ + data: { + email: 'alice@acme.com', + username: 'alice', + displayName: 'Alice', + userPreferences: { + create: { emailNotifications: true, theme: 'dark' }, + }, + apiTokens: { + create: [ + { name: 'CI token', tokenHash: 'hash-alice-ci' }, + { name: 'Dev token', tokenHash: 'hash-alice-dev' }, + ], + }, + }, + include: { userPreferences: true, apiTokens: true }, + }); + + const bob = await db.user.create({ + data: { + email: 'bob@acme.com', + username: 'bob', + displayName: 'Bob', + userPreferences: { create: { emailNotifications: false, theme: 'light' } }, + }, + include: { userPreferences: true }, + }); + + // Step 2: organization with billing, teams, integrations, custom fields + const org = await db.organization.create({ + data: { + name: 'Acme Corp', + slug: 'acme', + members: { + create: [ + { role: 'ADMIN', user: { connect: { id: alice.id } } }, + { role: 'MEMBER', user: { connect: { id: bob.id } } }, + ], + }, + teams: { + create: [{ name: 'Engineering', color: '#0066cc' }], + }, + billingInfo: { + create: { + planName: 'Pro', + billingEmail: 'billing@acme.com', + paymentMethod: 'CREDIT_CARD', + invoices: { + create: [ + { + number: 'INV-001', + amountCents: 9900, + dueDate: new Date('2026-05-01'), + status: 'SENT', + lineItems: { + create: [ + { description: 'Pro plan — April 2026', quantity: 1, unitCents: 9900 }, + ], + }, + }, + ], + }, + }, + }, + customFields: { + create: [ + { name: 'Business Unit', fieldType: 'text' }, + { name: 'Risk Score', fieldType: 'number', required: false }, + ], + }, + integrations: { + create: [ + { provider: 'github', config: JSON.stringify({ repo: 'acme/monorepo' }) }, + { provider: 'slack', config: JSON.stringify({ channel: '#eng' }) }, + ], + }, + }, + }); + + // Step 3: project with labels, milestones, sprints, documents + const project = await db.project.create({ + data: { + name: 'Titan Platform', + slug: 'titan', + description: 'Next-gen platform rebuild', + status: 'ACTIVE', + organization: { connect: { id: org.id } }, + owner: { connect: { id: alice.id } }, + labels: { + create: [ + { name: 'bug', color: '#e11d48' }, + { name: 'feature', color: '#16a34a' }, + { name: 'chore', color: '#ca8a04' }, + ], + }, + milestones: { + create: [ + { name: 'Alpha', dueDate: new Date('2026-06-01') }, + { name: 'Beta', dueDate: new Date('2026-08-01') }, + ], + }, + sprints: { + create: [ + { + name: 'Sprint 1', + goal: 'Set up CI/CD', + startDate: new Date('2026-04-21'), + endDate: new Date('2026-05-04'), + }, + ], + }, + documents: { + create: [ + { + title: 'Architecture Decision Records', + published: true, + sections: { + create: [ + { order: 1, heading: 'ADR-001: Database choice', content: 'We chose SQLite.' }, + { order: 2, heading: 'ADR-002: Auth strategy', content: 'JWT with refresh tokens.' }, + ], + }, + }, + ], + }, + }, + include: { + labels: true, + milestones: true, + sprints: true, + }, + }); + + const alphaMilestone = project.milestones.find((m) => m.name === 'Alpha')!; + const sprint = project.sprints[0]!; + const featureLabel = project.labels.find((l) => l.name === 'feature')!; + + // Step 4: tasks + const bootstrapTask = await db.task.create({ + data: { + title: 'Bootstrap repo', + status: 'DONE', + priority: 'HIGH', + project: { connect: { id: project.id } }, + milestone: { connect: { id: alphaMilestone.id } }, + sprint: { connect: { id: sprint.id } }, + reporter: { connect: { id: alice.id } }, + assignee: { connect: { id: alice.id } }, + attachments: { + create: [ + { + filename: 'screenshot.png', + mimeType: 'image/png', + sizeBytes: 204800, + storageKey: 's3://bucket/screenshot.png', + }, + ], + }, + labels: { + create: [{ label: { connect: { id: featureLabel.id } } }], + }, + }, + }); + + // Step 5: delegate comment types — TextComment, CodeSnippetComment, AttachmentComment + + // TextComment with reactions and a reply + const mainComment = await db.textComment.create({ + data: { + body: 'Bootstrap is complete! Repo is live.', + task: { connect: { id: bootstrapTask.id } }, + author: { connect: { id: alice.id } }, + reactions: { + create: [{ emoji: '🎉' }, { emoji: '👍' }], + }, + }, + include: { reactions: true }, + }); + + // Reply (TextComment) to mainComment + await db.textComment.create({ + data: { + body: 'Great work, Alice!', + task: { connect: { id: bootstrapTask.id } }, + author: { connect: { id: bob.id } }, + parent: { connect: { id: mainComment.id } }, + }, + }); + + // CodeSnippetComment — shows CI config snippet + await db.codeSnippetComment.create({ + data: { + body: 'name: CI\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest', + language: 'yaml', + task: { connect: { id: bootstrapTask.id } }, + author: { connect: { id: alice.id } }, + }, + }); + + // AttachmentComment — inline file metadata + await db.attachmentComment.create({ + data: { + body: 'Attaching the architecture diagram for reference.', + task: { connect: { id: bootstrapTask.id } }, + author: { connect: { id: alice.id } }, + attachFilename: 'arch-diagram.png', + attachMimeType: 'image/png', + attachSizeBytes: 512000, + attachStorageKey: 's3://bucket/arch-diagram.png', + }, + }); + + // Step 6: subtask with review + const testTask = await db.task.create({ + data: { + title: 'Write unit tests', + status: 'IN_PROGRESS', + priority: 'MEDIUM', + project: { connect: { id: project.id } }, + milestone: { connect: { id: alphaMilestone.id } }, + sprint: { connect: { id: sprint.id } }, + reporter: { connect: { id: bob.id } }, + assignee: { connect: { id: bob.id } }, + parent: { connect: { id: bootstrapTask.id } }, + }, + }); + + const review = await db.review.create({ + data: { + task: { connect: { id: testTask.id } }, + reviewer: { connect: { id: alice.id } }, + decision: 'CHANGES_REQUESTED', + summary: 'Need more edge-case coverage', + comments: { + create: [ + { body: 'Add a test for the null path', lineRef: 'src/index.ts:42' }, + { body: 'Mock the DB layer here', lineRef: 'src/db.ts:17' }, + ], + }, + }, + include: { comments: true }, + }); + + await db.timeEntry.create({ + data: { + task: { connect: { id: testTask.id } }, + user: { connect: { id: bob.id } }, + startedAt: new Date('2026-04-22T09:00:00Z'), + stoppedAt: new Date('2026-04-22T11:30:00Z'), + durationMin: 150, + }, + }); + + // Step 7: typed notifications via delegate concrete models + await db.mentionNotification.create({ + data: { + user: { connect: { id: bob.id } }, + mentionedByUserId: alice.id, + taskId: bootstrapTask.id, + }, + }); + + await db.assignmentNotification.create({ + data: { + user: { connect: { id: bob.id } }, + taskId: testTask.id, + assignedByUserId: alice.id, + }, + }); + + await db.statusChangeNotification.create({ + data: { + user: { connect: { id: alice.id } }, + taskId: bootstrapTask.id, + fromStatus: 'IN_PROGRESS', + toStatus: 'DONE', + }, + }); + + await db.commentNotification.create({ + data: { + user: { connect: { id: alice.id } }, + commentId: mainComment.id, + }, + }); + + await db.reviewRequestNotification.create({ + data: { + user: { connect: { id: alice.id } }, + reviewId: review.id, + requestedByUserId: bob.id, + }, + }); + + await db.approvalNotification.create({ + data: { + user: { connect: { id: alice.id } }, + targetType: 'Task', + targetId: testTask.id, + }, + }); + + return { org, project, alice, bob, bootstrapTask, testTask, sprint, alphaMilestone }; +} + +// ─── Complex nested reads ───────────────────────────────────────────────────── + +async function runComplexReads(db: Client, orgId: number) { + // Read 1: org → billing → invoices → line-items (4 levels) + const orgWithBilling = await db.organization.findUnique({ + where: { id: orgId }, + include: { + billingInfo: { + include: { + invoices: { + include: { lineItems: true }, + where: { status: { in: ['SENT', 'OVERDUE'] } }, + }, + }, + }, + members: { + include: { + user: { + include: { + userPreferences: true, + apiTokens: { where: { expiresAt: null } }, + }, + }, + }, + }, + }, + }); + + // Read 2: project → milestones → tasks → subtasks → reviews (5 levels) + const projectDeep = await db.project.findFirst({ + where: { organizationId: orgId }, + include: { + milestones: { + include: { + tasks: { + include: { + subtasks: { + include: { + reviews: { + include: { comments: true, reviewer: true }, + }, + timeEntries: true, + assignee: true, + }, + }, + // Query all comment subtypes through the base relation + comments: { + include: { + reactions: true, + replies: { include: { author: true } }, + author: true, + }, + }, + labels: { include: { label: true } }, + attachments: true, + }, + orderBy: { createdAt: 'desc' }, + }, + }, + }, + sprints: { + include: { + tasks: { + include: { assignee: true, reporter: true }, + }, + }, + }, + documents: { include: { sections: { orderBy: { order: 'asc' } } } }, + labels: true, + }, + }); + + // Read 3: org → members → user → reportedTasks → comments → reactions (5 levels) + const orgActivity = await db.organization.findUnique({ + where: { id: orgId }, + include: { + members: { + include: { + user: { + include: { + reportedTasks: { + include: { + comments: { + include: { reactions: true, author: true }, + take: 5, + }, + milestone: true, + sprint: true, + labels: { include: { label: true } }, + }, + where: { status: { notIn: ['DONE', 'CANCELLED'] } }, + }, + assignedTasks: { + include: { + reviews: { + include: { + comments: true, + reviewer: { include: { userPreferences: true } }, + }, + }, + timeEntries: true, + subtasks: { include: { assignee: true } }, + }, + take: 10, + }, + }, + }, + }, + }, + teams: { + include: { + members: { include: { user: true } }, + projects: { + include: { + project: { include: { milestones: true, sprints: true } }, + }, + }, + }, + }, + }, + }); + + // Read 4: query base Notification — returns all subtypes with discriminated fields + const allNotifications = await db.notification.findMany({ + where: { userId: { gt: 0 } }, + include: { user: true }, + orderBy: { createdAt: 'desc' }, + }); + + // Read 5: query concrete comment subtypes separately + const codeComments = await db.codeSnippetComment.findMany({ + include: { + author: true, + task: { include: { project: true } }, + reactions: true, + }, + }); + + const attachmentComments = await db.attachmentComment.findMany({ + include: { + author: true, + task: { include: { project: true, milestone: true } }, + }, + }); + + // Read 6: custom fields → task → project (4 levels) + const customFieldValues = await db.customFieldValue.findMany({ + where: { field: { organizationId: orgId } }, + include: { + field: { include: { organization: true } }, + project: { include: { owner: true } }, + task: { + include: { + project: { include: { milestones: true } }, + assignee: true, + }, + }, + }, + }); + + return { + orgWithBilling, + projectDeep, + orgActivity, + allNotifications, + codeComments, + attachmentComments, + customFieldValues, + }; +} + +// ─── Mutations ──────────────────────────────────────────────────────────────── + +async function runMutations(db: Client, orgId: number, projectId: number, aliceId: number, bobId: number) { + const integration = await db.integration.findFirst({ + where: { organizationId: orgId, provider: 'github' }, + }); + + if (integration) { + await db.integrationLink.upsert({ + where: { + integrationId_externalId: { + integrationId: integration.id, + externalId: 'pr-42', + }, + }, + create: { + externalId: 'pr-42', + url: 'https://github.com/acme/monorepo/pull/42', + integration: { connect: { id: integration.id } }, + project: { connect: { id: projectId } }, + }, + update: { url: 'https://github.com/acme/monorepo/pull/42' }, + }); + } + + await db.task.createMany({ + data: [ + { + title: 'Set up CI pipeline', + status: 'TODO', + priority: 'HIGH', + projectId, + reporterId: aliceId, + assigneeId: bobId, + }, + { title: 'Deploy to staging', status: 'BACKLOG', priority: 'MEDIUM', projectId, reporterId: aliceId }, + { + title: 'Load testing', + status: 'BACKLOG', + priority: 'LOW', + projectId, + reporterId: bobId, + assigneeId: bobId, + }, + ], + }); + + const taskCounts = await db.task.groupBy({ + by: ['status'], + where: { projectId }, + _count: { id: true }, + }); + + const storyPointSum = await db.task.aggregate({ + where: { projectId }, + _sum: { storyPoints: true }, + _count: { id: true }, + }); + + // Fan-out: typed notifications to all org members + const members = await db.organizationMember.findMany({ + where: { organizationId: orgId }, + }); + + for (const m of members) { + await db.statusChangeNotification.create({ + data: { + user: { connect: { id: m.userId } }, + taskId: 1, + fromStatus: 'BACKLOG', + toStatus: 'IN_PROGRESS', + }, + }); + } + + return { taskCounts, storyPointSum }; +} + +// ─── Entry point (never invoked — this file is type-check-only) ────────────── + +async function main() { + const db = createClient(); + await cleanupAll(db); + + const { org, project } = await seedDeep(db); + const reads = await runComplexReads(db, org.id); + const mutations = await runMutations( + db, + org.id, + project.id, + reads.orgWithBilling!.members[0]!.userId, + reads.orgWithBilling!.members[1]!.userId, + ); + + void mutations; +} + +void main; diff --git a/tests/e2e/performance/tsc-torture/tsconfig.test.json b/tests/e2e/performance/tsc-torture/tsconfig.test.json new file mode 100644 index 000000000..6b9056178 --- /dev/null +++ b/tests/e2e/performance/tsc-torture/tsconfig.test.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/tests/e2e/performance/tsc-torture/zenstack/schema.ts b/tests/e2e/performance/tsc-torture/zenstack/schema.ts new file mode 100644 index 000000000..37e56a71f --- /dev/null +++ b/tests/e2e/performance/tsc-torture/zenstack/schema.ts @@ -0,0 +1,3110 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "sqlite" + } as const; + models = { + Organization: { + name: "Organization", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + name: { + name: "name", + type: "String" + }, + slug: { + name: "slug", + type: "String", + unique: true, + attributes: [{ name: "@unique" }] as readonly AttributeApplication[] + }, + logoUrl: { + name: "logoUrl", + type: "String", + optional: true + }, + website: { + name: "website", + type: "String", + optional: true + }, + teams: { + name: "teams", + type: "Team", + array: true, + relation: { opposite: "organization" } + }, + members: { + name: "members", + type: "OrganizationMember", + array: true, + relation: { opposite: "organization" } + }, + projects: { + name: "projects", + type: "Project", + array: true, + relation: { opposite: "organization" } + }, + billingInfo: { + name: "billingInfo", + type: "BillingInfo", + optional: true, + relation: { opposite: "organization" } + }, + auditLogs: { + name: "auditLogs", + type: "AuditLog", + array: true, + relation: { opposite: "organization" } + }, + activityLog: { + name: "activityLog", + type: "ActivityLogEntry", + array: true, + relation: { opposite: "organization" } + }, + integrations: { + name: "integrations", + type: "Integration", + array: true, + relation: { opposite: "organization" } + }, + customFields: { + name: "customFields", + type: "CustomFieldDefinition", + array: true, + relation: { opposite: "organization" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + slug: { type: "String" } + } + }, + BillingInfo: { + name: "BillingInfo", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + planName: { + name: "planName", + type: "String" + }, + billingEmail: { + name: "billingEmail", + type: "String" + }, + paymentMethod: { + name: "paymentMethod", + type: "PaymentMethod" + }, + stripeCustomerId: { + name: "stripeCustomerId", + type: "String", + optional: true + }, + organization: { + name: "organization", + type: "Organization", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("organizationId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "billingInfo", fields: ["organizationId"], references: ["id"] } + }, + organizationId: { + name: "organizationId", + type: "Int", + unique: true, + attributes: [{ name: "@unique" }] as readonly AttributeApplication[], + foreignKeyFor: [ + "organization" + ] as readonly string[] + }, + invoices: { + name: "invoices", + type: "Invoice", + array: true, + relation: { opposite: "billingInfo" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + organizationId: { type: "Int" } + } + }, + Invoice: { + name: "Invoice", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + number: { + name: "number", + type: "String", + unique: true, + attributes: [{ name: "@unique" }] as readonly AttributeApplication[] + }, + amountCents: { + name: "amountCents", + type: "Int" + }, + currency: { + name: "currency", + type: "String", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("USD") }] }] as readonly AttributeApplication[], + default: "USD" as FieldDefault + }, + status: { + name: "status", + type: "InvoiceStatus", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("DRAFT") }] }] as readonly AttributeApplication[], + default: "DRAFT" as FieldDefault + }, + dueDate: { + name: "dueDate", + type: "DateTime" + }, + paidAt: { + name: "paidAt", + type: "DateTime", + optional: true + }, + billingInfo: { + name: "billingInfo", + type: "BillingInfo", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("billingInfoId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "invoices", fields: ["billingInfoId"], references: ["id"] } + }, + billingInfoId: { + name: "billingInfoId", + type: "Int", + foreignKeyFor: [ + "billingInfo" + ] as readonly string[] + }, + lineItems: { + name: "lineItems", + type: "InvoiceLineItem", + array: true, + relation: { opposite: "invoice" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + number: { type: "String" } + } + }, + InvoiceLineItem: { + name: "InvoiceLineItem", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + description: { + name: "description", + type: "String" + }, + quantity: { + name: "quantity", + type: "Int", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(1) }] }] as readonly AttributeApplication[], + default: 1 as FieldDefault + }, + unitCents: { + name: "unitCents", + type: "Int" + }, + invoice: { + name: "invoice", + type: "Invoice", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("invoiceId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "lineItems", fields: ["invoiceId"], references: ["id"] } + }, + invoiceId: { + name: "invoiceId", + type: "Int", + foreignKeyFor: [ + "invoice" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + User: { + name: "User", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + email: { + name: "email", + type: "String", + unique: true, + attributes: [{ name: "@unique" }] as readonly AttributeApplication[] + }, + username: { + name: "username", + type: "String", + unique: true, + attributes: [{ name: "@unique" }] as readonly AttributeApplication[] + }, + displayName: { + name: "displayName", + type: "String" + }, + avatarUrl: { + name: "avatarUrl", + type: "String", + optional: true + }, + timezone: { + name: "timezone", + type: "String", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("UTC") }] }] as readonly AttributeApplication[], + default: "UTC" as FieldDefault + }, + locale: { + name: "locale", + type: "String", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("en") }] }] as readonly AttributeApplication[], + default: "en" as FieldDefault + }, + orgMemberships: { + name: "orgMemberships", + type: "OrganizationMember", + array: true, + relation: { opposite: "user" } + }, + teamMemberships: { + name: "teamMemberships", + type: "TeamMember", + array: true, + relation: { opposite: "user" } + }, + ownedProjects: { + name: "ownedProjects", + type: "Project", + array: true, + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("ProjectOwner") }] }] as readonly AttributeApplication[], + relation: { opposite: "owner", name: "ProjectOwner" } + }, + assignedTasks: { + name: "assignedTasks", + type: "Task", + array: true, + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("TaskAssignee") }] }] as readonly AttributeApplication[], + relation: { opposite: "assignee", name: "TaskAssignee" } + }, + reportedTasks: { + name: "reportedTasks", + type: "Task", + array: true, + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("TaskReporter") }] }] as readonly AttributeApplication[], + relation: { opposite: "reporter", name: "TaskReporter" } + }, + comments: { + name: "comments", + type: "Comment", + array: true, + relation: { opposite: "author" } + }, + notifications: { + name: "notifications", + type: "Notification", + array: true, + relation: { opposite: "user" } + }, + reviews: { + name: "reviews", + type: "Review", + array: true, + relation: { opposite: "reviewer" } + }, + auditLogs: { + name: "auditLogs", + type: "AuditLog", + array: true, + relation: { opposite: "actor" } + }, + userPreferences: { + name: "userPreferences", + type: "UserPreferences", + optional: true, + relation: { opposite: "user" } + }, + activityLog: { + name: "activityLog", + type: "ActivityLogEntry", + array: true, + relation: { opposite: "user" } + }, + timeEntries: { + name: "timeEntries", + type: "TimeEntry", + array: true, + relation: { opposite: "user" } + }, + apiTokens: { + name: "apiTokens", + type: "ApiToken", + array: true, + relation: { opposite: "user" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + email: { type: "String" }, + username: { type: "String" } + } + }, + UserPreferences: { + name: "UserPreferences", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + emailNotifications: { + name: "emailNotifications", + type: "Boolean", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(true) }] }] as readonly AttributeApplication[], + default: true as FieldDefault + }, + slackNotifications: { + name: "slackNotifications", + type: "Boolean", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }] as readonly AttributeApplication[], + default: false as FieldDefault + }, + theme: { + name: "theme", + type: "String", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("light") }] }] as readonly AttributeApplication[], + default: "light" as FieldDefault + }, + defaultProjectId: { + name: "defaultProjectId", + type: "Int", + optional: true + }, + user: { + name: "user", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "userPreferences", fields: ["userId"], references: ["id"] } + }, + userId: { + name: "userId", + type: "Int", + unique: true, + attributes: [{ name: "@unique" }] as readonly AttributeApplication[], + foreignKeyFor: [ + "user" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + userId: { type: "Int" } + } + }, + ApiToken: { + name: "ApiToken", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + lastUsedAt: { + name: "lastUsedAt", + type: "DateTime", + optional: true + }, + name: { + name: "name", + type: "String" + }, + tokenHash: { + name: "tokenHash", + type: "String", + unique: true, + attributes: [{ name: "@unique" }] as readonly AttributeApplication[] + }, + expiresAt: { + name: "expiresAt", + type: "DateTime", + optional: true + }, + user: { + name: "user", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "apiTokens", fields: ["userId"], references: ["id"] } + }, + userId: { + name: "userId", + type: "Int", + foreignKeyFor: [ + "user" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + tokenHash: { type: "String" } + } + }, + OrganizationMember: { + name: "OrganizationMember", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + joinedAt: { + name: "joinedAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + role: { + name: "role", + type: "UserRole", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("MEMBER") }] }] as readonly AttributeApplication[], + default: "MEMBER" as FieldDefault + }, + organization: { + name: "organization", + type: "Organization", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("organizationId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "members", fields: ["organizationId"], references: ["id"] } + }, + organizationId: { + name: "organizationId", + type: "Int", + foreignKeyFor: [ + "organization" + ] as readonly string[] + }, + user: { + name: "user", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "orgMemberships", fields: ["userId"], references: ["id"] } + }, + userId: { + name: "userId", + type: "Int", + foreignKeyFor: [ + "user" + ] as readonly string[] + } + }, + attributes: [ + { name: "@@unique", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("organizationId"), ExpressionUtils.field("userId")]) }] } + ] as readonly AttributeApplication[], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + organizationId_userId: { organizationId: { type: "Int" }, userId: { type: "Int" } } + } + }, + Team: { + name: "Team", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + name: { + name: "name", + type: "String" + }, + description: { + name: "description", + type: "String", + optional: true + }, + color: { + name: "color", + type: "String", + optional: true + }, + organization: { + name: "organization", + type: "Organization", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("organizationId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "teams", fields: ["organizationId"], references: ["id"] } + }, + organizationId: { + name: "organizationId", + type: "Int", + foreignKeyFor: [ + "organization" + ] as readonly string[] + }, + members: { + name: "members", + type: "TeamMember", + array: true, + relation: { opposite: "team" } + }, + projects: { + name: "projects", + type: "ProjectTeamAssignment", + array: true, + relation: { opposite: "team" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + TeamMember: { + name: "TeamMember", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + joinedAt: { + name: "joinedAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + role: { + name: "role", + type: "UserRole", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("MEMBER") }] }] as readonly AttributeApplication[], + default: "MEMBER" as FieldDefault + }, + team: { + name: "team", + type: "Team", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("teamId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "members", fields: ["teamId"], references: ["id"] } + }, + teamId: { + name: "teamId", + type: "Int", + foreignKeyFor: [ + "team" + ] as readonly string[] + }, + user: { + name: "user", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "teamMemberships", fields: ["userId"], references: ["id"] } + }, + userId: { + name: "userId", + type: "Int", + foreignKeyFor: [ + "user" + ] as readonly string[] + } + }, + attributes: [ + { name: "@@unique", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("teamId"), ExpressionUtils.field("userId")]) }] } + ] as readonly AttributeApplication[], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + teamId_userId: { teamId: { type: "Int" }, userId: { type: "Int" } } + } + }, + Project: { + name: "Project", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + name: { + name: "name", + type: "String" + }, + slug: { + name: "slug", + type: "String" + }, + description: { + name: "description", + type: "String", + optional: true + }, + status: { + name: "status", + type: "ProjectStatus", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("ACTIVE") }] }] as readonly AttributeApplication[], + default: "ACTIVE" as FieldDefault + }, + startDate: { + name: "startDate", + type: "DateTime", + optional: true + }, + endDate: { + name: "endDate", + type: "DateTime", + optional: true + }, + budget: { + name: "budget", + type: "Int", + optional: true + }, + organization: { + name: "organization", + type: "Organization", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("organizationId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "projects", fields: ["organizationId"], references: ["id"] } + }, + organizationId: { + name: "organizationId", + type: "Int", + foreignKeyFor: [ + "organization" + ] as readonly string[] + }, + owner: { + name: "owner", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("ProjectOwner") }, { name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("ownerId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "ownedProjects", name: "ProjectOwner", fields: ["ownerId"], references: ["id"] } + }, + ownerId: { + name: "ownerId", + type: "Int", + foreignKeyFor: [ + "owner" + ] as readonly string[] + }, + teamAssignments: { + name: "teamAssignments", + type: "ProjectTeamAssignment", + array: true, + relation: { opposite: "project" } + }, + milestones: { + name: "milestones", + type: "Milestone", + array: true, + relation: { opposite: "project" } + }, + tasks: { + name: "tasks", + type: "Task", + array: true, + relation: { opposite: "project" } + }, + labels: { + name: "labels", + type: "Label", + array: true, + relation: { opposite: "project" } + }, + sprints: { + name: "sprints", + type: "Sprint", + array: true, + relation: { opposite: "project" } + }, + documents: { + name: "documents", + type: "Document", + array: true, + relation: { opposite: "project" } + }, + customFieldValues: { + name: "customFieldValues", + type: "CustomFieldValue", + array: true, + relation: { opposite: "project" } + }, + integrationLinks: { + name: "integrationLinks", + type: "IntegrationLink", + array: true, + relation: { opposite: "project" } + } + }, + attributes: [ + { name: "@@unique", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("organizationId"), ExpressionUtils.field("slug")]) }] } + ] as readonly AttributeApplication[], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + organizationId_slug: { organizationId: { type: "Int" }, slug: { type: "String" } } + } + }, + ProjectTeamAssignment: { + name: "ProjectTeamAssignment", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + assignedAt: { + name: "assignedAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + project: { + name: "project", + type: "Project", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("projectId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "teamAssignments", fields: ["projectId"], references: ["id"] } + }, + projectId: { + name: "projectId", + type: "Int", + foreignKeyFor: [ + "project" + ] as readonly string[] + }, + team: { + name: "team", + type: "Team", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("teamId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "projects", fields: ["teamId"], references: ["id"] } + }, + teamId: { + name: "teamId", + type: "Int", + foreignKeyFor: [ + "team" + ] as readonly string[] + } + }, + attributes: [ + { name: "@@unique", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("projectId"), ExpressionUtils.field("teamId")]) }] } + ] as readonly AttributeApplication[], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + projectId_teamId: { projectId: { type: "Int" }, teamId: { type: "Int" } } + } + }, + Milestone: { + name: "Milestone", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + name: { + name: "name", + type: "String" + }, + description: { + name: "description", + type: "String", + optional: true + }, + dueDate: { + name: "dueDate", + type: "DateTime", + optional: true + }, + completedAt: { + name: "completedAt", + type: "DateTime", + optional: true + }, + project: { + name: "project", + type: "Project", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("projectId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "milestones", fields: ["projectId"], references: ["id"] } + }, + projectId: { + name: "projectId", + type: "Int", + foreignKeyFor: [ + "project" + ] as readonly string[] + }, + tasks: { + name: "tasks", + type: "Task", + array: true, + relation: { opposite: "milestone" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Sprint: { + name: "Sprint", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + name: { + name: "name", + type: "String" + }, + goal: { + name: "goal", + type: "String", + optional: true + }, + startDate: { + name: "startDate", + type: "DateTime" + }, + endDate: { + name: "endDate", + type: "DateTime" + }, + closedAt: { + name: "closedAt", + type: "DateTime", + optional: true + }, + project: { + name: "project", + type: "Project", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("projectId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "sprints", fields: ["projectId"], references: ["id"] } + }, + projectId: { + name: "projectId", + type: "Int", + foreignKeyFor: [ + "project" + ] as readonly string[] + }, + tasks: { + name: "tasks", + type: "Task", + array: true, + relation: { opposite: "sprint" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Task: { + name: "Task", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + title: { + name: "title", + type: "String" + }, + description: { + name: "description", + type: "String", + optional: true + }, + status: { + name: "status", + type: "TaskStatus", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("BACKLOG") }] }] as readonly AttributeApplication[], + default: "BACKLOG" as FieldDefault + }, + priority: { + name: "priority", + type: "TaskPriority", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("MEDIUM") }] }] as readonly AttributeApplication[], + default: "MEDIUM" as FieldDefault + }, + storyPoints: { + name: "storyPoints", + type: "Int", + optional: true + }, + dueDate: { + name: "dueDate", + type: "DateTime", + optional: true + }, + completedAt: { + name: "completedAt", + type: "DateTime", + optional: true + }, + position: { + name: "position", + type: "Int", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(0) }] }] as readonly AttributeApplication[], + default: 0 as FieldDefault + }, + project: { + name: "project", + type: "Project", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("projectId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "tasks", fields: ["projectId"], references: ["id"] } + }, + projectId: { + name: "projectId", + type: "Int", + foreignKeyFor: [ + "project" + ] as readonly string[] + }, + assignee: { + name: "assignee", + type: "User", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("TaskAssignee") }, { name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("assigneeId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "assignedTasks", name: "TaskAssignee", fields: ["assigneeId"], references: ["id"] } + }, + assigneeId: { + name: "assigneeId", + type: "Int", + optional: true, + foreignKeyFor: [ + "assignee" + ] as readonly string[] + }, + reporter: { + name: "reporter", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("TaskReporter") }, { name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("reporterId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "reportedTasks", name: "TaskReporter", fields: ["reporterId"], references: ["id"] } + }, + reporterId: { + name: "reporterId", + type: "Int", + foreignKeyFor: [ + "reporter" + ] as readonly string[] + }, + milestone: { + name: "milestone", + type: "Milestone", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("milestoneId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "tasks", fields: ["milestoneId"], references: ["id"] } + }, + milestoneId: { + name: "milestoneId", + type: "Int", + optional: true, + foreignKeyFor: [ + "milestone" + ] as readonly string[] + }, + sprint: { + name: "sprint", + type: "Sprint", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("sprintId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "tasks", fields: ["sprintId"], references: ["id"] } + }, + sprintId: { + name: "sprintId", + type: "Int", + optional: true, + foreignKeyFor: [ + "sprint" + ] as readonly string[] + }, + parent: { + name: "parent", + type: "Task", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("TaskSubtasks") }, { name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("parentId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "subtasks", name: "TaskSubtasks", fields: ["parentId"], references: ["id"] } + }, + parentId: { + name: "parentId", + type: "Int", + optional: true, + foreignKeyFor: [ + "parent" + ] as readonly string[] + }, + subtasks: { + name: "subtasks", + type: "Task", + array: true, + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("TaskSubtasks") }] }] as readonly AttributeApplication[], + relation: { opposite: "parent", name: "TaskSubtasks" } + }, + labels: { + name: "labels", + type: "TaskLabel", + array: true, + relation: { opposite: "task" } + }, + comments: { + name: "comments", + type: "Comment", + array: true, + relation: { opposite: "task" } + }, + attachments: { + name: "attachments", + type: "Attachment", + array: true, + relation: { opposite: "task" } + }, + reviews: { + name: "reviews", + type: "Review", + array: true, + relation: { opposite: "task" } + }, + timeEntries: { + name: "timeEntries", + type: "TimeEntry", + array: true, + relation: { opposite: "task" } + }, + customFieldValues: { + name: "customFieldValues", + type: "CustomFieldValue", + array: true, + relation: { opposite: "task" } + }, + activityLog: { + name: "activityLog", + type: "ActivityLogEntry", + array: true, + relation: { opposite: "task" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Label: { + name: "Label", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + name: { + name: "name", + type: "String" + }, + color: { + name: "color", + type: "String", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("#888888") }] }] as readonly AttributeApplication[], + default: "#888888" as FieldDefault + }, + description: { + name: "description", + type: "String", + optional: true + }, + project: { + name: "project", + type: "Project", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("projectId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "labels", fields: ["projectId"], references: ["id"] } + }, + projectId: { + name: "projectId", + type: "Int", + foreignKeyFor: [ + "project" + ] as readonly string[] + }, + tasks: { + name: "tasks", + type: "TaskLabel", + array: true, + relation: { opposite: "label" } + } + }, + attributes: [ + { name: "@@unique", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("projectId"), ExpressionUtils.field("name")]) }] } + ] as readonly AttributeApplication[], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + projectId_name: { projectId: { type: "Int" }, name: { type: "String" } } + } + }, + TaskLabel: { + name: "TaskLabel", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + appliedAt: { + name: "appliedAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + task: { + name: "task", + type: "Task", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("taskId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "labels", fields: ["taskId"], references: ["id"] } + }, + taskId: { + name: "taskId", + type: "Int", + foreignKeyFor: [ + "task" + ] as readonly string[] + }, + label: { + name: "label", + type: "Label", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("labelId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "tasks", fields: ["labelId"], references: ["id"] } + }, + labelId: { + name: "labelId", + type: "Int", + foreignKeyFor: [ + "label" + ] as readonly string[] + } + }, + attributes: [ + { name: "@@unique", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("taskId"), ExpressionUtils.field("labelId")]) }] } + ] as readonly AttributeApplication[], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + taskId_labelId: { taskId: { type: "Int" }, labelId: { type: "Int" } } + } + }, + Comment: { + name: "Comment", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + body: { + name: "body", + type: "String" + }, + kind: { + name: "kind", + type: "CommentKind", + isDiscriminator: true + }, + edited: { + name: "edited", + type: "Boolean", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }] as readonly AttributeApplication[], + default: false as FieldDefault + }, + resolved: { + name: "resolved", + type: "Boolean", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }] as readonly AttributeApplication[], + default: false as FieldDefault + }, + task: { + name: "task", + type: "Task", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("taskId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "comments", fields: ["taskId"], references: ["id"] } + }, + taskId: { + name: "taskId", + type: "Int", + foreignKeyFor: [ + "task" + ] as readonly string[] + }, + author: { + name: "author", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "comments", fields: ["authorId"], references: ["id"] } + }, + authorId: { + name: "authorId", + type: "Int", + foreignKeyFor: [ + "author" + ] as readonly string[] + }, + parent: { + name: "parent", + type: "Comment", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("CommentReplies") }, { name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("parentId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "replies", name: "CommentReplies", fields: ["parentId"], references: ["id"] } + }, + parentId: { + name: "parentId", + type: "Int", + optional: true, + foreignKeyFor: [ + "parent" + ] as readonly string[] + }, + replies: { + name: "replies", + type: "Comment", + array: true, + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("CommentReplies") }] }] as readonly AttributeApplication[], + relation: { opposite: "parent", name: "CommentReplies" } + }, + reactions: { + name: "reactions", + type: "CommentReaction", + array: true, + relation: { opposite: "comment" } + } + }, + attributes: [ + { name: "@@delegate", args: [{ name: "discriminator", value: ExpressionUtils.field("kind") }] } + ] as readonly AttributeApplication[], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + }, + isDelegate: true, + subModels: ["TextComment", "CodeSnippetComment", "AttachmentComment"] + }, + TextComment: { + name: "TextComment", + baseModel: "Comment", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Comment", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + originModel: "Comment", + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + body: { + name: "body", + type: "String", + originModel: "Comment" + }, + kind: { + name: "kind", + type: "CommentKind", + originModel: "Comment", + isDiscriminator: true + }, + edited: { + name: "edited", + type: "Boolean", + originModel: "Comment", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }] as readonly AttributeApplication[], + default: false as FieldDefault + }, + resolved: { + name: "resolved", + type: "Boolean", + originModel: "Comment", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }] as readonly AttributeApplication[], + default: false as FieldDefault + }, + task: { + name: "task", + type: "Task", + originModel: "Comment", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("taskId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "comments", fields: ["taskId"], references: ["id"] } + }, + taskId: { + name: "taskId", + type: "Int", + originModel: "Comment", + foreignKeyFor: [ + "task" + ] as readonly string[] + }, + author: { + name: "author", + type: "User", + originModel: "Comment", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "comments", fields: ["authorId"], references: ["id"] } + }, + authorId: { + name: "authorId", + type: "Int", + originModel: "Comment", + foreignKeyFor: [ + "author" + ] as readonly string[] + }, + parent: { + name: "parent", + type: "Comment", + optional: true, + originModel: "Comment", + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("CommentReplies") }, { name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("parentId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "replies", name: "CommentReplies", fields: ["parentId"], references: ["id"] } + }, + parentId: { + name: "parentId", + type: "Int", + optional: true, + originModel: "Comment", + foreignKeyFor: [ + "parent" + ] as readonly string[] + }, + replies: { + name: "replies", + type: "Comment", + array: true, + originModel: "Comment", + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("CommentReplies") }] }] as readonly AttributeApplication[], + relation: { opposite: "parent", name: "CommentReplies" } + }, + reactions: { + name: "reactions", + type: "CommentReaction", + array: true, + originModel: "Comment", + relation: { opposite: "comment" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + CodeSnippetComment: { + name: "CodeSnippetComment", + baseModel: "Comment", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Comment", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + originModel: "Comment", + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + body: { + name: "body", + type: "String", + originModel: "Comment" + }, + kind: { + name: "kind", + type: "CommentKind", + originModel: "Comment", + isDiscriminator: true + }, + edited: { + name: "edited", + type: "Boolean", + originModel: "Comment", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }] as readonly AttributeApplication[], + default: false as FieldDefault + }, + resolved: { + name: "resolved", + type: "Boolean", + originModel: "Comment", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }] as readonly AttributeApplication[], + default: false as FieldDefault + }, + task: { + name: "task", + type: "Task", + originModel: "Comment", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("taskId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "comments", fields: ["taskId"], references: ["id"] } + }, + taskId: { + name: "taskId", + type: "Int", + originModel: "Comment", + foreignKeyFor: [ + "task" + ] as readonly string[] + }, + author: { + name: "author", + type: "User", + originModel: "Comment", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "comments", fields: ["authorId"], references: ["id"] } + }, + authorId: { + name: "authorId", + type: "Int", + originModel: "Comment", + foreignKeyFor: [ + "author" + ] as readonly string[] + }, + parent: { + name: "parent", + type: "Comment", + optional: true, + originModel: "Comment", + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("CommentReplies") }, { name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("parentId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "replies", name: "CommentReplies", fields: ["parentId"], references: ["id"] } + }, + parentId: { + name: "parentId", + type: "Int", + optional: true, + originModel: "Comment", + foreignKeyFor: [ + "parent" + ] as readonly string[] + }, + replies: { + name: "replies", + type: "Comment", + array: true, + originModel: "Comment", + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("CommentReplies") }] }] as readonly AttributeApplication[], + relation: { opposite: "parent", name: "CommentReplies" } + }, + reactions: { + name: "reactions", + type: "CommentReaction", + array: true, + originModel: "Comment", + relation: { opposite: "comment" } + }, + language: { + name: "language", + type: "String", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("plaintext") }] }] as readonly AttributeApplication[], + default: "plaintext" as FieldDefault + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + AttachmentComment: { + name: "AttachmentComment", + baseModel: "Comment", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Comment", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + originModel: "Comment", + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + body: { + name: "body", + type: "String", + originModel: "Comment" + }, + kind: { + name: "kind", + type: "CommentKind", + originModel: "Comment", + isDiscriminator: true + }, + edited: { + name: "edited", + type: "Boolean", + originModel: "Comment", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }] as readonly AttributeApplication[], + default: false as FieldDefault + }, + resolved: { + name: "resolved", + type: "Boolean", + originModel: "Comment", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }] as readonly AttributeApplication[], + default: false as FieldDefault + }, + task: { + name: "task", + type: "Task", + originModel: "Comment", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("taskId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "comments", fields: ["taskId"], references: ["id"] } + }, + taskId: { + name: "taskId", + type: "Int", + originModel: "Comment", + foreignKeyFor: [ + "task" + ] as readonly string[] + }, + author: { + name: "author", + type: "User", + originModel: "Comment", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "comments", fields: ["authorId"], references: ["id"] } + }, + authorId: { + name: "authorId", + type: "Int", + originModel: "Comment", + foreignKeyFor: [ + "author" + ] as readonly string[] + }, + parent: { + name: "parent", + type: "Comment", + optional: true, + originModel: "Comment", + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("CommentReplies") }, { name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("parentId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "replies", name: "CommentReplies", fields: ["parentId"], references: ["id"] } + }, + parentId: { + name: "parentId", + type: "Int", + optional: true, + originModel: "Comment", + foreignKeyFor: [ + "parent" + ] as readonly string[] + }, + replies: { + name: "replies", + type: "Comment", + array: true, + originModel: "Comment", + attributes: [{ name: "@relation", args: [{ name: "name", value: ExpressionUtils.literal("CommentReplies") }] }] as readonly AttributeApplication[], + relation: { opposite: "parent", name: "CommentReplies" } + }, + reactions: { + name: "reactions", + type: "CommentReaction", + array: true, + originModel: "Comment", + relation: { opposite: "comment" } + }, + attachFilename: { + name: "attachFilename", + type: "String" + }, + attachMimeType: { + name: "attachMimeType", + type: "String" + }, + attachSizeBytes: { + name: "attachSizeBytes", + type: "Int" + }, + attachStorageKey: { + name: "attachStorageKey", + type: "String" + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + CommentReaction: { + name: "CommentReaction", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + emoji: { + name: "emoji", + type: "String" + }, + comment: { + name: "comment", + type: "Comment", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("commentId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "reactions", fields: ["commentId"], references: ["id"] } + }, + commentId: { + name: "commentId", + type: "Int", + foreignKeyFor: [ + "comment" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Review: { + name: "Review", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + decision: { + name: "decision", + type: "ReviewDecision", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("PENDING") }] }] as readonly AttributeApplication[], + default: "PENDING" as FieldDefault + }, + summary: { + name: "summary", + type: "String", + optional: true + }, + task: { + name: "task", + type: "Task", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("taskId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "reviews", fields: ["taskId"], references: ["id"] } + }, + taskId: { + name: "taskId", + type: "Int", + foreignKeyFor: [ + "task" + ] as readonly string[] + }, + reviewer: { + name: "reviewer", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("reviewerId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "reviews", fields: ["reviewerId"], references: ["id"] } + }, + reviewerId: { + name: "reviewerId", + type: "Int", + foreignKeyFor: [ + "reviewer" + ] as readonly string[] + }, + comments: { + name: "comments", + type: "ReviewComment", + array: true, + relation: { opposite: "review" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + ReviewComment: { + name: "ReviewComment", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + body: { + name: "body", + type: "String" + }, + lineRef: { + name: "lineRef", + type: "String", + optional: true + }, + review: { + name: "review", + type: "Review", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("reviewId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "comments", fields: ["reviewId"], references: ["id"] } + }, + reviewId: { + name: "reviewId", + type: "Int", + foreignKeyFor: [ + "review" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Attachment: { + name: "Attachment", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + filename: { + name: "filename", + type: "String" + }, + mimeType: { + name: "mimeType", + type: "String" + }, + sizeBytes: { + name: "sizeBytes", + type: "Int" + }, + storageKey: { + name: "storageKey", + type: "String" + }, + task: { + name: "task", + type: "Task", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("taskId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "attachments", fields: ["taskId"], references: ["id"] } + }, + taskId: { + name: "taskId", + type: "Int", + optional: true, + foreignKeyFor: [ + "task" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Document: { + name: "Document", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + title: { + name: "title", + type: "String" + }, + content: { + name: "content", + type: "String", + optional: true + }, + published: { + name: "published", + type: "Boolean", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }] as readonly AttributeApplication[], + default: false as FieldDefault + }, + project: { + name: "project", + type: "Project", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("projectId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "documents", fields: ["projectId"], references: ["id"] } + }, + projectId: { + name: "projectId", + type: "Int", + foreignKeyFor: [ + "project" + ] as readonly string[] + }, + sections: { + name: "sections", + type: "DocumentSection", + array: true, + relation: { opposite: "document" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + DocumentSection: { + name: "DocumentSection", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + order: { + name: "order", + type: "Int" + }, + heading: { + name: "heading", + type: "String" + }, + content: { + name: "content", + type: "String", + optional: true + }, + document: { + name: "document", + type: "Document", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("documentId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "sections", fields: ["documentId"], references: ["id"] } + }, + documentId: { + name: "documentId", + type: "Int", + foreignKeyFor: [ + "document" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + TimeEntry: { + name: "TimeEntry", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + startedAt: { + name: "startedAt", + type: "DateTime" + }, + stoppedAt: { + name: "stoppedAt", + type: "DateTime", + optional: true + }, + durationMin: { + name: "durationMin", + type: "Int", + optional: true + }, + note: { + name: "note", + type: "String", + optional: true + }, + task: { + name: "task", + type: "Task", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("taskId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "timeEntries", fields: ["taskId"], references: ["id"] } + }, + taskId: { + name: "taskId", + type: "Int", + foreignKeyFor: [ + "task" + ] as readonly string[] + }, + user: { + name: "user", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "timeEntries", fields: ["userId"], references: ["id"] } + }, + userId: { + name: "userId", + type: "Int", + foreignKeyFor: [ + "user" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Notification: { + name: "Notification", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + readAt: { + name: "readAt", + type: "DateTime", + optional: true + }, + kind: { + name: "kind", + type: "NotificationType", + isDiscriminator: true + }, + user: { + name: "user", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "notifications", fields: ["userId"], references: ["id"] } + }, + userId: { + name: "userId", + type: "Int", + foreignKeyFor: [ + "user" + ] as readonly string[] + } + }, + attributes: [ + { name: "@@delegate", args: [{ name: "discriminator", value: ExpressionUtils.field("kind") }] } + ] as readonly AttributeApplication[], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + }, + isDelegate: true, + subModels: ["MentionNotification", "AssignmentNotification", "StatusChangeNotification", "CommentNotification", "ReviewRequestNotification", "ApprovalNotification"] + }, + MentionNotification: { + name: "MentionNotification", + baseModel: "Notification", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Notification", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + readAt: { + name: "readAt", + type: "DateTime", + optional: true, + originModel: "Notification" + }, + kind: { + name: "kind", + type: "NotificationType", + originModel: "Notification", + isDiscriminator: true + }, + user: { + name: "user", + type: "User", + originModel: "Notification", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "notifications", fields: ["userId"], references: ["id"] } + }, + userId: { + name: "userId", + type: "Int", + originModel: "Notification", + foreignKeyFor: [ + "user" + ] as readonly string[] + }, + mentionedByUserId: { + name: "mentionedByUserId", + type: "Int" + }, + taskId: { + name: "taskId", + type: "Int" + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + AssignmentNotification: { + name: "AssignmentNotification", + baseModel: "Notification", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Notification", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + readAt: { + name: "readAt", + type: "DateTime", + optional: true, + originModel: "Notification" + }, + kind: { + name: "kind", + type: "NotificationType", + originModel: "Notification", + isDiscriminator: true + }, + user: { + name: "user", + type: "User", + originModel: "Notification", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "notifications", fields: ["userId"], references: ["id"] } + }, + userId: { + name: "userId", + type: "Int", + originModel: "Notification", + foreignKeyFor: [ + "user" + ] as readonly string[] + }, + taskId: { + name: "taskId", + type: "Int" + }, + assignedByUserId: { + name: "assignedByUserId", + type: "Int" + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + StatusChangeNotification: { + name: "StatusChangeNotification", + baseModel: "Notification", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Notification", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + readAt: { + name: "readAt", + type: "DateTime", + optional: true, + originModel: "Notification" + }, + kind: { + name: "kind", + type: "NotificationType", + originModel: "Notification", + isDiscriminator: true + }, + user: { + name: "user", + type: "User", + originModel: "Notification", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "notifications", fields: ["userId"], references: ["id"] } + }, + userId: { + name: "userId", + type: "Int", + originModel: "Notification", + foreignKeyFor: [ + "user" + ] as readonly string[] + }, + taskId: { + name: "taskId", + type: "Int" + }, + fromStatus: { + name: "fromStatus", + type: "String" + }, + toStatus: { + name: "toStatus", + type: "String" + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + CommentNotification: { + name: "CommentNotification", + baseModel: "Notification", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Notification", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + readAt: { + name: "readAt", + type: "DateTime", + optional: true, + originModel: "Notification" + }, + kind: { + name: "kind", + type: "NotificationType", + originModel: "Notification", + isDiscriminator: true + }, + user: { + name: "user", + type: "User", + originModel: "Notification", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "notifications", fields: ["userId"], references: ["id"] } + }, + userId: { + name: "userId", + type: "Int", + originModel: "Notification", + foreignKeyFor: [ + "user" + ] as readonly string[] + }, + commentId: { + name: "commentId", + type: "Int" + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + ReviewRequestNotification: { + name: "ReviewRequestNotification", + baseModel: "Notification", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Notification", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + readAt: { + name: "readAt", + type: "DateTime", + optional: true, + originModel: "Notification" + }, + kind: { + name: "kind", + type: "NotificationType", + originModel: "Notification", + isDiscriminator: true + }, + user: { + name: "user", + type: "User", + originModel: "Notification", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "notifications", fields: ["userId"], references: ["id"] } + }, + userId: { + name: "userId", + type: "Int", + originModel: "Notification", + foreignKeyFor: [ + "user" + ] as readonly string[] + }, + reviewId: { + name: "reviewId", + type: "Int" + }, + requestedByUserId: { + name: "requestedByUserId", + type: "Int" + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + ApprovalNotification: { + name: "ApprovalNotification", + baseModel: "Notification", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Notification", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + readAt: { + name: "readAt", + type: "DateTime", + optional: true, + originModel: "Notification" + }, + kind: { + name: "kind", + type: "NotificationType", + originModel: "Notification", + isDiscriminator: true + }, + user: { + name: "user", + type: "User", + originModel: "Notification", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "notifications", fields: ["userId"], references: ["id"] } + }, + userId: { + name: "userId", + type: "Int", + originModel: "Notification", + foreignKeyFor: [ + "user" + ] as readonly string[] + }, + targetType: { + name: "targetType", + type: "String" + }, + targetId: { + name: "targetId", + type: "Int" + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + ActivityLogEntry: { + name: "ActivityLogEntry", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + action: { + name: "action", + type: "String" + }, + meta: { + name: "meta", + type: "String", + optional: true + }, + organization: { + name: "organization", + type: "Organization", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("organizationId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "activityLog", fields: ["organizationId"], references: ["id"] } + }, + organizationId: { + name: "organizationId", + type: "Int", + optional: true, + foreignKeyFor: [ + "organization" + ] as readonly string[] + }, + user: { + name: "user", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("userId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "activityLog", fields: ["userId"], references: ["id"] } + }, + userId: { + name: "userId", + type: "Int", + foreignKeyFor: [ + "user" + ] as readonly string[] + }, + task: { + name: "task", + type: "Task", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("taskId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "activityLog", fields: ["taskId"], references: ["id"] } + }, + taskId: { + name: "taskId", + type: "Int", + optional: true, + foreignKeyFor: [ + "task" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + AuditLog: { + name: "AuditLog", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + entityType: { + name: "entityType", + type: "String" + }, + entityId: { + name: "entityId", + type: "Int" + }, + action: { + name: "action", + type: "String" + }, + diff: { + name: "diff", + type: "String", + optional: true + }, + organization: { + name: "organization", + type: "Organization", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("organizationId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "auditLogs", fields: ["organizationId"], references: ["id"] } + }, + organizationId: { + name: "organizationId", + type: "Int", + foreignKeyFor: [ + "organization" + ] as readonly string[] + }, + actor: { + name: "actor", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("actorId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "auditLogs", fields: ["actorId"], references: ["id"] } + }, + actorId: { + name: "actorId", + type: "Int", + foreignKeyFor: [ + "actor" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + CustomFieldDefinition: { + name: "CustomFieldDefinition", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + name: { + name: "name", + type: "String" + }, + fieldType: { + name: "fieldType", + type: "String" + }, + required: { + name: "required", + type: "Boolean", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal(false) }] }] as readonly AttributeApplication[], + default: false as FieldDefault + }, + defaultValue: { + name: "defaultValue", + type: "String", + optional: true + }, + organization: { + name: "organization", + type: "Organization", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("organizationId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "customFields", fields: ["organizationId"], references: ["id"] } + }, + organizationId: { + name: "organizationId", + type: "Int", + foreignKeyFor: [ + "organization" + ] as readonly string[] + }, + values: { + name: "values", + type: "CustomFieldValue", + array: true, + relation: { opposite: "field" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + CustomFieldValue: { + name: "CustomFieldValue", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + value: { + name: "value", + type: "String" + }, + field: { + name: "field", + type: "CustomFieldDefinition", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("fieldId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "values", fields: ["fieldId"], references: ["id"] } + }, + fieldId: { + name: "fieldId", + type: "Int", + foreignKeyFor: [ + "field" + ] as readonly string[] + }, + project: { + name: "project", + type: "Project", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("projectId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "customFieldValues", fields: ["projectId"], references: ["id"] } + }, + projectId: { + name: "projectId", + type: "Int", + optional: true, + foreignKeyFor: [ + "project" + ] as readonly string[] + }, + task: { + name: "task", + type: "Task", + optional: true, + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("taskId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "customFieldValues", fields: ["taskId"], references: ["id"] } + }, + taskId: { + name: "taskId", + type: "Int", + optional: true, + foreignKeyFor: [ + "task" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Integration: { + name: "Integration", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + provider: { + name: "provider", + type: "String" + }, + accessToken: { + name: "accessToken", + type: "String", + optional: true + }, + config: { + name: "config", + type: "String", + optional: true + }, + organization: { + name: "organization", + type: "Organization", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("organizationId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "integrations", fields: ["organizationId"], references: ["id"] } + }, + organizationId: { + name: "organizationId", + type: "Int", + foreignKeyFor: [ + "organization" + ] as readonly string[] + }, + links: { + name: "links", + type: "IntegrationLink", + array: true, + relation: { opposite: "integration" } + } + }, + attributes: [ + { name: "@@unique", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("organizationId"), ExpressionUtils.field("provider")]) }] } + ] as readonly AttributeApplication[], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + organizationId_provider: { organizationId: { type: "Int" }, provider: { type: "String" } } + } + }, + IntegrationLink: { + name: "IntegrationLink", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + externalId: { + name: "externalId", + type: "String" + }, + url: { + name: "url", + type: "String", + optional: true + }, + integration: { + name: "integration", + type: "Integration", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("integrationId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "links", fields: ["integrationId"], references: ["id"] } + }, + integrationId: { + name: "integrationId", + type: "Int", + foreignKeyFor: [ + "integration" + ] as readonly string[] + }, + project: { + name: "project", + type: "Project", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("projectId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "integrationLinks", fields: ["projectId"], references: ["id"] } + }, + projectId: { + name: "projectId", + type: "Int", + foreignKeyFor: [ + "project" + ] as readonly string[] + } + }, + attributes: [ + { name: "@@unique", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("integrationId"), ExpressionUtils.field("externalId")]) }] } + ] as readonly AttributeApplication[], + idFields: ["id"], + uniqueFields: { + id: { type: "Int" }, + integrationId_externalId: { integrationId: { type: "Int" }, externalId: { type: "String" } } + } + } + } as const; + enums = { + UserRole: { + name: "UserRole", + values: { + SUPER_ADMIN: "SUPER_ADMIN", + ADMIN: "ADMIN", + MANAGER: "MANAGER", + MEMBER: "MEMBER", + GUEST: "GUEST" + } + }, + ProjectStatus: { + name: "ProjectStatus", + values: { + DRAFT: "DRAFT", + ACTIVE: "ACTIVE", + ARCHIVED: "ARCHIVED", + DELETED: "DELETED" + } + }, + TaskStatus: { + name: "TaskStatus", + values: { + BACKLOG: "BACKLOG", + TODO: "TODO", + IN_PROGRESS: "IN_PROGRESS", + IN_REVIEW: "IN_REVIEW", + DONE: "DONE", + CANCELLED: "CANCELLED" + } + }, + TaskPriority: { + name: "TaskPriority", + values: { + CRITICAL: "CRITICAL", + HIGH: "HIGH", + MEDIUM: "MEDIUM", + LOW: "LOW" + } + }, + CommentKind: { + name: "CommentKind", + values: { + TEXT: "TEXT", + CODE_SNIPPET: "CODE_SNIPPET", + ATTACHMENT: "ATTACHMENT" + } + }, + NotificationType: { + name: "NotificationType", + values: { + MENTION: "MENTION", + ASSIGNMENT: "ASSIGNMENT", + STATUS_CHANGE: "STATUS_CHANGE", + COMMENT: "COMMENT", + REVIEW_REQUEST: "REVIEW_REQUEST", + APPROVAL_NEEDED: "APPROVAL_NEEDED" + } + }, + ReviewDecision: { + name: "ReviewDecision", + values: { + PENDING: "PENDING", + APPROVED: "APPROVED", + REJECTED: "REJECTED", + CHANGES_REQUESTED: "CHANGES_REQUESTED" + } + }, + InvoiceStatus: { + name: "InvoiceStatus", + values: { + DRAFT: "DRAFT", + SENT: "SENT", + PAID: "PAID", + OVERDUE: "OVERDUE", + CANCELLED: "CANCELLED" + } + }, + PaymentMethod: { + name: "PaymentMethod", + values: { + CREDIT_CARD: "CREDIT_CARD", + BANK_TRANSFER: "BANK_TRANSFER", + PAYPAL: "PAYPAL", + CRYPTO: "CRYPTO" + } + } + } as const; + authType = "User" as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/e2e/performance/tsc-torture/zenstack/schema.zmodel b/tests/e2e/performance/tsc-torture/zenstack/schema.zmodel new file mode 100644 index 000000000..220d90ae5 --- /dev/null +++ b/tests/e2e/performance/tsc-torture/zenstack/schema.zmodel @@ -0,0 +1,614 @@ +// ZenStack v3 torture test schema — 30+ models with complex relations + +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +// ─── Enums ─────────────────────────────────────────────────────────────────── + +enum UserRole { + SUPER_ADMIN + ADMIN + MANAGER + MEMBER + GUEST +} + +enum ProjectStatus { + DRAFT + ACTIVE + ARCHIVED + DELETED +} + +enum TaskStatus { + BACKLOG + TODO + IN_PROGRESS + IN_REVIEW + DONE + CANCELLED +} + +enum TaskPriority { + CRITICAL + HIGH + MEDIUM + LOW +} + +enum CommentKind { + TEXT + CODE_SNIPPET + ATTACHMENT +} + +enum NotificationType { + MENTION + ASSIGNMENT + STATUS_CHANGE + COMMENT + REVIEW_REQUEST + APPROVAL_NEEDED +} + +enum ReviewDecision { + PENDING + APPROVED + REJECTED + CHANGES_REQUESTED +} + +enum InvoiceStatus { + DRAFT + SENT + PAID + OVERDUE + CANCELLED +} + +enum PaymentMethod { + CREDIT_CARD + BANK_TRANSFER + PAYPAL + CRYPTO +} + +// ─── Core identity & org models ────────────────────────────────────────────── + +model Organization { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + slug String @unique + logoUrl String? + website String? + + teams Team[] + members OrganizationMember[] + projects Project[] + billingInfo BillingInfo? + auditLogs AuditLog[] + activityLog ActivityLogEntry[] + integrations Integration[] + customFields CustomFieldDefinition[] +} + +model BillingInfo { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + planName String + billingEmail String + paymentMethod PaymentMethod + stripeCustomerId String? + + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId Int @unique + invoices Invoice[] +} + +model Invoice { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + number String @unique + amountCents Int + currency String @default("USD") + status InvoiceStatus @default(DRAFT) + dueDate DateTime + paidAt DateTime? + + billingInfo BillingInfo @relation(fields: [billingInfoId], references: [id]) + billingInfoId Int + lineItems InvoiceLineItem[] +} + +model InvoiceLineItem { + id Int @id @default(autoincrement()) + description String + quantity Int @default(1) + unitCents Int + + invoice Invoice @relation(fields: [invoiceId], references: [id]) + invoiceId Int +} + +model User { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique + username String @unique + displayName String + avatarUrl String? + timezone String @default("UTC") + locale String @default("en") + + orgMemberships OrganizationMember[] + teamMemberships TeamMember[] + ownedProjects Project[] @relation("ProjectOwner") + assignedTasks Task[] @relation("TaskAssignee") + reportedTasks Task[] @relation("TaskReporter") + comments Comment[] + notifications Notification[] + reviews Review[] + auditLogs AuditLog[] + userPreferences UserPreferences? + activityLog ActivityLogEntry[] + timeEntries TimeEntry[] + apiTokens ApiToken[] +} + +model UserPreferences { + id Int @id @default(autoincrement()) + emailNotifications Boolean @default(true) + slackNotifications Boolean @default(false) + theme String @default("light") + defaultProjectId Int? + + user User @relation(fields: [userId], references: [id]) + userId Int @unique +} + +model ApiToken { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + lastUsedAt DateTime? + name String + tokenHash String @unique + expiresAt DateTime? + + user User @relation(fields: [userId], references: [id]) + userId Int +} + +model OrganizationMember { + id Int @id @default(autoincrement()) + joinedAt DateTime @default(now()) + role UserRole @default(MEMBER) + + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId Int + user User @relation(fields: [userId], references: [id]) + userId Int + + @@unique([organizationId, userId]) +} + +// ─── Teams ─────────────────────────────────────────────────────────────────── + +model Team { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + description String? + color String? + + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId Int + members TeamMember[] + projects ProjectTeamAssignment[] +} + +model TeamMember { + id Int @id @default(autoincrement()) + joinedAt DateTime @default(now()) + role UserRole @default(MEMBER) + + team Team @relation(fields: [teamId], references: [id]) + teamId Int + user User @relation(fields: [userId], references: [id]) + userId Int + + @@unique([teamId, userId]) +} + +// ─── Projects ──────────────────────────────────────────────────────────────── + +model Project { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + slug String + description String? + status ProjectStatus @default(ACTIVE) + startDate DateTime? + endDate DateTime? + budget Int? + + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId Int + owner User @relation("ProjectOwner", fields: [ownerId], references: [id]) + ownerId Int + teamAssignments ProjectTeamAssignment[] + milestones Milestone[] + tasks Task[] + labels Label[] + sprints Sprint[] + documents Document[] + customFieldValues CustomFieldValue[] + integrationLinks IntegrationLink[] + + @@unique([organizationId, slug]) +} + +model ProjectTeamAssignment { + id Int @id @default(autoincrement()) + assignedAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id]) + projectId Int + team Team @relation(fields: [teamId], references: [id]) + teamId Int + + @@unique([projectId, teamId]) +} + +model Milestone { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + description String? + dueDate DateTime? + completedAt DateTime? + + project Project @relation(fields: [projectId], references: [id]) + projectId Int + tasks Task[] +} + +model Sprint { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + goal String? + startDate DateTime + endDate DateTime + closedAt DateTime? + + project Project @relation(fields: [projectId], references: [id]) + projectId Int + tasks Task[] +} + +// ─── Tasks ─────────────────────────────────────────────────────────────────── + +model Task { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String + description String? + status TaskStatus @default(BACKLOG) + priority TaskPriority @default(MEDIUM) + storyPoints Int? + dueDate DateTime? + completedAt DateTime? + position Int @default(0) + + project Project @relation(fields: [projectId], references: [id]) + projectId Int + assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id]) + assigneeId Int? + reporter User @relation("TaskReporter", fields: [reporterId], references: [id]) + reporterId Int + milestone Milestone? @relation(fields: [milestoneId], references: [id]) + milestoneId Int? + sprint Sprint? @relation(fields: [sprintId], references: [id]) + sprintId Int? + parent Task? @relation("TaskSubtasks", fields: [parentId], references: [id]) + parentId Int? + subtasks Task[] @relation("TaskSubtasks") + labels TaskLabel[] + comments Comment[] + attachments Attachment[] + reviews Review[] + timeEntries TimeEntry[] + customFieldValues CustomFieldValue[] + activityLog ActivityLogEntry[] +} + +model Label { + id Int @id @default(autoincrement()) + name String + color String @default("#888888") + description String? + + project Project @relation(fields: [projectId], references: [id]) + projectId Int + tasks TaskLabel[] + + @@unique([projectId, name]) +} + +model TaskLabel { + id Int @id @default(autoincrement()) + appliedAt DateTime @default(now()) + + task Task @relation(fields: [taskId], references: [id]) + taskId Int + label Label @relation(fields: [labelId], references: [id]) + labelId Int + + @@unique([taskId, labelId]) +} + +// ─── Comments & Reviews ────────────────────────────────────────────────────── + +model Comment { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + body String + kind CommentKind + edited Boolean @default(false) + resolved Boolean @default(false) + + task Task @relation(fields: [taskId], references: [id]) + taskId Int + author User @relation(fields: [authorId], references: [id]) + authorId Int + parent Comment? @relation("CommentReplies", fields: [parentId], references: [id]) + parentId Int? + replies Comment[] @relation("CommentReplies") + reactions CommentReaction[] + + @@delegate(kind) +} + +model TextComment extends Comment { + // plain-text comment — no extra fields beyond base +} + +model CodeSnippetComment extends Comment { + language String @default("plaintext") +} + +// AttachmentComment stores file metadata inline rather than via a separate Attachment join +model AttachmentComment extends Comment { + attachFilename String + attachMimeType String + attachSizeBytes Int + attachStorageKey String +} + +model CommentReaction { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + emoji String + + comment Comment @relation(fields: [commentId], references: [id]) + commentId Int +} + +model Review { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + decision ReviewDecision @default(PENDING) + summary String? + + task Task @relation(fields: [taskId], references: [id]) + taskId Int + reviewer User @relation(fields: [reviewerId], references: [id]) + reviewerId Int + comments ReviewComment[] +} + +model ReviewComment { + id Int @id @default(autoincrement()) + body String + lineRef String? + + review Review @relation(fields: [reviewId], references: [id]) + reviewId Int +} + +// ─── Files & Documents ─────────────────────────────────────────────────────── + +model Attachment { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + filename String + mimeType String + sizeBytes Int + storageKey String + + task Task? @relation(fields: [taskId], references: [id]) + taskId Int? +} + +model Document { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String + content String? + published Boolean @default(false) + + project Project @relation(fields: [projectId], references: [id]) + projectId Int + sections DocumentSection[] +} + +model DocumentSection { + id Int @id @default(autoincrement()) + order Int + heading String + content String? + + document Document @relation(fields: [documentId], references: [id]) + documentId Int +} + +// ─── Time tracking ─────────────────────────────────────────────────────────── + +model TimeEntry { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + startedAt DateTime + stoppedAt DateTime? + durationMin Int? + note String? + + task Task @relation(fields: [taskId], references: [id]) + taskId Int + user User @relation(fields: [userId], references: [id]) + userId Int +} + +// ─── Notifications & Activity ──────────────────────────────────────────────── + +model Notification { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + readAt DateTime? + kind NotificationType + + user User @relation(fields: [userId], references: [id]) + userId Int + + @@delegate(kind) +} + +model MentionNotification extends Notification { + mentionedByUserId Int + taskId Int +} + +model AssignmentNotification extends Notification { + taskId Int + assignedByUserId Int +} + +model StatusChangeNotification extends Notification { + taskId Int + fromStatus String + toStatus String +} + +model CommentNotification extends Notification { + commentId Int +} + +model ReviewRequestNotification extends Notification { + reviewId Int + requestedByUserId Int +} + +model ApprovalNotification extends Notification { + targetType String + targetId Int +} + +model ActivityLogEntry { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + action String + meta String? // JSON blob + + organization Organization? @relation(fields: [organizationId], references: [id]) + organizationId Int? + user User @relation(fields: [userId], references: [id]) + userId Int + task Task? @relation(fields: [taskId], references: [id]) + taskId Int? +} + +model AuditLog { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + entityType String + entityId Int + action String + diff String? // JSON blob + + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId Int + actor User @relation(fields: [actorId], references: [id]) + actorId Int +} + +// ─── Custom fields ─────────────────────────────────────────────────────────── + +model CustomFieldDefinition { + id Int @id @default(autoincrement()) + name String + fieldType String // "text"|"number"|"date"|"select" + required Boolean @default(false) + defaultValue String? + + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId Int + values CustomFieldValue[] +} + +model CustomFieldValue { + id Int @id @default(autoincrement()) + value String + + field CustomFieldDefinition @relation(fields: [fieldId], references: [id]) + fieldId Int + project Project? @relation(fields: [projectId], references: [id]) + projectId Int? + task Task? @relation(fields: [taskId], references: [id]) + taskId Int? +} + +// ─── Integrations ──────────────────────────────────────────────────────────── + +model Integration { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + provider String // "github"|"slack"|"jira" + accessToken String? + config String? // JSON blob + + organization Organization @relation(fields: [organizationId], references: [id]) + organizationId Int + links IntegrationLink[] + + @@unique([organizationId, provider]) +} + +model IntegrationLink { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + externalId String + url String? + + integration Integration @relation(fields: [integrationId], references: [id]) + integrationId Int + project Project @relation(fields: [projectId], references: [id]) + projectId Int + + @@unique([integrationId, externalId]) +} diff --git a/tests/regression/package.json b/tests/regression/package.json index e74dd7a31..2d5ade929 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.6.4", + "version": "3.7.0", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/test/issue-2567/input.ts b/tests/regression/test/issue-2567/input.ts new file mode 100644 index 000000000..2478a0599 --- /dev/null +++ b/tests/regression/test/issue-2567/input.ts @@ -0,0 +1,60 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaType as $Schema } from "./schema"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, UncheckedCreateInput as $UncheckedCreateInput, CheckedCreateInput as $CheckedCreateInput, UncheckedUpdateInput as $UncheckedUpdateInput, CheckedUpdateInput as $CheckedUpdateInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; +import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; +export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; +export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; +export type UserFindFirstArgs = $FindFirstArgs<$Schema, "User">; +export type UserExistsArgs = $ExistsArgs<$Schema, "User">; +export type UserCreateArgs = $CreateArgs<$Schema, "User">; +export type UserCreateManyArgs = $CreateManyArgs<$Schema, "User">; +export type UserCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "User">; +export type UserUpdateArgs = $UpdateArgs<$Schema, "User">; +export type UserUpdateManyArgs = $UpdateManyArgs<$Schema, "User">; +export type UserUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "User">; +export type UserUpsertArgs = $UpsertArgs<$Schema, "User">; +export type UserDeleteArgs = $DeleteArgs<$Schema, "User">; +export type UserDeleteManyArgs = $DeleteManyArgs<$Schema, "User">; +export type UserCountArgs = $CountArgs<$Schema, "User">; +export type UserAggregateArgs = $AggregateArgs<$Schema, "User">; +export type UserGroupByArgs = $GroupByArgs<$Schema, "User">; +export type UserWhereInput = $WhereInput<$Schema, "User">; +export type UserSelect = $SelectInput<$Schema, "User">; +export type UserInclude = $IncludeInput<$Schema, "User">; +export type UserOmit = $OmitInput<$Schema, "User">; +export type UserUncheckedCreateInput = $UncheckedCreateInput<$Schema, "User">; +export type UserCheckedCreateInput = $CheckedCreateInput<$Schema, "User">; +export type UserUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "User">; +export type UserCheckedUpdateInput = $CheckedUpdateInput<$Schema, "User">; +export type UserGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "User", Args, Options>; +export type PostFindManyArgs = $FindManyArgs<$Schema, "Post">; +export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; +export type PostFindFirstArgs = $FindFirstArgs<$Schema, "Post">; +export type PostExistsArgs = $ExistsArgs<$Schema, "Post">; +export type PostCreateArgs = $CreateArgs<$Schema, "Post">; +export type PostCreateManyArgs = $CreateManyArgs<$Schema, "Post">; +export type PostCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Post">; +export type PostUpdateArgs = $UpdateArgs<$Schema, "Post">; +export type PostUpdateManyArgs = $UpdateManyArgs<$Schema, "Post">; +export type PostUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Post">; +export type PostUpsertArgs = $UpsertArgs<$Schema, "Post">; +export type PostDeleteArgs = $DeleteArgs<$Schema, "Post">; +export type PostDeleteManyArgs = $DeleteManyArgs<$Schema, "Post">; +export type PostCountArgs = $CountArgs<$Schema, "Post">; +export type PostAggregateArgs = $AggregateArgs<$Schema, "Post">; +export type PostGroupByArgs = $GroupByArgs<$Schema, "Post">; +export type PostWhereInput = $WhereInput<$Schema, "Post">; +export type PostSelect = $SelectInput<$Schema, "Post">; +export type PostInclude = $IncludeInput<$Schema, "Post">; +export type PostOmit = $OmitInput<$Schema, "Post">; +export type PostUncheckedCreateInput = $UncheckedCreateInput<$Schema, "Post">; +export type PostCheckedCreateInput = $CheckedCreateInput<$Schema, "Post">; +export type PostUncheckedUpdateInput = $UncheckedUpdateInput<$Schema, "Post">; +export type PostCheckedUpdateInput = $CheckedUpdateInput<$Schema, "Post">; +export type PostGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Post", Args, Options>; diff --git a/tests/regression/test/issue-2567/models.ts b/tests/regression/test/issue-2567/models.ts new file mode 100644 index 000000000..03524da52 --- /dev/null +++ b/tests/regression/test/issue-2567/models.ts @@ -0,0 +1,11 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaType as $Schema } from "./schema"; +import type { ModelResult as $ModelResult } from "@zenstackhq/orm"; +export type User = $ModelResult<$Schema, "User">; +export type Post = $ModelResult<$Schema, "Post">; diff --git a/tests/regression/test/issue-2567/regression.test.ts b/tests/regression/test/issue-2567/regression.test.ts new file mode 100644 index 000000000..8577b36dc --- /dev/null +++ b/tests/regression/test/issue-2567/regression.test.ts @@ -0,0 +1,36 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; +import { schema } from './schema'; +import type { PostUncheckedCreateInput } from './input'; + +// https://github.com/zenstackhq/zenstack/issues/2567 +describe('Regression for issue #2567', () => { + it('Partial can be spread alongside an explicit FK', async () => { + const db = await createTestClient(schema); + const user = await db.user.create({ data: { email: 'user@example.com' } }); + + // Using PostUncheckedCreateInput (FK-only) for the partial type is the correct + // pattern — mirrors Prisma's UncheckedCreateInput. No type error. + async function buildPost(data: Partial = {}) { + return db.post.create({ + data: { + title: 'Test Post', + content: 'Test Content', + authorId: user.id, + ...data, + }, + }); + } + + const post = await buildPost(); + expect(post.title).toBe('Test Post'); + expect(post.authorId).toBe(user.id); + + const customPost = await buildPost({ title: 'Custom title' }); + expect(customPost.title).toBe('Custom title'); + + const user2 = await db.user.create({ data: { email: 'user2@example.com' } }); + const postWithUser2 = await buildPost({ authorId: user2.id }); + expect(postWithUser2.authorId).toBe(user2.id); + }); +}); diff --git a/tests/regression/test/issue-2567/schema.ts b/tests/regression/test/issue-2567/schema.ts new file mode 100644 index 000000000..e5b54b1f8 --- /dev/null +++ b/tests/regression/test/issue-2567/schema.ts @@ -0,0 +1,96 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "sqlite" + } as const; + models = { + User: { + name: "User", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault + }, + email: { + name: "email", + type: "String", + unique: true, + attributes: [{ name: "@unique" }] as readonly AttributeApplication[] + }, + posts: { + name: "posts", + type: "Post", + array: true, + relation: { opposite: "author" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + email: { type: "String" } + } + }, + Post: { + name: "Post", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + title: { + name: "title", + type: "String" + }, + content: { + name: "content", + type: "String" + }, + author: { + name: "author", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("String", [ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array("String", [ExpressionUtils.field("id")]) }] }] as readonly AttributeApplication[], + relation: { opposite: "posts", fields: ["authorId"], references: ["id"] } + }, + authorId: { + name: "authorId", + type: "String", + foreignKeyFor: [ + "author" + ] as readonly string[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + authType = "User" as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/regression/test/issue-2567/schema.zmodel b/tests/regression/test/issue-2567/schema.zmodel new file mode 100644 index 000000000..741f85e97 --- /dev/null +++ b/tests/regression/test/issue-2567/schema.zmodel @@ -0,0 +1,20 @@ +datasource db { + provider = 'sqlite' + url = 'file:./dev.db' +} + +model User { + id String @id @default(cuid()) + email String @unique + posts Post[] +} + +model Post { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String + content String + author User @relation(fields: [authorId], references: [id]) + authorId String +} diff --git a/tests/regression/test/issue-2631.test.ts b/tests/regression/test/issue-2631.test.ts new file mode 100644 index 000000000..84d270be6 --- /dev/null +++ b/tests/regression/test/issue-2631.test.ts @@ -0,0 +1,50 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +// Regression for #2631: ZenStack 3.5+ replaced Prisma's permissive +// datetime input coercion with a strict zod union, breaking every caller +// that passed ISO strings to `DateTime` fields. `DateTime` inputs now +// coerce strings the JS `Date` constructor parses back to `Date`, +// mirroring Prisma's pre-3.5 behaviour. +describe('Issue 2631 — DateTime input coercion', () => { + const schema = ` +model Event { + id Int @id @default(autoincrement()) + label String + when DateTime +} + `; + + let db: any; + + beforeEach(async () => { + db = await createTestClient(schema, { usePrismaPush: true, provider: 'sqlite' }); + }); + afterEach(async () => db?.$disconnect()); + + it('accepts a Date object', async () => { + const e = await db.event.create({ data: { label: 'date', when: new Date('2024-01-15T10:30:00Z') } }); + expect(e.when).toBeInstanceOf(Date); + }); + + it('accepts an ISO datetime string and coerces to Date', async () => { + const e = await db.event.create({ data: { label: 'iso', when: '2024-01-15T10:30:00.000Z' } }); + expect(e.when).toBeInstanceOf(Date); + }); + + it('accepts an ISO date string and coerces to Date', async () => { + const e = await db.event.create({ data: { label: 'date-only', when: '2024-01-15' } }); + expect(e.when).toBeInstanceOf(Date); + }); + + it('accepts a bare time-only string anchored to the Unix epoch', async () => { + const e = await db.event.create({ data: { label: 'time-only', when: '09:30:00' } }); + expect(e.when).toBeInstanceOf(Date); + expect((e.when as Date).getUTCHours()).toBe(9); + expect((e.when as Date).getUTCMinutes()).toBe(30); + }); + + it('rejects a non-parseable string', async () => { + await expect(db.event.create({ data: { label: 'junk', when: 'not-a-date' as any } })).rejects.toThrow(); + }); +}); diff --git a/tests/regression/test/issue-2633.test.ts b/tests/regression/test/issue-2633.test.ts new file mode 100644 index 000000000..d134732e2 --- /dev/null +++ b/tests/regression/test/issue-2633.test.ts @@ -0,0 +1,63 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +// Regression for #2633: writes to `@db.Time` / `@db.Timetz` columns failed +// with PG `22007 invalid input syntax for type time` because the dialect +// serialized JS Date values as ISO datetime strings. The dialect now reads +// the field's `@db.*` attribute and formats `HH:MM:SS.fff[+ZZ:ZZ]` for TIME +// / TIMETZ columns; other DateTime columns keep the existing ISO behaviour. +describe('Issue 2633 — write to @db.Time columns', () => { + describe.each([ + { name: '@db.Time', dbType: '@db.Time(6)' }, + { name: '@db.Timetz', dbType: '@db.Timetz(6)' }, + ])('$name', ({ dbType }) => { + const schema = ` +model TradingHour { + id Int @id @default(autoincrement()) + open DateTime ${dbType} + close DateTime ${dbType} +} + `; + + let client: any; + + beforeEach(async () => { + client = await createTestClient(schema, { + usePrismaPush: true, + provider: 'postgresql', + }); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('accepts a Date for the open / close fields', async () => { + const open = new Date('1970-01-01T09:00:00.000Z'); + const close = new Date('1970-01-01T16:30:00.000Z'); + + const row = await client.tradingHour.create({ data: { open, close } }); + + expect(row.id).toBeDefined(); + }); + + it('round-trips the time-of-day via createMany', async () => { + await client.tradingHour.createMany({ + data: [ + { open: new Date('1970-01-01T09:00:00.000Z'), close: new Date('1970-01-01T16:00:00.000Z') }, + { open: new Date('1970-01-01T10:30:00.000Z'), close: new Date('1970-01-01T17:30:00.000Z') }, + ], + }); + + const rows = await client.tradingHour.findMany({ orderBy: { id: 'asc' } }); + expect(rows).toHaveLength(2); + // The application reads `tw.open` / `tw.close` as Date objects. + expect(rows[0].open).toBeInstanceOf(Date); + expect(rows[0].close).toBeInstanceOf(Date); + expect(rows[0].open.toISOString()).toBe('1970-01-01T09:00:00.000Z'); + expect(rows[0].close.toISOString()).toBe('1970-01-01T16:00:00.000Z'); + expect(rows[1].open.toISOString()).toBe('1970-01-01T10:30:00.000Z'); + expect(rows[1].close.toISOString()).toBe('1970-01-01T17:30:00.000Z'); + }); + }); +}); diff --git a/tests/regression/test/issue-2639/regression.test.ts b/tests/regression/test/issue-2639/regression.test.ts new file mode 100644 index 000000000..f90800681 --- /dev/null +++ b/tests/regression/test/issue-2639/regression.test.ts @@ -0,0 +1,38 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { createSchemaFactory } from '@zenstackhq/zod'; +import { describe, expectTypeOf, it } from 'vitest'; +import z from 'zod'; +import { schema } from './schema'; + +// https://github.com/zenstackhq/zenstack/issues/2639 +// +// The user reported that the type inferred from `factory.makeModelSchema('Test')` +// was not assignable to the ORM-inferred model type because the zod-inferred +// `metaData` field included `null` while the ORM `JsonValue` did not. + +const factory = createSchemaFactory(schema); + +describe('Regression for issue #2639', () => { + it('zod-inferred model type is assignable to the ORM model type', async () => { + const db = await createTestClient(schema); + const _schema = factory.makeModelSchema('Test'); + type ZodTest = z.infer; + type OrmTest = Awaited>; + + // Mirrors the user's reproduction: + // function testFunction(test: OrmTest) {} + // testFunction({} as ZodTest) + expectTypeOf().toExtend(); + }); + + it('zod-inferred metaData allows null and is assignable to the ORM metaData', async () => { + const db = await createTestClient(schema); + const _schema = factory.makeModelSchema('Test'); + type ZodTest = z.infer; + type OrmTest = Awaited>; + + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + expectTypeOf().toExtend(); + }); +}); diff --git a/tests/regression/test/issue-2639/schema.ts b/tests/regression/test/issue-2639/schema.ts new file mode 100644 index 000000000..8e8088b86 --- /dev/null +++ b/tests/regression/test/issue-2639/schema.ts @@ -0,0 +1,86 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "postgresql" + } as const; + models = { + Test: { + name: "Test", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("uuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("uuid") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + metaData: { + name: "metaData", + type: "Json", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("{}") }] }] as readonly AttributeApplication[], + default: "{}" as FieldDefault + }, + name: { + name: "name", + type: "String" + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + typeDefs = { + BasicFields: { + name: "BasicFields", + fields: { + id: { + name: "id", + type: "String", + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("uuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("uuid") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + metaData: { + name: "metaData", + type: "Json", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("{}") }] }] as readonly AttributeApplication[], + default: "{}" as FieldDefault + } + } + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/regression/test/issue-2639/schema.zmodel b/tests/regression/test/issue-2639/schema.zmodel new file mode 100644 index 000000000..2a8c3387d --- /dev/null +++ b/tests/regression/test/issue-2639/schema.zmodel @@ -0,0 +1,15 @@ +datasource db { + provider = 'postgresql' + url = env("DATABASE_URL") +} + +type BasicFields { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + metaData Json @default("{}") +} + +model Test with BasicFields { + name String +} diff --git a/tests/regression/test/issue-2647/regression.test.ts b/tests/regression/test/issue-2647/regression.test.ts new file mode 100644 index 000000000..a4615efe7 --- /dev/null +++ b/tests/regression/test/issue-2647/regression.test.ts @@ -0,0 +1,33 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { createSchemaFactory } from '@zenstackhq/zod'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import z from 'zod'; +import { schema } from './schema'; + +// https://github.com/zenstackhq/zenstack/issues/2647 + +const factory = createSchemaFactory(schema); + +describe('Regression for issue #2647', () => { + it('ORM-inferred type for a required Json field allows null', async () => { + const db = await createTestClient(schema); + type Test = Awaited>; + + // A required Json column can still hold a JSON `null`, so the inferred + // model type for the field must include `null`. + expectTypeOf().toExtend(); + }); + + it('zod-inferred type for a required Json field allows null', () => { + const _schema = factory.makeModelSchema('Test'); + type Test = z.infer; + + expectTypeOf().toExtend(); + }); + + it('zod schema for a required Json field parses null at runtime', () => { + const _schema = factory.makeModelSchema('Test'); + const result = _schema.safeParse({ id: 'test', metaData: null }); + expect(result.success).toBe(true); + }); +}); diff --git a/tests/regression/test/issue-2647/schema.ts b/tests/regression/test/issue-2647/schema.ts new file mode 100644 index 000000000..073812271 --- /dev/null +++ b/tests/regression/test/issue-2647/schema.ts @@ -0,0 +1,37 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "postgresql" + } as const; + models = { + Test: { + name: "Test", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("uuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("uuid") as FieldDefault + }, + metaData: { + name: "metaData", + type: "Json" + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/regression/test/issue-2647/schema.zmodel b/tests/regression/test/issue-2647/schema.zmodel new file mode 100644 index 000000000..ac5f79d0b --- /dev/null +++ b/tests/regression/test/issue-2647/schema.zmodel @@ -0,0 +1,9 @@ +datasource db { + provider = 'postgresql' + url = env("DATABASE_URL") +} + +model Test { + id String @id @default(uuid()) + metaData Json +} diff --git a/tests/regression/test/issue-2654.test.ts b/tests/regression/test/issue-2654.test.ts new file mode 100644 index 000000000..aa66e39c6 --- /dev/null +++ b/tests/regression/test/issue-2654.test.ts @@ -0,0 +1,72 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2654 +describe('Regression for issue 2654', () => { + it('handles cyclic references between JSON typedefs', async () => { + const schema = ` +type A { + name String + b B +} + +type B { + a A[] + x String +} + +model User { + id String @id @default(cuid()) + a A @json +} +`; + + const db = await createTestClient(schema, { provider: 'postgresql' }); + + const data = { + id: 'u1', + a: { name: 'abc', b: { x: '123', a: [] } }, + }; + await expect(db.user.create({ data })).resolves.toMatchObject(data); + + const nested = { + id: 'u2', + a: { + name: 'root', + b: { + x: 'b1', + a: [{ name: 'inner', b: { x: 'b2', a: [] } }], + }, + }, + }; + await expect(db.user.create({ data: nested })).resolves.toMatchObject(nested); + }); + + it('handles self-referencing JSON typedef', async () => { + const schema = ` +type Tree { + name String + children Tree[]? +} + +model Node { + id String @id @default(cuid()) + tree Tree @json +} +`; + + const db = await createTestClient(schema, { provider: 'postgresql' }); + + const data = { + id: 'n1', + tree: { + name: 'root', + children: [ + { name: 'child1', children: [] }, + { name: 'child2', children: [{ name: 'grandchild', children: [] }] }, + ], + }, + }; + await expect(db.node.create({ data })).resolves.toMatchObject(data); + }); +}); diff --git a/tests/regression/test/policy-plugin-detection.test.ts b/tests/regression/test/policy-plugin-detection.test.ts new file mode 100644 index 000000000..48ce80b7c --- /dev/null +++ b/tests/regression/test/policy-plugin-detection.test.ts @@ -0,0 +1,29 @@ +import { PolicyPlugin } from '@zenstackhq/plugin-policy'; +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('PolicyPlugin detection', () => { + it('uses plugin id when constructor names are bundled or minified', async () => { + const MinifiedPolicyPlugin = class a extends PolicyPlugin {}; + const plugin = new MinifiedPolicyPlugin(); + + expect(plugin.id).toBe('policy'); + expect(plugin.constructor.name).toBe('a'); + + const db = await createTestClient( + ` +model User { + id String @id + name String + + @@allow('all', true) +} + `, + { plugins: [plugin] }, + ); + + await db.user.create({ data: { id: 'u1', name: 'User 1' } }); + + await expect(db.user.delete({ where: { id: 'u1' } })).resolves.toEqual({ id: 'u1', name: 'User 1' }); + }); +}); diff --git a/tests/runtimes/bun/bun-e2e.test.ts b/tests/runtimes/bun/bun-e2e.test.ts index 901daaddc..e6dca5170 100644 --- a/tests/runtimes/bun/bun-e2e.test.ts +++ b/tests/runtimes/bun/bun-e2e.test.ts @@ -6,7 +6,8 @@ import { TEST_PG_URL } from '@zenstackhq/testtools'; import { Database } from 'bun:sqlite'; import { afterEach, describe, expect, it } from 'bun:test'; import type { Dialect } from 'kysely'; -import { BunSqliteDialect } from 'kysely-bun-sqlite'; +// use explicit .js import to avoid bun test loading cjs version of the module +import { BunSqliteDialect } from 'kysely-bun-sqlite/dist/index.js'; import { Client, Pool } from 'pg'; import { schema } from './schemas/schema'; diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 726e8fa27..279f4a16e 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.6.4", + "version": "3.7.0", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index a87e0322b..9b407e2b3 100644 --- a/tests/runtimes/edge-runtime/package.json +++ b/tests/runtimes/edge-runtime/package.json @@ -1,6 +1,6 @@ { "name": "edge-runtime-e2e", - "version": "3.6.4", + "version": "3.7.0", "private": true, "type": "module", "scripts": {