From 86600721ed2edcf0766f0b7b7df76973a5bb048c Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:40:23 -0700 Subject: [PATCH] fix(orm): variance-annotate ToManyRelationUpdateInput to avoid excessively deep instantiation Passing a generated `XxxUpdateArgs`/`XxxUpsertArgs` type directly to the corresponding client method (e.g. `db.tag.upsert(args)`) on a schema with a many-to-many relation made TypeScript report TS2589 ("Type instantiation is excessively deep and possibly infinite") or run out of memory. The generated type uses the default `QueryOptions` while the client uses the projected `QueryRelevantOptions`, so TypeScript had to compare two instantiations of `ToManyRelationUpdateInput` with different `Options`. Without variance annotations it measured the variance structurally through the recursive nested update/upsert types, which is what blew up. `ToOneRelationUpdateInput` was already annotated for this reason; this applies the same to the to-many variant. Fixes #2778 Co-Authored-By: Claude Fable 5.1 --- packages/orm/src/client/crud-types.ts | 13 ++-- .../test/issue-2778/regression.test.ts | 39 ++++++++++ tests/regression/test/issue-2778/schema.ts | 72 +++++++++++++++++++ .../regression/test/issue-2778/schema.zmodel | 16 +++++ 4 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/regression/test/issue-2778/regression.test.ts create mode 100644 tests/regression/test/issue-2778/schema.ts create mode 100644 tests/regression/test/issue-2778/schema.zmodel diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index b92854807..d0e9ef940 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -2137,11 +2137,16 @@ type UpdateRelationFieldPayload< ? ToManyRelationUpdateInput : ToOneRelationUpdateInput; +// Variance-annotated for the same reason as `ToOneRelationUpdateInput` below: without the +// annotations, comparing two instantiations with different `Options` (e.g. a generated +// `XxxUpdateArgs`/`XxxUpsertArgs` type passed to a client method) makes TypeScript measure +// variance structurally through the recursive nested update/upsert types, which blows up into +// "Type instantiation is excessively deep and possibly infinite" (#2778). type ToManyRelationUpdateInput< - Schema extends SchemaDef, - Model extends GetModels, - Field extends RelationFields, - Options extends QueryOptions, + in out Schema extends SchemaDef, + in out Model extends GetModels, + in out Field extends RelationFields, + in out Options extends QueryOptions, > = Omit< { /** diff --git a/tests/regression/test/issue-2778/regression.test.ts b/tests/regression/test/issue-2778/regression.test.ts new file mode 100644 index 000000000..35043ed60 --- /dev/null +++ b/tests/regression/test/issue-2778/regression.test.ts @@ -0,0 +1,39 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import type { UpdateArgs, UpsertArgs } from '@zenstackhq/orm'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { schema, type SchemaType } from './schema'; + +// https://github.com/zenstackhq/zenstack/issues/2778 + +// These mirror the types emitted by `zenstack generate` into `input.ts` +type TagUpsertArgs = UpsertArgs; +type TagUpdateArgs = UpdateArgs; + +describe('Regression for issue #2778', () => { + it('accepts generated UpsertArgs/UpdateArgs with many-to-many relations without deep instantiation', async () => { + const db = await createTestClient(schema, { + usePrismaPush: true, + schemaFile: path.join(__dirname, 'schema.zmodel'), + }); + + // passing the generated args type directly to the client method used to trigger + // "Type instantiation is excessively deep and possibly infinite" + const upsertTag = (args: TagUpsertArgs) => db.tag.upsert(args); + const updateTag = (args: TagUpdateArgs) => db.tag.update(args); + + const tag = await upsertTag({ + where: { name: 't1' }, + create: { name: 't1', products: { create: { name: 'p1' } } }, + update: { name: 't2' }, + }); + expect(tag.name).toBe('t1'); + + const updated = await updateTag({ + where: { id: tag.id }, + data: { name: 't3', products: { create: { name: 'p2' } } }, + }); + expect(updated.name).toBe('t3'); + await expect(db.product.count()).resolves.toBe(2); + }); +}); diff --git a/tests/regression/test/issue-2778/schema.ts b/tests/regression/test/issue-2778/schema.ts new file mode 100644 index 000000000..7b925b640 --- /dev/null +++ b/tests/regression/test/issue-2778/schema.ts @@ -0,0 +1,72 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// 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 = { + Tag: { + name: "Tag", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("uuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("uuid") as FieldDefault + }, + name: { + name: "name", + type: "String", + unique: true, + attributes: [{ name: "@unique" }] as readonly AttributeApplication[] + }, + products: { + name: "products", + type: "Product", + array: true, + relation: { opposite: "tags" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" }, + name: { type: "String" } + } + }, + Product: { + name: "Product", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("uuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("uuid") as FieldDefault + }, + name: { + name: "name", + type: "String" + }, + tags: { + name: "tags", + type: "Tag", + array: true, + relation: { opposite: "products" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/regression/test/issue-2778/schema.zmodel b/tests/regression/test/issue-2778/schema.zmodel new file mode 100644 index 000000000..5b90d8f76 --- /dev/null +++ b/tests/regression/test/issue-2778/schema.zmodel @@ -0,0 +1,16 @@ +datasource db { + provider = 'sqlite' + url = 'file:./dev.db' +} + +model Tag { + id String @id @default(uuid()) + name String @unique + products Product[] +} + +model Product { + id String @id @default(uuid()) + name String + tags Tag[] +}