From 0aadb4ac7188c2910269e52d10263520c939804a Mon Sep 17 00:00:00 2001 From: sanny-io <3054653+sanny-io@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:02:48 -0700 Subject: [PATCH 1/5] feat(orm): strict types (#2792) --- packages/cli/test/ts-schema-gen.test.ts | 21 +++++++ .../fetch-client/test/fetch-client.test.ts | 4 +- .../test/schemas/basic/schema-lite.ts | 19 ++++++- .../test/schemas/basic/schema.zmodel | 8 ++- .../test/schemas/no-procs/schema.ts | 33 +++++++++++ .../fetch-client/test/typing.test-d.ts | 16 ++++++ .../test/react/react-typing.test-d.ts | 7 +++ .../test/schemas/basic/schema-lite.ts | 22 +++++++- .../test/schemas/basic/schema.zmodel | 30 ++++++---- .../test/svelte/svelte-typing-test.ts | 7 +++ .../test/vue/vue-typing-test.ts | 7 +++ packages/language/res/stdlib.zmodel | 5 ++ .../attribute-application-validator.ts | 9 +++ .../test/attribute-application.test.ts | 56 +++++++++++++++++++ packages/orm/src/client/crud-types.ts | 12 +++- packages/orm/src/client/zod/factory.ts | 3 +- packages/schema/src/schema.ts | 1 + packages/sdk/src/ts-schema-generator.ts | 4 ++ tests/e2e/orm/client-api/procedures.test.ts | 28 ++++++++++ .../orm/client-api/typed-json-fields.test.ts | 34 +++++++++++ tests/e2e/orm/schemas/procedures/schema.ts | 27 +++++++++ .../e2e/orm/schemas/procedures/schema.zmodel | 8 +++ 22 files changed, 341 insertions(+), 20 deletions(-) create mode 100644 packages/clients/fetch-client/test/schemas/no-procs/schema.ts diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index 38a0e5cc6..f1abf7a92 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -736,4 +736,25 @@ model Post { plugins: {}, }); }); + + it('supports @@strict for type defs', async () => { + const { schema } = await generateTsSchema(` +model User { + id String @id @default(uuid()) + profile Profile? @json +} + +type Profile { + bio String + + @@strict +} + `); + + expect(schema.typeDefs).toMatchObject({ + Profile: { + strict: true, + }, + }); + }); }); diff --git a/packages/clients/fetch-client/test/fetch-client.test.ts b/packages/clients/fetch-client/test/fetch-client.test.ts index 0d155c53a..14e86a805 100644 --- a/packages/clients/fetch-client/test/fetch-client.test.ts +++ b/packages/clients/fetch-client/test/fetch-client.test.ts @@ -481,7 +481,9 @@ describe('createClient', () => { 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 result = await (client as any).$procs.sendNotification.mutate({ + args: { notification: { message: 'hello' } }, + }); const [url, init] = mockFetch.mock.calls[0] ?? []; expect(url).toBe(`${ENDPOINT}/$procs/sendNotification`); diff --git a/packages/clients/fetch-client/test/schemas/basic/schema-lite.ts b/packages/clients/fetch-client/test/schemas/basic/schema-lite.ts index 39cbdc765..822e88692 100644 --- a/packages/clients/fetch-client/test/schemas/basic/schema-lite.ts +++ b/packages/clients/fetch-client/test/schemas/basic/schema-lite.ts @@ -5,7 +5,7 @@ /* eslint-disable */ -import { type SchemaDef, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; export class SchemaType implements SchemaDef { provider = { type: "sqlite" @@ -77,6 +77,21 @@ export class SchemaType implements SchemaDef { } } } as const; + typeDefs = { + Notification: { + name: "Notification", + fields: { + message: { + name: "message", + type: "String" + } + }, + attributes: [ + { name: "@@strict" } + ] as readonly AttributeApplication[], + strict: true + } + } as const; authType = "User" as const; procedures = { getStats: { @@ -85,7 +100,7 @@ export class SchemaType implements SchemaDef { }, sendNotification: { params: { - message: { name: "message", type: "String" } + notification: { name: "notification", type: "Notification" } }, returnType: "Boolean", mutation: true diff --git a/packages/clients/fetch-client/test/schemas/basic/schema.zmodel b/packages/clients/fetch-client/test/schemas/basic/schema.zmodel index 677819fe7..001c6795e 100644 --- a/packages/clients/fetch-client/test/schemas/basic/schema.zmodel +++ b/packages/clients/fetch-client/test/schemas/basic/schema.zmodel @@ -16,6 +16,12 @@ model Post { authorId String? } +type Notification { + message String + + @@strict +} + procedure getStats(): Int -mutation procedure sendNotification(message: String): Boolean +mutation procedure sendNotification(notification: Notification): Boolean diff --git a/packages/clients/fetch-client/test/schemas/no-procs/schema.ts b/packages/clients/fetch-client/test/schemas/no-procs/schema.ts new file mode 100644 index 000000000..7d64958c8 --- /dev/null +++ b/packages/clients/fetch-client/test/schemas/no-procs/schema.ts @@ -0,0 +1,33 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// 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 = { + Item: { + name: "Item", + 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 + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/packages/clients/fetch-client/test/typing.test-d.ts b/packages/clients/fetch-client/test/typing.test-d.ts index 882bc0908..1df90827f 100644 --- a/packages/clients/fetch-client/test/typing.test-d.ts +++ b/packages/clients/fetch-client/test/typing.test-d.ts @@ -217,3 +217,19 @@ describe('Extended result fields (ExtResult)', () => { }; }); }); + +describe('Custom types', () => { + it('supports @@strict', () => { + const client = createClient(schema, { endpoint: ENDPOINT }); + + client.$procs.sendNotification.mutate({ + args: { + notification: { + message: 'test', + // @ts-expect-error known properties + unknown: true, + }, + }, + }); + }); +}); diff --git a/packages/clients/tanstack-query/test/react/react-typing.test-d.ts b/packages/clients/tanstack-query/test/react/react-typing.test-d.ts index 6008ad878..b6538017a 100644 --- a/packages/clients/tanstack-query/test/react/react-typing.test-d.ts +++ b/packages/clients/tanstack-query/test/react/react-typing.test-d.ts @@ -127,6 +127,13 @@ describe('React client typing test', () => { client.foo.useUpdate(); client.bar.useCreate(); + + client.user.useCreate().mutate({ data: { email: 'test@example.com', profile: { bio: 'Programmer' } } }); + + client.user + .useCreate() + // @ts-expect-error known properties + .mutate({ data: { email: 'test@example.com', profile: { bio: 'Programmer', unknown: true } } }); }); it('reflects ExtQueryArgs and ExtResult inferred from a ClientContract type', () => { diff --git a/packages/clients/tanstack-query/test/schemas/basic/schema-lite.ts b/packages/clients/tanstack-query/test/schemas/basic/schema-lite.ts index 4ea2da51e..1f88ef758 100644 --- a/packages/clients/tanstack-query/test/schemas/basic/schema-lite.ts +++ b/packages/clients/tanstack-query/test/schemas/basic/schema-lite.ts @@ -5,7 +5,7 @@ /* eslint-disable */ -import { type SchemaDef, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; export class SchemaType implements SchemaDef { provider = { type: "sqlite" @@ -35,6 +35,11 @@ export class SchemaType implements SchemaDef { type: "Post", array: true, relation: { opposite: "owner" } + }, + profile: { + name: "profile", + type: "Profile", + optional: true } }, idFields: ["id"], @@ -166,6 +171,21 @@ export class SchemaType implements SchemaDef { } } } as const; + typeDefs = { + Profile: { + name: "Profile", + fields: { + bio: { + name: "bio", + type: "String" + } + }, + attributes: [ + { name: "@@strict" } + ] as readonly AttributeApplication[], + strict: true + } + } as const; authType = "User" as const; plugins = {}; } diff --git a/packages/clients/tanstack-query/test/schemas/basic/schema.zmodel b/packages/clients/tanstack-query/test/schemas/basic/schema.zmodel index d274e95c5..3e4aeb1d6 100644 --- a/packages/clients/tanstack-query/test/schemas/basic/schema.zmodel +++ b/packages/clients/tanstack-query/test/schemas/basic/schema.zmodel @@ -3,29 +3,30 @@ datasource db { } model User { - id String @id @default(cuid()) - email String @unique - name String? - posts Post[] + id String @id @default(cuid()) + email String @unique + name String? + posts Post[] + profile Profile? @json } model Post { - id String @id @default(cuid()) - title String - owner User? @relation(fields: [ownerId], references: [id]) - ownerId String? - category Category? @relation(fields: [categoryId], references: [id]) + id String @id @default(cuid()) + title String + owner User? @relation(fields: [ownerId], references: [id]) + ownerId String? + category Category? @relation(fields: [categoryId], references: [id]) categoryId String? } model Category { - id String @id @default(cuid()) - name String @unique + id String @id @default(cuid()) + name String @unique posts Post[] } model Foo { - id String @id @default(cuid()) + id String @id @default(cuid()) type String @@delegate(type) } @@ -33,3 +34,8 @@ model Foo { model Bar extends Foo { title String } + +type Profile { + bio String + @@strict +} diff --git a/packages/clients/tanstack-query/test/svelte/svelte-typing-test.ts b/packages/clients/tanstack-query/test/svelte/svelte-typing-test.ts index a2c83887b..9ff48e578 100644 --- a/packages/clients/tanstack-query/test/svelte/svelte-typing-test.ts +++ b/packages/clients/tanstack-query/test/svelte/svelte-typing-test.ts @@ -68,6 +68,13 @@ client.user data: { email: 'test@example.com' }, }); +client.user.useCreate().mutate({ data: { email: 'test@example.com', profile: { bio: 'Programmer' } } }); + +client.user + .useCreate() + // @ts-expect-error known properties + .mutate({ data: { email: 'test@example.com', profile: { bio: 'Programmer', unknown: true } } }); + client.user .useCreate() .mutateAsync({ data: { email: 'test@example.com' }, include: { posts: true } }) diff --git a/packages/clients/tanstack-query/test/vue/vue-typing-test.ts b/packages/clients/tanstack-query/test/vue/vue-typing-test.ts index e72f90445..ec118eda5 100644 --- a/packages/clients/tanstack-query/test/vue/vue-typing-test.ts +++ b/packages/clients/tanstack-query/test/vue/vue-typing-test.ts @@ -66,6 +66,13 @@ client.user .mutateAsync({ data: { email: 'test@example.com' }, include: { posts: true } }) .then((d) => check(d.posts[0]?.title)); +client.user.useCreate().mutate({ data: { email: 'test@example.com', profile: { bio: 'Programmer' } } }); + +client.user + .useCreate() + // @ts-expect-error known properties + .mutate({ data: { email: 'test@example.com', profile: { bio: 'Programmer', unknown: true } } }); + client.user .useCreateMany() .mutateAsync({ diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index aa62891de..4d9ba36c2 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -729,3 +729,8 @@ attribute @meta(_ name: String, _ value: Any) * Marks an attribute as deprecated. */ attribute @@@deprecated(_ message: String) + +/** + * Indicates a type def should reject unknown fields. + */ +attribute @@strict() @@@once @@@validation diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index d9568e9db..daaaf7b42 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -482,6 +482,15 @@ export default class AttributeApplicationValidator implements AstValidator { /relation "bar" is not optional/, ); }); + + describe('@@strict attribute', () => { + it('accepts type defs', async () => { + await loadSchema(` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + type Profile { + name String + + @@strict + } + `); + }); + + it('rejects non-type defs', async () => { + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id + name String + + @@strict + } + `, + /attribute "@@strict" can only be used on type definitions/, + ); + + await loadSchemaWithError( + ` + datasource db { + provider = 'sqlite' + url = 'file:./dev.db' + } + + model User { + id String @id + } + + enum Enum { + TEST + + @@strict + } + `, + /attribute "@@strict" can only be used on type definitions/, + ); + }); + }); }); diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 7547910a9..9541c90b7 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -338,7 +338,14 @@ export type TypeDefResult< >, Partial > & - Record; + (IsTypeDefStrict extends true ? {} : Record); + +export type IsTypeDefStrict> = + Schema['typeDefs'] extends Record + ? Schema['typeDefs'][TypeDef]['strict'] extends true + ? true + : false + : never; export type BatchResult = { count: number }; @@ -1480,7 +1487,8 @@ type MapFieldDefType< T['type'] extends GetEnums ? keyof GetEnum : T['type'] extends GetTypeDefs - ? TypeDefResult & Record + ? TypeDefResult & + (IsTypeDefStrict extends true ? {} : Record) : MapBaseType, T['optional'], T['array'] diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 322fa7721..0d335551a 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -462,7 +462,8 @@ export class ZodSchemaFactory< private makeTypeDefSchema(type: string): ZodType { const typeDef = getTypeDef(this.schema, type); invariant(typeDef, `Type definition "${type}" not found in schema`); - const schema = z.looseObject( + const func = typeDef.strict ? z.strictObject : z.looseObject; + const schema = func( Object.fromEntries( Object.entries(typeDef.fields).map(([field, def]) => { // Wrap nested typedef references in z.lazy() so cyclic or self-referencing diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index 62e892203..3953ec724 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -129,6 +129,7 @@ export type EnumDef = { export type TypeDefDef = { name: string; + strict?: boolean; fields: Record; attributes?: readonly AttributeApplication[]; }; diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index cfa261ad5..34d50794e 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -538,6 +538,10 @@ export class TsSchemaGenerator { : []), ]; + if (hasAttribute(td, '@@strict')) { + fields.push(ts.factory.createPropertyAssignment('strict', ts.factory.createTrue())); + } + return ts.factory.createObjectLiteralExpression(fields, true); } diff --git a/tests/e2e/orm/client-api/procedures.test.ts b/tests/e2e/orm/client-api/procedures.test.ts index 6bd7b5bfd..a92e1e87a 100644 --- a/tests/e2e/orm/client-api/procedures.test.ts +++ b/tests/e2e/orm/client-api/procedures.test.ts @@ -64,6 +64,18 @@ describe('Procedures tests', () => { return createdUsers; }); }, + + updateProfile: async ({ client, args: { userId, profile } }) => { + await client.user.update({ + data: { + profile, + }, + + where: { + id: userId, + }, + }); + }, }, }); }); @@ -214,4 +226,20 @@ describe('Procedures tests', () => { await expect(client.$procs.signUp({ args: { name: 'Alice' } })).rejects.toThrow(); await expect(client.user.count()).resolves.toBe(1); }); + + it('respects strict json', async () => { + const user = await client.$procs.signUp({ args: { name: 'Alice' } }); + await expect( + client.$procs.updateProfile({ + args: { + userId: user.id, + profile: { + bio: 'Programmer', + // @ts-expect-error + unknown: true, + }, + }, + }), + ).rejects.toThrow(/Unrecognized key: "unknown"/); + }); }); diff --git a/tests/e2e/orm/client-api/typed-json-fields.test.ts b/tests/e2e/orm/client-api/typed-json-fields.test.ts index f5a8945c1..56b83ed77 100644 --- a/tests/e2e/orm/client-api/typed-json-fields.test.ts +++ b/tests/e2e/orm/client-api/typed-json-fields.test.ts @@ -211,4 +211,38 @@ model User { }), ).rejects.toThrow(/invalid/i); }); + + it('rejects unknown fields when type is strict', async () => { + const schema = ` +type Profile { + name String + + @@strict +} + +model User { + id Int @id @default(autoincrement()) + profile Profile? @json +} + `; + + const client = await createTestClient(schema, { + usePrismaPush: true, + }); + + try { + await expect( + client.user.create({ + data: { + profile: { + name: 'Test', + unknown: true, + }, + }, + }), + ).rejects.toThrowError(/Unrecognized key: "unknown"/); + } finally { + await client.$disconnect(); + } + }); }); diff --git a/tests/e2e/orm/schemas/procedures/schema.ts b/tests/e2e/orm/schemas/procedures/schema.ts index b8261afe2..9b84da04c 100644 --- a/tests/e2e/orm/schemas/procedures/schema.ts +++ b/tests/e2e/orm/schemas/procedures/schema.ts @@ -32,6 +32,12 @@ export class SchemaType implements SchemaDef { type: "Role", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("USER") }] }] as readonly AttributeApplication[], default: "USER" as FieldDefault + }, + profile: { + name: "profile", + type: "Profile", + optional: true, + attributes: [{ name: "@json" }] as readonly AttributeApplication[] } }, idFields: ["id"], @@ -65,6 +71,19 @@ export class SchemaType implements SchemaDef { optional: true } } + }, + Profile: { + name: "Profile", + fields: { + bio: { + name: "bio", + type: "String" + } + }, + attributes: [ + { name: "@@strict" } + ] as readonly AttributeApplication[], + strict: true } } as const; enums = { @@ -115,6 +134,14 @@ export class SchemaType implements SchemaDef { returnType: "User", returnArray: true, mutation: true + }, + updateProfile: { + params: { + userId: { name: "userId", type: "Int" }, + profile: { name: "profile", type: "Profile" } + }, + returnType: "Void", + mutation: true } } as const; plugins = {}; diff --git a/tests/e2e/orm/schemas/procedures/schema.zmodel b/tests/e2e/orm/schemas/procedures/schema.zmodel index 25380dab3..66f9c6804 100644 --- a/tests/e2e/orm/schemas/procedures/schema.zmodel +++ b/tests/e2e/orm/schemas/procedures/schema.zmodel @@ -15,10 +15,17 @@ type Overview { meta Json? } +type Profile { + bio String + + @@strict +} + model User { id Int @id @default(autoincrement()) name String @unique role Role @default(USER) + profile Profile? @json } procedure getUser(id: Int): User @@ -27,3 +34,4 @@ mutation procedure signUp(name: String, role: Role?): User mutation procedure setAdmin(userId: Int): Void procedure getOverview(): Overview mutation procedure createMultiple(names: String[]): User[] +mutation procedure updateProfile(userId: Int, profile: Profile): Void From ffd36d3f46430cfa710bc3bf274052b523cf3901 Mon Sep 17 00:00:00 2001 From: Jiasheng Date: Sun, 23 Aug 2026 08:56:12 +0800 Subject: [PATCH 2/5] feat: add proxy module with createProxyApp function and update exports (#2808) --- packages/cli/package.json | 15 +- packages/cli/src/actions/proxy.ts | 228 +++--------------------------- packages/cli/src/proxy.ts | 209 +++++++++++++++++++++++++++ packages/cli/tsdown.config.ts | 5 +- 4 files changed, 248 insertions(+), 209 deletions(-) create mode 100644 packages/cli/src/proxy.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 5528ed2da..4720c2776 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,7 +37,20 @@ "pack": "pnpm pack" }, "exports": { - "./package.json": "./package.json" + "./package.json": { + "import": "./package.json", + "require": "./package.json" + }, + "./proxy": { + "import": { + "types": "./dist/proxy.d.mts", + "default": "./dist/proxy.mjs" + }, + "require": { + "types": "./dist/proxy.d.cts", + "default": "./dist/proxy.cjs" + } + } }, "dependencies": { "@zenstackhq/common-helpers": "workspace:*", diff --git a/packages/cli/src/actions/proxy.ts b/packages/cli/src/actions/proxy.ts index 2f4fbf35f..c18ff5a5e 100644 --- a/packages/cli/src/actions/proxy.ts +++ b/packages/cli/src/actions/proxy.ts @@ -1,3 +1,4 @@ +import { serve } from '@hono/node-server'; import { ConfigExpr, InvocationExpr, @@ -13,30 +14,41 @@ import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres'; import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite'; import type { SchemaDef } from '@zenstackhq/orm/schema'; import { PolicyPlugin } from '@zenstackhq/plugin-policy'; -import { RPCApiHandler } from '@zenstackhq/server/api'; -import { createHonoHandler } from '@zenstackhq/server/hono'; -import { serve } from '@hono/node-server'; -import { Hono, type Context, type MiddlewareHandler } from 'hono'; -import { cors } from 'hono/cors'; +import type { DataSourceProviderType } from '@zenstackhq/schema'; import type BetterSqlite3 from 'better-sqlite3'; import colors from 'colors'; import { createJiti } from 'jiti'; import type { createPool as MysqlCreatePool } from 'mysql2'; -import { verify } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import ora from 'ora'; import { detect, resolveCommand } from 'package-manager-detector'; import type { Pool as PgPoolType } from 'pg'; import { CliError } from '../cli-error'; +import { + createProxyApp, + type CreateProxyAppOptions, + createSignatureMiddleware, + normalizePublicKey, + ProxyAuthError, + type ProxyAuthErrorCode, + resolveClient, +} from '../proxy'; import { execSync } from '../utils/exec-utils'; -import { getVersion } from '../utils/version-utils'; import { getOutputPath, getSchemaFile, isPackageInstalled, loadPackage, loadSchemaDocument } from './action-utils'; -import type { DataSourceProviderType } from '@zenstackhq/schema'; import { runPull } from './db'; -import { z } from 'zod'; import { run as runGenerate } from './generate'; +export { + createProxyApp, + type CreateProxyAppOptions, + createSignatureMiddleware, + normalizePublicKey, + ProxyAuthError, + type ProxyAuthErrorCode, + resolveClient, +}; + type Options = { output?: string; schema?: string; @@ -48,34 +60,6 @@ type Options = { introspect?: boolean; }; -export const ProxyAuthError = { - MISSING_SIGNATURE_HEADER: 'Missing x-zenstack-signature header', - INVALID_TIMESTAMP: 'Request timestamp is expired or invalid', - INVALID_SIGNATURE_FORMAT: 'Invalid x-zenstack-signature format', -} as const; - -export type ProxyAuthErrorCode = keyof typeof ProxyAuthError; - -function rejectAuth(c: Context, code: ProxyAuthErrorCode) { - return c.json({ code, message: ProxyAuthError[code] }, 401); -} - -const UserClaimSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('superUser') }), - z.object({ type: z.literal('user'), data: z.record(z.string(), z.unknown()) }), -]); - -type UserClaim = z.infer; - -function normalizePublicKey(key: string): string { - key = key.trim(); - if (key.startsWith('-----BEGIN PUBLIC KEY-----')) { - return key; - } - const b64 = key.replace(/-/g, '+').replace(/_/g, '/'); - return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`; -} - export async function run(options: Options) { // Resolve public key: CLI arg takes precedence, then ZENSTACK_STUDIO_AUTH_KEY env var. options = { ...options, studioAuthKey: options.studioAuthKey ?? process.env['ZENSTACK_STUDIO_AUTH_KEY'] }; @@ -258,176 +242,6 @@ export async function createDialect(provider: string, databaseUrl: string, schem throw new CliError(`Unsupported database provider: ${provider}`); } } -export interface CreateProxyAppOptions { - client: ClientContract; - schema: SchemaDef; - authDb?: ClientContract; - auth?: { - studioAuthKey: string; - /** Seconds within which a signed request is considered valid. Defaults to 60. */ - signatureToleranceSecs: number; - }; - cors?: Parameters[0]; -} - -export function createProxyApp(options: CreateProxyAppOptions): Hono; -export function createProxyApp( - client: ClientContract, - schema: SchemaDef, - authDb?: ClientContract, - auth?: { - studioAuthKey: string; - signatureToleranceSecs: number; - }, -): Hono; -export function createProxyApp( - optionsOrClient: CreateProxyAppOptions | ClientContract, - schema?: SchemaDef, - authDb?: ClientContract, - auth?: { - studioAuthKey: string; - signatureToleranceSecs: number; - }, -): Hono { - let options: CreateProxyAppOptions; - if ('client' in optionsOrClient && 'schema' in optionsOrClient) { - options = optionsOrClient as CreateProxyAppOptions; - } else { - options = { - client: optionsOrClient as ClientContract, - schema: schema!, - authDb, - auth, - }; - } - - const app = new Hono(); - app.use('*', cors(options.cors)); - - if (options.auth?.studioAuthKey) { - const toleranceSecs = options.auth.signatureToleranceSecs; - const normalizedKey = normalizePublicKey(options.auth.studioAuthKey); - const sigMiddleware = createSignatureMiddleware(normalizedKey, toleranceSecs); - app.use('/api/model/*', sigMiddleware); - app.use('/api/schema', sigMiddleware); - } - - app.use( - '/api/model/*', - createHonoHandler({ - apiHandler: new RPCApiHandler({ schema: options.schema }), - getClient: (c) => - resolveClient(options.client, options.authDb ?? options.client, c, !!options.auth?.studioAuthKey), - }), - ); - - app.get('/api/schema', (c) => { - return c.json({ ...options.schema, zenstackVersion: getVersion() }); - }); - - return app; -} - -function createSignatureMiddleware(publicKey: string, toleranceSeconds: number): MiddlewareHandler { - let lastInvalidSigWarnAt = 0; - const WARN_THROTTLE_SECS = 60; - - function warnInvalidSignature() { - const now = Math.floor(Date.now() / 1000); - if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) { - lastInvalidSigWarnAt = now; - console.warn( - colors.yellow( - 'Warning: Received a request with an invalid signature. ' + - 'Please double-check whether you have the correct public API key configured.', - ), - ); - } - } - - return async (c, next) => { - const signatureHeader = c.req.header('x-zenstack-signature'); - if (!signatureHeader) { - return rejectAuth(c, 'MISSING_SIGNATURE_HEADER'); - } - - const parts = signatureHeader.split(','); - const timestampPart = parts.find((p) => p.startsWith('t=')); - const sigPart = parts.find((p) => p.startsWith('v1=')); - if (!timestampPart || !sigPart) { - return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT'); - } - const timestamp = timestampPart.substring(2); - const sig = sigPart.substring(3); - - const requestTime = parseInt(timestamp, 10); - const now = Math.floor(Date.now() / 1000); - if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) { - return rejectAuth(c, 'INVALID_TIMESTAMP'); - } - - let payload: string; - if (c.req.method === 'GET' || c.req.method === 'DELETE') { - const rawUrl = c.req.url; - const qMark = rawUrl.indexOf('?'); - payload = qMark >= 0 ? rawUrl.substring(qMark + 1) : ''; - } else { - payload = await c.req.text(); - } - - const authHeader = c.req.header('authorization'); - const authorizationToken = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : undefined; - - const message = authorizationToken ? `${payload}${timestamp}${authorizationToken}` : `${payload}${timestamp}`; - - try { - const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url')); - if (!isValid) { - warnInvalidSignature(); - return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT'); - } - } catch { - warnInvalidSignature(); - return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT'); - } - - return next(); - }; -} - -function resolveClient( - client: ClientContract, - authDb: ClientContract, - c: Context, - isAuthKeyEnabled: boolean, -): ClientContract { - const authHeader = c.req.header('authorization'); - - if (!isAuthKeyEnabled && !authHeader) { - return client; - } - - if (!authHeader?.startsWith('Bearer ')) { - return authDb; - } - - const token = authHeader.substring(7); - let claim: UserClaim; - try { - claim = UserClaimSchema.parse(JSON.parse(Buffer.from(token, 'base64').toString('utf8'))); - } catch (err) { - console.error( - colors.red(`Failed to parse user claim from token: ${err instanceof Error ? err.message : String(err)}`), - ); - return authDb; - } - - if (claim.type === 'superUser') { - return client; - } else { - return authDb.$setAuth(claim.data as any) as ClientContract; - } -} function startServer( client: ClientContract, diff --git a/packages/cli/src/proxy.ts b/packages/cli/src/proxy.ts new file mode 100644 index 000000000..56c389d19 --- /dev/null +++ b/packages/cli/src/proxy.ts @@ -0,0 +1,209 @@ +import type { ClientContract } from '@zenstackhq/orm'; +import type { SchemaDef } from '@zenstackhq/orm/schema'; +import { RPCApiHandler } from '@zenstackhq/server/api'; +import { createHonoHandler } from '@zenstackhq/server/hono'; +import colors from 'colors'; +import { Hono, type Context, type MiddlewareHandler } from 'hono'; +import { cors } from 'hono/cors'; +import { verify } from 'node:crypto'; +import { z } from 'zod'; +import { getVersion } from './utils/version-utils'; + +export const ProxyAuthError = { + MISSING_SIGNATURE_HEADER: 'Missing x-zenstack-signature header', + INVALID_TIMESTAMP: 'Request timestamp is expired or invalid', + INVALID_SIGNATURE_FORMAT: 'Invalid x-zenstack-signature format', +} as const; + +export type ProxyAuthErrorCode = keyof typeof ProxyAuthError; + +function rejectAuth(c: Context, code: ProxyAuthErrorCode) { + return c.json({ code, message: ProxyAuthError[code] }, 401); +} + +const UserClaimSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('superUser') }), + z.object({ type: z.literal('user'), data: z.record(z.string(), z.unknown()) }), +]); + +type UserClaim = z.infer; + +export function normalizePublicKey(key: string): string { + key = key.trim(); + if (key.startsWith('-----BEGIN PUBLIC KEY-----')) { + return key; + } + const b64 = key.replace(/-/g, '+').replace(/_/g, '/'); + return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`; +} + +export interface CreateProxyAppOptions { + client: ClientContract; + schema: SchemaDef; + authDb?: ClientContract; + auth?: { + studioAuthKey?: string; + /** Seconds within which a signed request is considered valid. Defaults to 60. */ + signatureToleranceSecs?: number; + }; + cors?: Parameters[0]; +} + +export function createProxyApp(options: CreateProxyAppOptions): Hono; +export function createProxyApp( + client: ClientContract, + schema: SchemaDef, + authDb?: ClientContract, + auth?: { + studioAuthKey?: string; + signatureToleranceSecs?: number; + }, +): Hono; +export function createProxyApp( + optionsOrClient: CreateProxyAppOptions | ClientContract, + schema?: SchemaDef, + authDb?: ClientContract, + auth?: { + studioAuthKey?: string; + signatureToleranceSecs?: number; + }, +): Hono { + let options: CreateProxyAppOptions; + if ('client' in optionsOrClient && 'schema' in optionsOrClient) { + options = optionsOrClient as CreateProxyAppOptions; + } else { + options = { + client: optionsOrClient as ClientContract, + schema: schema!, + authDb, + auth, + }; + } + + const app = new Hono(); + app.use('*', cors(options.cors)); + + if (options.auth?.studioAuthKey) { + const toleranceSecs = options.auth.signatureToleranceSecs ?? 60; + const normalizedKey = normalizePublicKey(options.auth.studioAuthKey); + const sigMiddleware = createSignatureMiddleware(normalizedKey, toleranceSecs); + app.use('/api/model/*', sigMiddleware); + app.use('/api/schema', sigMiddleware); + } + + app.use( + '/api/model/*', + createHonoHandler({ + apiHandler: new RPCApiHandler({ schema: options.schema }), + getClient: (c) => + resolveClient(options.client, options.authDb ?? options.client, c, !!options.auth?.studioAuthKey), + }), + ); + + app.get('/api/schema', (c) => { + return c.json({ ...options.schema, zenstackVersion: getVersion() }); + }); + + return app; +} + +export function createSignatureMiddleware(publicKey: string, toleranceSeconds: number): MiddlewareHandler { + let lastInvalidSigWarnAt = 0; + const WARN_THROTTLE_SECS = 60; + + function warnInvalidSignature() { + const now = Math.floor(Date.now() / 1000); + if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) { + lastInvalidSigWarnAt = now; + console.warn( + colors.yellow( + 'Warning: Received a request with an invalid signature. ' + + 'Please double-check whether you have the correct public API key configured.', + ), + ); + } + } + + return async (c, next) => { + const signatureHeader = c.req.header('x-zenstack-signature'); + if (!signatureHeader) { + return rejectAuth(c, 'MISSING_SIGNATURE_HEADER'); + } + + const parts = signatureHeader.split(','); + const timestampPart = parts.find((p) => p.startsWith('t=')); + const sigPart = parts.find((p) => p.startsWith('v1=')); + if (!timestampPart || !sigPart) { + return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT'); + } + const timestamp = timestampPart.substring(2); + const sig = sigPart.substring(3); + + const requestTime = parseInt(timestamp, 10); + const now = Math.floor(Date.now() / 1000); + if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) { + return rejectAuth(c, 'INVALID_TIMESTAMP'); + } + + let payload: string; + if (c.req.method === 'GET' || c.req.method === 'DELETE') { + const rawUrl = c.req.url; + const qMark = rawUrl.indexOf('?'); + payload = qMark >= 0 ? rawUrl.substring(qMark + 1) : ''; + } else { + payload = await c.req.text(); + } + + const authHeader = c.req.header('authorization'); + const authorizationToken = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : undefined; + + const message = authorizationToken ? `${payload}${timestamp}${authorizationToken}` : `${payload}${timestamp}`; + + try { + const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url')); + if (!isValid) { + warnInvalidSignature(); + return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT'); + } + } catch { + warnInvalidSignature(); + return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT'); + } + + return next(); + }; +} + +export function resolveClient( + client: ClientContract, + authDb: ClientContract, + c: Context, + isAuthKeyEnabled: boolean, +): ClientContract { + const authHeader = c.req.header('authorization'); + + if (!isAuthKeyEnabled && !authHeader) { + return client; + } + + if (!authHeader?.startsWith('Bearer ')) { + return authDb; + } + + const token = authHeader.substring(7); + let claim: UserClaim; + try { + claim = UserClaimSchema.parse(JSON.parse(Buffer.from(token, 'base64').toString('utf8'))); + } catch (err) { + console.error( + colors.red(`Failed to parse user claim from token: ${err instanceof Error ? err.message : String(err)}`), + ); + return authDb; + } + + if (claim.type === 'superUser') { + return client; + } else { + return authDb.$setAuth(claim.data as any) as ClientContract; + } +} diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index b475681d8..ca56c71bf 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -1,5 +1,8 @@ import { createConfig } from '@zenstackhq/tsdown-config'; export default createConfig({ - entry: { index: 'src/index.ts' }, + entry: { + index: 'src/index.ts', + proxy: 'src/proxy.ts', + }, }); From 553f89a36cd23ba4c44a9ec45888dc21264cd569 Mon Sep 17 00:00:00 2001 From: sanny-io <3054653+sanny-io@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:22:32 -0700 Subject: [PATCH 3/5] fix(zod): lite schema validation (#2794) Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- packages/cli/test/ts-schema-gen.test.ts | 119 +- packages/language/res/stdlib.zmodel | 51 +- packages/language/src/utils.ts | 7 + packages/sdk/src/ts-schema-generator.ts | 25 +- packages/zod/package.json | 3 +- packages/zod/test/factory.test.ts | 2550 ++++++++++++----------- packages/zod/test/schema/schema-lite.ts | 354 ++++ packages/zod/tsconfig.json | 5 +- pnpm-lock.yaml | 3 + 9 files changed, 1813 insertions(+), 1304 deletions(-) create mode 100644 packages/zod/test/schema/schema-lite.ts diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index f1abf7a92..ea58af7c4 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -445,7 +445,7 @@ model User { }); }); - it('supports lite schema generation', async () => { + it('strips non-lite attributes from lite schemas', async () => { const { schemaLite } = await generateTsSchema( ` model User { @@ -455,6 +455,10 @@ model User { @@map('users') } + +type Profile { + id String @id +} `, undefined, undefined, @@ -463,8 +467,119 @@ model User { ); expect(schemaLite!.models['User']!.attributes).toBeUndefined(); - expect(schemaLite!.models['User']!.fields['id']!.attributes).toBeUndefined(); + + expect(schemaLite!.models['User']!.fields['id']!.attributes).toMatchObject([ + { + name: '@default', + args: [ + { + name: 'value', + value: { + kind: 'call', + function: 'uuid', + args: undefined, + }, + }, + ], + }, + ]); + expect(schemaLite!.models['User']!.fields['email']!.attributes).toBeUndefined(); + expect(schemaLite!.typeDefs!['Profile']!.fields['id']!.attributes).toBeUndefined(); + }); + + it('does not strip lite attributes from lite schemas', async () => { + const { schemaLite } = await generateTsSchema( + ` +model User { + id String @id @default(uuid()) + name String + email String @unique @email @meta('description', 'HTML email address.') + + @@map('users') + @@meta('description', 'A registered user.') +} + +type Profile { + bio String + + @@meta('description', 'The profile of a user.') +} + `, + undefined, + undefined, + undefined, + true, + ); + + expect(schemaLite!.models['User']!.fields['email']?.attributes).toMatchObject([ + { + name: '@email', + }, + { + name: '@meta', + args: [ + { + name: 'name', + value: { + kind: 'literal', + value: 'description', + }, + }, + { + name: 'value', + value: { + kind: 'literal', + value: 'HTML email address.', + }, + }, + ], + }, + ]); + + expect(schemaLite!.models['User']!.attributes).toMatchObject([ + { + name: '@@meta', + args: [ + { + name: 'name', + value: { + kind: 'literal', + value: 'description', + }, + }, + { + name: 'value', + value: { + kind: 'literal', + value: 'A registered user.', + }, + }, + ], + }, + ]); + + expect(schemaLite!.typeDefs!['Profile']!.attributes).toMatchObject([ + { + name: '@@meta', + args: [ + { + name: 'name', + value: { + kind: 'literal', + value: 'description', + }, + }, + { + name: 'value', + value: { + kind: 'literal', + value: 'The profile of a user.', + }, + }, + ], + }, + ]); }); it('supports ignorable fields for @updatedAt', async () => { diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index 4d9ba36c2..a8970a1d0 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -224,7 +224,7 @@ attribute @id(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) * Defines a default value for a field. * @param value: An expression (e.g. 5, true, now(), auth()). */ -attribute @default(_ value: ContextType, map: String?) @@@prisma @@@once +attribute @default(_ value: ContextType, map: String?) @@@prisma @@@once @@@lite /** * Defines a unique constraint for this field. @@ -427,7 +427,7 @@ attribute @fullText() @@@targetField([StringField]) @@@once * updates have been made to a record. An update that only contains ignored fields does not change the * timestamp. */ -attribute @updatedAt(ignore: FieldReference[]?) @@@targetField([DateTimeField]) @@@prisma +attribute @updatedAt(ignore: FieldReference[]?) @@@targetField([DateTimeField]) @@@prisma @@@lite /** * Add full text index (MySQL only). @@ -520,97 +520,97 @@ attribute @@schema(_ map: String) @@@prisma /** * Validates length of a string field or list field. */ -attribute @length(_ min: Int?, _ max: Int?, _ message: String?) @@@targetField([StringField, ListField]) @@@validation +attribute @length(_ min: Int?, _ max: Int?, _ message: String?) @@@targetField([StringField, ListField]) @@@validation @@@lite /** * Validates a string field value starts with the given text. */ -attribute @startsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @startsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@lite /** * Validates a string field value ends with the given text. */ -attribute @endsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @endsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@lite /** * Validates a string field value contains the given text. */ -attribute @contains(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @contains(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@lite /** * Validates a string field value matches a regex. */ -attribute @regex(_ regex: String, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @regex(_ regex: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@lite /** * Validates a string field value is a valid email address. */ -attribute @email(_ message: String?) @@@targetField([StringField]) @@@validation +attribute @email(_ message: String?) @@@targetField([StringField]) @@@validation @@@lite /** * Validates a string field value is a valid ISO datetime. */ -attribute @datetime(_ message: String?) @@@targetField([StringField]) @@@validation +attribute @datetime(_ message: String?) @@@targetField([StringField]) @@@validation @@@lite /** * Validates a string field value is a valid ISO date. */ -attribute @date(_ message: String?) @@@targetField([StringField]) @@@validation +attribute @date(_ message: String?) @@@targetField([StringField]) @@@validation @@@lite /** * Validates a string field value is a valid ISO time. */ -attribute @time(_ precision: Int?, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @time(_ precision: Int?, _ message: String?) @@@targetField([StringField]) @@@validation @@@lite /** * Validates a string field value is a valid url. */ -attribute @url(_ message: String?) @@@targetField([StringField]) @@@validation +attribute @url(_ message: String?) @@@targetField([StringField]) @@@validation @@@lite /** * Validates a string field value is a valid E.164 phone number. */ -attribute @phone(_ message: String?) @@@targetField([StringField]) @@@validation +attribute @phone(_ message: String?) @@@targetField([StringField]) @@@validation @@@lite /** * Trims whitespaces from the start and end of the string. */ -attribute @trim() @@@targetField([StringField]) @@@validation +attribute @trim() @@@targetField([StringField]) @@@validation @@@lite /** * Transform entire string toLowerCase. */ -attribute @lower() @@@targetField([StringField]) @@@validation +attribute @lower() @@@targetField([StringField]) @@@validation @@@lite /** * Transform entire string toUpperCase. */ -attribute @upper() @@@targetField([StringField]) @@@validation +attribute @upper() @@@targetField([StringField]) @@@validation @@@lite /** * Validates a number field is greater than the given value. */ -attribute @gt(_ value: Any, _ message: String?) @@@targetField([IntField, FloatField, DecimalField, BigIntField]) @@@validation +attribute @gt(_ value: Any, _ message: String?) @@@targetField([IntField, FloatField, DecimalField, BigIntField]) @@@validation @@@lite /** * Validates a number field is greater than or equal to the given value. */ -attribute @gte(_ value: Any, _ message: String?) @@@targetField([IntField, FloatField, DecimalField, BigIntField]) @@@validation +attribute @gte(_ value: Any, _ message: String?) @@@targetField([IntField, FloatField, DecimalField, BigIntField]) @@@validation @@@lite /** * Validates a number field is less than the given value. */ -attribute @lt(_ value: Any, _ message: String?) @@@targetField([IntField, FloatField, DecimalField, BigIntField]) @@@validation +attribute @lt(_ value: Any, _ message: String?) @@@targetField([IntField, FloatField, DecimalField, BigIntField]) @@@validation @@@lite /** * Validates a number field is less than or equal to the given value. */ -attribute @lte(_ value: Any, _ message: String?) @@@targetField([IntField, FloatField, DecimalField, BigIntField]) @@@validation +attribute @lte(_ value: Any, _ message: String?) @@@targetField([IntField, FloatField, DecimalField, BigIntField]) @@@validation @@@lite /** * Validates the entity with a complex condition. */ -attribute @@validate(_ value: Boolean, _ message: String?, _ path: String[]?) @@@validation +attribute @@validate(_ value: Boolean, _ message: String?, _ path: String[]?) @@@validation @@@lite /** * Returns the length of a string field or a list field. @@ -718,18 +718,23 @@ attribute @@auth() /** * Attaches arbitrary metadata to a model or type def. */ -attribute @@meta(_ name: String, _ value: Any) +attribute @@meta(_ name: String, _ value: Any) @@@lite /** * Attaches arbitrary metadata to a field. */ -attribute @meta(_ name: String, _ value: Any) +attribute @meta(_ name: String, _ value: Any) @@@lite /** * Marks an attribute as deprecated. */ attribute @@@deprecated(_ message: String) +/** + * Indicates an attribute should not be stripped when generating lite schemas. + */ +attribute @@@lite() + /** * Indicates a type def should reject unknown fields. */ diff --git a/packages/language/src/utils.ts b/packages/language/src/utils.ts index 4fa380599..f733cb0d4 100644 --- a/packages/language/src/utils.ts +++ b/packages/language/src/utils.ts @@ -186,6 +186,13 @@ export function isNativeTypeMappingAttribute(node: AstNode): node is Attribute { return isPrismaAttribute(node) && node.name.startsWith('@db.'); } +/** + * Returns if the given node is a lite attribute. + */ +export function isLiteAttribute(node: AstNode): node is Attribute { + return isAttribute(node) && hasAttribute(node, '@@@lite'); +} + /** * 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. diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 34d50794e..5b0d56285 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -38,7 +38,13 @@ import { UnaryExpr, type Model, } from '@zenstackhq/language/ast'; -import { getAllAttributes, getAllFields, getAttributeArg, isDataFieldReference } from '@zenstackhq/language/utils'; +import { + getAllAttributes, + getAllFields, + getAttributeArg, + isDataFieldReference, + isLiteAttribute, +} from '@zenstackhq/language/utils'; import fs from 'node:fs'; import path from 'node:path'; import { match } from 'ts-pattern'; @@ -374,7 +380,7 @@ export class TsSchemaGenerator { private createDataModelObject(dm: DataModel, lite: boolean) { const allFields = getAllFields(dm); const allAttributes = lite - ? [] // in lite mode, skip all model-level attributes + ? getAllAttributes(dm).filter((attr) => isLiteAttribute(attr.decl.ref!)) : getAllAttributes(dm).filter((attr) => { // exclude `@@delegate` attribute from base model if (attr.decl.$refText === '@@delegate' && attr.$container !== dm) { @@ -502,7 +508,9 @@ export class TsSchemaGenerator { private createTypeDefObject(td: TypeDef, lite: boolean): ts.Expression { const allFields = getAllFields(td); - const allAttributes = getAllAttributes(td); + const attributes = lite + ? getAllAttributes(td).filter((attr) => isLiteAttribute(attr.decl.ref!)) + : getAllAttributes(td); const fields: ts.PropertyAssignment[] = [ // name @@ -523,13 +531,13 @@ export class TsSchemaGenerator { ), // attributes - ...(allAttributes.length > 0 + ...(attributes.length > 0 ? [ ts.factory.createPropertyAssignment( 'attributes', this.createAttributesTypeAssertion( ts.factory.createArrayLiteralExpression( - allAttributes.map((attr) => this.createAttributeObject(attr)), + attributes.map((attr) => this.createAttributeObject(attr)), true, ), ), @@ -764,14 +772,15 @@ export class TsSchemaGenerator { objectFields.push(ts.factory.createPropertyAssignment('isDiscriminator', ts.factory.createTrue())); } - // attributes, only when not in lite mode - if (!lite && field.attributes.length > 0) { + const attributes = lite ? field.attributes.filter((attr) => isLiteAttribute(attr.decl.ref!)) : field.attributes; + + if (attributes.length > 0) { objectFields.push( ts.factory.createPropertyAssignment( 'attributes', this.createAttributesTypeAssertion( ts.factory.createArrayLiteralExpression( - field.attributes.map((attr) => this.createAttributeObject(attr)), + attributes.map((attr) => this.createAttributeObject(attr)), ), ), ), diff --git a/packages/zod/package.json b/packages/zod/package.json index f7b90702e..0a6fe7efe 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -20,7 +20,7 @@ "lint": "eslint src --ext ts", "test": "vitest run", "pack": "pnpm pack", - "test:generate": "tsx ../../scripts/test-generate.ts ." + "test:generate": "tsx ../../scripts/test-generate.ts . --lite" }, "keywords": [ "zenstack", @@ -50,6 +50,7 @@ "@zenstackhq/tsdown-config": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", "@zenstackhq/vitest-config": "workspace:*", + "@types/node": "catalog:", "zod": "^4.1.0" }, "peerDependencies": { diff --git a/packages/zod/test/factory.test.ts b/packages/zod/test/factory.test.ts index a0bc7592c..19c5e9bce 100644 --- a/packages/zod/test/factory.test.ts +++ b/packages/zod/test/factory.test.ts @@ -2,1546 +2,1558 @@ import Decimal from 'decimal.js'; import { describe, expect, expectTypeOf, it } from 'vitest'; import { createSchemaFactory } from '../src/index'; import { schema } from './schema/schema'; +import { schema as schemaLite } from './schema/schema-lite'; import z from 'zod'; import type { JsonValue } from '../src/index'; -const factory = createSchemaFactory(schema); - -// A fully valid User object (without relations) -const validUser = { - id: 'user123', - email: 'test@example.com', - phone: '+15555555555', - username: 'johndoe', - website: null, - code: 'USR001', - age: 25, - score: 50.0, - bigNum: BigInt(100), - balance: 10.0, - active: true, - birthdate: null, - localTime: null, - createdAt: null, - avatar: null, - metadata: null, - status: 'ACTIVE', - address: null, -}; - -// A fully valid Post object (without relations) -const validPost = { - id: 'post123', - title: 'My First Post', - published: true, - authorId: null, - tags: ['announcement', 'update'], -}; - -describe('SchemaFactory - makeModelSchema', () => { - describe('scalar field types', () => { - it('infers correct field types for User', () => { - const _userSchema = factory.makeModelSchema('User'); - type User = z.infer; - - // required string fields - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - // optional string field (nullable + optional) - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - - // number fields (Int and Float both map to ZodNumber) - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - - // bigint - expectTypeOf().toEqualTypeOf(); - - // Decimal maps to ZodCustom - expectTypeOf().toEqualTypeOf(); - - // boolean - expectTypeOf().toEqualTypeOf(); - - // DateTime - expectTypeOf().toEqualTypeOf(); - - // optional Bytes - expectTypeOf().toEqualTypeOf(); - - // optional Json - expectTypeOf().toHaveProperty('metadata'); - expectTypeOf().toEqualTypeOf(); - - // required enum - expectTypeOf().toEqualTypeOf<'ACTIVE' | 'INACTIVE' | 'PENDING'>(); - - // optional typedef (Address): { street, city, zip? } | null | undefined - type Address = Exclude; - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf
(); - - // relation fields are NOT present by default — use include/select to opt in - expectTypeOf().not.toHaveProperty('posts'); - }); - - it('infers correct field types for Post', () => { - const _postSchema = factory.makeModelSchema('Post'); - type Post = z.infer; +describe.each([ + ['full', schema], + ['lite', schemaLite], +] as const)('%s schema', (_target, schemaToUse) => { + const factory = createSchemaFactory(schemaToUse); + + // A fully valid User object (without relations) + const validUser = { + id: 'user123', + email: 'test@example.com', + phone: '+15555555555', + username: 'johndoe', + website: null, + code: 'USR001', + age: 25, + score: 50.0, + bigNum: BigInt(100), + balance: 10.0, + active: true, + birthdate: null, + localTime: null, + createdAt: null, + avatar: null, + metadata: null, + status: 'ACTIVE', + address: null, + }; + + // A fully valid Post object (without relations) + const validPost = { + id: 'post123', + title: 'My First Post', + published: true, + authorId: null, + tags: ['announcement', 'update'], + }; + + describe('SchemaFactory - makeModelSchema', () => { + describe('scalar field types', () => { + it('infers correct field types for User', () => { + const _userSchema = factory.makeModelSchema('User'); + type User = z.infer; + + // required string fields + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // optional string field (nullable + optional) + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + + // number fields (Int and Float both map to ZodNumber) + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + + // bigint + expectTypeOf().toEqualTypeOf(); + + // Decimal maps to ZodCustom + expectTypeOf().toEqualTypeOf(); + + // boolean + expectTypeOf().toEqualTypeOf(); + + // DateTime + expectTypeOf().toEqualTypeOf(); + + // optional Bytes + expectTypeOf().toEqualTypeOf(); + + // optional Json + expectTypeOf().toHaveProperty('metadata'); + expectTypeOf().toEqualTypeOf(); + + // required enum + expectTypeOf().toEqualTypeOf<'ACTIVE' | 'INACTIVE' | 'PENDING'>(); + + // optional typedef (Address): { street, city, zip? } | null | undefined + type Address = Exclude; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf
(); + + // relation fields are NOT present by default — use include/select to opt in + expectTypeOf().not.toHaveProperty('posts'); + }); - // required string fields - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + it('infers correct field types for Post', () => { + const _postSchema = factory.makeModelSchema('Post'); + type Post = z.infer; - // required boolean - expectTypeOf().toEqualTypeOf(); + // required string fields + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); - // optional scalar (foreign key) - expectTypeOf().toEqualTypeOf(); + // required boolean + expectTypeOf().toEqualTypeOf(); - // scalar array - expectTypeOf().toEqualTypeOf(); + // optional scalar (foreign key) + expectTypeOf().toEqualTypeOf(); - const _createPostSchema = factory.makeModelCreateSchema('Post'); - type PostCreate = z.infer; + // scalar array + expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + const _createPostSchema = factory.makeModelCreateSchema('Post'); + type PostCreate = z.infer; - const _updatePostSchema = factory.makeModelUpdateSchema('Post'); - type PostUpdate = z.infer; + expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + const _updatePostSchema = factory.makeModelUpdateSchema('Post'); + type PostUpdate = z.infer; - // relation fields are NOT present by default — use include/select to opt in - expectTypeOf().not.toHaveProperty('author'); - }); + expectTypeOf().toEqualTypeOf(); - it('accepts a fully valid User (no relation fields)', () => { - const userSchema = factory.makeModelSchema('User'); - expect(userSchema.safeParse(validUser).success).toBe(true); - }); + // relation fields are NOT present by default — use include/select to opt in + expectTypeOf().not.toHaveProperty('author'); + }); - it('rejects relation fields in default schema (strict object)', () => { - const userSchema = factory.makeModelSchema('User'); - // relation fields are not part of the default schema, so they are rejected - const result = userSchema.safeParse({ ...validUser, posts: [] }); - expect(result.success).toBe(false); - }); + it('accepts a fully valid User (no relation fields)', () => { + const userSchema = factory.makeModelSchema('User'); + expect(userSchema.safeParse(validUser).success).toBe(true); + }); - it('accepts a fully valid Post', () => { - const postSchema = factory.makeModelSchema('Post'); - expect(postSchema.safeParse(validPost).success).toBe(true); - }); + it('rejects relation fields in default schema (strict object)', () => { + const userSchema = factory.makeModelSchema('User'); + // relation fields are not part of the default schema, so they are rejected + const result = userSchema.safeParse({ ...validUser, posts: [] }); + expect(result.success).toBe(false); + }); - it('rejects extra fields (strict object)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, unknownField: 'value' }); - expect(result.success).toBe(false); - }); + it('accepts a fully valid Post', () => { + const postSchema = factory.makeModelSchema('Post'); + expect(postSchema.safeParse(validPost).success).toBe(true); + }); - it('accepts DateTime as a Date object', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, createdAt: new Date() }); - expect(result.success).toBe(true); - }); + it('rejects extra fields (strict object)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, unknownField: 'value' }); + expect(result.success).toBe(false); + }); - it('accepts DateTime as an ISO datetime string', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ - ...validUser, - createdAt: '2024-01-15T10:30:00.000Z', + it('accepts DateTime as a Date object', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, createdAt: new Date() }); + expect(result.success).toBe(true); }); - expect(result.success).toBe(true); - }); - it('accepts Bytes as Uint8Array', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ - ...validUser, - avatar: new Uint8Array([1, 2, 3]), + it('accepts DateTime as an ISO datetime string', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ + ...validUser, + createdAt: '2024-01-15T10:30:00.000Z', + }); + expect(result.success).toBe(true); }); - expect(result.success).toBe(true); - }); - it('accepts BigInt values', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, bigNum: BigInt(999) }); - expect(result.success).toBe(true); - }); + it('accepts Bytes as Uint8Array', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ + ...validUser, + avatar: new Uint8Array([1, 2, 3]), + }); + expect(result.success).toBe(true); + }); - it('accepts Decimal as a number', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, balance: 42.5 }); - expect(result.success).toBe(true); - }); + it('accepts BigInt values', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, bigNum: BigInt(999) }); + expect(result.success).toBe(true); + }); - it('accepts Decimal as a numeric string', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, balance: '42.5' }); - expect(result.success).toBe(true); - }); + it('accepts Decimal as a number', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, balance: 42.5 }); + expect(result.success).toBe(true); + }); - it('accepts Decimal as a Decimal instance', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, balance: new Decimal('42.5') }); - expect(result.success).toBe(true); - }); + it('accepts Decimal as a numeric string', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, balance: '42.5' }); + expect(result.success).toBe(true); + }); - it('accepts Json values', () => { - const userSchema = factory.makeModelSchema('User'); - 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('accepts Decimal as a Decimal instance', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, balance: new Decimal('42.5') }); + expect(result.success).toBe(true); + }); - it('rejects invalid Json values', () => { - const userSchema = factory.makeModelSchema('User'); - // BigInt is not a JSON primitive - expect(userSchema.safeParse({ ...validUser, metadata: BigInt(1) }).success).toBe(false); - // Symbol is not a JSON value - expect(userSchema.safeParse({ ...validUser, metadata: Symbol('s') }).success).toBe(false); - // Functions are not JSON values - expect(userSchema.safeParse({ ...validUser, metadata: () => {} }).success).toBe(false); - // Nested non-JSON values are also rejected - expect(userSchema.safeParse({ ...validUser, metadata: { key: BigInt(1) } }).success).toBe(false); - expect(userSchema.safeParse({ ...validUser, metadata: [BigInt(1)] }).success).toBe(false); - }); + it('accepts Json values', () => { + const userSchema = factory.makeModelSchema('User'); + 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('infers correct input types for fields', () => { - const _userSchema = factory.makeModelSchema('User'); - type UserInput = z.input; - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - }); - }); + it('rejects invalid Json values', () => { + const userSchema = factory.makeModelSchema('User'); + // BigInt is not a JSON primitive + expect(userSchema.safeParse({ ...validUser, metadata: BigInt(1) }).success).toBe(false); + // Symbol is not a JSON value + expect(userSchema.safeParse({ ...validUser, metadata: Symbol('s') }).success).toBe(false); + // Functions are not JSON values + expect(userSchema.safeParse({ ...validUser, metadata: () => {} }).success).toBe(false); + // Nested non-JSON values are also rejected + expect(userSchema.safeParse({ ...validUser, metadata: { key: BigInt(1) } }).success).toBe(false); + expect(userSchema.safeParse({ ...validUser, metadata: [BigInt(1)] }).success).toBe(false); + }); - describe('string validation attributes', () => { - it('rejects invalid email for @email field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, email: 'not-an-email' }); - expect(result.success).toBe(false); + it('infers correct input types for fields', () => { + const _userSchema = factory.makeModelSchema('User'); + type UserInput = z.input; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); }); - it('accepts valid email for @email field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, email: 'valid@domain.com' }); - expect(result.success).toBe(true); - }); + describe('string validation attributes', () => { + it('rejects invalid email for @email field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, email: 'not-an-email' }); + expect(result.success).toBe(false); + }); - it('rejects username too short for @length(3, 50)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, username: 'ab' }); - expect(result.success).toBe(false); - }); + it('accepts valid email for @email field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, email: 'valid@domain.com' }); + expect(result.success).toBe(true); + }); - it('rejects username too long for @length(3, 50)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, username: 'a'.repeat(51) }); - expect(result.success).toBe(false); - }); + it('rejects username too short for @length(3, 50)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, username: 'ab' }); + expect(result.success).toBe(false); + }); - it('accepts username within @length bounds', () => { - const userSchema = factory.makeModelSchema('User'); - expect(userSchema.safeParse({ ...validUser, username: 'abc' }).success).toBe(true); - expect(userSchema.safeParse({ ...validUser, username: 'a'.repeat(50) }).success).toBe(true); - }); + it('rejects username too long for @length(3, 50)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, username: 'a'.repeat(51) }); + expect(result.success).toBe(false); + }); - it('rejects invalid URL for @url field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, website: 'not-a-url' }); - expect(result.success).toBe(false); - }); + it('accepts username within @length bounds', () => { + const userSchema = factory.makeModelSchema('User'); + expect(userSchema.safeParse({ ...validUser, username: 'abc' }).success).toBe(true); + expect(userSchema.safeParse({ ...validUser, username: 'a'.repeat(50) }).success).toBe(true); + }); - it('accepts valid URL for @url field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, website: 'https://example.com' }); - expect(result.success).toBe(true); - }); + it('rejects invalid URL for @url field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, website: 'not-a-url' }); + expect(result.success).toBe(false); + }); - it('accepts null for optional @url field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, website: null }); - expect(result.success).toBe(true); - }); + it('accepts valid URL for @url field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, website: 'https://example.com' }); + expect(result.success).toBe(true); + }); - it('rejects invalid phone number for @phone field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, phone: 'not-a-phone' }); - expect(result.success).toBe(false); - }); + it('accepts null for optional @url field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, website: null }); + expect(result.success).toBe(true); + }); - it('accepts valid phone number for @phone field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, phone: '+15555555555' }); - expect(result.success).toBe(true); - }); + it('rejects invalid phone number for @phone field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, phone: 'not-a-phone' }); + expect(result.success).toBe(false); + }); - it('rejects invalid date for @date field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, birthdate: 'not-a-date' }); - expect(result.success).toBe(false); - }); + it('accepts valid phone number for @phone field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, phone: '+15555555555' }); + expect(result.success).toBe(true); + }); - it('accepts valid date for @date field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, birthdate: '2000-01-01' }); - expect(result.success).toBe(true); - }); + it('rejects invalid date for @date field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, birthdate: 'not-a-date' }); + expect(result.success).toBe(false); + }); - it('accepts null for optional @date field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, birthdate: null }); - expect(result.success).toBe(true); - }); + it('accepts valid date for @date field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, birthdate: '2000-01-01' }); + expect(result.success).toBe(true); + }); - it('rejects invalid time for @time field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, localTime: 'not-a-time' }); - expect(result.success).toBe(false); - }); + it('accepts null for optional @date field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, birthdate: null }); + expect(result.success).toBe(true); + }); - it('accepts valid time for @time field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, localTime: '03:15:00' }); - expect(result.success).toBe(true); - }); + it('rejects invalid time for @time field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, localTime: 'not-a-time' }); + expect(result.success).toBe(false); + }); - it('accepts null for optional @time field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, localTime: null }); - expect(result.success).toBe(true); - }); + it('accepts valid time for @time field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, localTime: '03:15:00' }); + expect(result.success).toBe(true); + }); - it('rejects code that does not start with "USR" for @startsWith', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, code: 'ABC001' }); - expect(result.success).toBe(false); - }); + it('accepts null for optional @time field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, localTime: null }); + expect(result.success).toBe(true); + }); - it('accepts code starting with "USR" for @startsWith', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, code: 'USR_ANYTHING' }); - expect(result.success).toBe(true); - }); - }); + it('rejects code that does not start with "USR" for @startsWith', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, code: 'ABC001' }); + expect(result.success).toBe(false); + }); - describe('number validation attributes', () => { - it('rejects age = 0 for @gt(0)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, age: 0 }); - expect(result.success).toBe(false); + it('accepts code starting with "USR" for @startsWith', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, code: 'USR_ANYTHING' }); + expect(result.success).toBe(true); + }); }); - it('rejects age = 151 for @lte(150)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, age: 151 }); - expect(result.success).toBe(false); - }); + describe('number validation attributes', () => { + it('rejects age = 0 for @gt(0)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, age: 0 }); + expect(result.success).toBe(false); + }); - it('accepts age within @gt(0) and @lte(150) bounds', () => { - const userSchema = factory.makeModelSchema('User'); - // Note: @@validate(age >= 18) also applies, so the minimum valid age is 18 - expect(userSchema.safeParse({ ...validUser, age: 18 }).success).toBe(true); - expect(userSchema.safeParse({ ...validUser, age: 150 }).success).toBe(true); - }); + it('rejects age = 151 for @lte(150)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, age: 151 }); + expect(result.success).toBe(false); + }); - it('rejects score < 0 for @gte(0)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, score: -0.1 }); - expect(result.success).toBe(false); - }); + it('accepts age within @gt(0) and @lte(150) bounds', () => { + const userSchema = factory.makeModelSchema('User'); + // Note: @@validate(age >= 18) also applies, so the minimum valid age is 18 + expect(userSchema.safeParse({ ...validUser, age: 18 }).success).toBe(true); + expect(userSchema.safeParse({ ...validUser, age: 150 }).success).toBe(true); + }); - it('rejects score = 100 for @lt(100)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, score: 100.0 }); - expect(result.success).toBe(false); - }); + it('rejects score < 0 for @gte(0)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, score: -0.1 }); + expect(result.success).toBe(false); + }); - it('accepts score within @gte(0) and @lt(100) bounds', () => { - const userSchema = factory.makeModelSchema('User'); - expect(userSchema.safeParse({ ...validUser, score: 0 }).success).toBe(true); - expect(userSchema.safeParse({ ...validUser, score: 99.9 }).success).toBe(true); - }); - }); + it('rejects score = 100 for @lt(100)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, score: 100.0 }); + expect(result.success).toBe(false); + }); - describe('bigint validation attributes', () => { - it('rejects bigNum < 0 for @gte(0)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, bigNum: BigInt(-1) }); - expect(result.success).toBe(false); + it('accepts score within @gte(0) and @lt(100) bounds', () => { + const userSchema = factory.makeModelSchema('User'); + expect(userSchema.safeParse({ ...validUser, score: 0 }).success).toBe(true); + expect(userSchema.safeParse({ ...validUser, score: 99.9 }).success).toBe(true); + }); }); - it('accepts bigNum = 0 for @gte(0)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, bigNum: BigInt(0) }); - expect(result.success).toBe(true); - }); - }); + describe('bigint validation attributes', () => { + it('rejects bigNum < 0 for @gte(0)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, bigNum: BigInt(-1) }); + expect(result.success).toBe(false); + }); - describe('decimal validation attributes', () => { - it('rejects balance = 0 (number) for @gt(0)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, balance: 0 }); - expect(result.success).toBe(false); + it('accepts bigNum = 0 for @gte(0)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, bigNum: BigInt(0) }); + expect(result.success).toBe(true); + }); }); - it('rejects balance = "0.0" (string) for @gt(0)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, balance: '0.0' }); - expect(result.success).toBe(false); - }); + describe('decimal validation attributes', () => { + it('rejects balance = 0 (number) for @gt(0)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, balance: 0 }); + expect(result.success).toBe(false); + }); - it('rejects balance = Decimal("0") for @gt(0)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, balance: new Decimal('0') }); - expect(result.success).toBe(false); - }); + it('rejects balance = "0.0" (string) for @gt(0)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, balance: '0.0' }); + expect(result.success).toBe(false); + }); - it('accepts balance = 0.01 (number) for @gt(0)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, balance: 0.01 }); - expect(result.success).toBe(true); - }); - }); + it('rejects balance = Decimal("0") for @gt(0)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, balance: new Decimal('0') }); + expect(result.success).toBe(false); + }); - describe('enum fields', () => { - it('accepts valid enum values', () => { - const userSchema = factory.makeModelSchema('User'); - expect(userSchema.safeParse({ ...validUser, status: 'ACTIVE' }).success).toBe(true); - expect(userSchema.safeParse({ ...validUser, status: 'INACTIVE' }).success).toBe(true); - expect(userSchema.safeParse({ ...validUser, status: 'PENDING' }).success).toBe(true); + it('accepts balance = 0.01 (number) for @gt(0)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, balance: 0.01 }); + expect(result.success).toBe(true); + }); }); - it('rejects invalid enum value', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, status: 'ADMIN' }); - expect(result.success).toBe(false); - }); - }); + describe('enum fields', () => { + it('accepts valid enum values', () => { + const userSchema = factory.makeModelSchema('User'); + expect(userSchema.safeParse({ ...validUser, status: 'ACTIVE' }).success).toBe(true); + expect(userSchema.safeParse({ ...validUser, status: 'INACTIVE' }).success).toBe(true); + expect(userSchema.safeParse({ ...validUser, status: 'PENDING' }).success).toBe(true); + }); - describe('typedef (embedded type) fields', () => { - it('accepts null for optional typedef field', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, address: null }); - expect(result.success).toBe(true); + it('rejects invalid enum value', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, status: 'ADMIN' }); + expect(result.success).toBe(false); + }); }); - it('accepts valid Address object', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ - ...validUser, - address: { residents: [], street: '123 Main St', city: 'Springfield', zip: null }, + describe('typedef (embedded type) fields', () => { + it('accepts null for optional typedef field', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, address: null }); + expect(result.success).toBe(true); }); - expect(result.success).toBe(true); - }); - it('accepts Address with optional zip present', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ - ...validUser, - address: { residents: [], street: '123 Main St', city: 'Springfield', zip: '12345' }, + it('accepts valid Address object', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ + ...validUser, + address: { residents: [], street: '123 Main St', city: 'Springfield', zip: null }, + }); + expect(result.success).toBe(true); }); - expect(result.success).toBe(true); - }); - it('rejects Address with extra fields (strict object)', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ - ...validUser, - address: { residents: [], street: '123 Main St', city: 'Springfield', zip: null, extra: 'field' }, + it('accepts Address with optional zip present', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ + ...validUser, + address: { residents: [], street: '123 Main St', city: 'Springfield', zip: '12345' }, + }); + expect(result.success).toBe(true); }); - expect(result.success).toBe(false); - }); - it('rejects Address missing required fields', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ - ...validUser, - address: { residents: [], street: '123 Main St' }, + it('rejects Address with extra fields (strict object)', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ + ...validUser, + address: { residents: [], street: '123 Main St', city: 'Springfield', zip: null, extra: 'field' }, + }); + expect(result.success).toBe(false); }); - expect(result.success).toBe(false); - }); - }); - describe('@@validate custom validation', () => { - it('fails when @@validate condition is false (age < 18 passes field but fails model validation)', () => { - const userSchema = factory.makeModelSchema('User'); - // age: 16 passes @gt(0) and @lte(150) but fails @@validate(age >= 18) - const result = userSchema.safeParse({ ...validUser, age: 16 }); - expect(result.success).toBe(false); + it('rejects Address missing required fields', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ + ...validUser, + address: { residents: [], street: '123 Main St' }, + }); + expect(result.success).toBe(false); + }); }); - it('@@validate error contains the configured message', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, age: 16 }); - expect(result.success).toBe(false); - if (!result.success) { - const messages = result.error.issues.map((i) => i.message); - expect(messages).toContain('Must be adult'); - } - }); + describe('@@validate custom validation', () => { + it('fails when @@validate condition is false (age < 18 passes field but fails model validation)', () => { + const userSchema = factory.makeModelSchema('User'); + // age: 16 passes @gt(0) and @lte(150) but fails @@validate(age >= 18) + const result = userSchema.safeParse({ ...validUser, age: 16 }); + expect(result.success).toBe(false); + }); - it('@@validate error uses the configured path', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, age: 16 }); - expect(result.success).toBe(false); - if (!result.success) { - const paths = result.error.issues.map((i) => i.path); - expect(paths).toContainEqual(['age']); - } - }); + it('@@validate error contains the configured message', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, age: 16 }); + expect(result.success).toBe(false); + if (!result.success) { + const messages = result.error.issues.map((i) => i.message); + expect(messages).toContain('Must be adult'); + } + }); - it('passes when @@validate condition is satisfied', () => { - const userSchema = factory.makeModelSchema('User'); - const result = userSchema.safeParse({ ...validUser, age: 18 }); - expect(result.success).toBe(true); - }); - }); + it('@@validate error uses the configured path', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, age: 16 }); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path); + expect(paths).toContainEqual(['age']); + } + }); - describe('error handling', () => { - it('throws when model is not found', () => { - expect(() => factory.makeModelSchema('Unknown' as any)).toThrow('Model "Unknown" not found in schema'); + it('passes when @@validate condition is satisfied', () => { + const userSchema = factory.makeModelSchema('User'); + const result = userSchema.safeParse({ ...validUser, age: 18 }); + expect(result.success).toBe(true); + }); }); - }); -}); - -describe('SchemaFactory - makeTypeSchema', () => { - it('generates schema for Address typedef', () => { - const addressSchema = factory.makeTypeSchema('Address'); - expect( - addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }).success, - ).toBe(true); - }); - - it('rejects Address with missing required field', () => { - const addressSchema = factory.makeTypeSchema('Address'); - const result = addressSchema.safeParse({ residents: [], street: '123 Main' }); - expect(result.success).toBe(false); - }); - it('rejects Address with extra fields (strict object)', () => { - const addressSchema = factory.makeTypeSchema('Address'); - const result = addressSchema.safeParse({ - residents: [], - street: '123 Main', - city: 'Springfield', - zip: null, - extra: 'field', + describe('error handling', () => { + it('throws when model is not found', () => { + expect(() => factory.makeModelSchema('Unknown' as any)).toThrow('Model "Unknown" not found in schema'); + }); }); - expect(result.success).toBe(false); }); - it('accepts Address with optional zip as null', () => { - const addressSchema = factory.makeTypeSchema('Address'); - expect( - addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }).success, - ).toBe(true); - }); - - it('accepts Address with optional zip as a string', () => { - const addressSchema = factory.makeTypeSchema('Address'); - expect( - addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: '12345' }).success, - ).toBe(true); - }); - - describe('extra validations', () => { - it('passes when zip is null', () => { + describe('SchemaFactory - makeTypeSchema', () => { + it('generates schema for Address typedef', () => { const addressSchema = factory.makeTypeSchema('Address'); expect( addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }).success, ).toBe(true); }); - it('passes when zip is omitted', () => { - const addressSchema = factory.makeTypeSchema('Address'); - expect(addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield' }).success).toBe( - true, - ); - }); - - it('passes when zip is exactly 5 characters', () => { + it('rejects Address with missing required field', () => { const addressSchema = factory.makeTypeSchema('Address'); - expect( - addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: '90210' }) - .success, - ).toBe(true); - }); - - it('fails when zip is fewer than 5 characters', () => { - const addressSchema = factory.makeTypeSchema('Address'); - const result = addressSchema.safeParse({ - residents: [], - street: '123 Main', - city: 'Springfield', - zip: '123', - }); - expect(result.success).toBe(false); - }); - - it('fails when zip is more than 5 characters', () => { - const addressSchema = factory.makeTypeSchema('Address'); - const result = addressSchema.safeParse({ - residents: [], - street: '123 Main', - city: 'Springfield', - zip: '123456', - }); + const result = addressSchema.safeParse({ residents: [], street: '123 Main' }); expect(result.success).toBe(false); }); - it('error message matches the configured message', () => { + it('rejects Address with extra fields (strict object)', () => { const addressSchema = factory.makeTypeSchema('Address'); const result = addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', - zip: '123', + zip: null, + extra: 'field', }); expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues.map((i) => i.message)).toContain('Zip code must be exactly 5 characters'); - } }); - it('error path points to the zip field', () => { + it('accepts Address with optional zip as null', () => { const addressSchema = factory.makeTypeSchema('Address'); - const result = addressSchema.safeParse({ - residents: [], - street: '123 Main', - city: 'Springfield', - zip: '123', - }); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues.map((i) => i.path)).toContainEqual(['zip']); - } + expect( + addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }).success, + ).toBe(true); }); - it('fails when city is too short', () => { + it('accepts Address with optional zip as a string', () => { const addressSchema = factory.makeTypeSchema('Address'); - const result = addressSchema.safeParse({ residents: [], street: '123 Main', city: '', zip: '12345' }); - expect(result.success).toBe(false); - }); - - it('also validates when Address is embedded in User', () => { - const userSchema = factory.makeModelSchema('User'); - const validUser = { - id: 'u1', - email: 'a@b.com', - phone: '+15555555555', - username: 'alice', - website: null, - code: 'USR01', - age: 20, - score: 50, - bigNum: BigInt(0), - balance: 1, - active: true, - birthdate: null, - localTime: null, - createdAt: null, - avatar: null, - metadata: null, - status: 'ACTIVE', - address: { residents: [], street: '123 Main', city: 'Springfield', zip: '90210' }, - }; - expect(userSchema.safeParse(validUser).success).toBe(true); expect( - userSchema.safeParse({ - ...validUser, - address: { residents: ['Alice'], street: '123 Main', city: 'Springfield', zip: '123' }, - }).success, - ).toBe(false); + addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: '12345' }) + .success, + ).toBe(true); }); - }); -}); - -describe('SchemaFactory - @meta description', () => { - it('applies @@meta description to model schema', () => { - const userSchema = factory.makeModelSchema('User'); - expect(userSchema.meta()?.description).toBe('A user of the system'); - }); - - it('applies @meta description to model field schema', () => { - const userSchema = factory.makeModelSchema('User'); - expect(userSchema.shape.email.meta()?.description).toBe("The user's email address"); - }); - - it('does not set description when @meta("description") is absent', () => { - const userSchema = factory.makeModelSchema('User'); - expect(userSchema.shape.active.meta()?.description).toBeUndefined(); - }); - it('applies @@meta description to model create schema', () => { - const createSchema = factory.makeModelCreateSchema('User'); - expect(createSchema.meta()?.description).toBe('A user of the system'); - }); - - it('applies @meta description to model create field schema', () => { - const createSchema = factory.makeModelCreateSchema('User'); - expect(createSchema.shape.email.meta()?.description).toBe("The user's email address"); - }); - - it('applies @@meta description to model update schema', () => { - const updateSchema = factory.makeModelUpdateSchema('User'); - expect(updateSchema.meta()?.description).toBe('A user of the system'); - }); - - it('applies @meta description to model update field schema', () => { - const updateSchema = factory.makeModelUpdateSchema('User'); - expect(updateSchema.shape.email.meta()?.description).toBe("The user's email address"); - }); - - it('applies @@meta description to typedef schema', () => { - const addressSchema = factory.makeTypeSchema('Address'); - expect(addressSchema.meta()?.description).toBe('A mailing address'); - }); - - it('applies @meta description to typedef field schema', () => { - const addressSchema = factory.makeTypeSchema('Address'); - expect(addressSchema.shape.street.meta()?.description).toBe('Street address line'); - }); - - it('applies @@meta description to enum schema', () => { - const statusSchema = factory.makeEnumSchema('Status'); - expect(statusSchema.meta()?.description).toBe('User account status'); - }); + describe('extra validations', () => { + it('passes when zip is null', () => { + const addressSchema = factory.makeTypeSchema('Address'); + expect( + addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: null }) + .success, + ).toBe(true); + }); - it('does not set description for model without @@meta("description")', () => { - const postSchema = factory.makeModelSchema('Post'); - expect(postSchema.meta()?.description).toBeUndefined(); - }); -}); + it('passes when zip is omitted', () => { + const addressSchema = factory.makeTypeSchema('Address'); + expect( + addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield' }).success, + ).toBe(true); + }); -describe('SchemaFactory - makeEnumSchema', () => { - it('accepts all valid enum values', () => { - const statusSchema = factory.makeEnumSchema('Status'); - expect(statusSchema.safeParse('ACTIVE').success).toBe(true); - expect(statusSchema.safeParse('INACTIVE').success).toBe(true); - expect(statusSchema.safeParse('PENDING').success).toBe(true); - }); + it('passes when zip is exactly 5 characters', () => { + const addressSchema = factory.makeTypeSchema('Address'); + expect( + addressSchema.safeParse({ residents: [], street: '123 Main', city: 'Springfield', zip: '90210' }) + .success, + ).toBe(true); + }); - it('rejects values not in the enum', () => { - const statusSchema = factory.makeEnumSchema('Status'); - expect(statusSchema.safeParse('ADMIN').success).toBe(false); - expect(statusSchema.safeParse('active').success).toBe(false); - expect(statusSchema.safeParse('').success).toBe(false); - expect(statusSchema.safeParse(null).success).toBe(false); - expect(statusSchema.safeParse(42).success).toBe(false); - }); + it('fails when zip is fewer than 5 characters', () => { + const addressSchema = factory.makeTypeSchema('Address'); + const result = addressSchema.safeParse({ + residents: [], + street: '123 Main', + city: 'Springfield', + zip: '123', + }); + expect(result.success).toBe(false); + }); - it('infers enum value union type', () => { - const _statusSchema = factory.makeEnumSchema('Status'); - type Status = z.infer; - expectTypeOf().toEqualTypeOf<'ACTIVE' | 'INACTIVE' | 'PENDING'>(); - }); + it('fails when zip is more than 5 characters', () => { + const addressSchema = factory.makeTypeSchema('Address'); + const result = addressSchema.safeParse({ + residents: [], + street: '123 Main', + city: 'Springfield', + zip: '123456', + }); + expect(result.success).toBe(false); + }); - it('throws when enum is not found', () => { - expect(() => factory.makeEnumSchema('Unknown' as any)).toThrow(); - }); -}); + it('error message matches the configured message', () => { + const addressSchema = factory.makeTypeSchema('Address'); + const result = addressSchema.safeParse({ + residents: [], + street: '123 Main', + city: 'Springfield', + zip: '123', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.map((i) => i.message)).toContain( + 'Zip code must be exactly 5 characters', + ); + } + }); -// --- Computed fields tests --- - -const validProduct = { - id: 'prod1', - name: 'Widget', - price: 10.0, - discount: 2.0, - finalPrice: 8.0, -}; - -describe('SchemaFactory - computed fields', () => { - describe('makeModelSchema includes computed fields', () => { - it('accepts a Product with computed field present', () => { - const productSchema = factory.makeModelSchema('Product'); - expect(productSchema.safeParse(validProduct).success).toBe(true); - }); + it('error path points to the zip field', () => { + const addressSchema = factory.makeTypeSchema('Address'); + const result = addressSchema.safeParse({ + residents: [], + street: '123 Main', + city: 'Springfield', + zip: '123', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.map((i) => i.path)).toContainEqual(['zip']); + } + }); - it('rejects a Product missing the computed field', () => { - const productSchema = factory.makeModelSchema('Product'); - const { finalPrice: _, ...withoutComputed } = validProduct; - expect(productSchema.safeParse(withoutComputed).success).toBe(false); - }); + it('fails when city is too short', () => { + const addressSchema = factory.makeTypeSchema('Address'); + const result = addressSchema.safeParse({ residents: [], street: '123 Main', city: '', zip: '12345' }); + expect(result.success).toBe(false); + }); - it('infers computed field in model schema type', () => { - const _schema = factory.makeModelSchema('Product'); - type Product = z.infer; - expectTypeOf().toEqualTypeOf(); + it('also validates when Address is embedded in User', () => { + const userSchema = factory.makeModelSchema('User'); + const validUser = { + id: 'u1', + email: 'a@b.com', + phone: '+15555555555', + username: 'alice', + website: null, + code: 'USR01', + age: 20, + score: 50, + bigNum: BigInt(0), + balance: 1, + active: true, + birthdate: null, + localTime: null, + createdAt: null, + avatar: null, + metadata: null, + status: 'ACTIVE', + address: { residents: [], street: '123 Main', city: 'Springfield', zip: '90210' }, + }; + expect(userSchema.safeParse(validUser).success).toBe(true); + expect( + userSchema.safeParse({ + ...validUser, + address: { residents: ['Alice'], street: '123 Main', city: 'Springfield', zip: '123' }, + }).success, + ).toBe(false); + }); }); }); - describe('makeModelCreateSchema excludes computed fields', () => { - it('accepts a Product without the computed field', () => { - const createSchema = factory.makeModelCreateSchema('Product'); - expect(createSchema.safeParse({ name: 'Widget', price: 10.0 }).success).toBe(true); - }); - - it('rejects a Product with the computed field (strict)', () => { - const createSchema = factory.makeModelCreateSchema('Product'); - expect(createSchema.safeParse({ name: 'Widget', price: 10.0, finalPrice: 8.0 }).success).toBe(false); + describe('SchemaFactory - @meta description', () => { + it('applies @@meta description to model schema', () => { + const userSchema = factory.makeModelSchema('User'); + expect(userSchema.meta()?.description).toBe('A user of the system'); }); - it('does not include computed field in create schema type', () => { - const _schema = factory.makeModelCreateSchema('Product'); - type ProductCreate = z.infer; - expectTypeOf().not.toHaveProperty('finalPrice'); - // own fields are present - expectTypeOf().toHaveProperty('name'); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - // field with default is optional - expectTypeOf().toHaveProperty('discount'); + it('applies @meta description to model field schema', () => { + const userSchema = factory.makeModelSchema('User'); + expect(userSchema.shape.email.meta()?.description).toBe("The user's email address"); }); - }); - describe('makeModelUpdateSchema excludes computed fields', () => { - it('accepts a Product update without the computed field', () => { - const updateSchema = factory.makeModelUpdateSchema('Product'); - expect(updateSchema.safeParse({ price: 12.0 }).success).toBe(true); + it('does not set description when @meta("description") is absent', () => { + const userSchema = factory.makeModelSchema('User'); + expect(userSchema.shape.active.meta()?.description).toBeUndefined(); }); - it('rejects a Product update with the computed field (strict)', () => { - const updateSchema = factory.makeModelUpdateSchema('Product'); - expect(updateSchema.safeParse({ price: 12.0, finalPrice: 10.0 }).success).toBe(false); + it('applies @@meta description to model create schema', () => { + const createSchema = factory.makeModelCreateSchema('User'); + expect(createSchema.meta()?.description).toBe('A user of the system'); }); - it('does not include computed field in update schema type', () => { - const _schema = factory.makeModelUpdateSchema('Product'); - type ProductUpdate = z.infer; - expectTypeOf().not.toHaveProperty('finalPrice'); - // own fields are present (all optional in update) - expectTypeOf().toHaveProperty('name'); + it('applies @meta description to model create field schema', () => { + const createSchema = factory.makeModelCreateSchema('User'); + expect(createSchema.shape.email.meta()?.description).toBe("The user's email address"); }); - }); -}); -// --- Delegate model tests --- - -const validVideo = { - id: 1, - createdAt: new Date(), - assetType: 'Video', - duration: 120, - url: 'https://example.com/video.mp4', -}; - -const validImage = { - id: 2, - createdAt: new Date(), - assetType: 'Image', - format: 'png', - width: 800, -}; - -describe('SchemaFactory - delegate models', () => { - describe('makeModelSchema for delegate base model', () => { - it('accepts a valid Asset', () => { - const assetSchema = factory.makeModelSchema('Asset'); - expect(assetSchema.safeParse({ id: 1, createdAt: new Date(), assetType: 'Video' }).success).toBe(true); + it('applies @@meta description to model update schema', () => { + const updateSchema = factory.makeModelUpdateSchema('User'); + expect(updateSchema.meta()?.description).toBe('A user of the system'); }); - it('includes discriminator field in model schema type', () => { - const _schema = factory.makeModelSchema('Asset'); - type Asset = z.infer; - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + it('applies @meta description to model update field schema', () => { + const updateSchema = factory.makeModelUpdateSchema('User'); + expect(updateSchema.shape.email.meta()?.description).toBe("The user's email address"); }); - }); - describe('makeModelSchema for derived models', () => { - it('accepts a valid Video (includes inherited + own fields)', () => { - const videoSchema = factory.makeModelSchema('Video'); - expect(videoSchema.safeParse(validVideo).success).toBe(true); + it('applies @@meta description to typedef schema', () => { + const addressSchema = factory.makeTypeSchema('Address'); + expect(addressSchema.meta()?.description).toBe('A mailing address'); }); - it('accepts a valid Image (includes inherited + own fields)', () => { - const imageSchema = factory.makeModelSchema('Image'); - expect(imageSchema.safeParse(validImage).success).toBe(true); + it('applies @meta description to typedef field schema', () => { + const addressSchema = factory.makeTypeSchema('Address'); + expect(addressSchema.shape.street.meta()?.description).toBe('Street address line'); }); - it('rejects Video missing own fields', () => { - const videoSchema = factory.makeModelSchema('Video'); - const { duration: _, url: _u, ...withoutOwn } = validVideo; - expect(videoSchema.safeParse(withoutOwn).success).toBe(false); + it('applies @@meta description to enum schema', () => { + const statusSchema = factory.makeEnumSchema('Status'); + expect(statusSchema.meta()?.description).toBe('User account status'); }); - it('infers correct types for derived model including inherited fields', () => { - const _schema = factory.makeModelSchema('Video'); - type Video = z.infer; - // inherited fields - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - // own fields - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + it('does not set description for model without @@meta("description")', () => { + const postSchema = factory.makeModelSchema('Post'); + expect(postSchema.meta()?.description).toBeUndefined(); }); }); - describe('makeModelCreateSchema excludes discriminator', () => { - it('accepts Video create without discriminator and inherited fields', () => { - const createSchema = factory.makeModelCreateSchema('Video'); - // Only own non-inherited, non-discriminator fields should be required - expect(createSchema.safeParse({ duration: 120, url: 'https://example.com/video.mp4' }).success).toBe(true); + describe('SchemaFactory - makeEnumSchema', () => { + it('accepts all valid enum values', () => { + const statusSchema = factory.makeEnumSchema('Status'); + expect(statusSchema.safeParse('ACTIVE').success).toBe(true); + expect(statusSchema.safeParse('INACTIVE').success).toBe(true); + expect(statusSchema.safeParse('PENDING').success).toBe(true); }); - it('rejects Video create with discriminator field (strict)', () => { - const createSchema = factory.makeModelCreateSchema('Video'); - expect( - createSchema.safeParse({ - duration: 120, - url: 'https://example.com/video.mp4', - assetType: 'Video', - }).success, - ).toBe(false); - }); - - it('does not include discriminator fields in create schema type', () => { - const _schema = factory.makeModelCreateSchema('Video'); - type VideoCreate = z.infer; - // discriminator and originModel fields should be excluded - expectTypeOf().not.toHaveProperty('assetType'); - // own fields should be present - expectTypeOf().toHaveProperty('duration'); - expectTypeOf().toHaveProperty('url'); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + it('rejects values not in the enum', () => { + const statusSchema = factory.makeEnumSchema('Status'); + expect(statusSchema.safeParse('ADMIN').success).toBe(false); + expect(statusSchema.safeParse('active').success).toBe(false); + expect(statusSchema.safeParse('').success).toBe(false); + expect(statusSchema.safeParse(null).success).toBe(false); + expect(statusSchema.safeParse(42).success).toBe(false); }); - it('excludes discriminator from base delegate create schema', () => { - const createSchema = factory.makeModelCreateSchema('Asset'); - // discriminator should not be included - expect(createSchema.safeParse({ assetType: 'Video' }).success).toBe(false); - // empty create (id has default, createdAt has default, assetType is discriminator) - expect(createSchema.safeParse({}).success).toBe(true); + it('infers enum value union type', () => { + const _statusSchema = factory.makeEnumSchema('Status'); + type Status = z.infer; + expectTypeOf().toEqualTypeOf<'ACTIVE' | 'INACTIVE' | 'PENDING'>(); }); - it('does not include discriminator in base delegate create schema type', () => { - const _schema = factory.makeModelCreateSchema('Asset'); - type AssetCreate = z.infer; - expectTypeOf().not.toHaveProperty('assetType'); + it('throws when enum is not found', () => { + expect(() => factory.makeEnumSchema('Unknown' as any)).toThrow(); }); }); - describe('makeModelUpdateSchema excludes discriminator and originModel fields', () => { - it('accepts Video update with only own fields', () => { - const updateSchema = factory.makeModelUpdateSchema('Video'); - expect(updateSchema.safeParse({ duration: 180 }).success).toBe(true); - }); - - it('rejects Video update with discriminator field (strict)', () => { - const updateSchema = factory.makeModelUpdateSchema('Video'); - expect(updateSchema.safeParse({ duration: 180, assetType: 'Video' }).success).toBe(false); - }); - - it('does not include discriminator fields in update schema type', () => { - const _schema = factory.makeModelUpdateSchema('Video'); - type VideoUpdate = z.infer; - expectTypeOf().not.toHaveProperty('assetType'); - // own fields should be present (all optional in update) - expectTypeOf().toHaveProperty('duration'); - expectTypeOf().toHaveProperty('url'); - }); + // --- Computed fields tests --- + + const validProduct = { + id: 'prod1', + name: 'Widget', + price: 10.0, + discount: 2.0, + finalPrice: 8.0, + }; + + describe('SchemaFactory - computed fields', () => { + describe('makeModelSchema includes computed fields', () => { + it('accepts a Product with computed field present', () => { + const productSchema = factory.makeModelSchema('Product'); + expect(productSchema.safeParse(validProduct).success).toBe(true); + }); - it('does not include discriminator in base delegate update schema type', () => { - const _schema = factory.makeModelUpdateSchema('Asset'); - type AssetUpdate = z.infer; - expectTypeOf().not.toHaveProperty('assetType'); - }); - }); -}); + it('rejects a Product missing the computed field', () => { + const productSchema = factory.makeModelSchema('Product'); + const { finalPrice: _, ...withoutComputed } = validProduct; + expect(productSchema.safeParse(withoutComputed).success).toBe(false); + }); -// --------------------------------------------------------------------------- -// makeModelSchema — ORM-style options (omit / include / select) -// --------------------------------------------------------------------------- - -// User without username (the omit use-case baseline) -const validUserNoUsername = (() => { - const { username: _, ...rest } = validUser; - return rest; -})(); - -describe('SchemaFactory - makeModelSchema with options', () => { - // ── omit ──────────────────────────────────────────────────────────────── - describe('omit', () => { - it('excludes the omitted scalar field at runtime', () => { - const schema = factory.makeModelSchema('User', { omit: { username: true } }); - // validUserNoUsername has no username field — should pass - expect(schema.safeParse(validUserNoUsername).success).toBe(true); + it('infers computed field in model schema type', () => { + const _schema = factory.makeModelSchema('Product'); + type Product = z.infer; + expectTypeOf().toEqualTypeOf(); + }); }); - it('rejects when the omitted field is present (strict object)', () => { - const schema = factory.makeModelSchema('User', { omit: { username: true } }); - // passing the full validUser (which has username) must fail because - // the schema is strict and username is no longer a known key - expect(schema.safeParse(validUser).success).toBe(false); - }); + describe('makeModelCreateSchema excludes computed fields', () => { + it('accepts a Product without the computed field', () => { + const createSchema = factory.makeModelCreateSchema('Product'); + expect(createSchema.safeParse({ name: 'Widget', price: 10.0 }).success).toBe(true); + }); - it('infers omitted field is absent from the output type', () => { - const _schema = factory.makeModelSchema('User', { omit: { username: true } }); - type Result = z.infer; - expectTypeOf().not.toHaveProperty('username'); - }); + it('rejects a Product with the computed field (strict)', () => { + const createSchema = factory.makeModelCreateSchema('Product'); + expect(createSchema.safeParse({ name: 'Widget', price: 10.0, finalPrice: 8.0 }).success).toBe(false); + }); - it('keeps all other scalar fields when one is omitted', () => { - const _schema = factory.makeModelSchema('User', { omit: { username: true } }); - type Result = z.infer; - expectTypeOf().toHaveProperty('id'); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toHaveProperty('email'); - expectTypeOf().toHaveProperty('phone'); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + it('does not include computed field in create schema type', () => { + const _schema = factory.makeModelCreateSchema('Product'); + type ProductCreate = z.infer; + expectTypeOf().not.toHaveProperty('finalPrice'); + // own fields are present + expectTypeOf().toHaveProperty('name'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // field with default is optional + expectTypeOf().toHaveProperty('discount'); + }); }); - it('omit: {} (empty) keeps all scalar fields', () => { - const schema = factory.makeModelSchema('User', { omit: {} }); - expect(schema.safeParse(validUser).success).toBe(true); - }); + describe('makeModelUpdateSchema excludes computed fields', () => { + it('accepts a Product update without the computed field', () => { + const updateSchema = factory.makeModelUpdateSchema('Product'); + expect(updateSchema.safeParse({ price: 12.0 }).success).toBe(true); + }); - it('can omit multiple fields', () => { - const schema = factory.makeModelSchema('User', { omit: { username: true, avatar: true } }); - const { username: _u, avatar: _a, ...rest } = validUser; - expect(schema.safeParse(rest).success).toBe(true); - }); + it('rejects a Product update with the computed field (strict)', () => { + const updateSchema = factory.makeModelUpdateSchema('Product'); + expect(updateSchema.safeParse({ price: 12.0, finalPrice: 10.0 }).success).toBe(false); + }); - it('infers multiple omitted fields absent', () => { - const _schema = factory.makeModelSchema('User', { omit: { username: true, avatar: true } }); - type Result = z.infer; - expectTypeOf().not.toHaveProperty('username'); - expectTypeOf().not.toHaveProperty('avatar'); - expectTypeOf().toHaveProperty('email'); - expectTypeOf().toHaveProperty('phone'); + it('does not include computed field in update schema type', () => { + const _schema = factory.makeModelUpdateSchema('Product'); + type ProductUpdate = z.infer; + expectTypeOf().not.toHaveProperty('finalPrice'); + // own fields are present (all optional in update) + expectTypeOf().toHaveProperty('name'); + }); }); }); - // ── include ───────────────────────────────────────────────────────────── - describe('include', () => { - it('adds the relation field alongside all scalars', () => { - const schema = factory.makeModelSchema('User', { include: { posts: true } }); - // All scalar fields must still be present - expect(schema.safeParse(validUser).success).toBe(true); - }); - - it('the included relation field is optional', () => { - const schema = factory.makeModelSchema('User', { include: { posts: true } }); - // omitting posts should still pass - expect(schema.safeParse(validUser).success).toBe(true); - }); - - it('infers included relation field in output type', () => { - const _schema = factory.makeModelSchema('User', { include: { posts: true } }); - type Result = z.infer; - expectTypeOf().toHaveProperty('posts'); - const _postSchema = factory.makeModelSchema('Post'); - type Post = z.infer; - expectTypeOf().toEqualTypeOf(); - }); - - it('infers scalar fields still present when using include', () => { - const _schema = factory.makeModelSchema('User', { include: { posts: true } }); - type Result = z.infer; - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - }); + // --- Delegate model tests --- + + const validVideo = { + id: 1, + createdAt: new Date(), + assetType: 'Video', + duration: 120, + url: 'https://example.com/video.mp4', + }; + + const validImage = { + id: 2, + createdAt: new Date(), + assetType: 'Image', + format: 'png', + width: 800, + }; + + describe('SchemaFactory - delegate models', () => { + describe('makeModelSchema for delegate base model', () => { + it('accepts a valid Asset', () => { + const assetSchema = factory.makeModelSchema('Asset'); + expect(assetSchema.safeParse({ id: 1, createdAt: new Date(), assetType: 'Video' }).success).toBe(true); + }); - it('include with nested select on relation', () => { - const schema = factory.makeModelSchema('User', { - include: { posts: { select: { title: true } } }, + it('includes discriminator field in model schema type', () => { + const _schema = factory.makeModelSchema('Asset'); + type Asset = z.infer; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); - // posts with only title should pass - expect(schema.safeParse({ ...validUser, posts: [{ title: 'Hello' }] }).success).toBe(true); - // posts with extra field should fail (strict) - expect(schema.safeParse({ ...validUser, posts: [{ title: 'Hello', published: true }] }).success).toBe( - false, - ); }); - it('infers nested select shape on included relation', () => { - const _schema = factory.makeModelSchema('User', { - include: { posts: { select: { title: true } } }, + describe('makeModelSchema for derived models', () => { + it('accepts a valid Video (includes inherited + own fields)', () => { + const videoSchema = factory.makeModelSchema('Video'); + expect(videoSchema.safeParse(validVideo).success).toBe(true); }); - type Result = z.infer; - type Posts = Exclude; - type Post = Posts extends Array ? P : never; - expectTypeOf().toHaveProperty('title'); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().not.toHaveProperty('id'); - }); - }); - // ── include + omit ─────────────────────────────────────────────────────── - describe('include + omit', () => { - it('omits the scalar field and adds the relation', () => { - const schema = factory.makeModelSchema('User', { - omit: { username: true }, - include: { posts: true }, + it('accepts a valid Image (includes inherited + own fields)', () => { + const imageSchema = factory.makeModelSchema('Image'); + expect(imageSchema.safeParse(validImage).success).toBe(true); }); - expect(schema.safeParse({ ...validUserNoUsername, posts: [] }).success).toBe(true); - }); - it('rejects when omitted field is present', () => { - const schema = factory.makeModelSchema('User', { - omit: { username: true }, - include: { posts: true }, + it('rejects Video missing own fields', () => { + const videoSchema = factory.makeModelSchema('Video'); + const { duration: _, url: _u, ...withoutOwn } = validVideo; + expect(videoSchema.safeParse(withoutOwn).success).toBe(false); }); - expect(schema.safeParse({ ...validUser, posts: [] }).success).toBe(false); - }); - it('infers combined shape correctly', () => { - const _schema = factory.makeModelSchema('User', { - omit: { username: true }, - include: { posts: true }, + it('infers correct types for derived model including inherited fields', () => { + const _schema = factory.makeModelSchema('Video'); + type Video = z.infer; + // inherited fields + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // own fields + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); - type Result = z.infer; - expectTypeOf().not.toHaveProperty('username'); - expectTypeOf().toHaveProperty('email'); - expectTypeOf().toHaveProperty('phone'); - expectTypeOf().toHaveProperty('posts'); }); - }); - // ── select ─────────────────────────────────────────────────────────────── - describe('select', () => { - it('returns only the selected scalar fields', () => { - const schema = factory.makeModelSchema('User', { select: { id: true, email: true } }); - expect(schema.safeParse({ id: 'u1', email: 'a@b.com' }).success).toBe(true); - }); + describe('makeModelCreateSchema excludes discriminator', () => { + it('accepts Video create without discriminator and inherited fields', () => { + const createSchema = factory.makeModelCreateSchema('Video'); + // Only own non-inherited, non-discriminator fields should be required + expect(createSchema.safeParse({ duration: 120, url: 'https://example.com/video.mp4' }).success).toBe( + true, + ); + }); - it('rejects when a non-selected field is present (strict)', () => { - const schema = factory.makeModelSchema('User', { select: { id: true, email: true } }); - expect(schema.safeParse({ id: 'u1', email: 'a@b.com', username: 'alice' }).success).toBe(false); - }); + it('rejects Video create with discriminator field (strict)', () => { + const createSchema = factory.makeModelCreateSchema('Video'); + expect( + createSchema.safeParse({ + duration: 120, + url: 'https://example.com/video.mp4', + assetType: 'Video', + }).success, + ).toBe(false); + }); - it('rejects when a selected field is missing', () => { - const schema = factory.makeModelSchema('User', { select: { id: true, email: true } }); - expect(schema.safeParse({ id: 'u1' }).success).toBe(false); - }); + it('does not include discriminator fields in create schema type', () => { + const _schema = factory.makeModelCreateSchema('Video'); + type VideoCreate = z.infer; + // discriminator and originModel fields should be excluded + expectTypeOf().not.toHaveProperty('assetType'); + // own fields should be present + expectTypeOf().toHaveProperty('duration'); + expectTypeOf().toHaveProperty('url'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); - it('infers only selected fields in output type', () => { - const _schema = factory.makeModelSchema('User', { select: { id: true, email: true } }); - type Result = z.infer; - expectTypeOf().toHaveProperty('id'); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toHaveProperty('email'); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().not.toHaveProperty('username'); - expectTypeOf().not.toHaveProperty('posts'); - expectTypeOf().not.toHaveProperty('phone'); - }); + it('excludes discriminator from base delegate create schema', () => { + const createSchema = factory.makeModelCreateSchema('Asset'); + // discriminator should not be included + expect(createSchema.safeParse({ assetType: 'Video' }).success).toBe(false); + // empty create (id has default, createdAt has default, assetType is discriminator) + expect(createSchema.safeParse({}).success).toBe(true); + }); - it('select with a relation field (true) includes the relation', () => { - const schema = factory.makeModelSchema('User', { select: { id: true, posts: true } }); - expect(schema.safeParse({ id: 'u1', posts: [] }).success).toBe(true); - // email should not be present - expect(schema.safeParse({ id: 'u1', posts: [], email: 'a@b.com' }).success).toBe(false); + it('does not include discriminator in base delegate create schema type', () => { + const _schema = factory.makeModelCreateSchema('Asset'); + type AssetCreate = z.infer; + expectTypeOf().not.toHaveProperty('assetType'); + }); }); - it('infers relation field type when selected with true', () => { - const _schema = factory.makeModelSchema('User', { select: { id: true, posts: true } }); - type Result = z.infer; - expectTypeOf().toHaveProperty('id'); - expectTypeOf().toHaveProperty('posts'); - expectTypeOf().not.toHaveProperty('email'); - expectTypeOf().not.toHaveProperty('phone'); - }); + describe('makeModelUpdateSchema excludes discriminator and originModel fields', () => { + it('accepts Video update with only own fields', () => { + const updateSchema = factory.makeModelUpdateSchema('Video'); + expect(updateSchema.safeParse({ duration: 180 }).success).toBe(true); + }); - it('select with nested options on a relation', () => { - const schema = factory.makeModelSchema('User', { - select: { - id: true, - posts: { select: { title: true, published: true } }, - }, - }); - expect(schema.safeParse({ id: 'u1', posts: [{ title: 'Hello', published: true }] }).success).toBe(true); - // extra field in nested post - expect(schema.safeParse({ id: 'u1', posts: [{ title: 'Hello', published: true, id: 'p1' }] }).success).toBe( - false, - ); - }); + it('rejects Video update with discriminator field (strict)', () => { + const updateSchema = factory.makeModelUpdateSchema('Video'); + expect(updateSchema.safeParse({ duration: 180, assetType: 'Video' }).success).toBe(false); + }); - it('infers nested select shape on relation when selected with options', () => { - const _schema = factory.makeModelSchema('User', { - select: { - id: true, - posts: { select: { title: true } }, - }, - }); - type Result = z.infer; - type Posts = Exclude; - type Post = Posts extends Array ? P : never; - expectTypeOf().toHaveProperty('title'); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().not.toHaveProperty('id'); - expectTypeOf().not.toHaveProperty('published'); - }); + it('does not include discriminator fields in update schema type', () => { + const _schema = factory.makeModelUpdateSchema('Video'); + type VideoUpdate = z.infer; + expectTypeOf().not.toHaveProperty('assetType'); + // own fields should be present (all optional in update) + expectTypeOf().toHaveProperty('duration'); + expectTypeOf().toHaveProperty('url'); + }); - it('select on Post with author relation (nested include)', () => { - const schema = factory.makeModelSchema('Post', { - select: { - id: true, - author: { select: { id: true, email: true } }, - }, - }); - expect(schema.safeParse({ id: 'p1', author: { id: 'u1', email: 'a@b.com' } }).success).toBe(true); - // author with extra field - expect(schema.safeParse({ id: 'p1', author: { id: 'u1', email: 'a@b.com', username: 'x' } }).success).toBe( - false, - ); + it('does not include discriminator in base delegate update schema type', () => { + const _schema = factory.makeModelUpdateSchema('Asset'); + type AssetUpdate = z.infer; + expectTypeOf().not.toHaveProperty('assetType'); + }); }); }); - // ── invalid option combinations ─────────────────────────────────────────── - describe('invalid option combinations', () => { - it('throws when select and include are used together', () => { - expect(() => - factory.makeModelSchema('User', { select: { id: true }, include: { posts: true } } as any), - ).toThrow('`select` and `include` cannot be used together'); - }); - - it('throws when select and omit are used together', () => { - expect(() => - factory.makeModelSchema('User', { select: { id: true }, omit: { username: true } } as any), - ).toThrow('`select` and `omit` cannot be used together'); - }); - - it('throws when select and include are used together in nested relation options', () => { - expect(() => - factory.makeModelSchema('User', { - include: { posts: { select: { id: true }, include: {} } as any }, - }), - ).toThrow('`select` and `include` cannot be used together'); - }); + // --------------------------------------------------------------------------- + // makeModelSchema — ORM-style options (omit / include / select) + // --------------------------------------------------------------------------- + + // User without username (the omit use-case baseline) + const validUserNoUsername = (() => { + const { username: _, ...rest } = validUser; + return rest; + })(); + + describe('SchemaFactory - makeModelSchema with options', () => { + // ── omit ──────────────────────────────────────────────────────────────── + describe('omit', () => { + it('excludes the omitted scalar field at runtime', () => { + const schema = factory.makeModelSchema('User', { omit: { username: true } }); + // validUserNoUsername has no username field — should pass + expect(schema.safeParse(validUserNoUsername).success).toBe(true); + }); - it('throws when select references a non-existent field', () => { - expect(() => factory.makeModelSchema('User', { select: { nonExistent: true } as any })).toThrow( - 'Field "nonExistent" does not exist on model "User"', - ); - }); + it('rejects when the omitted field is present (strict object)', () => { + const schema = factory.makeModelSchema('User', { omit: { username: true } }); + // passing the full validUser (which has username) must fail because + // the schema is strict and username is no longer a known key + expect(schema.safeParse(validUser).success).toBe(false); + }); - it('throws when select provides nested options for a scalar field', () => { - expect(() => - factory.makeModelSchema('User', { select: { email: { select: { id: true } } } as any }), - ).toThrow('Field "email" on model "User" is a scalar field and cannot have nested options'); - }); + it('infers omitted field is absent from the output type', () => { + const _schema = factory.makeModelSchema('User', { omit: { username: true } }); + type Result = z.infer; + expectTypeOf().not.toHaveProperty('username'); + }); - it('throws when include references a non-existent field', () => { - expect(() => factory.makeModelSchema('User', { include: { nonExistent: true } as any })).toThrow( - 'Field "nonExistent" does not exist on model "User"', - ); - }); + it('keeps all other scalar fields when one is omitted', () => { + const _schema = factory.makeModelSchema('User', { omit: { username: true } }); + type Result = z.infer; + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('phone'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); - it('throws when include references a scalar field', () => { - expect(() => factory.makeModelSchema('User', { include: { email: true } as any })).toThrow( - 'Field "email" on model "User" is not a relation field and cannot be used in "include"', - ); - }); + it('omit: {} (empty) keeps all scalar fields', () => { + const schema = factory.makeModelSchema('User', { omit: {} }); + expect(schema.safeParse(validUser).success).toBe(true); + }); - it('throws when omit references a non-existent field', () => { - expect(() => factory.makeModelSchema('User', { omit: { nonExistent: true } as any })).toThrow( - 'Field "nonExistent" does not exist on model "User"', - ); - }); + it('can omit multiple fields', () => { + const schema = factory.makeModelSchema('User', { omit: { username: true, avatar: true } }); + const { username: _u, avatar: _a, ...rest } = validUser; + expect(schema.safeParse(rest).success).toBe(true); + }); - it('throws when omit references a relation field', () => { - expect(() => factory.makeModelSchema('User', { omit: { posts: true } as any })).toThrow( - 'Field "posts" on model "User" is a relation field and cannot be used in "omit"', - ); + it('infers multiple omitted fields absent', () => { + const _schema = factory.makeModelSchema('User', { omit: { username: true, avatar: true } }); + type Result = z.infer; + expectTypeOf().not.toHaveProperty('username'); + expectTypeOf().not.toHaveProperty('avatar'); + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('phone'); + }); }); - }); - // ── optionality ───────────────────────────────────────────────────────── - describe('optionality', () => { - // optionality: 'all' — every field becomes optional - describe("optionality: 'all'", () => { - it('accepts an empty object when optionality is all', () => { - const schema = factory.makeModelSchema('User', { optionality: 'all' }); - expect(schema.safeParse({}).success).toBe(true); + // ── include ───────────────────────────────────────────────────────────── + describe('include', () => { + it('adds the relation field alongside all scalars', () => { + const schema = factory.makeModelSchema('User', { include: { posts: true } }); + // All scalar fields must still be present + expect(schema.safeParse(validUser).success).toBe(true); }); - it('accepts a fully populated object when optionality is all', () => { - const schema = factory.makeModelSchema('User', { optionality: 'all' }); + it('the included relation field is optional', () => { + const schema = factory.makeModelSchema('User', { include: { posts: true } }); + // omitting posts should still pass expect(schema.safeParse(validUser).success).toBe(true); }); - it('rejects extra fields when optionality is all (still strict)', () => { - const schema = factory.makeModelSchema('User', { optionality: 'all' }); - expect(schema.safeParse({ ...validUser, unknownField: 'x' }).success).toBe(false); + it('infers included relation field in output type', () => { + const _schema = factory.makeModelSchema('User', { include: { posts: true } }); + type Result = z.infer; + expectTypeOf().toHaveProperty('posts'); + const _postSchema = factory.makeModelSchema('Post'); + type Post = z.infer; + expectTypeOf().toEqualTypeOf(); }); - it('infers all fields as optional when optionality is all', () => { - const _schema = factory.makeModelSchema('User', { optionality: 'all' }); + it('infers scalar fields still present when using include', () => { + const _schema = factory.makeModelSchema('User', { include: { posts: true } }); type Result = z.infer; - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); }); - it('still validates field constraints when the field is provided with optionality all', () => { - const schema = factory.makeModelSchema('User', { optionality: 'all' }); - // email constraint still applies when email is provided - expect(schema.safeParse({ email: 'not-an-email' }).success).toBe(false); - expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true); - // empty object passes (all optional, null comparisons in @@validate pass through) - expect(schema.safeParse({}).success).toBe(true); + it('include with nested select on relation', () => { + const schema = factory.makeModelSchema('User', { + include: { posts: { select: { title: true } } }, + }); + // posts with only title should pass + expect(schema.safeParse({ ...validUser, posts: [{ title: 'Hello' }] }).success).toBe(true); + // posts with extra field should fail (strict) + expect(schema.safeParse({ ...validUser, posts: [{ title: 'Hello', published: true }] }).success).toBe( + false, + ); }); - it('combines optionality all with omit', () => { + it('infers nested select shape on included relation', () => { + const _schema = factory.makeModelSchema('User', { + include: { posts: { select: { title: true } } }, + }); + type Result = z.infer; + type Posts = Exclude; + type Post = Posts extends Array ? P : never; + expectTypeOf().toHaveProperty('title'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toHaveProperty('id'); + }); + }); + + // ── include + omit ─────────────────────────────────────────────────────── + describe('include + omit', () => { + it('omits the scalar field and adds the relation', () => { const schema = factory.makeModelSchema('User', { omit: { username: true }, - optionality: 'all', + include: { posts: true }, }); - // empty object is fine (all optional, username omitted) - expect(schema.safeParse({}).success).toBe(true); - // username must not be present (strict + omitted) - expect(schema.safeParse({ username: 'alice' }).success).toBe(false); - // other fields are optional - expect(schema.safeParse({ email: 'a@b.com' }).success).toBe(true); + expect(schema.safeParse({ ...validUserNoUsername, posts: [] }).success).toBe(true); }); - it('combines optionality all with select', () => { + it('rejects when omitted field is present', () => { const schema = factory.makeModelSchema('User', { - select: { id: true, email: true }, - optionality: 'all', + omit: { username: true }, + include: { posts: true }, }); - // both fields optional → empty passes (no @@validate fields in shape) - expect(schema.safeParse({}).success).toBe(true); - // non-selected field rejected - expect(schema.safeParse({ id: 'u1', username: 'x' }).success).toBe(false); - // subset passes - expect(schema.safeParse({ id: 'u1' }).success).toBe(true); + expect(schema.safeParse({ ...validUser, posts: [] }).success).toBe(false); }); - it('preserves @meta description on fields wrapped by optionality all', () => { - const schema = factory.makeModelSchema('User', { optionality: 'all' }); - expect(schema.shape.email.meta()?.description).toBe("The user's email address"); + it('infers combined shape correctly', () => { + const _schema = factory.makeModelSchema('User', { + omit: { username: true }, + include: { posts: true }, + }); + type Result = z.infer; + expectTypeOf().not.toHaveProperty('username'); + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toHaveProperty('phone'); + expectTypeOf().toHaveProperty('posts'); }); }); - // optionality: 'defaults' — only fields with @default or @updatedAt become optional - describe("optionality: 'defaults'", () => { - it('makes fields with @default optional', () => { - // Product.discount has @default(0), Product.id has @default(cuid()) - // finalPrice is computed (no @default) so it must still be provided - const schema = factory.makeModelSchema('Product', { optionality: 'defaults' }); - // omitting id and discount (both have defaults) should pass - expect(schema.safeParse({ name: 'Widget', price: 10.0, finalPrice: 8.0 }).success).toBe(true); + // ── select ─────────────────────────────────────────────────────────────── + describe('select', () => { + it('returns only the selected scalar fields', () => { + const schema = factory.makeModelSchema('User', { select: { id: true, email: true } }); + expect(schema.safeParse({ id: 'u1', email: 'a@b.com' }).success).toBe(true); }); - it('keeps fields without @default required with optionality defaults', () => { - const schema = factory.makeModelSchema('Product', { optionality: 'defaults' }); - // omitting name (no default) should fail - expect(schema.safeParse({ price: 10.0, finalPrice: 8.0 }).success).toBe(false); - // omitting price (no default) should fail - expect(schema.safeParse({ name: 'Widget', finalPrice: 8.0 }).success).toBe(false); - // omitting finalPrice (computed, no default) should fail - expect(schema.safeParse({ name: 'Widget', price: 10.0 }).success).toBe(false); + it('rejects when a non-selected field is present (strict)', () => { + const schema = factory.makeModelSchema('User', { select: { id: true, email: true } }); + expect(schema.safeParse({ id: 'u1', email: 'a@b.com', username: 'alice' }).success).toBe(false); }); - it('infers fields with @default as optional and others as required', () => { - const _schema = factory.makeModelSchema('Product', { optionality: 'defaults' }); - type Result = z.infer; - // optionality: 'defaults' is now resolved statically via FieldHasDefault, - // which inspects the `default` and `updatedAt` fields on FieldDef. - // id has @default(cuid()) → optional - expectTypeOf().toEqualTypeOf(); - // discount has @default(0) → optional - expectTypeOf().toEqualTypeOf(); - // name has no default → required (unchanged) - expectTypeOf().toEqualTypeOf(); - // price has no default → required (unchanged) - expectTypeOf().toEqualTypeOf(); - }); - - it('also makes already-optional (nullable) fields optional with optionality defaults', () => { - // User.website is optional: true (nullable optional in the schema) - // optionality: 'defaults' should also make it optional in the output - const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); - // website being absent should still pass since it is an optional field - const { website: _, ...withoutWebsite } = validUser; - expect(schema.safeParse(withoutWebsite).success).toBe(true); - }); - - it('combines optionality defaults with omit', () => { - // omit finalPrice (computed) and apply defaults optionality - const schema = factory.makeModelSchema('Product', { - omit: { finalPrice: true }, - optionality: 'defaults', - }); - // id and discount have defaults → optional; name and price required - expect(schema.safeParse({ name: 'Widget', price: 10.0 }).success).toBe(true); - // finalPrice must be absent - expect(schema.safeParse({ name: 'Widget', price: 10.0, finalPrice: 8.0 }).success).toBe(false); + it('rejects when a selected field is missing', () => { + const schema = factory.makeModelSchema('User', { select: { id: true, email: true } }); + expect(schema.safeParse({ id: 'u1' }).success).toBe(false); }); - it('combines optionality defaults with select (only selected fields apply defaults logic)', () => { - // select only `id` (has default) and `name` (no default) - const schema = factory.makeModelSchema('Product', { - select: { id: true, name: true }, - optionality: 'defaults', - }); - // id has default → optional; name has no default → required - expect(schema.safeParse({ name: 'Widget' }).success).toBe(true); - expect(schema.safeParse({}).success).toBe(false); - // non-selected field rejected - expect(schema.safeParse({ name: 'Widget', price: 10.0 }).success).toBe(false); + it('infers only selected fields in output type', () => { + const _schema = factory.makeModelSchema('User', { select: { id: true, email: true } }); + type Result = z.infer; + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toHaveProperty('email'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toHaveProperty('username'); + expectTypeOf().not.toHaveProperty('posts'); + expectTypeOf().not.toHaveProperty('phone'); }); - it('preserves @meta description on fields wrapped by optionality defaults', () => { - // id has @default, so it gets wrapped; email has no @default but has @meta - const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); - expect(schema.shape.email.meta()?.description).toBe("The user's email address"); + it('select with a relation field (true) includes the relation', () => { + const schema = factory.makeModelSchema('User', { select: { id: true, posts: true } }); + expect(schema.safeParse({ id: 'u1', posts: [] }).success).toBe(true); + // email should not be present + expect(schema.safeParse({ id: 'u1', posts: [], email: 'a@b.com' }).success).toBe(false); }); - }); - // Additional type-level assertions for optionality: 'all' - describe("optionality: 'all' — type inference", () => { - it('infers all scalar fields as optional (including already-optional)', () => { - const _schema = factory.makeModelSchema('User', { optionality: 'all' }); + it('infers relation field type when selected with true', () => { + const _schema = factory.makeModelSchema('User', { select: { id: true, posts: true } }); type Result = z.infer; - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - // already-optional nullable field - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toHaveProperty('id'); + expectTypeOf().toHaveProperty('posts'); + expectTypeOf().not.toHaveProperty('email'); + expectTypeOf().not.toHaveProperty('phone'); }); - it('infers omitted field absent even with optionality all', () => { - const _schema = factory.makeModelSchema('User', { - omit: { username: true }, - optionality: 'all', + it('select with nested options on a relation', () => { + const schema = factory.makeModelSchema('User', { + select: { + id: true, + posts: { select: { title: true, published: true } }, + }, }); - type Result = z.infer; - expectTypeOf().not.toHaveProperty('username'); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + expect(schema.safeParse({ id: 'u1', posts: [{ title: 'Hello', published: true }] }).success).toBe(true); + // extra field in nested post + expect( + schema.safeParse({ id: 'u1', posts: [{ title: 'Hello', published: true, id: 'p1' }] }).success, + ).toBe(false); }); - it('infers selected fields as optional when optionality is all', () => { + it('infers nested select shape on relation when selected with options', () => { const _schema = factory.makeModelSchema('User', { - select: { id: true, email: true }, - optionality: 'all', + select: { + id: true, + posts: { select: { title: true } }, + }, }); type Result = z.infer; - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().not.toHaveProperty('username'); + type Posts = Exclude; + type Post = Posts extends Array ? P : never; + expectTypeOf().toHaveProperty('title'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toHaveProperty('id'); + expectTypeOf().not.toHaveProperty('published'); + }); + + it('select on Post with author relation (nested include)', () => { + const schema = factory.makeModelSchema('Post', { + select: { + id: true, + author: { select: { id: true, email: true } }, + }, + }); + expect(schema.safeParse({ id: 'p1', author: { id: 'u1', email: 'a@b.com' } }).success).toBe(true); + // author with extra field + expect( + schema.safeParse({ id: 'p1', author: { id: 'u1', email: 'a@b.com', username: 'x' } }).success, + ).toBe(false); }); }); - // Additional cases for optionality: 'defaults' with User model - describe("optionality: 'defaults' — User model", () => { - it('makes @default(cuid) id field optional on User', () => { - const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); - const { id: _, ...withoutId } = validUser; - expect(schema.safeParse(withoutId).success).toBe(true); + // ── invalid option combinations ─────────────────────────────────────────── + describe('invalid option combinations', () => { + it('throws when select and include are used together', () => { + expect(() => + factory.makeModelSchema('User', { select: { id: true }, include: { posts: true } } as any), + ).toThrow('`select` and `include` cannot be used together'); }); - it('keeps non-default fields required on User', () => { - const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); - const { email: _, ...withoutEmail } = validUser; - expect(schema.safeParse(withoutEmail).success).toBe(false); + it('throws when select and omit are used together', () => { + expect(() => + factory.makeModelSchema('User', { select: { id: true }, omit: { username: true } } as any), + ).toThrow('`select` and `omit` cannot be used together'); }); - it('still accepts the full valid User object', () => { - const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); - expect(schema.safeParse(validUser).success).toBe(true); + it('throws when select and include are used together in nested relation options', () => { + expect(() => + factory.makeModelSchema('User', { + include: { posts: { select: { id: true }, include: {} } as any }, + }), + ).toThrow('`select` and `include` cannot be used together'); }); - it('makes @default(autoincrement) and @default(now) fields optional on Asset', () => { - const schema = factory.makeModelSchema('Asset', { optionality: 'defaults' }); - // assetType has no default — must be provided - expect(schema.safeParse({ assetType: 'Video' }).success).toBe(true); - // omitting assetType fails - expect(schema.safeParse({}).success).toBe(false); + it('throws when select references a non-existent field', () => { + expect(() => factory.makeModelSchema('User', { select: { nonExistent: true } as any })).toThrow( + 'Field "nonExistent" does not exist on model "User"', + ); }); - }); - // makeModelCreateSchema / makeModelUpdateSchema - describe('makeModelCreateSchema and makeModelUpdateSchema', () => { - it('makeModelCreateSchema makes @default fields optional', () => { - const createSchema = factory.makeModelCreateSchema('User'); - const { id: _, ...withoutId } = validUser; - expect(createSchema.safeParse(withoutId).success).toBe(true); + it('throws when select provides nested options for a scalar field', () => { + expect(() => + factory.makeModelSchema('User', { select: { email: { select: { id: true } } } as any }), + ).toThrow('Field "email" on model "User" is a scalar field and cannot have nested options'); }); - it('makeModelUpdateSchema makes all fields optional', () => { - const updateSchema = factory.makeModelUpdateSchema('User'); - expect(updateSchema.safeParse({}).success).toBe(true); - expect(updateSchema.safeParse({ email: 'a@b.com' }).success).toBe(true); + it('throws when include references a non-existent field', () => { + expect(() => factory.makeModelSchema('User', { include: { nonExistent: true } as any })).toThrow( + 'Field "nonExistent" does not exist on model "User"', + ); }); - it('makeModelUpdateSchema still validates constraints when field is provided', () => { - const updateSchema = factory.makeModelUpdateSchema('User'); - expect(updateSchema.safeParse({ email: 'not-an-email' }).success).toBe(false); - expect(updateSchema.safeParse({ email: 'valid@example.com' }).success).toBe(true); + it('throws when include references a scalar field', () => { + expect(() => factory.makeModelSchema('User', { include: { email: true } as any })).toThrow( + 'Field "email" on model "User" is not a relation field and cannot be used in "include"', + ); }); - it('makeModelUpdateSchema preserves @meta description on fields', () => { - const updateSchema = factory.makeModelUpdateSchema('User'); - expect(updateSchema.shape.email.meta()?.description).toBe("The user's email address"); + it('throws when omit references a non-existent field', () => { + expect(() => factory.makeModelSchema('User', { omit: { nonExistent: true } as any })).toThrow( + 'Field "nonExistent" does not exist on model "User"', + ); }); - it('makeModelCreateSchema preserves @meta description on fields', () => { - const createSchema = factory.makeModelCreateSchema('User'); - expect(createSchema.shape.email.meta()?.description).toBe("The user's email address"); + it('throws when omit references a relation field', () => { + expect(() => factory.makeModelSchema('User', { omit: { posts: true } as any })).toThrow( + 'Field "posts" on model "User" is a relation field and cannot be used in "omit"', + ); }); }); - }); - // ── runtime error handling ──────────────────────────────────────────────── - describe('runtime validation still applies with options', () => { - it('@@validate still runs with omit when the referenced field is present in the shape', () => { - // omitting `username` leaves `age` in the shape, so @@validate(age >= 18) still fires - const schema = factory.makeModelSchema('User', { omit: { username: true } }); - expect(schema.safeParse({ ...validUserNoUsername, age: 16 }).success).toBe(false); - expect(schema.safeParse({ ...validUserNoUsername, age: 18 }).success).toBe(true); - }); + // ── optionality ───────────────────────────────────────────────────────── + describe('optionality', () => { + // optionality: 'all' — every field becomes optional + describe("optionality: 'all'", () => { + it('accepts an empty object when optionality is all', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + expect(schema.safeParse({}).success).toBe(true); + }); - it('@@validate is skipped when its referenced field is omitted', () => { - // omitting `age` removes the field that @@validate(age >= 18) references, - // so the rule is silently skipped — age: 16 is no longer validated - const { age: _, username: _u, ...validUserNoAgeOrUsername } = validUser; - const schema = factory.makeModelSchema('User', { omit: { age: true, username: true } }); - expect(schema.safeParse(validUserNoAgeOrUsername).success).toBe(true); - }); + it('accepts a fully populated object when optionality is all', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + expect(schema.safeParse(validUser).success).toBe(true); + }); - it('field validation still runs with select options', () => { - const schema = factory.makeModelSchema('User', { select: { email: true } }); - expect(schema.safeParse({ email: 'not-an-email' }).success).toBe(false); - expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true); - }); + it('rejects extra fields when optionality is all (still strict)', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + expect(schema.safeParse({ ...validUser, unknownField: 'x' }).success).toBe(false); + }); + + it('infers all fields as optional when optionality is all', () => { + const _schema = factory.makeModelSchema('User', { optionality: 'all' }); + type Result = z.infer; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('still validates field constraints when the field is provided with optionality all', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + // email constraint still applies when email is provided + expect(schema.safeParse({ email: 'not-an-email' }).success).toBe(false); + expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true); + // empty object passes (all optional, null comparisons in @@validate pass through) + expect(schema.safeParse({}).success).toBe(true); + }); + + it('combines optionality all with omit', () => { + const schema = factory.makeModelSchema('User', { + omit: { username: true }, + optionality: 'all', + }); + // empty object is fine (all optional, username omitted) + expect(schema.safeParse({}).success).toBe(true); + // username must not be present (strict + omitted) + expect(schema.safeParse({ username: 'alice' }).success).toBe(false); + // other fields are optional + expect(schema.safeParse({ email: 'a@b.com' }).success).toBe(true); + }); + + it('combines optionality all with select', () => { + const schema = factory.makeModelSchema('User', { + select: { id: true, email: true }, + optionality: 'all', + }); + // both fields optional → empty passes (no @@validate fields in shape) + expect(schema.safeParse({}).success).toBe(true); + // non-selected field rejected + expect(schema.safeParse({ id: 'u1', username: 'x' }).success).toBe(false); + // subset passes + expect(schema.safeParse({ id: 'u1' }).success).toBe(true); + }); + + it('preserves @meta description on fields wrapped by optionality all', () => { + const schema = factory.makeModelSchema('User', { optionality: 'all' }); + expect(schema.shape.email.meta()?.description).toBe("The user's email address"); + }); + }); + + // optionality: 'defaults' — only fields with @default or @updatedAt become optional + describe("optionality: 'defaults'", () => { + it('makes fields with @default optional', () => { + // Product.discount has @default(0), Product.id has @default(cuid()) + // finalPrice is computed (no @default) so it must still be provided + const schema = factory.makeModelSchema('Product', { optionality: 'defaults' }); + // omitting id and discount (both have defaults) should pass + expect(schema.safeParse({ name: 'Widget', price: 10.0, finalPrice: 8.0 }).success).toBe(true); + }); + + it('keeps fields without @default required with optionality defaults', () => { + const schema = factory.makeModelSchema('Product', { optionality: 'defaults' }); + // omitting name (no default) should fail + expect(schema.safeParse({ price: 10.0, finalPrice: 8.0 }).success).toBe(false); + // omitting price (no default) should fail + expect(schema.safeParse({ name: 'Widget', finalPrice: 8.0 }).success).toBe(false); + // omitting finalPrice (computed, no default) should fail + expect(schema.safeParse({ name: 'Widget', price: 10.0 }).success).toBe(false); + }); + + it('infers fields with @default as optional and others as required', () => { + const _schema = factory.makeModelSchema('Product', { optionality: 'defaults' }); + type Result = z.infer; + // optionality: 'defaults' is now resolved statically via FieldHasDefault, + // which inspects the `default` and `updatedAt` fields on FieldDef. + // id has @default(cuid()) → optional + expectTypeOf().toEqualTypeOf(); + // discount has @default(0) → optional + expectTypeOf().toEqualTypeOf(); + // name has no default → required (unchanged) + expectTypeOf().toEqualTypeOf(); + // price has no default → required (unchanged) + expectTypeOf().toEqualTypeOf(); + }); + + it('also makes already-optional (nullable) fields optional with optionality defaults', () => { + // User.website is optional: true (nullable optional in the schema) + // optionality: 'defaults' should also make it optional in the output + const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); + // website being absent should still pass since it is an optional field + const { website: _, ...withoutWebsite } = validUser; + expect(schema.safeParse(withoutWebsite).success).toBe(true); + }); - it('@@validate is skipped with select when the referenced field is not selected', () => { - // selecting only `email` omits `age`, so @@validate(age >= 18) is skipped - const schema = factory.makeModelSchema('User', { select: { email: true } }); - // would fail @@validate if age were present and < 18, but age isn't in the shape - expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true); + it('combines optionality defaults with omit', () => { + // omit finalPrice (computed) and apply defaults optionality + const schema = factory.makeModelSchema('Product', { + omit: { finalPrice: true }, + optionality: 'defaults', + }); + // id and discount have defaults → optional; name and price required + expect(schema.safeParse({ name: 'Widget', price: 10.0 }).success).toBe(true); + // finalPrice must be absent + expect(schema.safeParse({ name: 'Widget', price: 10.0, finalPrice: 8.0 }).success).toBe(false); + }); + + it('combines optionality defaults with select (only selected fields apply defaults logic)', () => { + // select only `id` (has default) and `name` (no default) + const schema = factory.makeModelSchema('Product', { + select: { id: true, name: true }, + optionality: 'defaults', + }); + // id has default → optional; name has no default → required + expect(schema.safeParse({ name: 'Widget' }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + // non-selected field rejected + expect(schema.safeParse({ name: 'Widget', price: 10.0 }).success).toBe(false); + }); + + it('preserves @meta description on fields wrapped by optionality defaults', () => { + // id has @default, so it gets wrapped; email has no @default but has @meta + const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); + expect(schema.shape.email.meta()?.description).toBe("The user's email address"); + }); + }); + + // Additional type-level assertions for optionality: 'all' + describe("optionality: 'all' — type inference", () => { + it('infers all scalar fields as optional (including already-optional)', () => { + const _schema = factory.makeModelSchema('User', { optionality: 'all' }); + type Result = z.infer; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // already-optional nullable field + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('infers omitted field absent even with optionality all', () => { + const _schema = factory.makeModelSchema('User', { + omit: { username: true }, + optionality: 'all', + }); + type Result = z.infer; + expectTypeOf().not.toHaveProperty('username'); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('infers selected fields as optional when optionality is all', () => { + const _schema = factory.makeModelSchema('User', { + select: { id: true, email: true }, + optionality: 'all', + }); + type Result = z.infer; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toHaveProperty('username'); + }); + }); + + // Additional cases for optionality: 'defaults' with User model + describe("optionality: 'defaults' — User model", () => { + it('makes @default(cuid) id field optional on User', () => { + const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); + const { id: _, ...withoutId } = validUser; + expect(schema.safeParse(withoutId).success).toBe(true); + }); + + it('keeps non-default fields required on User', () => { + const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); + const { email: _, ...withoutEmail } = validUser; + expect(schema.safeParse(withoutEmail).success).toBe(false); + }); + + it('still accepts the full valid User object', () => { + const schema = factory.makeModelSchema('User', { optionality: 'defaults' }); + expect(schema.safeParse(validUser).success).toBe(true); + }); + + it('makes @default(autoincrement) and @default(now) fields optional on Asset', () => { + const schema = factory.makeModelSchema('Asset', { optionality: 'defaults' }); + // assetType has no default — must be provided + expect(schema.safeParse({ assetType: 'Video' }).success).toBe(true); + // omitting assetType fails + expect(schema.safeParse({}).success).toBe(false); + }); + }); + + // makeModelCreateSchema / makeModelUpdateSchema + describe('makeModelCreateSchema and makeModelUpdateSchema', () => { + it('makeModelCreateSchema makes @default fields optional', () => { + const createSchema = factory.makeModelCreateSchema('User'); + const { id: _, ...withoutId } = validUser; + expect(createSchema.safeParse(withoutId).success).toBe(true); + }); + + it('makeModelUpdateSchema makes all fields optional', () => { + const updateSchema = factory.makeModelUpdateSchema('User'); + expect(updateSchema.safeParse({}).success).toBe(true); + expect(updateSchema.safeParse({ email: 'a@b.com' }).success).toBe(true); + }); + + it('makeModelUpdateSchema still validates constraints when field is provided', () => { + const updateSchema = factory.makeModelUpdateSchema('User'); + expect(updateSchema.safeParse({ email: 'not-an-email' }).success).toBe(false); + expect(updateSchema.safeParse({ email: 'valid@example.com' }).success).toBe(true); + }); + + it('makeModelUpdateSchema preserves @meta description on fields', () => { + const updateSchema = factory.makeModelUpdateSchema('User'); + expect(updateSchema.shape.email.meta()?.description).toBe("The user's email address"); + }); + + it('makeModelCreateSchema preserves @meta description on fields', () => { + const createSchema = factory.makeModelCreateSchema('User'); + expect(createSchema.shape.email.meta()?.description).toBe("The user's email address"); + }); + }); }); - it('@@validate still runs with select when the referenced field is selected', () => { - // selecting both `email` and `age` keeps the @@validate(age >= 18) rule active - const schema = factory.makeModelSchema('User', { select: { email: true, age: true } }); - expect(schema.safeParse({ email: 'valid@example.com', age: 16 }).success).toBe(false); - expect(schema.safeParse({ email: 'valid@example.com', age: 18 }).success).toBe(true); + // ── runtime error handling ──────────────────────────────────────────────── + describe('runtime validation still applies with options', () => { + it('@@validate still runs with omit when the referenced field is present in the shape', () => { + // omitting `username` leaves `age` in the shape, so @@validate(age >= 18) still fires + const schema = factory.makeModelSchema('User', { omit: { username: true } }); + expect(schema.safeParse({ ...validUserNoUsername, age: 16 }).success).toBe(false); + expect(schema.safeParse({ ...validUserNoUsername, age: 18 }).success).toBe(true); + }); + + it('@@validate is skipped when its referenced field is omitted', () => { + // omitting `age` removes the field that @@validate(age >= 18) references, + // so the rule is silently skipped — age: 16 is no longer validated + const { age: _, username: _u, ...validUserNoAgeOrUsername } = validUser; + const schema = factory.makeModelSchema('User', { omit: { age: true, username: true } }); + expect(schema.safeParse(validUserNoAgeOrUsername).success).toBe(true); + }); + + it('field validation still runs with select options', () => { + const schema = factory.makeModelSchema('User', { select: { email: true } }); + expect(schema.safeParse({ email: 'not-an-email' }).success).toBe(false); + expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true); + }); + + it('@@validate is skipped with select when the referenced field is not selected', () => { + // selecting only `email` omits `age`, so @@validate(age >= 18) is skipped + const schema = factory.makeModelSchema('User', { select: { email: true } }); + // would fail @@validate if age were present and < 18, but age isn't in the shape + expect(schema.safeParse({ email: 'valid@example.com' }).success).toBe(true); + }); + + it('@@validate still runs with select when the referenced field is selected', () => { + // selecting both `email` and `age` keeps the @@validate(age >= 18) rule active + const schema = factory.makeModelSchema('User', { select: { email: true, age: true } }); + expect(schema.safeParse({ email: 'valid@example.com', age: 16 }).success).toBe(false); + expect(schema.safeParse({ email: 'valid@example.com', age: 18 }).success).toBe(true); + }); }); }); }); diff --git a/packages/zod/test/schema/schema-lite.ts b/packages/zod/test/schema/schema-lite.ts new file mode 100644 index 000000000..c1f44d019 --- /dev/null +++ b/packages/zod/test/schema/schema-lite.ts @@ -0,0 +1,354 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// 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, + 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[] + }, + phone: { + name: "phone", + type: "String", + attributes: [{ name: "@phone" }] 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: "String", + optional: true, + attributes: [{ name: "@date" }] as readonly AttributeApplication[] + }, + localTime: { + name: "localTime", + type: "String", + optional: true, + attributes: [{ name: "@time" }] as readonly AttributeApplication[] + }, + createdAt: { + name: "createdAt", + 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 + }, + 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, + 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, + 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, + default: ExpressionUtils.call("cuid") as FieldDefault + }, + name: { + name: "name", + type: "String" + }, + price: { + name: "price", + type: "Float" + }, + discount: { + name: "discount", + type: "Float", + default: 0 as FieldDefault + }, + finalPrice: { + name: "finalPrice", + type: "Float", + 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, + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + default: ExpressionUtils.call("now") as FieldDefault + }, + assetType: { + name: "assetType", + type: "String", + isDiscriminator: true + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + }, + isDelegate: true, + subModels: ["Video", "Image"] + }, + Video: { + name: "Video", + baseModel: "Asset", + fields: { + id: { + name: "id", + type: "Int", + id: true, + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Asset", + 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, + default: ExpressionUtils.call("autoincrement") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + originModel: "Asset", + 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/zod/tsconfig.json b/packages/zod/tsconfig.json index e7ce31be8..6aa4df997 100644 --- a/packages/zod/tsconfig.json +++ b/packages/zod/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "@zenstackhq/typescript-config/base.json", - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"], + "compilerOptions": { + "types": ["node"] + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa83e04c3..ad8a7ca3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1028,6 +1028,9 @@ importers: specifier: 'catalog:' version: 10.6.0 devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.24 '@zenstackhq/eslint-config': specifier: workspace:* version: link:../config/eslint-config From a46d1a66df7a987189d4359c25bdee2bc111ae95 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:24:46 +0800 Subject: [PATCH 4/5] [CI] Bump version 3.9.2 (#2810) Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com> --- package.json | 2 +- packages/auth-adapters/better-auth/package.json | 2 +- packages/cli/package.json | 2 +- packages/clients/client-helpers/package.json | 2 +- packages/clients/fetch-client/package.json | 2 +- packages/clients/tanstack-query/package.json | 2 +- packages/common-helpers/package.json | 2 +- packages/config/eslint-config/package.json | 2 +- packages/config/tsdown-config/package.json | 2 +- packages/config/typescript-config/package.json | 2 +- packages/config/vitest-config/package.json | 2 +- packages/create-zenstack/package.json | 2 +- packages/ide/vscode/package.json | 2 +- packages/language/package.json | 2 +- packages/orm/package.json | 2 +- packages/plugins/policy/package.json | 2 +- packages/plugins/soft-delete/package.json | 2 +- packages/schema/package.json | 2 +- packages/sdk/package.json | 2 +- packages/server/package.json | 2 +- packages/testtools/package.json | 2 +- packages/zod/package.json | 2 +- samples/orm/package.json | 2 +- samples/taskforge/package.json | 2 +- tests/e2e/package.json | 2 +- tests/regression/package.json | 2 +- tests/runtimes/bun/package.json | 2 +- tests/runtimes/edge-runtime/package.json | 2 +- 28 files changed, 28 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 9ba93da23..f882ddfc6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "zenstack-v3", "displayName": "ZenStack", "description": "ZenStack", - "version": "3.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index 251b7440c..eca308d13 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/cli/package.json b/packages/cli/package.json index 4720c2776..040a4cc5b 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index 2365b05cd..e3d78453d 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/fetch-client/package.json b/packages/clients/fetch-client/package.json index 2a346c2f2..9f1ced501 100644 --- a/packages/clients/fetch-client/package.json +++ b/packages/clients/fetch-client/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/fetch-client", "displayName": "ZenStack Fetch Client", "description": "Simple fetch-based client for consuming ZenStack's RPC-style CRUD API", - "version": "3.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index f0797c59b..68335e478 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 71ea97760..1638a83d3 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index 3ea4179b8..05495eba9 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.9.1", + "version": "3.9.2", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/tsdown-config/package.json b/packages/config/tsdown-config/package.json index ab173f37a..22f8400e4 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.9.1", + "version": "3.9.2", "private": true, "type": "module", "license": "MIT", diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 8a04ef805..389552bd3 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.9.1", + "version": "3.9.2", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index df6894189..2fa25f0fd 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.9.1", + "version": "3.9.2", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 7b8baef5e..3406e1d34 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index b3aee168e..9b6bf479a 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.9.1", + "version": "3.9.2", "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 e515552dd..f93aaafb9 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/package.json b/packages/orm/package.json index 491ded680..c6a9419d9 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index 06e88e406..d9f929f7d 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/plugins/soft-delete/package.json b/packages/plugins/soft-delete/package.json index 07265a3d9..c921bc8af 100644 --- a/packages/plugins/soft-delete/package.json +++ b/packages/plugins/soft-delete/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-soft-delete", "displayName": "ZenStack Soft Delete Plugin", "description": "ZenStack plugin that implements soft-delete by intercepting Kysely queries", - "version": "3.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/schema/package.json b/packages/schema/package.json index fbcc63754..672a9917c 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6b1f3cf7b..cc25aa3e4 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/server/package.json b/packages/server/package.json index 753bd3361..5276c6a81 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 5665c0604..6e58df121 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/zod/package.json b/packages/zod/package.json index 0a6fe7efe..cf316c038 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.9.1", + "version": "3.9.2", "type": "module", "author": { "name": "ZenStack Team", diff --git a/samples/orm/package.json b/samples/orm/package.json index a2dc38851..f0ffff09e 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.9.1", + "version": "3.9.2", "description": "", "main": "index.js", "private": true, diff --git a/samples/taskforge/package.json b/samples/taskforge/package.json index f47ce79cc..5a4c26c6d 100644 --- a/samples/taskforge/package.json +++ b/samples/taskforge/package.json @@ -1,6 +1,6 @@ { "name": "taskforge", - "version": "3.9.1", + "version": "3.9.2", "type": "module", "private": true, "description": "A CLI for a team collaboration / project-tracking platform, built on ZenStack v3 (ORM) and better-auth.", diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 4cdb8cc07..b38c2512b 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.9.1", + "version": "3.9.2", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index 6afa59680..ceaf71ba6 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.9.1", + "version": "3.9.2", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 50f87900c..1820347d6 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.9.1", + "version": "3.9.2", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index 0e746b961..a44893a67 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.9.1", + "version": "3.9.2", "private": true, "type": "module", "scripts": { From ca354fea188595b11561344bfa015fdc4bca1e0a Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Sun, 23 Aug 2026 16:52:22 +0800 Subject: [PATCH 5/5] fix(sdk): respect @@strict inherited from mixins in type def generation (#2812) Co-authored-by: Claude Fable 5 --- packages/cli/test/ts-schema-gen.test.ts | 23 +++++++++++++++++++++++ packages/sdk/src/ts-schema-generator.ts | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index ea58af7c4..06a1cab57 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -872,4 +872,27 @@ type Profile { }, }); }); + + it('supports @@strict inherited from mixins', async () => { + const { schema } = await generateTsSchema(` +model User { + id String @id @default(uuid()) + profile Profile? @json +} + +type Strict { + @@strict +} + +type Profile with Strict { + bio String +} + `); + + expect(schema.typeDefs).toMatchObject({ + Profile: { + strict: true, + }, + }); + }); }); diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 5b0d56285..1ab6befbc 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -546,7 +546,7 @@ export class TsSchemaGenerator { : []), ]; - if (hasAttribute(td, '@@strict')) { + if (getAllAttributes(td).some((attr) => attr.decl.$refText === '@@strict')) { fields.push(ts.factory.createPropertyAssignment('strict', ts.factory.createTrue())); }