From 2e480c84ad35f7ecf87b26e5385567885544f8da Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:15:00 +0100 Subject: [PATCH 1/4] feat(wabe): add support for virtual fields --- .../wabe/src/database/DatabaseController.ts | 276 ++++++++++++++++-- .../wabe/src/graphql/GraphQLSchema.test.ts | 220 ++++++++++---- packages/wabe/src/graphql/parser.ts | 35 +++ packages/wabe/src/hooks/index.ts | 6 + packages/wabe/src/hooks/virtualFields.test.ts | 176 +++++++++++ packages/wabe/src/hooks/virtualFields.ts | 38 +++ packages/wabe/src/schema/Schema.ts | 20 +- 7 files changed, 690 insertions(+), 81 deletions(-) create mode 100644 packages/wabe/src/hooks/virtualFields.test.ts create mode 100644 packages/wabe/src/hooks/virtualFields.ts diff --git a/packages/wabe/src/database/DatabaseController.ts b/packages/wabe/src/database/DatabaseController.ts index b94adbd6..d628a959 100644 --- a/packages/wabe/src/database/DatabaseController.ts +++ b/packages/wabe/src/database/DatabaseController.ts @@ -23,6 +23,20 @@ import type { export type Select = Record type SelectWithObject = Record +type RuntimeVirtualField = { + type: 'Virtual' + dependsOn: string[] + callback: (object: Record) => unknown +} + +const isVirtualField = (field: unknown): field is RuntimeVirtualField => { + if (!field || typeof field !== 'object') return false + + if (!('type' in field) || field.type !== 'Virtual') return false + + return true +} + export class DatabaseController { public adapter: DatabaseAdapter @@ -47,6 +61,146 @@ export class DatabaseController { return realClass?.fields[fieldName] as { type: string; class?: string } | undefined } + _getVirtualFieldsForClass(className: keyof T['types'], context: WabeContext) { + const currentClass = this._getClass(className, context) + + if (!currentClass) return {} + + const virtualFields: Record = {} + + for (const [fieldName, fieldDefinition] of Object.entries(currentClass.fields)) { + if (!isVirtualField(fieldDefinition)) continue + + virtualFields[fieldName] = fieldDefinition + } + + return virtualFields + } + + _buildReadSelects({ + className, + context, + selectWithoutPointers, + }: { + className: keyof T['types'] + context: WabeContext + selectWithoutPointers: Select + }) { + const virtualFieldsByName = this._getVirtualFieldsForClass(className, context) + const requestedVirtualFields: string[] = [] + + const userSelect: Select = {} + + for (const [fieldName, selected] of Object.entries(selectWithoutPointers)) { + if (!selected) continue + + const virtualField = virtualFieldsByName[fieldName] + + if (virtualField) { + requestedVirtualFields.push(fieldName) + continue + } + + userSelect[fieldName] = true + } + + const adapterSelect: Select = { ...userSelect } + + for (const virtualFieldName of requestedVirtualFields) { + const virtualField = virtualFieldsByName[virtualFieldName] + + if (!virtualField) continue + + for (const dependencyField of virtualField.dependsOn) { + const dependencyName = String(dependencyField) + + // Virtual dependencies are only useful for computation and must never reach adapters. + if (virtualFieldsByName[dependencyName]) continue + + adapterSelect[dependencyName] = true + } + } + + return { + userSelect, + adapterSelect, + } + } + + _projectObjectForUserSelect({ + object, + select, + }: { + object: Record | null | undefined + select?: SelectWithObject + }): any { + if (!object) return object + if (!select) return object + + const projectedObject: Record = {} + + for (const [fieldName, selected] of Object.entries(select)) { + if (!selected) continue + if (!(fieldName in object)) continue + + projectedObject[fieldName] = object[fieldName] + } + + return projectedObject + } + + _stripVirtualFieldsFromPayload({ + className, + context, + payload, + }: { + className: keyof T['types'] + context: WabeContext + payload: unknown + }): any { + if (!payload || typeof payload !== 'object') return {} + + const virtualFields = this._getVirtualFieldsForClass(className, context) + + if (Object.keys(virtualFields).length === 0) return payload + + const filteredPayload: Record = {} + + for (const [fieldName, value] of Object.entries(payload)) { + if (virtualFields[fieldName]) continue + + filteredPayload[fieldName] = value + } + + return filteredPayload + } + + _stripVirtualFieldsFromSchema(schema: SchemaInterface): SchemaInterface { + const classes = schema.classes?.map((classDefinition) => { + const filteredFieldEntries = Object.entries(classDefinition.fields).filter( + ([_fieldName, fieldDefinition]) => !isVirtualField(fieldDefinition), + ) + const filteredFields = Object.fromEntries(filteredFieldEntries) + + const allowedFieldNames = new Set(Object.keys(filteredFields)) + + const filteredIndexes = classDefinition.indexes?.filter((index) => + allowedFieldNames.has(index.field), + ) + + return { + ...classDefinition, + fields: filteredFields, + indexes: filteredIndexes, + } + }) + + return { + ...schema, + classes, + } + } + _getSelectMinusPointersAndRelations({ className, context, @@ -390,17 +544,22 @@ export class DatabaseController { ) if (isRelation && object[pointerField]) { - const selectWithoutTotalCount = Object.entries(currentSelect || {}).reduce( - (acc2, [key, value]) => { - if (key === 'totalCount') return acc2 - - return { - ...acc2, - [key]: value, - } - }, - {} as Record, - ) + const computedSelectWithoutTotalCount = currentSelect + ? Object.entries(currentSelect).reduce((acc2, [key, value]) => { + if (key === 'totalCount') return acc2 + + return { + ...acc2, + [key]: value, + } + }, {}) + : undefined + + const selectWithoutTotalCount = + computedSelectWithoutTotalCount && + Object.keys(computedSelectWithoutTotalCount).length > 0 + ? computedSelectWithoutTotalCount + : { id: true } const relationObjects = await this.getObjects({ className: currentClassName, @@ -435,11 +594,11 @@ export class DatabaseController { } createClassIfNotExist(className: string, schema: SchemaInterface): Promise { - return this.adapter.createClassIfNotExist(className, schema) + return this.adapter.createClassIfNotExist(className, this._stripVirtualFieldsFromSchema(schema)) } initializeDatabase(schema: SchemaInterface): Promise { - return this.adapter.initializeDatabase(schema) + return this.adapter.initializeDatabase(this._stripVirtualFieldsFromSchema(schema)) } async count({ @@ -489,12 +648,24 @@ export class DatabaseController { context, select: select as SelectWithObject, }) + const { userSelect, adapterSelect } = this._buildReadSelects({ + className, + context, + selectWithoutPointers, + }) const hook = !_skipHooks ? initializeHook({ className, context, - select: selectWithoutPointers, + select: { + ...userSelect, + ...Object.fromEntries( + Object.keys(this._getVirtualFieldsForClass(className, context)) + .filter((fieldName) => !!selectWithoutPointers[fieldName]) + .map((fieldName) => [fieldName, true]), + ), + }, objectLoader: this._loadObjectForHooks(className, context), objectsLoader: this._loadObjectsForHooks(className, context), }) @@ -511,7 +682,7 @@ export class DatabaseController { acc[fieldName] = true return acc - }, selectWithoutPointers) + }, adapterSelect) const objectToReturn = await this.adapter.getObject({ className, @@ -541,8 +712,13 @@ export class DatabaseController { // @ts-expect-error object: finalObject, }) + const objectAfterHooks = afterReadResult?.object || finalObject + const objectProjectedForUser = this._projectObjectForUserSelect({ + object: objectAfterHooks, + select: select as SelectWithObject, + }) - return afterReadResult?.object || finalObject + return objectProjectedForUser } async getObjects< @@ -564,6 +740,11 @@ export class DatabaseController { context, select: select as SelectWithObject, }) + const { userSelect, adapterSelect } = this._buildReadSelects({ + className, + context, + selectWithoutPointers, + }) const whereWithPointer = await this._getWhereObjectWithPointerOrRelation( className, @@ -577,12 +758,19 @@ export class DatabaseController { acc[fieldName] = true return acc - }, selectWithoutPointers) + }, adapterSelect) const hook = !_skipHooks ? initializeHook({ className, - select: selectWithoutPointers, + select: { + ...userSelect, + ...Object.fromEntries( + Object.keys(this._getVirtualFieldsForClass(className, context)) + .filter((fieldName) => !!selectWithoutPointers[fieldName]) + .map((fieldName) => [fieldName, true]), + ), + }, context, objectLoader: this._loadObjectForHooks(className, context), objectsLoader: this._loadObjectsForHooks(className, context), @@ -627,10 +815,16 @@ export class DatabaseController { // @ts-expect-error objects: objectsWithPointers, }) + const objectsAfterHooks = afterReadResults?.objects || objectsWithPointers + const projectedObjects = objectsAfterHooks.map((object) => + this._projectObjectForUserSelect({ + object, + select: select as SelectWithObject, + }), + ) - return (afterReadResults?.objects || objectsWithPointers) as unknown as Promise< - OutputType[] - > + // Projection keeps only user-requested top-level fields, including virtual fields. + return projectedObjects } async createObject< @@ -653,11 +847,17 @@ export class DatabaseController { data, select: select as Select, adapterCallback: async (newData) => { + const payload = this._stripVirtualFieldsFromPayload({ + className, + context, + payload: newData || data, + }) + const res = await this.adapter.createObject({ className, context, select, - data: newData || data, + data: payload, }) return { id: res.id } @@ -668,10 +868,12 @@ export class DatabaseController { if (select && Object.keys(select).length === 0) return null + const selectWithoutPrivateFields = select ? selectFieldsWithoutPrivateFields(select) : undefined + return this.getObject({ className, context: contextWithRoot(context), - select: selectFieldsWithoutPrivateFields(select), + select: selectWithoutPrivateFields, id: res.id, }) } @@ -723,7 +925,13 @@ export class DatabaseController { className, select, context, - data: arrayOfComputedData, + data: arrayOfComputedData.map((payload) => + this._stripVirtualFieldsFromPayload({ + className, + context, + payload, + }), + ), first, offset, order, @@ -768,13 +976,18 @@ export class DatabaseController { }: UpdateObjectOptions): Promise> { if (_skipHooks) { const whereWithACLCondition = this._buildWhereWithACL({}, context, 'write') + const payload = this._stripVirtualFieldsFromPayload({ + className, + context, + payload: data, + }) return this.adapter.updateObject({ className, select, id, context, - data, + data: payload, where: whereWithACLCondition, }) as Promise> } @@ -789,13 +1002,18 @@ export class DatabaseController { select: select as Select, adapterCallback: async (newData) => { const whereWithACLCondition = this._buildWhereWithACL({}, context, 'write') + const payload = this._stripVirtualFieldsFromPayload({ + className, + context, + payload: newData || data, + }) await this.adapter.updateObject({ className, select, id, context, - data: newData || data, + data: payload, where: whereWithACLCondition, }) @@ -858,7 +1076,11 @@ export class DatabaseController { className, context, select, - data: resultsAfterBeforeUpdate?.newData[0] || data, + data: this._stripVirtualFieldsFromPayload({ + className, + context, + payload: resultsAfterBeforeUpdate?.newData[0] || data || {}, + }), where: whereWithACLCondition, first, offset, diff --git a/packages/wabe/src/graphql/GraphQLSchema.test.ts b/packages/wabe/src/graphql/GraphQLSchema.test.ts index d7493dc3..3eae9681 100644 --- a/packages/wabe/src/graphql/GraphQLSchema.test.ts +++ b/packages/wabe/src/graphql/GraphQLSchema.test.ts @@ -262,7 +262,15 @@ describe('GraphqlSchema', () => { const createResult = await rootClient.request(gql` ${fragments} mutation createTestClass { - createTestClass(input: { fields: { field: "testField", field1: "value1", field2: "value2" } }) { + createTestClass( + input: { + fields: { + field: "testField" + field1: "value1" + field2: "value2" + } + } + ) { testClass { ...ExtendedFields } @@ -394,7 +402,16 @@ describe('GraphqlSchema', () => { const createResult = await rootClient.request(gql` ${fragments} mutation createTestClass { - createTestClass(input: { fields: { field1: "created1", field2: "created2", field3: "created3", field4: "created4" } }) { + createTestClass( + input: { + fields: { + field1: "created1" + field2: "created2" + field3: "created3" + field4: "created4" + } + } + ) { testClass { ...CompleteFields } @@ -1363,63 +1380,53 @@ describe('GraphqlSchema', () => { {}, ) - const res = await client.request( - gql` - query testClasses { - testClasses(where: { AND: [{ age: { equalTo: 30 } }, { search: { contains: "t" } }] }) { - totalCount - } + const res = await client.request(gql` + query testClasses { + testClasses(where: { AND: [{ age: { equalTo: 30 } }, { search: { contains: "t" } }] }) { + totalCount } - `, - ) + } + `) expect(res.testClasses.totalCount).toEqual(1) - const res2 = await client.request( - gql` - query testClasses { - testClasses(where: { search: { contains: "invalid" } }) { - totalCount - } + const res2 = await client.request(gql` + query testClasses { + testClasses(where: { search: { contains: "invalid" } }) { + totalCount } - `, - ) + } + `) expect(res2.testClasses.totalCount).toEqual(0) - const res3 = await client.request( - gql` - query testClasses { - testClasses(where: { search: { contains: "test" } }) { - totalCount - } + const res3 = await client.request(gql` + query testClasses { + testClasses(where: { search: { contains: "test" } }) { + totalCount } - `, - ) + } + `) expect(res3.testClasses.totalCount).toEqual(1) - const res4 = await client.request( - gql` - query testClasses { - testClasses(where: { AND: [{ age: { equalTo: 1111 } }, { search: { contains: "test" } }] }) { - totalCount - } + const res4 = await client.request(gql` + query testClasses { + testClasses(where: { AND: [{ age: { equalTo: 1111 } }, { search: { contains: "test" } }] }) { + totalCount } - `, - ) + } + `) expect(res4.testClasses.totalCount).toEqual(0) - const res5 = await client.request( - gql` - query testClasses { - testClasses(where: { AND: [{ age: { equalTo: 30 } }, { search: { contains: "" } }] }) { - totalCount - } + const res5 = await client.request(gql` + query testClasses { + testClasses(where: { AND: [{ age: { equalTo: 30 } }, { search: { contains: "" } }] }) { + totalCount } - `, - ) + } + `) expect(res5.testClasses.totalCount).toEqual(1) @@ -1472,15 +1479,13 @@ describe('GraphqlSchema', () => { {}, ) - const res = await client.request( - gql` - query testClasses { - testClasses { - totalCount - } + const res = await client.request(gql` + query testClasses { + testClasses { + totalCount } - `, - ) + } + `) expect(res.testClasses.totalCount).toEqual(1) @@ -1893,6 +1898,117 @@ describe('GraphqlSchema', () => { }) }) + it('should resolve virtual fields in GraphQL queries', async () => { + const { client, wabe } = await createWabe({ + classes: [ + { + name: 'VirtualPerson', + fields: { + firstName: { type: 'String' }, + lastName: { type: 'String' }, + age: { type: 'Int' }, + fullName: { + type: 'Virtual', + returnType: 'String', + // @ts-expect-error + dependsOn: ['firstName', 'lastName'], + callback: (object: any) => + `${object.firstName || ''} ${object.lastName || ''}`.trim(), + }, + isAdult: { + type: 'Virtual', + returnType: 'Boolean', + // @ts-expect-error + dependsOn: ['age'], + callback: (object: any) => (object.age || 0) >= 18, + }, + }, + permissions: { + read: { requireAuthentication: false }, + create: { requireAuthentication: false }, + update: { requireAuthentication: false }, + delete: { requireAuthentication: false }, + }, + }, + ], + }) + + const created = await client.request<{ + createVirtualPerson: { + virtualPerson: { + id: string + } + } + }>(gql` + mutation createVirtualPerson { + createVirtualPerson(input: { fields: { firstName: "Ada", lastName: "Lovelace", age: 37 } }) { + virtualPerson { + id + } + } + } + `) + + const read = await client.request<{ + virtualPerson: { + id: string + firstName: string + lastName: string + age: number + fullName: string + isAdult: boolean + } + }>( + gql` + query virtualPerson($id: ID) { + virtualPerson(id: $id) { + id + firstName + lastName + age + fullName + isAdult + } + } + `, + { id: created.createVirtualPerson.virtualPerson.id }, + ) + + expect(read.virtualPerson.id).toBe(created.createVirtualPerson.virtualPerson.id) + expect(read.virtualPerson.firstName).toBe('Ada') + expect(read.virtualPerson.lastName).toBe('Lovelace') + expect(read.virtualPerson.age).toBe(37) + expect(read.virtualPerson.fullName).toBe('Ada Lovelace') + expect(read.virtualPerson.isAdult).toBe(true) + + const list = await client.request<{ + virtualPersons: { + edges: Array<{ + node: { + fullName: string + isAdult: boolean + } + }> + } + }>(gql` + query virtualPersons { + virtualPersons { + edges { + node { + fullName + isAdult + } + } + } + } + `) + + expect(list.virtualPersons.edges[0]?.node.fullName).toBe('Ada Lovelace') + expect(list.virtualPersons.edges[0]?.node.isAdult).toBe(true) + + await wabe.close() + }) + it('should have ConnectionObject on field of relation in ObjectType', () => { expect( getTypeFromGraphQLSchema({ @@ -4026,7 +4142,9 @@ describe('GraphqlSchema', () => { })) as any expect(field2BeforeUpdate2[0]?.field2).toEqual([ - { id: resAfterAdd.createTestClass2.testClass2.field2.edges[0].node.id }, + { + id: resAfterAdd.createTestClass2.testClass2.field2.edges[0].node.id, + }, ]) const resAfterUpdate = await client.request(gql` diff --git a/packages/wabe/src/graphql/parser.ts b/packages/wabe/src/graphql/parser.ts index 3da82804..4cc5e54a 100644 --- a/packages/wabe/src/graphql/parser.ts +++ b/packages/wabe/src/graphql/parser.ts @@ -179,6 +179,23 @@ export const GraphqlParser: GraphqlParserConstructor = return acc } + if (currentField?.type === 'Virtual') { + if (isWhereType) return acc + + const graphqlType = getGraphqlType({ + type: currentField.returnType, + }) + + acc[key] = { + type: + currentField?.required && !forceRequiredToFalse + ? new GraphQLNonNull(graphqlType) + : graphqlType, + } + + return acc + } + const graphqlType = getGraphqlType({ ...currentField, // We never come here, complicated to good type this @@ -448,6 +465,24 @@ export const GraphqlParser: GraphqlParserConstructor = return acc } + if (currentField?.type === 'Virtual') { + if (graphqlObjectType !== 'Object') return acc + + const graphqlType = getGraphqlType({ + type: currentField.returnType, + isWhereType, + }) + + acc[key] = { + type: + currentField?.required && !forceRequiredToFalse + ? new GraphQLNonNull(graphqlType) + : graphqlType, + } + + return acc + } + if (currentField?.type === 'File') { if (graphqlObjectType === 'Object') acc[key] = { diff --git a/packages/wabe/src/hooks/index.ts b/packages/wabe/src/hooks/index.ts index e96feb1d..a108fdad 100644 --- a/packages/wabe/src/hooks/index.ts +++ b/packages/wabe/src/hooks/index.ts @@ -42,6 +42,7 @@ import { defaultCallAuthenticationProviderOnBeforeCreateUser, defaultCallAuthenticationProviderOnBeforeUpdateUser, } from './authentication' +import { defaultVirtualFieldsAfterRead } from './virtualFields' export enum OperationType { AfterCreate = 'afterCreate', @@ -314,6 +315,11 @@ export const getDefaultHooks = (): Hook[] => [ priority: 1, callback: defaultAfterReadFile, }, + { + operationType: OperationType.AfterRead, + priority: 2, + callback: defaultVirtualFieldsAfterRead, + }, { operationType: OperationType.AfterDelete, priority: 1, diff --git a/packages/wabe/src/hooks/virtualFields.test.ts b/packages/wabe/src/hooks/virtualFields.test.ts new file mode 100644 index 00000000..1c8bb5a6 --- /dev/null +++ b/packages/wabe/src/hooks/virtualFields.test.ts @@ -0,0 +1,176 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test' +import type { Wabe } from '../server' +import { type DevWabeTypes } from '../utils/helper' +import { closeTests, setupTests } from '../utils/testHelper' + +describe('Virtual fields integration', () => { + let wabe: Wabe + + beforeAll(async () => { + const setup = await setupTests([ + { + name: 'VirtualPerson', + fields: { + firstName: { type: 'String' }, + lastName: { type: 'String' }, + age: { type: 'Int' }, + fullName: { + type: 'Virtual', + returnType: 'String', + dependsOn: ['firstName', 'lastName'], + callback: (object: any) => `${object.firstName} ${object.lastName}`.trim(), + }, + isAdult: { + type: 'Virtual', + returnType: 'Boolean', + dependsOn: ['age'], + callback: (object: any) => (object.age ?? 0) >= 18, + }, + }, + permissions: { + read: { requireAuthentication: false }, + create: { requireAuthentication: false }, + update: { requireAuthentication: false }, + delete: { requireAuthentication: false }, + }, + }, + ]) + + wabe = setup.wabe + }) + + afterAll(async () => { + await closeTests(wabe) + }) + + beforeEach(async () => { + await wabe.controllers.database.clearDatabase() + }) + + it('computes virtual fields from dependsOn without leaking dependencies', async () => { + const created = await wabe.controllers.database.createObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + data: { + // @ts-expect-error + firstName: 'Ada', + lastName: 'Lovelace', + age: 37, + }, + select: { id: true }, + }) + + const result = await wabe.controllers.database.getObject({ + // @ts-expect-error Test class only exists in test schema + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + id: created?.id || '', + select: { + // @ts-expect-error + fullName: true, + }, + }) + + const resultAny: any = result + expect(resultAny?.fullName).toBe('Ada Lovelace') + expect(resultAny?.firstName).toBeUndefined() + expect(resultAny?.lastName).toBeUndefined() + }) + + it('loads dependencies in adapter select and never requests virtual keys directly on database', async () => { + const adapterGetObjectSpy = spyOn(wabe.controllers.database.adapter, 'getObject') + + const created = await wabe.controllers.database.createObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + data: { + // @ts-expect-error + firstName: 'Grace', + lastName: 'Hopper', + age: 30, + }, + select: { id: true }, + }) + + adapterGetObjectSpy.mockClear() + + await wabe.controllers.database.getObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + id: created?.id || '', + select: { + // @ts-expect-error + fullName: true, + isAdult: true, + }, + }) + + expect(adapterGetObjectSpy.mock.calls.length).toBeGreaterThanOrEqual(1) + const adapterCallWithVirtualDependencies = adapterGetObjectSpy.mock.calls.find((call: any) => { + const options = call[0] + return ( + options?.className === 'VirtualPerson' && + options?.id === (created?.id || '') && + options?.select?.firstName === true && + options?.select?.lastName === true && + options?.select?.age === true && + options?.select?.fullName === undefined && + options?.select?.isAdult === undefined + ) + }) + + expect(adapterCallWithVirtualDependencies).toBeDefined() + }) + + it('ignores virtual fields in create and update payloads', async () => { + const created = await wabe.controllers.database.createObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + data: { + // @ts-expect-error + firstName: 'Initial', + lastName: 'User', + age: 20, + fullName: 'MUST_BE_IGNORED', + }, + select: { id: true }, + }) + + await wabe.controllers.database.updateObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + id: created?.id || '', + data: { + // @ts-expect-error + firstName: 'Updated', + fullName: 'MUST_STILL_BE_IGNORED', + }, + select: {}, + }) + + const read = await wabe.controllers.database.getObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + id: created?.id || '', + select: { + // @ts-expect-error + firstName: true, + lastName: true, + fullName: true, + }, + }) + + // @ts-expect-error + expect(read?.firstName).toBe('Updated') + // @ts-expect-error + expect(read?.lastName).toBe('User') + // @ts-expect-error + expect(read?.fullName).toBe('Updated User') + }) +}) diff --git a/packages/wabe/src/hooks/virtualFields.ts b/packages/wabe/src/hooks/virtualFields.ts new file mode 100644 index 00000000..fd0d81fc --- /dev/null +++ b/packages/wabe/src/hooks/virtualFields.ts @@ -0,0 +1,38 @@ +import type { Hook } from '.' +import type { DevWabeTypes } from '../utils/helper' + +type VirtualFieldDefinition = { + type: 'Virtual' + dependsOn: string[] + callback: (object: Record) => unknown +} + +const isVirtualFieldDefinition = (value: unknown): value is VirtualFieldDefinition => { + if (!value || typeof value !== 'object') return false + + return 'type' in value && value.type === 'Virtual' +} + +export const defaultVirtualFieldsAfterRead: Hook['callback'] = (hookObject) => { + const object = hookObject.object + + if (!object) return + + const classDefinition = hookObject.context.wabe.config.schema?.classes?.find( + (c) => c.name === hookObject.className, + ) + + if (!classDefinition) return + + const selectedFields = Object.keys(hookObject.select || {}) + + if (selectedFields.length === 0) return + + for (const fieldName of selectedFields) { + const fieldDefinition = classDefinition.fields[fieldName] + + if (!isVirtualFieldDefinition(fieldDefinition)) continue + + object[fieldName] = fieldDefinition.callback(object) + } +} diff --git a/packages/wabe/src/schema/Schema.ts b/packages/wabe/src/schema/Schema.ts index 5b202b7d..66e123a6 100644 --- a/packages/wabe/src/schema/Schema.ts +++ b/packages/wabe/src/schema/Schema.ts @@ -27,6 +27,8 @@ export type WabeRelationTypes = 'Pointer' | 'Relation' export type WabeFieldTypes = WabeCustomTypes | WabePrimaryTypes | WabeRelationTypes +export type VirtualReturnType = 'String' | 'Int' | 'Float' | 'Boolean' | 'Date' | 'Email' | 'Phone' + export type WabeObject = { name: string fields: SchemaFields @@ -92,7 +94,15 @@ type TypeFieldCustomEnums = { defaultValue?: any } -export type TypeField = ( +type TypeFieldVirtual = { + type: 'Virtual' + returnType: VirtualReturnType + defaultValue?: never + dependsOn: Array + callback: (object: T['types'][K] & { id: string }) => string | number | boolean | Date | null +} + +export type TypeField = ( | TypeFieldBase | TypeFieldBase | TypeFieldBase @@ -108,10 +118,14 @@ export type TypeField = ( | TypeFieldFile | TypeFieldCustomScalars | TypeFieldCustomEnums + | TypeFieldVirtual ) & FieldBase -export type SchemaFields = Record> +export type SchemaFields< + T extends WabeTypes, + K extends keyof T['types'] = keyof T['types'], +> = Record> export type ResolverType = { required?: boolean @@ -183,7 +197,7 @@ export type ClassIndexes = Array<{ export interface ClassInterface { name: string - fields: SchemaFields + fields: SchemaFields description?: string permissions?: ClassPermissions searchableFields?: SearchableFields From 6b5feb712532789ece118487330b1dd808613870 Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:59:37 +0100 Subject: [PATCH 2/4] feat: add documentation --- .../docs/documentation/schema/classes.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/wabe-documentation/docs/documentation/schema/classes.md b/packages/wabe-documentation/docs/documentation/schema/classes.md index 7f541d51..fb892112 100644 --- a/packages/wabe-documentation/docs/documentation/schema/classes.md +++ b/packages/wabe-documentation/docs/documentation/schema/classes.md @@ -17,6 +17,7 @@ Wabe supports the following native field types (with the ability to extend throu - **Array**: Arrays of values (can specify element type) - **Pointer**: Single reference to another class object - **Relation**: Multiple references to objects of another class +- **Virtual**: Computed fields derived from other fields at read time - **Hash**: Secure password hashing The `classes` array allow you to create new classe in your project with some fields. In the example below, a company class had a required name and a logo. @@ -197,6 +198,57 @@ const run = async () => { await run(); ``` +#### Virtual + +Virtual fields are computed at read time from other fields. They are not stored in the database and cannot be used in create or update operations. Use them to derive values like full names, computed booleans, or formatted strings without duplicating data. + +Each virtual field requires: + +- **returnType**: The GraphQL type of the computed value (`String`, `Int`, `Float`, `Boolean`, `Date`, `Email`, or `Phone`) +- **dependsOn**: An array of field names that the callback needs to compute the value +- **callback**: A function that receives the object (including `id` and all `dependsOn` fields) and returns the computed value + +When you select a virtual field in a query, only the computed value is returned—the dependency fields are not exposed unless you explicitly select them. + +```ts +import { Wabe } from "wabe"; + +const run = async () => { + const wabe = new Wabe({ + // ... others config fields + schema: { + classes: [ + { + name: "Person", + fields: { + firstName: { type: "String" }, + lastName: { type: "String" }, + age: { type: "Int" }, + fullName: { + type: "Virtual", + returnType: "String", + dependsOn: ["firstName", "lastName"], + callback: (object) => + `${object.firstName} ${object.lastName}`.trim(), + }, + isAdult: { + type: "Virtual", + returnType: "Boolean", + dependsOn: ["age"], + callback: (object) => (object.age ?? 0) >= 18, + }, + }, + }, + ], + }, + }); + + await wabe.start(); +}; + +await run(); +``` + ## Indexes Indexes improve query performance by creating optimized data structures for specific fields. When defining indexes, you specify: From aa048a3066dc033fe9e0b81ed3c5e0977ea6bd56 Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:06:28 +0100 Subject: [PATCH 3/4] feat: support object --- .../wabe/src/graphql/GraphQLSchema.test.ts | 147 ++++++++++++++++++ packages/wabe/src/graphql/parser.ts | 96 ++++++++++++ packages/wabe/src/hooks/virtualFields.test.ts | 97 ++++++++++-- packages/wabe/src/schema/Schema.ts | 40 ++++- 4 files changed, 362 insertions(+), 18 deletions(-) diff --git a/packages/wabe/src/graphql/GraphQLSchema.test.ts b/packages/wabe/src/graphql/GraphQLSchema.test.ts index 3eae9681..8f28ee87 100644 --- a/packages/wabe/src/graphql/GraphQLSchema.test.ts +++ b/packages/wabe/src/graphql/GraphQLSchema.test.ts @@ -2009,6 +2009,153 @@ describe('GraphqlSchema', () => { await wabe.close() }) + it('should resolve virtual field returning array in GraphQL queries', async () => { + const { client, wabe } = await createWabe({ + classes: [ + { + name: 'VirtualPersonWithArray', + fields: { + firstName: { type: 'String' }, + lastName: { type: 'String' }, + nameParts: { + type: 'Virtual', + returnType: 'Array', + typeValue: 'String', + // @ts-expect-error + dependsOn: ['firstName', 'lastName'], + callback: (object: any) => + [object.firstName || '', object.lastName || ''].filter(Boolean), + }, + }, + permissions: { + read: { requireAuthentication: false }, + create: { requireAuthentication: false }, + update: { requireAuthentication: false }, + delete: { requireAuthentication: false }, + }, + }, + ], + }) + + const created = await client.request<{ + createVirtualPersonWithArray: { + virtualPersonWithArray: { id: string } + } + }>(gql` + mutation createVirtualPersonWithArray { + createVirtualPersonWithArray(input: { fields: { firstName: "Ada", lastName: "Lovelace" } }) { + virtualPersonWithArray { + id + } + } + } + `) + + const read = await client.request<{ + virtualPersonWithArray: { + id: string + firstName: string + lastName: string + nameParts: string[] + } + }>( + gql` + query virtualPersonWithArray($id: ID) { + virtualPersonWithArray(id: $id) { + id + firstName + lastName + nameParts + } + } + `, + { id: created.createVirtualPersonWithArray.virtualPersonWithArray.id }, + ) + + expect(read.virtualPersonWithArray.nameParts).toEqual(['Ada', 'Lovelace']) + + await wabe.close() + }) + + it('should resolve virtual field returning array of objects in GraphQL queries', async () => { + const { client, wabe } = await createWabe({ + classes: [ + { + name: 'VirtualPersonWithObjectArray', + fields: { + firstName: { type: 'String' }, + lastName: { type: 'String' }, + nameInfos: { + type: 'Virtual', + returnType: 'Array', + typeValue: 'Object', + object: { + name: 'NameInfo', + fields: { + label: { type: 'String' }, + value: { type: 'String' }, + }, + }, + // @ts-expect-error + dependsOn: ['firstName', 'lastName'], + callback: (object: any) => [ + { label: 'First', value: object.firstName || '' }, + { label: 'Last', value: object.lastName || '' }, + ], + }, + }, + permissions: { + read: { requireAuthentication: false }, + create: { requireAuthentication: false }, + update: { requireAuthentication: false }, + delete: { requireAuthentication: false }, + }, + }, + ], + }) + + const created = await client.request<{ + createVirtualPersonWithObjectArray: { + virtualPersonWithObjectArray: { id: string } + } + }>(gql` + mutation createVirtualPersonWithObjectArray { + createVirtualPersonWithObjectArray(input: { fields: { firstName: "Grace", lastName: "Hopper" } }) { + virtualPersonWithObjectArray { + id + } + } + } + `) + + const read = await client.request<{ + virtualPersonWithObjectArray: { + id: string + nameInfos: Array<{ label: string; value: string }> + } + }>( + gql` + query virtualPersonWithObjectArray($id: ID) { + virtualPersonWithObjectArray(id: $id) { + id + nameInfos { + label + value + } + } + } + `, + { id: created.createVirtualPersonWithObjectArray.virtualPersonWithObjectArray.id }, + ) + + expect(read.virtualPersonWithObjectArray.nameInfos).toEqual([ + { label: 'First', value: 'Grace' }, + { label: 'Last', value: 'Hopper' }, + ]) + + await wabe.close() + }) + it('should have ConnectionObject on field of relation in ObjectType', () => { expect( getTypeFromGraphQLSchema({ diff --git a/packages/wabe/src/graphql/parser.ts b/packages/wabe/src/graphql/parser.ts index 4cc5e54a..1f677ec2 100644 --- a/packages/wabe/src/graphql/parser.ts +++ b/packages/wabe/src/graphql/parser.ts @@ -182,6 +182,53 @@ export const GraphqlParser: GraphqlParserConstructor = if (currentField?.type === 'Virtual') { if (isWhereType) return acc + if (currentField.returnType === 'Object' && 'object' in currentField) { + acc[key] = { + type: callBackForObjectType({ + required: currentField.object?.required, + description: currentField.description, + objectToParse: currentField.object, + nameOfTheObject: `${nameOfTheObject}${keyWithFirstLetterUppercase}`, + }), + } + return acc + } + + if (currentField.returnType === 'Array' && 'typeValue' in currentField) { + if (currentField.typeValue === 'Object' && 'object' in currentField) { + const objectList = new GraphQLList( + callBackForObjectType({ + required: currentField.object?.required, + description: currentField.description, + objectToParse: currentField.object, + nameOfTheObject: `${nameOfTheObject}${currentField.object.name}`, + }), + ) + acc[key] = { + type: + currentField.required && !forceRequiredToFalse + ? new GraphQLNonNull(objectList) + : objectList, + } + } else if ( + currentField.typeValue && + templateScalarType[currentField.typeValue as WabePrimaryTypes] + ) { + const graphqlType = getGraphqlType({ + type: 'Array', + typeValue: currentField.typeValue as WabePrimaryTypes, + requiredValue: (currentField as any).requiredValue, + }) + acc[key] = { + type: + currentField.required && !forceRequiredToFalse + ? new GraphQLNonNull(graphqlType) + : graphqlType, + } + } + return acc + } + const graphqlType = getGraphqlType({ type: currentField.returnType, }) @@ -426,6 +473,7 @@ export const GraphqlParser: GraphqlParserConstructor = const rawFields = keysOfObject.reduce( (acc, key) => { + const keyWithFirstLetterUppercase = `${key.charAt(0).toUpperCase()}${key.slice(1)}` const currentField = schemaFields[key] const isRelation = currentField?.type === 'Relation' @@ -468,6 +516,54 @@ export const GraphqlParser: GraphqlParserConstructor = if (currentField?.type === 'Virtual') { if (graphqlObjectType !== 'Object') return acc + if (currentField.returnType === 'Object' && 'object' in currentField) { + acc[key] = { + type: callback({ + required: currentField.object?.required, + description: currentField.description, + objectToParse: currentField.object, + nameOfTheObject: `${nameOfTheObject}${keyWithFirstLetterUppercase}`, + }), + } + return acc + } + + if (currentField.returnType === 'Array' && 'typeValue' in currentField) { + if (currentField.typeValue === 'Object' && 'object' in currentField) { + const objectList = new GraphQLList( + callback({ + required: currentField.object?.required, + description: currentField.description, + objectToParse: currentField.object, + nameOfTheObject: `${nameOfTheObject}${currentField.object.name}`, + }), + ) + acc[key] = { + type: + currentField.required && !forceRequiredToFalse + ? new GraphQLNonNull(objectList) + : objectList, + } + } else if ( + currentField.typeValue && + templateScalarType[currentField.typeValue as WabePrimaryTypes] + ) { + const graphqlType = getGraphqlType({ + type: 'Array', + typeValue: currentField.typeValue as WabePrimaryTypes, + requiredValue: (currentField as any).requiredValue, + isWhereType, + }) + acc[key] = { + type: + currentField.required && !forceRequiredToFalse + ? new GraphQLNonNull(graphqlType) + : graphqlType, + } + } + return acc + } + const graphqlType = getGraphqlType({ type: currentField.returnType, isWhereType, diff --git a/packages/wabe/src/hooks/virtualFields.test.ts b/packages/wabe/src/hooks/virtualFields.test.ts index 1c8bb5a6..518bbe7e 100644 --- a/packages/wabe/src/hooks/virtualFields.test.ts +++ b/packages/wabe/src/hooks/virtualFields.test.ts @@ -1,4 +1,12 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test' +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + spyOn, +} from 'bun:test' import type { Wabe } from '../server' import { type DevWabeTypes } from '../utils/helper' import { closeTests, setupTests } from '../utils/testHelper' @@ -18,7 +26,8 @@ describe('Virtual fields integration', () => { type: 'Virtual', returnType: 'String', dependsOn: ['firstName', 'lastName'], - callback: (object: any) => `${object.firstName} ${object.lastName}`.trim(), + callback: (object: any) => + `${object.firstName} ${object.lastName}`.trim(), }, isAdult: { type: 'Virtual', @@ -26,6 +35,25 @@ describe('Virtual fields integration', () => { dependsOn: ['age'], callback: (object: any) => (object.age ?? 0) >= 18, }, + nameInfo: { + type: 'Virtual', + returnType: 'Object', + object: { + name: 'NameInfo', + fields: { + full: { type: 'String' }, + initials: { type: 'String' }, + }, + }, + dependsOn: ['firstName', 'lastName'], + callback: (object: any) => ({ + full: `${object.firstName} ${object.lastName}`.trim(), + initials: + [object.firstName?.[0], object.lastName?.[0]] + .filter(Boolean) + .join('.') || '', + }), + }, }, permissions: { read: { requireAuthentication: false }, @@ -79,7 +107,10 @@ describe('Virtual fields integration', () => { }) it('loads dependencies in adapter select and never requests virtual keys directly on database', async () => { - const adapterGetObjectSpy = spyOn(wabe.controllers.database.adapter, 'getObject') + const adapterGetObjectSpy = spyOn( + wabe.controllers.database.adapter, + 'getObject' + ) const created = await wabe.controllers.database.createObject({ // @ts-expect-error @@ -109,18 +140,19 @@ describe('Virtual fields integration', () => { }) expect(adapterGetObjectSpy.mock.calls.length).toBeGreaterThanOrEqual(1) - const adapterCallWithVirtualDependencies = adapterGetObjectSpy.mock.calls.find((call: any) => { - const options = call[0] - return ( - options?.className === 'VirtualPerson' && - options?.id === (created?.id || '') && - options?.select?.firstName === true && - options?.select?.lastName === true && - options?.select?.age === true && - options?.select?.fullName === undefined && - options?.select?.isAdult === undefined - ) - }) + const adapterCallWithVirtualDependencies = + adapterGetObjectSpy.mock.calls.find((call: any) => { + const options = call[0] + return ( + options?.className === 'VirtualPerson' && + options?.id === (created?.id || '') && + options?.select?.firstName === true && + options?.select?.lastName === true && + options?.select?.age === true && + options?.select?.fullName === undefined && + options?.select?.isAdult === undefined + ) + }) expect(adapterCallWithVirtualDependencies).toBeDefined() }) @@ -173,4 +205,39 @@ describe('Virtual fields integration', () => { // @ts-expect-error expect(read?.fullName).toBe('Updated User') }) + + it('returns virtual field as object with 2 fields and expects correct structure', async () => { + const created = await wabe.controllers.database.createObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + data: { + // @ts-expect-error + firstName: 'Alan', + lastName: 'Turing', + age: 41, + }, + select: { id: true }, + }) + + const result = await wabe.controllers.database.getObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + id: created?.id || '', + select: { + // @ts-expect-error + nameInfo: true, + }, + }) + + const resultAny: any = result + const expectedNameInfo = { + full: 'Alan Turing', + initials: 'A.T', + } + expect(resultAny?.nameInfo).toEqual(expectedNameInfo) + expect(resultAny?.nameInfo.full).toBe('Alan Turing') + expect(resultAny?.nameInfo.initials).toBe('A.T') + }) }) diff --git a/packages/wabe/src/schema/Schema.ts b/packages/wabe/src/schema/Schema.ts index 66e123a6..1ff30301 100644 --- a/packages/wabe/src/schema/Schema.ts +++ b/packages/wabe/src/schema/Schema.ts @@ -27,7 +27,16 @@ export type WabeRelationTypes = 'Pointer' | 'Relation' export type WabeFieldTypes = WabeCustomTypes | WabePrimaryTypes | WabeRelationTypes -export type VirtualReturnType = 'String' | 'Int' | 'Float' | 'Boolean' | 'Date' | 'Email' | 'Phone' +export type VirtualReturnType = + | 'String' + | 'Int' + | 'Float' + | 'Boolean' + | 'Date' + | 'Email' + | 'Phone' + | 'Object' + | 'Array' export type WabeObject = { name: string @@ -94,14 +103,39 @@ type TypeFieldCustomEnums = { defaultValue?: any } -type TypeFieldVirtual = { +type TypeFieldVirtualScalar = { type: 'Virtual' - returnType: VirtualReturnType + returnType: 'String' | 'Int' | 'Float' | 'Boolean' | 'Date' | 'Email' | 'Phone' defaultValue?: never dependsOn: Array callback: (object: T['types'][K] & { id: string }) => string | number | boolean | Date | null } +type TypeFieldVirtualObject = { + type: 'Virtual' + returnType: 'Object' + object: WabeObject + defaultValue?: never + dependsOn: Array + callback: (object: T['types'][K] & { id: string }) => Record | null +} + +type TypeFieldVirtualArray = { + type: 'Virtual' + returnType: 'Array' + defaultValue?: never + dependsOn: Array + callback: (object: T['types'][K] & { id: string }) => unknown[] | null +} & ( + | { typeValue: WabePrimaryTypes } + | { typeValue: 'Object'; object: WabeObject } +) + +type TypeFieldVirtual = + | TypeFieldVirtualScalar + | TypeFieldVirtualObject + | TypeFieldVirtualArray + export type TypeField = ( | TypeFieldBase | TypeFieldBase From 91e62aa4a33fb8741bbb6f277b86a508ee4c5c24 Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:06:44 +0100 Subject: [PATCH 4/4] feat: format --- .../wabe/src/graphql/GraphQLSchema.test.ts | 4 +- packages/wabe/src/hooks/virtualFields.test.ts | 47 +++++++------------ packages/wabe/src/schema/Schema.ts | 5 +- 3 files changed, 20 insertions(+), 36 deletions(-) diff --git a/packages/wabe/src/graphql/GraphQLSchema.test.ts b/packages/wabe/src/graphql/GraphQLSchema.test.ts index 8f28ee87..670fb273 100644 --- a/packages/wabe/src/graphql/GraphQLSchema.test.ts +++ b/packages/wabe/src/graphql/GraphQLSchema.test.ts @@ -2120,7 +2120,9 @@ describe('GraphqlSchema', () => { } }>(gql` mutation createVirtualPersonWithObjectArray { - createVirtualPersonWithObjectArray(input: { fields: { firstName: "Grace", lastName: "Hopper" } }) { + createVirtualPersonWithObjectArray( + input: { fields: { firstName: "Grace", lastName: "Hopper" } } + ) { virtualPersonWithObjectArray { id } diff --git a/packages/wabe/src/hooks/virtualFields.test.ts b/packages/wabe/src/hooks/virtualFields.test.ts index 518bbe7e..5fceb011 100644 --- a/packages/wabe/src/hooks/virtualFields.test.ts +++ b/packages/wabe/src/hooks/virtualFields.test.ts @@ -1,12 +1,4 @@ -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - it, - spyOn, -} from 'bun:test' +import { afterAll, beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test' import type { Wabe } from '../server' import { type DevWabeTypes } from '../utils/helper' import { closeTests, setupTests } from '../utils/testHelper' @@ -26,8 +18,7 @@ describe('Virtual fields integration', () => { type: 'Virtual', returnType: 'String', dependsOn: ['firstName', 'lastName'], - callback: (object: any) => - `${object.firstName} ${object.lastName}`.trim(), + callback: (object: any) => `${object.firstName} ${object.lastName}`.trim(), }, isAdult: { type: 'Virtual', @@ -49,9 +40,7 @@ describe('Virtual fields integration', () => { callback: (object: any) => ({ full: `${object.firstName} ${object.lastName}`.trim(), initials: - [object.firstName?.[0], object.lastName?.[0]] - .filter(Boolean) - .join('.') || '', + [object.firstName?.[0], object.lastName?.[0]].filter(Boolean).join('.') || '', }), }, }, @@ -107,10 +96,7 @@ describe('Virtual fields integration', () => { }) it('loads dependencies in adapter select and never requests virtual keys directly on database', async () => { - const adapterGetObjectSpy = spyOn( - wabe.controllers.database.adapter, - 'getObject' - ) + const adapterGetObjectSpy = spyOn(wabe.controllers.database.adapter, 'getObject') const created = await wabe.controllers.database.createObject({ // @ts-expect-error @@ -140,19 +126,18 @@ describe('Virtual fields integration', () => { }) expect(adapterGetObjectSpy.mock.calls.length).toBeGreaterThanOrEqual(1) - const adapterCallWithVirtualDependencies = - adapterGetObjectSpy.mock.calls.find((call: any) => { - const options = call[0] - return ( - options?.className === 'VirtualPerson' && - options?.id === (created?.id || '') && - options?.select?.firstName === true && - options?.select?.lastName === true && - options?.select?.age === true && - options?.select?.fullName === undefined && - options?.select?.isAdult === undefined - ) - }) + const adapterCallWithVirtualDependencies = adapterGetObjectSpy.mock.calls.find((call: any) => { + const options = call[0] + return ( + options?.className === 'VirtualPerson' && + options?.id === (created?.id || '') && + options?.select?.firstName === true && + options?.select?.lastName === true && + options?.select?.age === true && + options?.select?.fullName === undefined && + options?.select?.isAdult === undefined + ) + }) expect(adapterCallWithVirtualDependencies).toBeDefined() }) diff --git a/packages/wabe/src/schema/Schema.ts b/packages/wabe/src/schema/Schema.ts index 1ff30301..0ade3159 100644 --- a/packages/wabe/src/schema/Schema.ts +++ b/packages/wabe/src/schema/Schema.ts @@ -126,10 +126,7 @@ type TypeFieldVirtualArray = { defaultValue?: never dependsOn: Array callback: (object: T['types'][K] & { id: string }) => unknown[] | null -} & ( - | { typeValue: WabePrimaryTypes } - | { typeValue: 'Object'; object: WabeObject } -) +} & ({ typeValue: WabePrimaryTypes } | { typeValue: 'Object'; object: WabeObject }) type TypeFieldVirtual = | TypeFieldVirtualScalar