diff --git a/.changeset/runtime-validation-improvements.md b/.changeset/runtime-validation-improvements.md new file mode 100644 index 0000000000..7851e07074 --- /dev/null +++ b/.changeset/runtime-validation-improvements.md @@ -0,0 +1,5 @@ +--- +"jazz-tools": patch +--- + +Introduced runtime validation for schema-based CoValues. All mutations now accept a `validation` option of `strict` or `loose`. `setDefaultValidationMode()` can also be used to enable or disable validation across the entire app. Currently, the default validation mode is `warn`: updates and inserts of invalid data will still be allowed, but a console warning will be issued. The usage of `setDefaultValidationMode("strict")` is encouraged, as it will be the default mode in the future. diff --git a/.changeset/smooth-lions-joke.md b/.changeset/smooth-lions-joke.md new file mode 100644 index 0000000000..7e8fac27c0 --- /dev/null +++ b/.changeset/smooth-lions-joke.md @@ -0,0 +1,5 @@ +--- +"jazz-tools": patch +--- + +Removed the legacy `coField` and `Encoders` exports and completed the runtime schema migration to the new schema descriptors. Apps still using the old schema APIs should migrate to the current `co`/zod based schemas. diff --git a/examples/chat-rn-expo/src/schema.ts b/examples/chat-rn-expo/src/schema.ts index 5d1c50d6d4..c5bca7ea23 100644 --- a/examples/chat-rn-expo/src/schema.ts +++ b/examples/chat-rn-expo/src/schema.ts @@ -1,4 +1,6 @@ -import { co, z } from "jazz-tools"; +import { co, z, setDefaultValidationMode } from "jazz-tools"; + +setDefaultValidationMode("strict"); export const Message = co.map({ text: co.plainText(), diff --git a/examples/chat-rn/src/schema.ts b/examples/chat-rn/src/schema.ts index 5dc242c3db..ad6b1e9d3d 100644 --- a/examples/chat-rn/src/schema.ts +++ b/examples/chat-rn/src/schema.ts @@ -1,4 +1,6 @@ -import { co } from "jazz-tools"; +import { co, setDefaultValidationMode } from "jazz-tools"; + +setDefaultValidationMode("strict"); export const Message = co.map({ text: co.plainText(), diff --git a/examples/chat/src/schema.ts b/examples/chat/src/schema.ts index f739c7777d..3f426236b1 100644 --- a/examples/chat/src/schema.ts +++ b/examples/chat/src/schema.ts @@ -1,4 +1,6 @@ -import { co } from "jazz-tools"; +import { co, setDefaultValidationMode } from "jazz-tools"; + +setDefaultValidationMode("strict"); export const Message = co .map({ diff --git a/examples/form/src/schema.ts b/examples/form/src/schema.ts index b060b0fff6..2e7da425ce 100644 --- a/examples/form/src/schema.ts +++ b/examples/form/src/schema.ts @@ -1,4 +1,6 @@ -import { co, z } from "jazz-tools"; +import { co, z, setDefaultValidationMode } from "jazz-tools"; + +setDefaultValidationMode("strict"); export const BubbleTeaAddOnTypes = [ "Pearl", diff --git a/examples/inspector/tests/lib/data.ts b/examples/inspector/tests/lib/data.ts index 756a1eff1b..562b64342f 100644 --- a/examples/inspector/tests/lib/data.ts +++ b/examples/inspector/tests/lib/data.ts @@ -1,4 +1,10 @@ -import { FileStream, ImageDefinition, co, z } from "jazz-tools"; +import { + FileStream, + ImageDefinition, + co, + z, + setDefaultValidationMode, +} from "jazz-tools"; import { Issue, Organization, @@ -7,6 +13,8 @@ import { ReactionsList, } from "./schema"; +setDefaultValidationMode("strict"); + const projectsData: { name: string; description: string; @@ -73,7 +81,9 @@ export const createFile = () => { export const createImage = () => { return ImageDefinition.create({ + original: FileStream.create(), originalSize: [1920, 1080], + progressive: false, placeholderDataURL: "data:image/jpeg;base64,...", }); }; @@ -82,6 +92,8 @@ export const createOrganization = () => { return Organization.create({ name: "Garden Computing", image: ImageDefinition.create({ + original: FileStream.create(), + progressive: false, originalSize: [1920, 1080], placeholderDataURL: "data:image/jpeg;base64,...", }), diff --git a/examples/jazz-nextjs/src/schema.ts b/examples/jazz-nextjs/src/schema.ts index 92745b9a83..1f1269873b 100644 --- a/examples/jazz-nextjs/src/schema.ts +++ b/examples/jazz-nextjs/src/schema.ts @@ -1,4 +1,6 @@ -import { co, Group, Loaded, z } from "jazz-tools"; +import { co, Group, Loaded, z, setDefaultValidationMode } from "jazz-tools"; + +setDefaultValidationMode("strict"); export const TodoProfile = co .profile({ diff --git a/examples/multi-cursors/src/schema.ts b/examples/multi-cursors/src/schema.ts index 7b96f14da3..2ac1111546 100644 --- a/examples/multi-cursors/src/schema.ts +++ b/examples/multi-cursors/src/schema.ts @@ -1,6 +1,8 @@ -import { co, z } from "jazz-tools"; +import { co, z, setDefaultValidationMode } from "jazz-tools"; import { Camera, Cursor } from "./types"; +setDefaultValidationMode("strict"); + export const CursorFeed = co.feed(Cursor); export type CursorFeed = co.loaded; diff --git a/examples/music-player/src/1_schema.ts b/examples/music-player/src/1_schema.ts index fa63c7f153..362e860764 100644 --- a/examples/music-player/src/1_schema.ts +++ b/examples/music-player/src/1_schema.ts @@ -1,4 +1,11 @@ -import { co, z, setDefaultSchemaPermissions } from "jazz-tools"; +import { + co, + z, + setDefaultSchemaPermissions, + setDefaultValidationMode, +} from "jazz-tools"; + +setDefaultValidationMode("strict"); setDefaultSchemaPermissions({ onInlineCreate: "sameAsContainer", diff --git a/examples/organization/src/schema.ts b/examples/organization/src/schema.ts index 4d305609ab..b1f481ebbf 100644 --- a/examples/organization/src/schema.ts +++ b/examples/organization/src/schema.ts @@ -1,6 +1,8 @@ -import { co, z } from "jazz-tools"; +import { co, z, setDefaultValidationMode } from "jazz-tools"; import { getRandomUsername } from "./util"; +setDefaultValidationMode("strict"); + export const Project = co .map({ name: z.string(), diff --git a/examples/passkey-rn/src/schema.ts b/examples/passkey-rn/src/schema.ts index 83cefafde0..85d832b6eb 100644 --- a/examples/passkey-rn/src/schema.ts +++ b/examples/passkey-rn/src/schema.ts @@ -1,4 +1,6 @@ -import { co } from "jazz-tools"; +import { co, setDefaultValidationMode } from "jazz-tools"; + +setDefaultValidationMode("strict"); /** * Simple note schema to demonstrate Jazz functionality with passkey auth. diff --git a/examples/todo/src/1_schema.ts b/examples/todo/src/1_schema.ts index 175116da69..71fa7a587f 100644 --- a/examples/todo/src/1_schema.ts +++ b/examples/todo/src/1_schema.ts @@ -1,4 +1,6 @@ -import { co, z } from "jazz-tools"; +import { co, z, setDefaultValidationMode } from "jazz-tools"; + +setDefaultValidationMode("strict"); /** Walkthrough: Defining the data model with CoJSON * diff --git a/examples/vector-search/src/schema.ts b/examples/vector-search/src/schema.ts index 11afba661d..9d87ac5993 100644 --- a/examples/vector-search/src/schema.ts +++ b/examples/vector-search/src/schema.ts @@ -1,4 +1,6 @@ -import { co, z } from "jazz-tools"; +import { co, z, setDefaultValidationMode } from "jazz-tools"; + +setDefaultValidationMode("strict"); export const JazzProfile = co.profile(); diff --git a/homepage/homepage/content/docs/core-concepts/covalues/overview.mdx b/homepage/homepage/content/docs/core-concepts/covalues/overview.mdx index 9bead6edd7..cc63b3eed2 100644 --- a/homepage/homepage/content/docs/core-concepts/covalues/overview.mdx +++ b/homepage/homepage/content/docs/core-concepts/covalues/overview.mdx @@ -56,6 +56,10 @@ Jazz will automatically create the CoValues for you. ``` + +Starting from Jazz 0.20.10, it is possible to opt in to run-time schema validation on writes. This will ensure that only data which conforms to the defined schema can be inserted into the CoValue. This helps to enforce data integrity and improve the type-safety of your application. The validation strategy is currently warn by default: updates and inserts of invalid data will still be allowed, but will give a console warning. This can be changed using the setDefaultValidationMode(), which accepts `strict` (throw if invalid), `warn` (warn if invalid) and `loose` (allow invalid), as options. + + To learn more about how permissions work when creating nested CoValues with plain JSON objects, refer to [Ownership on inline CoValue creation](/docs/permissions-and-sharing/cascading-permissions#ownership-on-inline-covalue-creation). diff --git a/packages/jazz-tools/src/better-auth/database-adapter/repository/generic.ts b/packages/jazz-tools/src/better-auth/database-adapter/repository/generic.ts index d92d859d8d..b4aa98bacb 100644 --- a/packages/jazz-tools/src/better-auth/database-adapter/repository/generic.ts +++ b/packages/jazz-tools/src/better-auth/database-adapter/repository/generic.ts @@ -70,9 +70,15 @@ export class JazzRepository { if (!uniqueId) { // Use the same owner of the table. - const node = schema.create(data, { - owner: list.$jazz.owner, - }); + const node = schema.create( + { + ...data, + _deleted: false, + }, + { + owner: list.$jazz.owner, + }, + ); list.$jazz.push(node); diff --git a/packages/jazz-tools/src/better-auth/database-adapter/repository/session.ts b/packages/jazz-tools/src/better-auth/database-adapter/repository/session.ts index 6abbf72760..1636dbb6e9 100644 --- a/packages/jazz-tools/src/better-auth/database-adapter/repository/session.ts +++ b/packages/jazz-tools/src/better-auth/database-adapter/repository/session.ts @@ -66,10 +66,16 @@ export class SessionRepository extends JazzRepository { }, }); - const session = this.getSchema("session").create(data, { - unique: data.token, - owner: this.owner, - }); + const session = this.getSchema("session").create( + { + ...data, + _deleted: false, + }, + { + unique: data.token, + owner: this.owner, + }, + ); sessions.$jazz.push(session); diff --git a/packages/jazz-tools/src/better-auth/database-adapter/repository/user.ts b/packages/jazz-tools/src/better-auth/database-adapter/repository/user.ts index 42a780c166..c0bf103481 100644 --- a/packages/jazz-tools/src/better-auth/database-adapter/repository/user.ts +++ b/packages/jazz-tools/src/better-auth/database-adapter/repository/user.ts @@ -31,7 +31,14 @@ export class UserRepository extends JazzRepository { throw new Error("Email already exists"); } - const user = await super.create(model, data, uniqueId); + const user = await super.create( + model, + { + sessions: [], + ...data, + }, + uniqueId, + ); await this.updateEmailIndex(userEmail, user.$jazz.id); diff --git a/packages/jazz-tools/src/better-auth/database-adapter/tests/index.test.ts b/packages/jazz-tools/src/better-auth/database-adapter/tests/index.test.ts index e24afa9dbe..eb0047b194 100644 --- a/packages/jazz-tools/src/better-auth/database-adapter/tests/index.test.ts +++ b/packages/jazz-tools/src/better-auth/database-adapter/tests/index.test.ts @@ -286,7 +286,7 @@ describe("JazzBetterAuthDatabaseAdapter tests", async () => { expect( handleSyncMessageSpy.mock.calls.filter(([msg]) => msg.id === user.id), - ).toHaveLength(3); + ).toHaveLength(2); }); it("should return the new entity with date objects", async () => { diff --git a/packages/jazz-tools/src/better-auth/database-adapter/tests/repository/user.test.ts b/packages/jazz-tools/src/better-auth/database-adapter/tests/repository/user.test.ts index 5b2183a515..64936f6073 100644 --- a/packages/jazz-tools/src/better-auth/database-adapter/tests/repository/user.test.ts +++ b/packages/jazz-tools/src/better-auth/database-adapter/tests/repository/user.test.ts @@ -348,47 +348,6 @@ describe("UserRepository", () => { expect(newEmailUsers[0]?.$jazz.id).toBe(user.$jazz.id); }); - it("should update user email to null and remove from email index", async () => { - const userRepository = new UserRepository( - databaseSchema, - databaseRoot, - worker, - ); - - const user = await userRepository.create("user", { - email: "test@example.com", - name: "Test User", - }); - - // Update email to null - const updatedUsers = await userRepository.update( - "user", - [ - { - field: "id", - operator: "eq", - value: user.$jazz.id, - connector: "AND", - }, - ], - { email: null }, - ); - - expect(updatedUsers.length).toBe(1); - expect(updatedUsers[0]?.email).toBe(null); - - // Verify email is no longer findable - const emailUsers = await userRepository.findMany("user", [ - { - field: "email", - operator: "eq", - value: "test@example.com", - connector: "AND", - }, - ]); - expect(emailUsers.length).toBe(0); - }); - it("should handle update with no matching users", async () => { const userRepository = new UserRepository( databaseSchema, @@ -565,20 +524,6 @@ describe("UserRepository", () => { name: "Test User", }); - // First set email to null - await userRepository.update( - "user", - [ - { - field: "id", - operator: "eq", - value: user.$jazz.id, - connector: "AND", - }, - ], - { email: null }, - ); - // Then delete the user const deletedCount = await userRepository.deleteValue("user", [ { field: "id", operator: "eq", value: user.$jazz.id, connector: "AND" }, diff --git a/packages/jazz-tools/src/browser/tests/createInviteLink.test.ts b/packages/jazz-tools/src/browser/tests/createInviteLink.test.ts index 0ef7396a28..dd95c9fd7c 100644 --- a/packages/jazz-tools/src/browser/tests/createInviteLink.test.ts +++ b/packages/jazz-tools/src/browser/tests/createInviteLink.test.ts @@ -1,4 +1,4 @@ -import { CoMap, co, coField, z } from "jazz-tools"; +import { co, z } from "jazz-tools"; import { expect, test } from "vitest"; import { createInviteLink } from "../index.js"; import { setupTwoNodes } from "./utils.js"; diff --git a/packages/jazz-tools/src/react-core/tests/useCoState.test.ts b/packages/jazz-tools/src/react-core/tests/useCoState.test.ts index 46c1196c21..92e12f6243 100644 --- a/packages/jazz-tools/src/react-core/tests/useCoState.test.ts +++ b/packages/jazz-tools/src/react-core/tests/useCoState.test.ts @@ -593,7 +593,7 @@ describe("useCoState", () => { it("should immediately load deeploaded data when available locally", async () => { const Message = co.map({ - content: CoRichText, + content: co.richText(), }); const Messages = co.list(Message); const Thread = co.map({ diff --git a/packages/jazz-tools/src/react/tests/useAcceptInvite.test.ts b/packages/jazz-tools/src/react/tests/useAcceptInvite.test.ts index 947b736eb7..1a8e736d22 100644 --- a/packages/jazz-tools/src/react/tests/useAcceptInvite.test.ts +++ b/packages/jazz-tools/src/react/tests/useAcceptInvite.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { CoMap, Group, ID, co, coField, z } from "jazz-tools"; +import { Group, co, z } from "jazz-tools"; import { assertLoaded } from "jazz-tools/testing"; import { describe, expect, it } from "vitest"; import { createInviteLink, useAcceptInvite } from "../index.js"; diff --git a/packages/jazz-tools/src/tools/coValues/account.ts b/packages/jazz-tools/src/tools/coValues/account.ts index c207267336..44c2ad9940 100644 --- a/packages/jazz-tools/src/tools/coValues/account.ts +++ b/packages/jazz-tools/src/tools/coValues/account.ts @@ -37,7 +37,7 @@ import { RefsToResolveStrict, RegisteredSchemas, Resolved, - SchemaInit, + Schema, SubscribeListenerOptions, SubscribeRestArgs, TypeSym, @@ -48,8 +48,10 @@ import { coValuesCache, createInboxRoot, ensureCoValueLoaded, + hydrateCoreCoValueSchema, inspect, instantiateRefEncodedWithInit, + isRefEncoded, loadCoValue, loadCoValueWithoutMe, parseSubscribeRestArgs, @@ -58,6 +60,9 @@ import { InstanceOfSchemaCoValuesMaybeLoaded, LoadedAndRequired, } from "../internal.js"; +import type { CoreAccountSchema } from "../implementation/zodSchema/schemaTypes/AccountSchema.js"; +import type { AccountSchema as HydratedAccountSchema } from "../implementation/zodSchema/schemaTypes/AccountSchema.js"; +import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; export type AccountCreationProps = { name: string; @@ -67,6 +72,7 @@ export type AccountCreationProps = { /** @category Identity & Permissions */ export class Account extends CoValueBase implements CoValue { declare [TypeSym]: "Account"; + static coValueSchema?: HydratedAccountSchema; /** * Jazz methods for Accounts are inside this property. @@ -76,18 +82,6 @@ export class Account extends CoValueBase implements CoValue { */ declare $jazz: AccountJazzApi; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static _schema: any = { - profile: { - ref: () => Profile, - optional: false, - } satisfies RefEncoded, - root: { - ref: () => RegisteredSchemas["CoMap"], - optional: true, - } satisfies RefEncoded, - }; - declare readonly profile: MaybeLoaded; declare readonly root: MaybeLoaded; @@ -102,10 +96,16 @@ export class Account extends CoValueBase implements CoValue { AccountAndGroupProxyHandler as ProxyHandler, ); + const accountSchema = assertCoValueSchema( + this.constructor, + "Account", + "create", + ); + Object.defineProperties(this, { [TypeSym]: { value: "Account", enumerable: false, configurable: true }, $jazz: { - value: new AccountJazzApi(proxy, options.fromRaw), + value: new AccountJazzApi(proxy, options.fromRaw, accountSchema), enumerable: false, configurable: true, }, @@ -430,6 +430,8 @@ export class Account extends CoValueBase implements CoValue { } class AccountJazzApi extends CoValueJazzApi { + private descriptorCache = new Map(); + /** * Whether this account is the owner of the local node. * @@ -442,6 +444,7 @@ class AccountJazzApi extends CoValueJazzApi { constructor( private account: A, public raw: RawAccount, + private coValueSchema: CoreAccountSchema, ) { super(account); this.isLocalNodeOwner = this.raw.id === this.localNode.getCurrentAgent().id; @@ -474,7 +477,10 @@ class AccountJazzApi extends CoValueJazzApi { | CoID | undefined; if (!refId) { - const descriptor = this.schema[key]; + const descriptor = this.getDescriptor(key); + if (!descriptor || !isRefEncoded(descriptor)) { + throw new Error(`Cannot set unknown account key ${key}`); + } const newOwnerStrategy = descriptor.permissions?.newInlineOwnerStrategy; const onCreate = descriptor.permissions?.onCreate; const coValue = instantiateRefEncodedWithInit( @@ -499,13 +505,20 @@ class AccountJazzApi extends CoValueJazzApi { * Get the descriptor for a given key * @internal */ - getDescriptor(key: string) { - if (key === "profile") { - return this.schema.profile; - } else if (key === "root") { - return this.schema.root; + getDescriptor(key: string): Schema | undefined { + if (this.descriptorCache.has(key)) { + return this.descriptorCache.get(key); + } + + const accountSchema = this.coValueSchema; + + const descriptor = accountSchema.getDescriptorsSchema().shape[key]; + if (descriptor) { + this.descriptorCache.set(key, descriptor); + return descriptor; } + this.descriptorCache.set(key, undefined); return undefined; } @@ -533,7 +546,7 @@ class AccountJazzApi extends CoValueJazzApi { ? (new Ref( profileID, this.loadedAs, - this.schema.profile as RefEncoded< + this.getDescriptor("profile") as RefEncoded< LoadedAndRequired<(typeof this.account)["profile"]> & CoValue >, this.account, @@ -543,7 +556,7 @@ class AccountJazzApi extends CoValueJazzApi { ? (new Ref( rootID, this.loadedAs, - this.schema.root as RefEncoded< + this.getDescriptor("root") as RefEncoded< LoadedAndRequired<(typeof this.account)["root"]> & CoValue >, this.account, @@ -602,14 +615,6 @@ class AccountJazzApi extends CoValueJazzApi { return this.localNode.syncManager.waitForAllCoValuesSync(options?.timeout); } - /** @internal */ - get schema(): { - profile: RefEncoded; - root: RefEncoded; - } { - return (this.account.constructor as typeof Account)._schema; - } - get loadedAs(): Account | AnonymousJazzAgent { if (this.isLocalNodeOwner) return this.account; @@ -640,19 +645,7 @@ export const AccountAndGroupProxyHandler: ProxyHandler = { } }, set(target, key, value, receiver) { - if ( - target instanceof Account && - (key === "profile" || key === "root") && - typeof value === "object" && - SchemaInit in value - ) { - (target.constructor as typeof Account)._schema ||= {}; - (target.constructor as typeof Account)._schema[key] = value[SchemaInit]; - return true; - } else if ( - target instanceof Account && - (key === "profile" || key === "root") - ) { + if (target instanceof Account && (key === "profile" || key === "root")) { if (value) { target.$jazz.set(key, value); } @@ -663,18 +656,7 @@ export const AccountAndGroupProxyHandler: ProxyHandler = { } }, defineProperty(target, key, descriptor) { - if ( - (key === "profile" || key === "root") && - typeof descriptor.value === "object" && - SchemaInit in descriptor.value - ) { - (target.constructor as typeof Account)._schema ||= {}; - (target.constructor as typeof Account)._schema[key] = - descriptor.value[SchemaInit]; - return true; - } else { - return Reflect.defineProperty(target, key, descriptor); - } + return Reflect.defineProperty(target, key, descriptor); }, }; diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index 819b2b7710..b4e7d514ee 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -30,7 +30,6 @@ import { RefsToResolveStrict, Resolved, Schema, - SchemaFor, SubscribeListenerOptions, SubscribeRestArgs, TypeSym, @@ -40,9 +39,7 @@ import { CoValueJazzApi, ItemsSym, Ref, - SchemaInit, accessChildById, - coField, ensureCoValueLoaded, inspect, instantiateRefEncodedWithInit, @@ -52,7 +49,25 @@ import { parseSubscribeRestArgs, subscribeToCoValueWithoutMe, subscribeToExistingCoValue, + CoreFileStreamSchema, + CoValueCreateOptionsInternal, } from "../internal.js"; +import { z } from "../implementation/zodSchema/zodReExport.js"; +import { + CoreCoFeedSchema, + createCoreCoFeedSchema, +} from "../implementation/zodSchema/schemaTypes/CoFeedSchema.js"; +import { + executeValidation, + GlobalValidationMode, + resolveValidationMode, + type LocalValidationMode, +} from "../implementation/zodSchema/validationSettings.js"; +import { + expectArraySchema, + normalizeZodSchema, +} from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; +import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; /** @deprecated Use CoFeedEntry instead */ export type CoStreamEntry = CoFeedEntry; @@ -96,30 +111,9 @@ export { CoFeed as CoStream }; * @category CoValues */ export class CoFeed extends CoValueBase implements CoValue { + static coValueSchema?: CoreCoFeedSchema; declare $jazz: CoFeedJazzApi; - /** - * Declare a `CoFeed` by subclassing `CoFeed.Of(...)` and passing the item schema using a `co` primitive or a `coField.ref`. - * - * @example - * ```ts - * class ColorFeed extends CoFeed.Of(coField.string) {} - * class AnimalFeed extends CoFeed.Of(coField.ref(Animal)) {} - * ``` - * - * @category Declaration - */ - static Of(item: Item): typeof CoFeed { - const cls = class CoFeedOf extends CoFeed { - [coField.items] = item; - }; - - cls._schema ||= {}; - cls._schema[ItemsSym] = (item as any)[SchemaInit]; - - return cls; - } - /** @category Type Helpers */ declare [TypeSym]: "CoStream"; static { @@ -128,10 +122,6 @@ export class CoFeed extends CoValueBase implements CoValue { /** @internal This is only a marker type and doesn't exist at runtime */ [ItemsSym]!: Item; - /** @internal */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static _schema: any; - /** * The current account's view of this `CoFeed` * @category Content @@ -203,10 +193,15 @@ export class CoFeed extends CoValueBase implements CoValue { /** @internal */ constructor(options: { fromRaw: RawCoStream }) { super(); + const coFeedSchema = assertCoValueSchema( + this.constructor, + "CoFeed", + "load", + ); Object.defineProperties(this, { $jazz: { - value: new CoFeedJazzApi(this, options.fromRaw), + value: new CoFeedJazzApi(this, options.fromRaw, coFeedSchema), enumerable: false, configurable: true, }, @@ -223,15 +218,9 @@ export class CoFeed extends CoValueBase implements CoValue { static create( this: CoValueClass, init: S extends CoFeed ? Item[] : never, - options?: - | { - owner: Account | Group; - unique?: CoValueUniqueness["uniqueness"]; - firstComesWins?: boolean; - } - | Account - | Group, + options?: CoValueCreateOptionsInternal, ) { + const coFeedSchema = assertCoValueSchema(this, "CoFeed", "create"); const { owner, uniqueness, firstComesWins } = parseCoValueCreateOptions(options); const initMeta = firstComesWins ? { fww: "init" } : undefined; @@ -239,8 +228,20 @@ export class CoFeed extends CoValueBase implements CoValue { const processedInit: JsonValue[] = []; if (init) { - // @ts-expect-error - _schema is not defined on the class - const itemDescriptor = this._schema[ItemsSym] as Schema; + const validation = + options && typeof options === "object" && "validation" in options + ? options.validation + : undefined; + const validationMode = resolveValidationMode(validation); + + // Validate using the full schema - init is an array, so it will match the array branch + // of the union (instanceof CoFeed | array of items) + if (validationMode !== "loose") { + const fullSchema = coFeedSchema.getValidationSchema(); + executeValidation(fullSchema, init, validationMode) as typeof init; + } + + const itemDescriptor = coFeedSchema.getDescriptorsSchema(); for (let index = 0; index < init.length; index++) { const item = init[index]; @@ -256,6 +257,7 @@ export class CoFeed extends CoValueBase implements CoValue { firstComesWins, } : undefined, + validationMode, ), ); } @@ -337,7 +339,7 @@ export class CoFeed extends CoValueBase implements CoValue { [key: string]: unknown; in: { [key: string]: unknown }; } { - const itemDescriptor = this.$jazz.schema[ItemsSym] as Schema; + const itemDescriptor = this.$jazz.getItemsDescriptor(); const mapper = itemDescriptor === "json" ? (v: unknown) => v @@ -375,10 +377,11 @@ export class CoFeed extends CoValueBase implements CoValue { static schema( // eslint-disable-next-line @typescript-eslint/no-explicit-any this: { new (...args: any): V } & typeof CoFeed, - def: { [ItemsSym]: V["$jazz"]["schema"][ItemsSym] }, + def: { + [ItemsSym]: CoFieldInit ? Item : never>; + }, ) { - this._schema ||= {}; - Object.assign(this._schema, def); + this.coValueSchema = createCoreCoFeedSchema(def[ItemsSym]); } /** @@ -436,6 +439,7 @@ function processCoFeedItem( fieldName: string; firstComesWins: boolean; }, + validationMode?: GlobalValidationMode, ) { if (itemDescriptor === "json") { return item as JsonValue; @@ -454,6 +458,7 @@ function processCoFeedItem( newOwnerStrategy, onCreate, unique, + validationMode, ); refId = coValue.$jazz.id; } @@ -467,10 +472,19 @@ export class CoFeedJazzApi extends CoValueJazzApi { constructor( private coFeed: F, public raw: RawCoStream, + private coFeedSchema: CoreCoFeedSchema, ) { super(coFeed); } + private getItemSchema(): z.ZodType { + const fieldSchema = expectArraySchema( + this.coFeedSchema.getValidationSchema(), + ).element; + + return normalizeZodSchema(fieldSchema); + } + get owner(): Group { return getCoValueOwner(this.coFeed); } @@ -496,15 +510,42 @@ export class CoFeedJazzApi extends CoValueJazzApi { * @category Content */ push(...items: CoFieldInit>[]): void { + const validationMode = resolveValidationMode(); + if (validationMode !== "loose" && this.coFeedSchema) { + const schema = z.array(this.getItemSchema()); + executeValidation(schema, items, validationMode) as CoFieldInit< + CoFeedItem + >[]; + } + this.pushLoose(...items); + } + + /** + * Push items to this `CoFeed` without applying schema validation. + * + * @category Content + */ + pushLoose(...items: CoFieldInit>[]): void { for (const item of items) { - this.pushItem(item); + this.pushItem(item, { validationMode: "loose" }); } } - private pushItem(item: CoFieldInit>) { - const itemDescriptor = this.schema[ItemsSym] as Schema; + private pushItem( + item: CoFieldInit>, + { validationMode }: { validationMode?: LocalValidationMode }, + ) { + const itemDescriptor = this.getItemsDescriptor(); - this.raw.push(processCoFeedItem(itemDescriptor, item, this.owner)); + this.raw.push( + processCoFeedItem( + itemDescriptor, + item, + this.owner, + undefined, + validationMode, + ), + ); } /** @@ -562,15 +603,8 @@ export class CoFeedJazzApi extends CoValueJazzApi { * Get the descriptor for the items in the `CoFeed` * @internal */ - getItemsDescriptor(): Schema | undefined { - return this.schema[ItemsSym]; - } - - /** @internal */ - get schema(): { - [ItemsSym]: SchemaFor> | any; - } { - return (this.coFeed.constructor as typeof CoFeed)._schema; + getItemsDescriptor(): Schema { + return this.coFeedSchema.getDescriptorsSchema(); } } @@ -663,7 +697,7 @@ export const CoStreamPerAccountProxyHandler = ( rawEntry, innerTarget.$jazz.loadedAs, key as unknown as ID, - innerTarget.$jazz.schema[ItemsSym], + innerTarget.$jazz.getItemsDescriptor(), ); Object.defineProperty(entry, "all", { @@ -680,7 +714,7 @@ export const CoStreamPerAccountProxyHandler = ( rawEntry.value, innerTarget.$jazz.loadedAs, key as unknown as ID, - innerTarget.$jazz.schema[ItemsSym], + innerTarget.$jazz.getItemsDescriptor(), ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -732,7 +766,7 @@ const CoStreamPerSessionProxyHandler = ( cojsonInternals.isAccountID(by) ? (by as unknown as ID) : undefined, - innerTarget.$jazz.schema[ItemsSym], + innerTarget.$jazz.getItemsDescriptor(), ); Object.defineProperty(entry, "all", { @@ -749,7 +783,7 @@ const CoStreamPerSessionProxyHandler = ( cojsonInternals.isAccountID(by) ? (by as unknown as ID) : undefined, - innerTarget.$jazz.schema[ItemsSym], + innerTarget.$jazz.getItemsDescriptor(), ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -803,6 +837,8 @@ export class FileStream extends CoValueBase implements CoValue { /** @category Type Helpers */ declare [TypeSym]: "BinaryCoStream"; + static coValueSchema?: CoreFileStreamSchema; + constructor( options: | { diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 3326b4cfcd..4d4b6837f4 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -16,7 +16,6 @@ import { RefsToResolveStrict, Resolved, Schema, - SchemaFor, SubscribeListenerOptions, SubscribeRestArgs, TypeSym, @@ -27,9 +26,7 @@ import { AnonymousJazzAgent, ItemsSym, Ref, - SchemaInit, accessChildByKey, - coField, ensureCoValueLoaded, inspect, instantiateRefEncodedWithInit, @@ -40,7 +37,20 @@ import { parseSubscribeRestArgs, subscribeToCoValueWithoutMe, subscribeToExistingCoValue, + CoValueCreateOptionsInternal, } from "../internal.js"; +import { z } from "../implementation/zodSchema/zodReExport.js"; +import { CoreCoListSchema } from "../implementation/zodSchema/schemaTypes/CoListSchema.js"; +import { + executeValidation, + resolveValidationMode, + type LocalValidationMode, +} from "../implementation/zodSchema/validationSettings.js"; +import { + expectArraySchema, + normalizeZodSchema, +} from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; +import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; /** * CoLists are collaborative versions of plain arrays. @@ -68,38 +78,10 @@ export class CoList extends Array implements ReadonlyArray, CoValue { + static coValueSchema?: CoreCoListSchema; declare $jazz: CoListJazzApi; declare $isLoaded: true; - /** - * Declare a `CoList` by subclassing `CoList.Of(...)` and passing the item schema using `co`. - * - * @example - * ```ts - * class ColorList extends CoList.Of( - * coField.string - * ) {} - * class AnimalList extends CoList.Of( - * coField.ref(Animal) - * ) {} - * ``` - * - * @category Declaration - */ - static Of(item: Item): typeof CoList { - // TODO: cache superclass for item class - return class CoListOf extends CoList { - [coField.items] = item; - }; - } - - /** - * @ignore - * @deprecated Use UPPERCASE `CoList.Of` instead! */ - static of(..._args: never): never { - throw new Error("Can't use Array.of with CoLists"); - } - /** @category Type Helpers */ declare [TypeSym]: "CoList"; static { @@ -108,10 +90,6 @@ export class CoList /** @internal This is only a marker type and doesn't exist at runtime */ [ItemsSym]!: Item; - /** @internal */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static _schema: any; - static get [Symbol.species]() { return Array; } @@ -122,9 +100,14 @@ export class CoList const proxy = new Proxy(this, CoListProxyHandler as ProxyHandler); if (options && "fromRaw" in options) { + const coListSchema = assertCoValueSchema( + this.constructor, + "CoList", + "load", + ); Object.defineProperties(this, { $jazz: { - value: new CoListJazzApi(proxy, () => options.fromRaw), + value: new CoListJazzApi(proxy, () => options.fromRaw, coListSchema), enumerable: false, configurable: true, }, @@ -160,22 +143,28 @@ export class CoList static create( this: CoValueClass, items: L[number][], - options?: - | { - owner: Account | Group; - unique?: CoValueUniqueness["uniqueness"]; - firstComesWins?: boolean; - } - | Account - | Group, + options?: CoValueCreateOptionsInternal, ) { + const coListSchema = assertCoValueSchema(this, "CoList", "create"); + const validationMode = resolveValidationMode( + options && "validation" in options ? options.validation : undefined, + ); + + if (validationMode !== "loose") { + executeValidation( + coListSchema.getValidationSchema(), + items, + validationMode, + ) as typeof items; + } + const instance = new this(); const { owner, uniqueness, firstComesWins } = parseCoValueCreateOptions(options); Object.defineProperties(instance, { $jazz: { - value: new CoListJazzApi(instance, () => raw), + value: new CoListJazzApi(instance, () => raw, coListSchema), enumerable: false, configurable: true, }, @@ -183,13 +172,15 @@ export class CoList }); const initMeta = firstComesWins ? { fww: "init" } : undefined; + const itemDescriptor = instance.$jazz.getItemsDescriptor(); const raw = owner.$jazz.raw.createList( toRawItems( items, - instance.$jazz.schema[ItemsSym], + itemDescriptor, owner, firstComesWins, uniqueness?.uniqueness, + options && "validation" in options ? options.validation : undefined, ), null, "private", @@ -202,7 +193,7 @@ export class CoList // eslint-disable-next-line @typescript-eslint/no-explicit-any toJSON(_key?: string, seenAbove?: ID[]): any[] { - const itemDescriptor = this.$jazz.schema[ItemsSym] as Schema; + const itemDescriptor = this.$jazz.getItemsDescriptor(); if (itemDescriptor === "json") { return this.$jazz.raw.asArray(); } else if ("encoded" in itemDescriptor) { @@ -235,16 +226,6 @@ export class CoList return new this({ fromRaw: raw }); } - /** @internal */ - static schema( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this: { new (...args: any): V } & typeof CoList, - def: { [ItemsSym]: V["$jazz"]["schema"][ItemsSym] }, - ) { - this._schema ||= {}; - Object.assign(this._schema, def); - } - /** * Load a `CoList` with a given ID, as a given account. * @@ -557,19 +538,51 @@ export class CoListJazzApi extends CoValueJazzApi { constructor( private coList: L, private getRaw: () => RawCoList, + private coListSchema: CoreCoListSchema, ) { super(coList); } + private getItemSchema(): z.ZodType { + const fieldSchema = expectArraySchema( + this.coListSchema.getValidationSchema(), + ).element; + + return normalizeZodSchema(fieldSchema); + } + /** @category Collaboration */ get owner(): Group { return getCoValueOwner(this.coList); } - set(index: number, value: CoFieldInit>): void { - const itemDescriptor = this.schema[ItemsSym]; - const rawValue = toRawItems([value], itemDescriptor, this.owner)[0]!; - if (rawValue === null && !itemDescriptor.optional) { + set( + index: number, + value: CoFieldInit>, + options?: { validation?: LocalValidationMode }, + ): void { + const validationMode = resolveValidationMode(options?.validation); + if (validationMode !== "loose" && this.coListSchema) { + const fieldSchema = this.getItemSchema(); + executeValidation(fieldSchema, value, validationMode) as CoFieldInit< + CoListItem + >; + } + + const itemDescriptor = this.getItemsDescriptor(); + const rawValue = toRawItems( + [value], + itemDescriptor, + this.owner, + undefined, + undefined, + options?.validation, + )[0]!; + if ( + rawValue === null && + isRefEncoded(itemDescriptor) && + !itemDescriptor.optional + ) { throw new Error(`Cannot set required reference ${index} to undefined`); } this.raw.replace(index, rawValue); @@ -582,8 +595,33 @@ export class CoListJazzApi extends CoValueJazzApi { * @category Content */ push(...items: CoFieldInit>[]): number { + const validationMode = resolveValidationMode(); + if (validationMode !== "loose" && this.coListSchema) { + const schema = z.array(this.getItemSchema()); + executeValidation(schema, items, validationMode) as CoFieldInit< + CoListItem + >[]; + } + return this.pushLoose(...items); + } + + /** + * Appends new elements to the end of an array, and returns the new length of the array. + * Schema validation is not applied to the items. + * @param items New elements to add to the array. + * + * @category Content + */ + pushLoose(...items: CoFieldInit>[]): number { this.raw.appendItems( - toRawItems(items, this.schema[ItemsSym], this.owner), + toRawItems( + items, + this.getItemsDescriptor(), + this.owner, + undefined, + undefined, + "loose", + ), undefined, "private", ); @@ -598,10 +636,31 @@ export class CoListJazzApi extends CoValueJazzApi { * @category Content */ unshift(...items: CoFieldInit>[]): number { + const validationMode = resolveValidationMode(); + if (validationMode !== "loose" && this.coListSchema) { + const schema = z.array(this.getItemSchema()); + executeValidation(schema, items, validationMode) as CoFieldInit< + CoListItem + >[]; + } + return this.unshiftLoose(...items); + } + + /** + * Inserts new elements at the start of an array, and returns the new length of the array. + * Schema validation is not applied to the items. + * @param items Elements to insert at the start of the array. + * + * @category Content + */ + unshiftLoose(...items: CoFieldInit>[]): number { for (const item of toRawItems( items as CoFieldInit>[], - this.schema[ItemsSym], + this.getItemsDescriptor(), this.owner, + undefined, + undefined, + "loose", )) { this.raw.prepend(item); } @@ -639,6 +698,7 @@ export class CoListJazzApi extends CoValueJazzApi { /** * Removes elements from an array and, if necessary, inserts new elements in their place, returning the deleted elements. + * Items are validated using the schema. * @param start The zero-based location in the array from which to start removing elements. * @param deleteCount The number of elements to remove. * @param items Elements to insert into the array in place of the deleted elements. @@ -650,6 +710,31 @@ export class CoListJazzApi extends CoValueJazzApi { start: number, deleteCount: number, ...items: CoFieldInit>[] + ): CoListItem[] { + const validationMode = resolveValidationMode(); + if (validationMode !== "loose" && this.coListSchema) { + const schema = z.array(this.getItemSchema()); + executeValidation(schema, items, validationMode) as CoFieldInit< + CoListItem + >[]; + } + + return this.spliceLoose(start, deleteCount, ...items); + } + + /** + * Removes elements from an array and, if necessary, inserts new elements in their place, returning the deleted elements. + * @param start The zero-based location in the array from which to start removing elements. + * @param deleteCount The number of elements to remove. + * @param items Elements to insert into the array in place of the deleted elements. + * @returns An array containing the elements that were deleted. + * + * @category Content + */ + spliceLoose( + start: number, + deleteCount: number, + ...items: CoFieldInit>[] ): CoListItem[] { const deleted = this.coList.slice(start, start + deleteCount); @@ -663,8 +748,11 @@ export class CoListJazzApi extends CoValueJazzApi { const rawItems = toRawItems( items as CoListItem[], - this.schema[ItemsSym], + this.getItemsDescriptor(), this.owner, + undefined, + undefined, + "loose", ); // If there are no items to insert, return the deleted items @@ -771,9 +859,19 @@ export class CoListJazzApi extends CoValueJazzApi { * * @category Content */ - applyDiff(result: CoFieldInit>[]): L { + applyDiff( + result: CoFieldInit>[], + options?: { validation?: LocalValidationMode }, + ): L { + const validationMode = resolveValidationMode(options?.validation); + if (validationMode !== "loose" && this.coListSchema) { + const schema = z.array(this.getItemSchema()); + executeValidation(schema, result, validationMode) as CoFieldInit< + CoListItem + >[]; + } const current = this.raw.asArray() as CoFieldInit>[]; - const comparator = isRefEncoded(this.schema[ItemsSym]) + const comparator = isRefEncoded(this.getItemsDescriptor()) ? (aIdx: number, bIdx: number) => { const oldCoValueId = (current[aIdx] as CoValue)?.$jazz?.id; const newCoValueId = (result[bIdx] as CoValue)?.$jazz?.id; @@ -793,7 +891,7 @@ export class CoListJazzApi extends CoValueJazzApi { this.raw.core.pauseNotifyUpdate(); for (const [from, to, insert] of patches.reverse()) { - this.splice(from, to - from, ...insert); + this.spliceLoose(from, to - from, ...insert); } this.raw.core.resumeNotifyUpdate(); @@ -860,8 +958,8 @@ export class CoListJazzApi extends CoValueJazzApi { * Get the descriptor for the items in the `CoList` * @internal */ - getItemsDescriptor(): Schema | undefined { - return this.schema[ItemsSym]; + getItemsDescriptor(): Schema { + return this.coListSchema.getDescriptorsSchema(); } /** @@ -897,7 +995,7 @@ export class CoListJazzApi extends CoValueJazzApi { (idx) => this.raw.get(idx) as unknown as ID, () => Array.from({ length: this.raw.entries().length }, (_, idx) => idx), this.loadedAs, - (_idx) => this.schema[ItemsSym] as RefEncoded, + (_idx) => this.getItemsDescriptor() as RefEncoded, // eslint-disable-next-line @typescript-eslint/no-explicit-any ) as any; } @@ -922,13 +1020,6 @@ export class CoListJazzApi extends CoValueJazzApi { get raw(): RawCoList { return this.getRaw(); } - - /** @internal */ - get schema(): { - [ItemsSym]: SchemaFor> | any; - } { - return (this.coList.constructor as typeof CoList)._schema; - } } /** @@ -944,6 +1035,7 @@ function toRawItems( owner: Group, firstComesWins = false, uniqueness?: CoValueUniqueness["uniqueness"], + validationMode?: LocalValidationMode, ): JsonValue[] { let rawItems: JsonValue[] = []; if (itemDescriptor === "json") { @@ -969,6 +1061,7 @@ function toRawItems( uniqueness ? { uniqueness: uniqueness, fieldName: `${index}`, firstComesWins } : undefined, + validationMode, ); refId = coValue.$jazz.id; } @@ -987,7 +1080,11 @@ function getCoListItemValue(target: CoList, key: string) { return undefined; } - const itemDescriptor: Schema = target.$jazz.schema[ItemsSym]; + const itemDescriptor = target.$jazz.getItemsDescriptor(); + + if (!itemDescriptor) { + return undefined; + } if (itemDescriptor === "json") { return rawValue; @@ -1023,17 +1120,6 @@ const CoListProxyHandler: ProxyHandler = { return Reflect.set(target, key, value, receiver); } - if (key === ItemsSym && typeof value === "object" && SchemaInit in value) { - const constructor = target.constructor as typeof CoList; - - if (!constructor._schema) { - constructor._schema = {}; - } - - constructor._schema[ItemsSym] = value[SchemaInit]; - return true; - } - if (!isNaN(+key)) { throw Error("Cannot update a CoList directly. Use `$jazz.set` instead."); } @@ -1041,23 +1127,7 @@ const CoListProxyHandler: ProxyHandler = { return Reflect.set(target, key, value, receiver); }, defineProperty(target, key, descriptor) { - if ( - descriptor.value && - key === ItemsSym && - typeof descriptor.value === "object" && - SchemaInit in descriptor.value - ) { - const constructor = target.constructor as typeof CoList; - - if (!constructor._schema) { - constructor._schema = {}; - } - - constructor._schema[ItemsSym] = descriptor.value[SchemaInit]; - return true; - } else { - return Reflect.defineProperty(target, key, descriptor); - } + return Reflect.defineProperty(target, key, descriptor); }, has(target, key) { if (typeof key === "string" && !isNaN(+key)) { diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 98b5cde96a..e75364bef1 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -37,10 +37,8 @@ import { Account, CoValueBase, CoValueJazzApi, - ItemsSym, Ref, RegisteredSchemas, - SchemaInit, accessChildById, accessChildByKey, ensureCoValueLoaded, @@ -53,7 +51,20 @@ import { parseSubscribeRestArgs, subscribeToCoValueWithoutMe, subscribeToExistingCoValue, + CoreCoMapSchema, + CoValueCreateOptionsInternal, } from "../internal.js"; +import { z } from "../implementation/zodSchema/zodReExport.js"; +import { + executeValidation, + resolveValidationMode, + type LocalValidationMode, +} from "../implementation/zodSchema/validationSettings.js"; +import { + expectObjectSchema, + normalizeZodSchema, +} from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; +import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; export type CoMapEdit = { value?: V; @@ -69,27 +80,24 @@ export type CoMapEdits = { [Key in CoKeys]?: LastAndAllCoMapEdits; }; -type CoMapFieldSchema = { - [key: string]: Schema; -} & { [ItemsSym]?: Schema }; - /** * CoMaps are collaborative versions of plain objects, mapping string-like keys to values. * * @categoryDescription Declaration * Declare your own CoMap schemas by subclassing `CoMap` and assigning field schemas with `co`. * - * Optional `coField.ref(...)` fields must be marked with `{ optional: true }`. + * Optional refs can be declared with `co.optional(...)`. * * ```ts - * import { coField, CoMap } from "jazz-tools"; + * import { co, z } from "jazz-tools"; * - * class Person extends CoMap { - * name = coField.string; - * age = coField.number; - * pet = coField.ref(Animal); - * car = coField.ref(Car, { optional: true }); - * } + * const Pet = co.map({ name: z.string() }); + * const Person = co.map({ + * name: z.string(), + * age: z.number(), + * pet: Pet, + * car: co.optional(Pet), + * }); * ``` * * @categoryDescription Content @@ -122,8 +130,7 @@ export class CoMap extends CoValueBase implements CoValue { */ declare $jazz: CoMapJazzApi; - /** @internal */ - static _schema: CoMapFieldSchema; + static coValueSchema?: CoreCoMapSchema; /** @internal */ constructor(options: { fromRaw: RawCoMap } | undefined) { @@ -133,9 +140,14 @@ export class CoMap extends CoValueBase implements CoValue { if (options) { if ("fromRaw" in options) { + const coMapSchema = assertCoValueSchema( + this.constructor, + "CoMap", + "load", + ); Object.defineProperties(this, { $jazz: { - value: new CoMapJazzApi(proxy, () => options.fromRaw), + value: new CoMapJazzApi(proxy, () => options.fromRaw, coMapSchema), enumerable: false, configurable: true, }, @@ -171,17 +183,11 @@ export class CoMap extends CoValueBase implements CoValue { static create( this: CoValueClass, init: Simplify>, - options?: - | { - owner?: Account | Group; - unique?: CoValueUniqueness["uniqueness"]; - } - | Account - | Group, + options?: CoValueCreateOptionsInternal, ) { + const coMapSchema = assertCoValueSchema(this, "CoMap", "create"); const instance = new this(); - - return CoMap._createCoMap(instance, init, options); + return CoMap._createCoMap(instance, coMapSchema, init, options); } /** @@ -242,22 +248,28 @@ export class CoMap extends CoValueBase implements CoValue { */ static _createCoMap( instance: M, + schema: CoreCoMapSchema, init: Simplify>, - options?: - | { - owner?: Account | Group; - unique?: CoValueUniqueness["uniqueness"]; - firstComesWins?: boolean; - } - | Account - | Group, + options?: CoValueCreateOptionsInternal, ): M { + const validationMode = resolveValidationMode( + options && "validation" in options ? options.validation : undefined, + ); + + if (schema && validationMode !== "loose") { + executeValidation( + schema.getValidationSchema(), + init, + validationMode, + ) as typeof init; + } + const { owner, uniqueness, firstComesWins } = parseCoValueCreateOptions(options); Object.defineProperties(instance, { $jazz: { - value: new CoMapJazzApi(instance, () => raw), + value: new CoMapJazzApi(instance, () => raw, schema), enumerable: false, configurable: true, }, @@ -269,6 +281,7 @@ export class CoMap extends CoValueBase implements CoValue { owner, firstComesWins, uniqueness, + options && "validation" in options ? options.validation : undefined, ); return instance; @@ -284,6 +297,7 @@ export class CoMap extends CoValueBase implements CoValue { owner: Group, firstComesWins: boolean, uniqueness?: CoValueUniqueness, + validationMode?: LocalValidationMode, ) { const rawOwner = owner.$jazz.raw; @@ -323,6 +337,7 @@ export class CoMap extends CoValueBase implements CoValue { firstComesWins, } : undefined, + validationMode, ); refId = coValue.$jazz.id; } @@ -340,35 +355,6 @@ export class CoMap extends CoValueBase implements CoValue { return rawOwner.createMap(rawInit, null, "private", uniqueness, initMeta); } - /** - * Declare a Record-like CoMap schema, by extending `CoMap.Record(...)` and passing the value schema using `co`. Keys are always `string`. - * - * @example - * ```ts - * import { coField, CoMap } from "jazz-tools"; - * - * class ColorToFruitMap extends CoMap.Record( - * coField.ref(Fruit) - * ) {} - * - * // assume we have map: ColorToFruitMap - * // and strawberry: Fruit - * map["red"] = strawberry; - * ``` - * - * @category Declaration - */ - static Record(value: Value) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging - class RecordLikeCoMap extends CoMap { - [ItemsSym] = value; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging - interface RecordLikeCoMap extends Record {} - - return RecordLikeCoMap; - } - /** * Load a `CoMap` with a given ID, as a given account. * @@ -546,6 +532,7 @@ export class CoMap extends CoValueBase implements CoValue { unique: CoValueUniqueness["uniqueness"]; owner: Account | Group; resolve?: RefsToResolveStrict; + validation?: LocalValidationMode; }, ): Promise>> { return internalLoadUnique(this, { @@ -557,10 +544,13 @@ export class CoMap extends CoValueBase implements CoValue { (this as any).create(options.value, { owner: options.owner, unique: options.unique, + validation: options.validation, }); }, onUpdateWhenFound(value) { - value.$jazz.applyDiff(options.value); + value.$jazz.applyDiff(options.value, { + validation: options.validation, + }); }, }); } @@ -605,9 +595,12 @@ export class CoMap extends CoValueBase implements CoValue { * Contains CoMap Jazz methods that are part of the {@link CoMap.$jazz`} property. */ class CoMapJazzApi extends CoValueJazzApi { + private descriptorCache = new Map(); + constructor( private coMap: M, private getRaw: () => RawCoMap, + private coMapSchema: CoreCoMapSchema, ) { super(coMap); } @@ -616,6 +609,28 @@ class CoMapJazzApi extends CoValueJazzApi { return getCoValueOwner(this.coMap); } + private getPropertySchema(key: string): z.ZodType { + const fullSchema = this.coMapSchema.getValidationSchema(); + let objectValidation: z.ZodObject | undefined; + + try { + objectValidation = expectObjectSchema(fullSchema); + } catch { + // Base/core schemas may expose a non-union validation schema (e.g. z.any()). + // In those cases we keep legacy dynamic behavior and skip strict per-field validation. + return z.any(); + } + + const fieldSchema = + objectValidation.shape[key] ?? objectValidation.def.catchall; + + if (fieldSchema === undefined) { + throw new Error(`Field ${key} is not defined in the CoMap schema`); + } + + return normalizeZodSchema(fieldSchema); + } + /** * Check if a key is defined in the CoMap. * @@ -635,10 +650,25 @@ class CoMapJazzApi extends CoValueJazzApi { * * @param key The key to set * @param value The value to set + * @param options Optional options for setting the value * * @category Content */ - set>(key: K, value: CoFieldInit): void { + set>( + key: K, + value: CoFieldInit, + options?: { validation?: LocalValidationMode }, + ): void { + // Validate the value based on the resolved validation mode + const validationMode = resolveValidationMode(options?.validation); + if (validationMode !== "loose" && this.coMapSchema) { + // Get the field schema for this specific key from the shape + const fieldSchema = this.getPropertySchema(key); + executeValidation(fieldSchema, value, validationMode) as CoFieldInit< + M[K] + >; + } + const descriptor = this.getDescriptor(key as string); if (!descriptor) { @@ -667,6 +697,8 @@ class CoMapJazzApi extends CoValueJazzApi { this.owner, newOwnerStrategy, onCreate, + undefined, + options?.validation, ); refId = coValue.$jazz.id; } @@ -699,11 +731,16 @@ class CoMapJazzApi extends CoValueJazzApi { * * @param newValues - The new values to apply to the CoMap. For collaborative values, * both CoValues and JSON values are supported. + * @param options Optional options for applying the diff. + * @param options.validation The validation mode to use. Defaults to "strict". * @returns The modified CoMap. * * @category Content */ - applyDiff(newValues: Partial>): M { + applyDiff( + newValues: Partial>, + options?: { validation?: LocalValidationMode }, + ): M { for (const key in newValues) { if (Object.prototype.hasOwnProperty.call(newValues, key)) { const tKey = key as keyof typeof newValues & keyof this; @@ -716,13 +753,13 @@ class CoMapJazzApi extends CoValueJazzApi { if (descriptor === "json" || "encoded" in descriptor) { if (currentValue !== newValue) { - this.set(tKey as any, newValue as CoFieldInit); + this.set(tKey as any, newValue as CoFieldInit, options); } } else if (isRefEncoded(descriptor)) { const currentId = (currentValue as CoValue | undefined)?.$jazz.id; let newId = (newValue as CoValue | undefined)?.$jazz?.id; if (currentId !== newId) { - this.set(tKey as any, newValue as CoFieldInit); + this.set(tKey as any, newValue as CoFieldInit, options); } } } @@ -790,11 +827,20 @@ class CoMapJazzApi extends CoValueJazzApi { * @internal */ getDescriptor(key: string): Schema | undefined { - return ( - this.schema?.[key] || - this.schema?.[ItemsSym] || - (this.coMap as any)[ItemsSym] - ); + if (this.descriptorCache.has(key)) { + return this.descriptorCache.get(key); + } + + const descriptorsSchema = this.coMapSchema.getDescriptorsSchema(); + const descriptor = + descriptorsSchema.shape[key] ?? descriptorsSchema.catchall; + if (descriptor) { + this.descriptorCache.set(key, descriptor); + return descriptor; + } + + this.descriptorCache.set(key, undefined); + return undefined; } /** @@ -890,11 +936,6 @@ class CoMapJazzApi extends CoValueJazzApi { override get raw() { return this.getRaw(); } - - /** @internal */ - get schema(): CoMapFieldSchema { - return (this.coMap.constructor as typeof CoMap)._schema; - } } export type CoKeys = Exclude< @@ -960,9 +1001,7 @@ export type CoMapInit = { // TODO: cache handlers per descriptor for performance? const CoMapProxyHandler: ProxyHandler = { get(target, key, receiver) { - if (key === "_schema" || key === ItemsSym) { - return Reflect.get(target, key); - } else if (key in target) { + if (key in target) { return Reflect.get(target, key, receiver); } else { if (typeof key !== "string") { @@ -989,17 +1028,6 @@ const CoMapProxyHandler: ProxyHandler = { } }, set(target, key, value, receiver) { - if ( - typeof key === "string" && - typeof value === "object" && - value !== null && - SchemaInit in value - ) { - (target.constructor as typeof CoMap)._schema ||= {}; - (target.constructor as typeof CoMap)._schema[key] = value[SchemaInit]; - return true; - } - if (typeof key !== "string") { return Reflect.set(target, key, value, receiver); } @@ -1011,21 +1039,10 @@ const CoMapProxyHandler: ProxyHandler = { throw Error("Cannot update a CoMap directly. Use `$jazz.set` instead."); }, defineProperty(target, key, attributes) { - if ( - "value" in attributes && - typeof attributes.value === "object" && - SchemaInit in attributes.value - ) { - (target.constructor as typeof CoMap)._schema ||= {}; - (target.constructor as typeof CoMap)._schema[key as string] = - attributes.value[SchemaInit]; - return true; - } else { - return Reflect.defineProperty(target, key, attributes); - } + return Reflect.defineProperty(target, key, attributes); }, ownKeys(target) { - const keys = Reflect.ownKeys(target).filter((k) => k !== ItemsSym); + const keys = Reflect.ownKeys(target); for (const key of target.$jazz.raw.keys()) { if (!keys.includes(key)) { diff --git a/packages/jazz-tools/src/tools/coValues/coPlainText.ts b/packages/jazz-tools/src/tools/coValues/coPlainText.ts index 850183c0e3..2d95d9141d 100644 --- a/packages/jazz-tools/src/tools/coValues/coPlainText.ts +++ b/packages/jazz-tools/src/tools/coValues/coPlainText.ts @@ -19,13 +19,17 @@ import { parseSubscribeRestArgs, subscribeToCoValueWithoutMe, subscribeToExistingCoValue, + CorePlainTextSchema, + CoValueCreateOptionsInternal, } from "../internal.js"; import { Account } from "./account.js"; import { getCoValueOwner, Group } from "./group.js"; +import { type CoreRichTextSchema } from "../implementation/zodSchema/schemaTypes/RichTextSchema.js"; export type TextPos = OpID; export class CoPlainText extends String implements CoValue { + static coValueSchema?: CorePlainTextSchema | CoreRichTextSchema; declare [TypeSym]: "CoPlainText"; declare $jazz: CoTextJazzApi; @@ -102,7 +106,7 @@ export class CoPlainText extends String implements CoValue { static create( this: CoValueClass, text: string, - options?: { owner: Account | Group } | Account | Group, + options?: CoValueCreateOptionsInternal, ) { const { owner } = parseCoValueCreateOptions(options); return new this({ text, owner }); diff --git a/packages/jazz-tools/src/tools/coValues/coVector.ts b/packages/jazz-tools/src/tools/coValues/coVector.ts index 1a4e8119bc..34b0505574 100644 --- a/packages/jazz-tools/src/tools/coValues/coVector.ts +++ b/packages/jazz-tools/src/tools/coValues/coVector.ts @@ -20,6 +20,7 @@ import { parseSubscribeRestArgs, subscribeToCoValueWithoutMe, subscribeToExistingCoValue, + CoreCoVectorSchema, } from "../internal.js"; /** @@ -45,6 +46,8 @@ export class CoVector private declare _isVectorLoaded: boolean; private declare _requiredDimensionsCount: number; + static coValueSchema?: CoreCoVectorSchema; + constructor( options: | { diff --git a/packages/jazz-tools/src/tools/coValues/interfaces.ts b/packages/jazz-tools/src/tools/coValues/interfaces.ts index aa8f2903c7..8840b043a2 100644 --- a/packages/jazz-tools/src/tools/coValues/interfaces.ts +++ b/packages/jazz-tools/src/tools/coValues/interfaces.ts @@ -30,6 +30,7 @@ import { activeAccountContext, coValueClassFromCoValueClassOrSchema, inspect, + LocalValidationMode, } from "../internal.js"; import type { BranchDefinition, @@ -37,10 +38,15 @@ import type { } from "../subscribe/types.js"; import { CoValueHeader } from "cojson"; import { JazzError } from "../subscribe/JazzError.js"; +import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; /** @category Abstract interfaces */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export interface CoValueClass { +export interface CoValueClass< + Value extends CoValue = CoValue, + Schema extends CoreCoValueSchema = CoreCoValueSchema, +> { + coValueSchema?: Schema; /** @ignore */ // eslint-disable-next-line @typescript-eslint/no-explicit-any new (...args: any[]): Value; @@ -465,17 +471,37 @@ export function isAnonymousAgentInstance( return TypeSym in instance && instance[TypeSym] === "Anonymous"; } +export type CoValueCreateOptions< + MoreOptions extends object = {}, + Owner extends Group | Account = Group, +> = + | undefined + | Owner + | (( + | { + owner: Owner; + // we want to have explicit owner if unique is provided + unique: CoValueUniqueness["uniqueness"]; + validation?: LocalValidationMode; + } + | { + owner?: Owner; + unique?: undefined; + validation?: LocalValidationMode; + } + ) & + MoreOptions); + +export type CoValueCreateOptionsInternal = CoValueCreateOptions< + { + onCreate?: OnCreateCallback; + firstComesWins?: boolean; + }, + Account | Group +>; + export function parseCoValueCreateOptions( - options: - | { - owner?: Account | Group; - unique?: CoValueUniqueness["uniqueness"]; - onCreate?: OnCreateCallback; - firstComesWins?: boolean; - } - | Account - | Group - | undefined, + options: CoValueCreateOptionsInternal, ): { owner: Group; uniqueness?: CoValueUniqueness; diff --git a/packages/jazz-tools/src/tools/coValues/profile.ts b/packages/jazz-tools/src/tools/coValues/profile.ts index 4f474d2695..23320c5a97 100644 --- a/packages/jazz-tools/src/tools/coValues/profile.ts +++ b/packages/jazz-tools/src/tools/coValues/profile.ts @@ -6,14 +6,13 @@ import { Group, Simplify, TypeSym, - coField, } from "../internal.js"; /** @category Identity & Permissions */ export class Profile extends CoMap { - readonly name = coField.string; - readonly inbox? = coField.optional.string; - readonly inboxInvite? = coField.optional.string; + declare readonly name: string; + declare readonly inbox?: string; + declare readonly inboxInvite?: string; /** * Creates a new profile with the given initial values and owner. diff --git a/packages/jazz-tools/src/tools/coValues/schemaUnion.ts b/packages/jazz-tools/src/tools/coValues/schemaUnion.ts index a307f5ef0e..c39a5feb46 100644 --- a/packages/jazz-tools/src/tools/coValues/schemaUnion.ts +++ b/packages/jazz-tools/src/tools/coValues/schemaUnion.ts @@ -35,99 +35,9 @@ export type SchemaUnionDiscriminator = (discriminable: { /** * SchemaUnion allows you to create union types of CoValues that can be discriminated at runtime. * - * @categoryDescription Declaration - * Declare your union types by extending `SchemaUnion.Of(...)` and passing a discriminator function that determines which concrete type to use based on the raw data. - * - * ```ts - * import { SchemaUnion, CoMap } from "jazz-tools"; - * - * class BaseWidget extends CoMap { - * type = coField.string; - * } - * - * class ButtonWidget extends BaseWidget { - * type = coField.literal("button"); - * label = coField.string; - * } - * - * class SliderWidget extends BaseWidget { - * type = coField.literal("slider"); - * min = coField.number; - * max = coField.number; - * } - * - * const WidgetUnion = SchemaUnion.Of((raw) => { - * switch (raw.get("type")) { - * case "button": return ButtonWidget; - * case "slider": return SliderWidget; - * default: throw new Error("Unknown widget type"); - * } - * }); - * ``` - * * @category CoValues */ export abstract class SchemaUnion extends CoValueBase implements CoValue { - /** - * Create a new union type from a discriminator function. - * - * The discriminator function receives the raw data and should return the appropriate concrete class to use for that data. - * - * When loading a SchemaUnion, the correct subclass will be instantiated based on the discriminator. - * - * @param discriminator - Function that determines which concrete type to use - * @returns A new class that can create/load instances of the union type - * - * @example - * ```ts - * const WidgetUnion = SchemaUnion.Of((raw) => { - * switch (raw.get("type")) { - * case "button": return ButtonWidget; - * case "slider": return SliderWidget; - * default: throw new Error("Unknown widget type"); - * } - * }); - * - * const widget = await loadCoValue(WidgetUnion, id, me, {}); - * - * // You can narrow the returned instance to a subclass by using `instanceof` - * if (widget instanceof ButtonWidget) { - * console.log(widget.label); - * } else if (widget instanceof SliderWidget) { - * console.log(widget.min, widget.max); - * } - * ``` - * - * @category Declaration - **/ - static Of( - discriminator: SchemaUnionDiscriminator, - ): SchemaUnionConcreteSubclass { - return class SchemaUnionClass extends SchemaUnion { - declare $jazz: CoValueJazzApi; - - static override create( - this: CoValueClass, - init: Simplify>, - owner: Account | Group, - ): V { - const ResolvedClass = discriminator(new Map(Object.entries(init))); - // @ts-expect-error - create is a static method in the CoMap class - return ResolvedClass.create(init, owner); - } - - static override fromRaw( - this: CoValueClass & CoValueFromRaw, - raw: T["$jazz"]["raw"], - ): T { - const ResolvedClass = discriminator( - raw as RawCoMap, - ) as unknown as CoValueClass & CoValueFromRaw; - return ResolvedClass.fromRaw(raw); - } - } as unknown as SchemaUnionConcreteSubclass; - } - static create( this: CoValueClass, init: Simplify>, @@ -138,7 +48,7 @@ export abstract class SchemaUnion extends CoValueBase implements CoValue { /** * Create an instance from raw data. This is called internally and should not be used directly. - * Use {@link SchemaUnion.Of} to create a union type instead. + * Use `co.discriminatedUnion(...)` to create union schemas instead. * * @internal */ @@ -203,3 +113,35 @@ export abstract class SchemaUnion extends CoValueBase implements CoValue { return subscribeToCoValueWithoutMe(this, id, options, listener); } } + +/** + * @internal + * Create a SchemaUnion subclass from a discriminator function. + */ +export function schemaUnionClassFromDiscriminator( + discriminator: SchemaUnionDiscriminator, +): SchemaUnionConcreteSubclass { + return class SchemaUnionClass extends SchemaUnion { + declare $jazz: CoValueJazzApi; + + static override create( + this: CoValueClass, + init: Simplify>, + owner: Account | Group, + ): V { + const ResolvedClass = discriminator(new Map(Object.entries(init))); + // @ts-expect-error - create is a static method in the CoMap class + return ResolvedClass.create(init, owner); + } + + static override fromRaw( + this: CoValueClass & CoValueFromRaw, + raw: T["$jazz"]["raw"], + ): T { + const ResolvedClass = discriminator( + raw as RawCoMap, + ) as unknown as CoValueClass & CoValueFromRaw; + return ResolvedClass.fromRaw(raw); + } + } as unknown as SchemaUnionConcreteSubclass; +} diff --git a/packages/jazz-tools/src/tools/exports.ts b/packages/jazz-tools/src/tools/exports.ts index 205643e602..89b6b3ac1f 100644 --- a/packages/jazz-tools/src/tools/exports.ts +++ b/packages/jazz-tools/src/tools/exports.ts @@ -12,8 +12,6 @@ export * as z from "./implementation/zodSchema/zodReExport.js"; export type { CoValue, ID } from "./internal.js"; -export { Encoders, coField } from "./internal.js"; - export { Inbox, InboxSender } from "./internal.js"; export { Group } from "./internal.js"; @@ -68,6 +66,7 @@ export { unstable_loadUnique, getUnloadedCoValueWithoutId, setDefaultSchemaPermissions, + setDefaultValidationMode, deleteCoValues, getJazzErrorType, } from "./internal.js"; diff --git a/packages/jazz-tools/src/tools/implementation/schema.ts b/packages/jazz-tools/src/tools/implementation/schema.ts deleted file mode 100644 index 2ddfc60084..0000000000 --- a/packages/jazz-tools/src/tools/implementation/schema.ts +++ /dev/null @@ -1,273 +0,0 @@ -import type { CoValueUniqueness, JsonValue, RawCoValue } from "cojson"; -import { CojsonInternalTypes } from "cojson"; -import { - Account, - type CoValue, - type CoValueClass, - CoValueFromRaw, - extendContainerOwner, - Group, - type GroupRole, - ItemsSym, - LoadedAndRequired, - type NewInlineOwnerStrategy, - type RefOnCreateCallback, - type RefPermissions, - SchemaInit, - isCoValueClass, -} from "../internal.js"; - -/** @category Schema definition */ -export const Encoders = { - Date: { - encode: (value: Date) => value.toISOString(), - decode: (value: JsonValue) => new Date(value as string), - }, - OptionalDate: { - encode: (value: Date | undefined) => value?.toISOString() || null, - decode: (value: JsonValue) => - value === null ? undefined : new Date(value as string), - }, -}; - -const optional = { - ref: optionalRef, - json>(): T | undefined { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return { [SchemaInit]: "json" satisfies Schema } as any; - }, - encoded(arg: OptionalEncoder): T | undefined { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return { [SchemaInit]: { encoded: arg } satisfies Schema } as any; - }, - string: { - [SchemaInit]: "json" satisfies Schema, - } as unknown as string | undefined, - number: { - [SchemaInit]: "json" satisfies Schema, - } as unknown as number | undefined, - boolean: { - [SchemaInit]: "json" satisfies Schema, - } as unknown as boolean | undefined, - null: { - [SchemaInit]: "json" satisfies Schema, - } as unknown as null | undefined, - Date: { - [SchemaInit]: { encoded: Encoders.OptionalDate } satisfies Schema, - } as unknown as Date | undefined, - literal( - ..._lit: T - ): T[number] | undefined { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return { [SchemaInit]: "json" satisfies Schema } as any; - }, -}; - -/** @category Schema definition */ -export const coField = { - string: { - [SchemaInit]: "json" satisfies Schema, - } as unknown as string, - number: { - [SchemaInit]: "json" satisfies Schema, - } as unknown as number, - boolean: { - [SchemaInit]: "json" satisfies Schema, - } as unknown as boolean, - null: { - [SchemaInit]: "json" satisfies Schema, - } as unknown as null, - Date: { - [SchemaInit]: { encoded: Encoders.Date } satisfies Schema, - } as unknown as Date, - literal(..._lit: T): T[number] { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return { [SchemaInit]: "json" satisfies Schema } as any; - }, - json>(): T { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return { [SchemaInit]: "json" satisfies Schema } as any; - }, - encoded(arg: Encoder): T { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return { [SchemaInit]: { encoded: arg } satisfies Schema } as any; - }, - ref, - items: ItemsSym as ItemsSym, - optional, -}; - -function optionalRef( - arg: C | ((raw: InstanceType["$jazz"]["raw"]) => C), - options: { permissions: RefPermissions }, -): InstanceType | null | undefined { - return ref(arg, { optional: true, permissions: options.permissions }); -} - -function ref( - arg: C | ((raw: InstanceType["$jazz"]["raw"]) => C), - options: { permissions?: RefPermissions }, -): InstanceType | null; -function ref( - arg: C | ((raw: InstanceType["$jazz"]["raw"]) => C), - options: { optional: true; permissions?: RefPermissions }, -): InstanceType | null | undefined; -function ref< - C extends CoValueClass, - Options extends { optional?: boolean; permissions?: RefPermissions }, ->( - arg: C | ((raw: InstanceType["$jazz"]["raw"]) => C), - options: Options, -): Options extends { optional: true } - ? InstanceType | null | undefined - : InstanceType | null { - return { - [SchemaInit]: { - ref: arg, - optional: options.optional || false, - permissions: options.permissions, - } satisfies Schema, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; -} - -export type JsonEncoded = "json"; -export type EncodedAs = { encoded: Encoder | OptionalEncoder }; -export type RefEncoded = { - ref: CoValueClass | ((raw: RawCoValue) => CoValueClass); - optional: boolean; - permissions?: RefPermissions; -}; - -export function isRefEncoded( - schema: Schema, -): schema is RefEncoded { - return ( - typeof schema === "object" && - "ref" in schema && - "optional" in schema && - typeof schema.ref === "function" - ); -} - -export function instantiateRefEncodedFromRaw( - schema: RefEncoded, - raw: RawCoValue, -): V { - return isCoValueClass(schema.ref) - ? schema.ref.fromRaw(raw) - : (schema.ref as (raw: RawCoValue) => CoValueClass & CoValueFromRaw)( - raw, - ).fromRaw(raw); -} - -/** - * Derive a child uniqueness from a parent uniqueness and a field name. - * - * For string uniqueness: `parentUnique + "/" + fieldName` - * For object uniqueness: `{ ...parentUnique, _field: existingField + "/" + fieldName }` - * - * @param parentUniqueness - The parent's uniqueness value - * @param fieldName - The name of the field containing the child - * @returns The derived uniqueness for the child - */ -export function deriveChildUniqueness( - parentUniqueness: CoValueUniqueness["uniqueness"], - fieldName: string, -): CoValueUniqueness["uniqueness"] { - if (typeof parentUniqueness === "string") { - return `${parentUniqueness}@@${fieldName}`; - } - if (typeof parentUniqueness === "object" && parentUniqueness !== null) { - const existingField = parentUniqueness._field ?? ""; - return { - ...parentUniqueness, - _field: existingField ? `${existingField}/${fieldName}` : fieldName, - }; - } - // For boolean/null/undefined, return as-is (no derivation needed) - return parentUniqueness; -} - -/** - * Creates a new CoValue of the given ref type, using the provided init values. - * - * @param schema - The schema of the CoValue to create. - * @param init - The init values to use to create the CoValue. - * @param containerOwner - The owner of the referencing CoValue. Will be used - * to determine the owner of the new CoValue - * @param newOwnerStrategy - The strategy to use to determine the owner of the new CoValue - * @param onCreate - The callback to call when the new CoValue is created - * @param parentUniqueness - The parent's uniqueness value (if the parent is unique) - * @param fieldName - The name of the field containing this ref (for deriving child uniqueness) - * @returns The created CoValue. - */ -export function instantiateRefEncodedWithInit( - schema: RefEncoded, - init: any, - containerOwner: Group, - newOwnerStrategy: NewInlineOwnerStrategy = extendContainerOwner, - onCreate?: RefOnCreateCallback, - unique?: { - uniqueness: CoValueUniqueness["uniqueness"]; - fieldName: string; - firstComesWins: boolean; - }, -): V { - if (!isCoValueClass(schema.ref)) { - throw Error( - `Cannot automatically create CoValue from value: ${JSON.stringify(init)}. Use the CoValue schema's create() method instead.`, - ); - } - const owner = newOwnerStrategy(() => Group.create(), containerOwner, init); - onCreate?.(owner, init); - - // Derive child uniqueness if parent is unique and child uses the same owner - let childUniqueness: CoValueUniqueness["uniqueness"] | undefined; - if (unique !== undefined) { - const isSameOwner = owner === containerOwner; - if (isSameOwner) { - childUniqueness = deriveChildUniqueness( - unique.uniqueness, - unique.fieldName, - ); - } else if ( - typeof unique.uniqueness === "string" || - (typeof unique.uniqueness === "object" && unique.uniqueness !== null) - ) { - // Log warning when parent has meaningful uniqueness but child uses a different owner - console.warn( - `Inline CoValue at field "${unique.fieldName}" has a different owner than its unique parent. ` + - `The child will not inherit uniqueness. Consider using "sameAsContainer" permission ` + - `for CoValues within unique parents.`, - ); - } - } - - // @ts-expect-error - create is a static method in all CoValue classes - return schema.ref.create(init, { - owner, - unique: childUniqueness, - firstComesWins: unique?.firstComesWins, - }); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type Schema = JsonEncoded | RefEncoded | EncodedAs; - -export type SchemaFor = LoadedAndRequired extends CoValue - ? RefEncoded> - : LoadedAndRequired extends JsonValue - ? JsonEncoded - : EncodedAs>; - -export type Encoder = { - encode: (value: V) => JsonValue; - decode: (value: JsonValue) => V; -}; -export type OptionalEncoder = - | Encoder - | { - encode: (value: V | undefined) => JsonValue; - decode: (value: JsonValue) => V | undefined; - }; diff --git a/packages/jazz-tools/src/tools/implementation/schemaRuntime.ts b/packages/jazz-tools/src/tools/implementation/schemaRuntime.ts new file mode 100644 index 0000000000..c17d5c23d3 --- /dev/null +++ b/packages/jazz-tools/src/tools/implementation/schemaRuntime.ts @@ -0,0 +1,140 @@ +import type { CoValueUniqueness, JsonValue, RawCoValue } from "cojson"; +import { + CoValue, + type CoValueClass, + CoValueFromRaw, + type GlobalValidationMode, + Group, + LoadedAndRequired, + type NewInlineOwnerStrategy, + type RefOnCreateCallback, + type RefPermissions, + extendContainerOwner, + isCoValueClass, +} from "../internal.js"; + +export type JsonEncoded = "json"; +export type Encoder = { + encode: (value: V) => JsonValue; + decode: (value: JsonValue) => V; +}; +export type OptionalEncoder = + | Encoder + | { + encode: (value: V | undefined) => JsonValue; + decode: (value: JsonValue) => V | undefined; + }; +export type EncodedAs = { encoded: Encoder | OptionalEncoder }; +export type RefEncoded = { + ref: CoValueClass | ((raw: RawCoValue) => CoValueClass); + optional: boolean; + permissions?: RefPermissions; +}; + +export function isRefEncoded( + schema: Schema, +): schema is RefEncoded { + return ( + typeof schema === "object" && + "ref" in schema && + "optional" in schema && + typeof schema.ref === "function" + ); +} + +export function instantiateRefEncodedFromRaw( + schema: RefEncoded, + raw: RawCoValue, +): V { + return isCoValueClass(schema.ref) + ? schema.ref.fromRaw(raw) + : (schema.ref as (raw: RawCoValue) => CoValueClass & CoValueFromRaw)( + raw, + ).fromRaw(raw); +} + +/** + * Derive a child uniqueness from a parent uniqueness and a field name. + */ +export function deriveChildUniqueness( + parentUniqueness: CoValueUniqueness["uniqueness"], + fieldName: string, +): CoValueUniqueness["uniqueness"] { + if (typeof parentUniqueness === "string") { + return `${parentUniqueness}@@${fieldName}`; + } + if (typeof parentUniqueness === "object" && parentUniqueness !== null) { + const existingField = parentUniqueness._field ?? ""; + return { + ...parentUniqueness, + _field: existingField ? `${existingField}/${fieldName}` : fieldName, + }; + } + return parentUniqueness; +} + +/** + * Creates a new CoValue of the given ref type, using the provided init values. + */ +export function instantiateRefEncodedWithInit( + schema: RefEncoded, + init: any, + containerOwner: Group, + newOwnerStrategy: NewInlineOwnerStrategy = extendContainerOwner, + onCreate?: RefOnCreateCallback, + unique?: { + uniqueness: CoValueUniqueness["uniqueness"]; + fieldName: string; + firstComesWins: boolean; + }, + validationMode?: GlobalValidationMode, +): V { + const resolvedRef = isCoValueClass(schema.ref) + ? schema.ref + : (schema.ref as (raw: RawCoValue) => CoValueClass)(init as RawCoValue); + + if (!isCoValueClass(resolvedRef)) { + throw Error( + `Cannot automatically create CoValue from value: ${JSON.stringify(init)}. Use the CoValue schema's create() method instead.`, + ); + } + const owner = newOwnerStrategy(() => Group.create(), containerOwner, init); + onCreate?.(owner, init); + + let childUniqueness: CoValueUniqueness["uniqueness"] | undefined; + if (unique !== undefined) { + const isSameOwner = owner === containerOwner; + if (isSameOwner) { + childUniqueness = deriveChildUniqueness( + unique.uniqueness, + unique.fieldName, + ); + } else if ( + typeof unique.uniqueness === "string" || + (typeof unique.uniqueness === "object" && unique.uniqueness !== null) + ) { + console.warn( + `Inline CoValue at field "${unique.fieldName}" has a different owner than its unique parent. ` + + `The child will not inherit uniqueness. Consider using "sameAsContainer" permission ` + + `for CoValues within unique parents.`, + ); + } + } + + // @ts-expect-error - create is a static method in all CoValue classes + return resolvedRef.create(init, { + owner, + validation: validationMode, + unique: childUniqueness, + firstComesWins: unique?.firstComesWins, + }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Schema = JsonEncoded | RefEncoded | EncodedAs; + +export type SchemaFor = LoadedAndRequired extends CoValue + ? RefEncoded> + : LoadedAndRequired extends JsonValue + ? JsonEncoded + : EncodedAs>; diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/initializeBuiltinSchemas.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/initializeBuiltinSchemas.ts new file mode 100644 index 0000000000..0a01271cdc --- /dev/null +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/initializeBuiltinSchemas.ts @@ -0,0 +1,9 @@ +import { CoMap } from "../../coValues/coMap.js"; +import { Account } from "../../coValues/account.js"; +import { Profile } from "../../coValues/profile.js"; +import { coMapDefiner, coProfileDefiner } from "./zodCo.js"; +import { coAccountDefiner } from "./zodCo.js"; + +CoMap.coValueSchema = coMapDefiner({}); +Account.coValueSchema = coAccountDefiner(); +Profile.coValueSchema = coProfileDefiner(); diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts index 6b96acf384..1c0a9f8f29 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts @@ -1,27 +1,24 @@ -import { RawCoList, RawCoMap } from "cojson"; import { - Account, + Account as AccountClass, AccountSchema, CoDiscriminatedUnionSchema, - CoFeed, + CoFeed as CoFeedClass, CoFeedSchema, - CoList, + CoList as CoListClass, CoListSchema, - CoMap, + CoMap as CoMapClass, CoMapSchema, - CoPlainText, - CoRichText, + CoPlainText as CoPlainTextClass, + CoRichText as CoRichTextClass, CoValueClass, - FileStream, + FileStream as FileStreamClass, FileStreamSchema, CoVectorSchema, PlainTextSchema, - SchemaUnion, + schemaUnionClassFromDiscriminator, isCoValueClass, - Group, - CoVector, + CoVector as CoVectorClass, } from "../../../internal.js"; -import { coField } from "../../schema.js"; import { CoreCoValueSchema } from "../schemaTypes/CoValueSchema.js"; import { RichTextSchema } from "../schemaTypes/RichTextSchema.js"; @@ -34,23 +31,6 @@ import { CoValueClassOrSchema, CoValueSchemaFromCoreSchema, } from "../zodSchema.js"; -import { - SchemaField, - schemaFieldToCoFieldDef, -} from "./schemaFieldToCoFieldDef.js"; - -/** - * A platform agnostic way to check if we're in development mode - * - * Works in Node.js and bundled code, falls back to false if process is not available - */ -const isDev = (function () { - try { - return process.env.NODE_ENV === "development"; - } catch { - return false; - } -})(); // Note: if you're editing this function, edit the `isAnyCoValueSchema` // function in `zodReExport.ts` as well @@ -89,76 +69,68 @@ export function hydrateCoreCoValueSchema( throw new Error( `co.optional() of collaborative types is not supported as top-level schema: ${JSON.stringify(schema)}`, ); - } else if (schema.builtin === "CoMap" || schema.builtin === "Account") { - const def = schema.getDefinition(); - const ClassToExtend = schema.builtin === "Account" ? Account : CoMap; - - const coValueClass = class ZCoMap extends ClassToExtend { - constructor(options: { fromRaw: RawCoMap } | undefined) { - super(options); - for (const [fieldName, fieldType] of Object.entries(def.shape)) { - (this as any)[fieldName] = schemaFieldToCoFieldDef( - fieldType as SchemaField, - ); - } - if (def.catchall) { - (this as any)[coField.items] = schemaFieldToCoFieldDef( - def.catchall as SchemaField, - ); - } - } - }; + } else if (schema.builtin === "CoMap") { + const coValueClass = class _CoMap extends CoMapClass {}; + coValueClass.coValueSchema = new CoMapSchema( + schema as any, + coValueClass as any, + ); - const coValueSchema = - ClassToExtend === Account - ? new AccountSchema(schema as any, coValueClass as any) - : new CoMapSchema(schema as any, coValueClass as any); + return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; + } else if (schema.builtin === "Account") { + const coValueClass = class _Account extends AccountClass {}; + coValueClass.coValueSchema = new AccountSchema( + schema as any, + coValueClass as any, + ); - return coValueSchema as unknown as CoValueSchemaFromCoreSchema; + return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoList") { const element = schema.element; - const coValueClass = class ZCoList extends CoList { - constructor(options: { fromRaw: RawCoList } | undefined) { - super(options); - (this as any)[coField.items] = schemaFieldToCoFieldDef( - element as SchemaField, - ); - } - }; - - const coValueSchema = new CoListSchema(element, coValueClass as any); + const coValueClass = class _CoList extends CoListClass {}; + coValueClass.coValueSchema = new CoListSchema(element, coValueClass as any); - return coValueSchema as unknown as CoValueSchemaFromCoreSchema; + return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoFeed") { - const coValueClass = CoFeed.Of( - schemaFieldToCoFieldDef(schema.element as SchemaField), + const coValueClass = class _CoFeed extends CoFeedClass {}; + coValueClass.coValueSchema = new CoFeedSchema( + schema.element, + coValueClass as any, ); - const coValueSchema = new CoFeedSchema(schema.element, coValueClass); - return coValueSchema as unknown as CoValueSchemaFromCoreSchema; + return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "FileStream") { - const coValueClass = FileStream; - return new FileStreamSchema(coValueClass) as CoValueSchemaFromCoreSchema; + const coValueClass = class _FileStream extends FileStreamClass {}; + coValueClass.coValueSchema = new FileStreamSchema(coValueClass as any); + return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoVector") { const dimensions = schema.dimensions; - const coValueClass = class CoVectorWithDimensions extends CoVector { + const coValueClass = class _CoVector extends CoVectorClass { protected static requiredDimensionsCount = dimensions; }; - - return new CoVectorSchema( + coValueClass.coValueSchema = new CoVectorSchema( dimensions, - coValueClass, - ) as CoValueSchemaFromCoreSchema; + coValueClass as any, + ); + + return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoPlainText") { - const coValueClass = CoPlainText; - return new PlainTextSchema(coValueClass) as CoValueSchemaFromCoreSchema; + const coValueClass = class _CoPlainText extends CoPlainTextClass {}; + coValueClass.coValueSchema = new PlainTextSchema(coValueClass as any); + return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoRichText") { - const coValueClass = CoRichText; - return new RichTextSchema(coValueClass) as CoValueSchemaFromCoreSchema; + const coValueClass = class _CoRichText extends CoRichTextClass {}; + coValueClass.coValueSchema = new RichTextSchema(coValueClass as any); + return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoDiscriminatedUnion") { - const coValueClass = SchemaUnion.Of(schemaUnionDiscriminatorFor(schema)); - const coValueSchema = new CoDiscriminatedUnionSchema(schema, coValueClass); - return coValueSchema as CoValueSchemaFromCoreSchema; + const coValueClass = schemaUnionClassFromDiscriminator( + schemaUnionDiscriminatorFor(schema), + ); + coValueClass.coValueSchema = new CoDiscriminatedUnionSchema( + schema, + coValueClass, + ); + return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "Group") { return new GroupSchema() as CoValueSchemaFromCoreSchema; } else { diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.ts index a484e3977b..7398ff50ec 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.ts @@ -1,5 +1,6 @@ import type { JsonValue } from "cojson"; import { + type Schema, CoValueClass, isCoValueClass, schemaToRefPermissions, @@ -11,7 +12,6 @@ import { type DiscriminableCoValueSchemas, type RefOnCreateCallback, } from "../../../internal.js"; -import { coField } from "../../schema.js"; import { CoreCoValueSchema } from "../schemaTypes/CoValueSchema.js"; import { isUnionOfPrimitivesDeeply, @@ -29,6 +29,12 @@ import { import { ZodPrimitiveSchema } from "../zodSchema.js"; import { isCoValueSchema } from "./coValueSchemaTransformation.js"; +const optionalDateEncoder = { + encode: (value: Date | undefined) => value?.toISOString() || null, + decode: (value: JsonValue) => + value === null ? undefined : new Date(value as string), +}; + /** * Types of objects that can be nested inside CoValue schema containers */ @@ -56,206 +62,192 @@ export type SchemaField = | z.core.$ZodDefault | z.core.$ZodCatch; -function makeCodecCoField( +function makeCodecSchema( codec: z.core.$ZodCodec, -) { - return coField.optional.encoded({ - encode: (value: any) => { - if (value === undefined) return undefined as unknown as JsonValue; - if (value === null) return null; - return codec._zod.def.reverseTransform(value, { - value, - issues: [], - }) as JsonValue; - }, - decode: (value) => { - if (value === null) return null; - if (value === undefined) return undefined; - return codec._zod.def.transform(value, { value, issues: [] }); +): Schema { + return { + encoded: { + encode: (value: any) => { + if (value === undefined) return undefined as unknown as JsonValue; + if (value === null) return null; + return codec._zod.def.reverseTransform(value, { + value, + issues: [], + }) as JsonValue; + }, + decode: (value) => { + if (value === null) return null; + if (value === undefined) return undefined; + return codec._zod.def.transform(value, { value, issues: [] }); + }, }, - }); + }; } -// CoFieldDefs are inherently type-unsafe. This type exists only for documentation purposes. -type CoFieldDef = any; -const schemaFieldCache = new WeakMap(); +const schemaFieldCache = new WeakMap(); -function cacheSchemaField(schema: SchemaField, value: CoFieldDef): CoFieldDef { +function cacheSchemaField(schema: SchemaField, value: Schema): Schema { schemaFieldCache.set(schema, value); return value; } -export function schemaFieldToCoFieldDef(schema: SchemaField): CoFieldDef { - const cachedCoFieldDef = schemaFieldCache.get(schema); - if (cachedCoFieldDef !== undefined) { - return cachedCoFieldDef; +const ZOD_JSON_TYPES = new Set([ + "string", + "number", + "boolean", + "null", + "enum", + "template_literal", + "object", + "record", + "array", + "tuple", + "intersection", +]); + +function unsupportedZodTypeError(schema: SchemaField): Error { + return new Error( + `Unsupported zod type: ${(schema as any)?._zod?.def?.type || JSON.stringify(schema)}`, + ); +} + +function resolveCoSchemaField( + schema: CoreCoValueSchema & { getCoValueClass: () => CoValueClass }, +): Schema { + return { + ref: schema.getCoValueClass(), + optional: schema.builtin === "CoOptional", + permissions: schemaFieldPermissions(schema), + }; +} + +function validateLiteralValues(literals: readonly unknown[]) { + if (literals.some((literal) => typeof literal === "undefined")) { + throw new Error("z.literal() with undefined is not supported"); + } + if (literals.some((literal) => literal === null)) { + throw new Error("z.literal() with null is not supported"); } + if (literals.some((literal) => typeof literal === "bigint")) { + throw new Error("z.literal() with bigint is not supported"); + } +} - if (isCoValueClass(schema)) { - return cacheSchemaField( - schema, - coField.ref(schema, { - permissions: getDefaultRefPermissions(), - }), - ); - } else if (isCoValueSchema(schema)) { - if (schema.builtin === "CoOptional") { - return cacheSchemaField( - schema, - coField.ref(schema.getCoValueClass(), { - optional: true, - permissions: schemaFieldPermissions(schema), - }), - ); - } - return cacheSchemaField( - schema, - coField.ref(schema.getCoValueClass(), { - permissions: schemaFieldPermissions(schema), - }), - ); - } else { - if ("_zod" in schema) { - const zodSchemaDef = schema._zod.def; +function resolveZodSchemaField(schema: SchemaField): Schema { + if (!("_zod" in schema)) { + throw new Error(`Unsupported zod type: ${schema}`); + } + + const zodSchemaDef = schema._zod.def; + + switch (zodSchemaDef.type) { + case "optional": + case "nullable": { + const inner = zodSchemaDef.innerType as SchemaField; + const innerZodType = inner as unknown as z.ZodTypeAny; if ( - zodSchemaDef.type === "optional" || - zodSchemaDef.type === "nullable" - ) { - const inner = zodSchemaDef.innerType as SchemaField; - const coFieldDef: any = schemaFieldToCoFieldDef(inner); - if ( - zodSchemaDef.type === "nullable" && - coFieldDef === coField.optional.Date - ) { - // We do not currently have a way to encode null Date coFields. - // We only support encoding optional (i.e. Date | undefined) coFields. - throw new Error("Nullable z.date() is not supported"); - } - // Primitive coField types support null and undefined as values, - // so we can just return the inner type here and rely on support - // for null/undefined at the type level - return cacheSchemaField(schema, coFieldDef); - } else if (zodSchemaDef.type === "string") { - return cacheSchemaField(schema, coField.string); - } else if (zodSchemaDef.type === "number") { - return cacheSchemaField(schema, coField.number); - } else if (zodSchemaDef.type === "boolean") { - return cacheSchemaField(schema, coField.boolean); - } else if (zodSchemaDef.type === "null") { - return cacheSchemaField(schema, coField.null); - } else if (zodSchemaDef.type === "enum") { - return cacheSchemaField(schema, coField.string); - } else if (zodSchemaDef.type === "readonly") { - return cacheSchemaField( - schema, - schemaFieldToCoFieldDef( - (schema as unknown as ZodReadonly).def.innerType as SchemaField, - ), - ); - } else if (zodSchemaDef.type === "date") { - return cacheSchemaField(schema, coField.optional.Date); - } else if (zodSchemaDef.type === "template_literal") { - return cacheSchemaField(schema, coField.string); - } else if (zodSchemaDef.type === "lazy") { - // Mostly to support z.json() - return cacheSchemaField( - schema, - schemaFieldToCoFieldDef( - (schema as unknown as ZodLazy).unwrap() as SchemaField, - ), - ); - } else if ( - zodSchemaDef.type === "default" || - zodSchemaDef.type === "catch" + zodSchemaDef.type === "nullable" && + innerZodType?._zod?.def?.type === "date" ) { - console.warn( - "z.default()/z.catch() are not supported in collaborative schemas. They will be ignored.", - ); + throw new Error("Nullable z.date() is not supported"); + } + return resolveSchemaField(inner); + } - return cacheSchemaField( - schema, - schemaFieldToCoFieldDef( - (schema as unknown as ZodDefault | ZodCatch).def - .innerType as SchemaField, - ), - ); - } else if (zodSchemaDef.type === "literal") { - if ( - zodSchemaDef.values.some((literal) => typeof literal === "undefined") - ) { - throw new Error("z.literal() with undefined is not supported"); - } - if (zodSchemaDef.values.some((literal) => literal === null)) { - throw new Error("z.literal() with null is not supported"); - } - if ( - zodSchemaDef.values.some((literal) => typeof literal === "bigint") - ) { - throw new Error("z.literal() with bigint is not supported"); - } - return cacheSchemaField( - schema, - coField.literal( - ...(zodSchemaDef.values as Exclude< - (typeof zodSchemaDef.values)[number], - undefined | null | bigint - >[]), - ), - ); - } else if ( - zodSchemaDef.type === "object" || - zodSchemaDef.type === "record" || - zodSchemaDef.type === "array" || - zodSchemaDef.type === "tuple" || - zodSchemaDef.type === "intersection" - ) { - return cacheSchemaField(schema, coField.json()); - } else if (zodSchemaDef.type === "union") { - if (isUnionOfPrimitivesDeeply(schema)) { - return cacheSchemaField(schema, coField.json()); - } else { - throw new Error( - "z.union()/z.discriminatedUnion() of collaborative types is not supported. Use co.discriminatedUnion() instead.", - ); - } - } else if (zodSchemaDef.type === "pipe") { - const isCodec = - zodSchemaDef.transform !== undefined && - zodSchemaDef.reverseTransform !== undefined; - - if (!isCodec) { - throw new Error( - "z.pipe() is not supported. Only z.codec() is supported.", - ); - } + case "readonly": + return resolveSchemaField( + (schema as unknown as ZodReadonly).def.innerType as SchemaField, + ); - try { - schemaFieldToCoFieldDef(zodSchemaDef.in as SchemaField); - } catch (error) { - if (error instanceof Error) { - error.message = `z.codec() is only supported if the input schema is already supported. ${error.message}`; - } + case "date": + return { encoded: optionalDateEncoder }; - throw error; - } + case "lazy": + // Mostly to support z.json() + return resolveSchemaField( + (schema as unknown as ZodLazy).unwrap() as SchemaField, + ); - return cacheSchemaField( - schema, - makeCodecCoField( - schema as z.core.$ZodCodec, - ), + case "default": + case "catch": + console.warn( + "z.default()/z.catch() are not supported in collaborative schemas. They will be ignored.", + ); + return resolveSchemaField( + (schema as unknown as ZodDefault | ZodCatch).def + .innerType as SchemaField, + ); + + case "literal": + validateLiteralValues(zodSchemaDef.values); + return "json"; + + case "union": + if (!isUnionOfPrimitivesDeeply(schema)) { + throw new Error( + "z.union()/z.discriminatedUnion() of collaborative types is not supported. Use co.discriminatedUnion() instead.", ); - } else { + } + return "json"; + + case "pipe": { + const isCodec = + zodSchemaDef.transform !== undefined && + zodSchemaDef.reverseTransform !== undefined; + + if (!isCodec) { throw new Error( - `Unsupported zod type: ${(schema._zod?.def as any)?.type || JSON.stringify(schema)}`, + "z.pipe() is not supported. Only z.codec() is supported.", ); } - } else { - throw new Error(`Unsupported zod type: ${schema}`); + + try { + resolveSchemaField(zodSchemaDef.in as SchemaField); + } catch (error) { + if (error instanceof Error) { + error.message = `z.codec() is only supported if the input schema is already supported. ${error.message}`; + } + throw error; + } + + return makeCodecSchema( + schema as z.core.$ZodCodec, + ); } + + default: + if (ZOD_JSON_TYPES.has(zodSchemaDef.type)) { + return "json"; + } + throw unsupportedZodTypeError(schema); } } +export function resolveSchemaField(schema: SchemaField): Schema { + const cachedSchema = schemaFieldCache.get(schema); + if (cachedSchema !== undefined) { + return cachedSchema; + } + + const resolved = isCoValueClass(schema) + ? ({ + ref: schema, + optional: false, + permissions: getDefaultRefPermissions(), + } satisfies Schema) + : isCoValueSchema(schema) + ? resolveCoSchemaField( + schema as CoreCoValueSchema & { getCoValueClass: () => CoValueClass }, + ) + : resolveZodSchemaField(schema); + + return cacheSchemaField(schema, resolved); +} + +export function schemaFieldToCoFieldDef(schema: SchemaField): Schema { + return resolveSchemaField(schema); +} + function schemaFieldPermissions(schema: CoreCoValueSchema): RefPermissions { if (schema.builtin === "CoOptional") { return schemaFieldPermissions((schema as any).innerType); diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts new file mode 100644 index 0000000000..d736121472 --- /dev/null +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts @@ -0,0 +1,54 @@ +import type { CoreAccountSchema } from "./schemaTypes/AccountSchema.js"; +import type { CoreCoFeedSchema } from "./schemaTypes/CoFeedSchema.js"; +import type { CoreCoListSchema } from "./schemaTypes/CoListSchema.js"; +import type { CoreCoMapSchema } from "./schemaTypes/CoMapSchema.js"; +import type { CoreCoValueSchema } from "./schemaTypes/CoValueSchema.js"; + +type ConstructorWithSchema = { + name?: string; + coValueSchema?: S; +}; + +function assertCoreCoValueSchema( + constructor: C, + expectedSchemaType: string, + operation: "create" | "load" | "resolve", +): NonNullable { + const schema = constructor.coValueSchema; + if (!schema) { + const className = constructor.name || "AnonymousCoValue"; + throw new Error( + `[schema-invariant] ${className}.${operation} requires a coValueSchema. ` + + `Attach a schema via co.map/co.list/co.feed/co.account before using this class.`, + ); + } + + if (schema.builtin !== expectedSchemaType) { + const className = constructor.name || "AnonymousCoValue"; + throw new Error( + `[schema-invariant] ${className}.${operation} requires a ${expectedSchemaType} schema. ` + + `Got ${schema.builtin} instead.`, + ); + } + + return schema as NonNullable; +} + +type CoValueSchema = + | CoreCoMapSchema + | CoreCoListSchema + | CoreCoFeedSchema + | CoreAccountSchema; + +export function assertCoValueSchema< + T extends CoValueSchema["builtin"], + C extends ConstructorWithSchema, +>( + constructor: C, + type: T, + operation: "create" | "load" | "resolve", +): Extract { + const schema = assertCoreCoValueSchema(constructor, type, operation); + + return schema as Extract; +} diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaPermissions.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaPermissions.ts index 9e76ff5ce3..9cbfbc0ee7 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaPermissions.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaPermissions.ts @@ -1,4 +1,11 @@ -import { Account, Group, TypeSym, type GroupRole } from "../../internal.js"; +import { + Account, + CoValueCreateOptions, + CoValueCreateOptionsInternal, + Group, + TypeSym, + type GroupRole, +} from "../../internal.js"; /** * Defines how a nested CoValue’s owner is obtained when creating CoValues from JSON. diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts index d3dd656026..abf3745895 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts @@ -19,6 +19,7 @@ import { z } from "../zodReExport.js"; import { AnyZodOrCoValueSchema, Loaded, ResolveQuery } from "../zodSchema.js"; import { CoMapSchema, + CoMapDescriptorsSchema, CoreCoMapSchema, createCoreCoMapSchema, } from "./CoMapSchema.js"; @@ -51,8 +52,26 @@ export class AccountSchema< collaborative = true as const; builtin = "Account" as const; shape: Shape; + getDescriptorsSchema: () => CoMapDescriptorsSchema; getDefinition: () => CoMapSchemaDefinition; + #validationSchema: z.ZodType | undefined = undefined; + + getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + + this.#validationSchema = z.instanceof(Account).or( + z.object({ + profile: this.shape.profile.getValidationSchema(), + root: z.optional(this.shape.root.getValidationSchema()), + }), + ); + + return this.#validationSchema; + }; + /** * Default resolve query to be used when loading instances of this schema. * This resolve query will be used when no resolve query is provided to the load method. @@ -65,6 +84,7 @@ export class AccountSchema< private coValueClass: typeof Account, ) { this.shape = coreSchema.shape; + this.getDescriptorsSchema = coreSchema.getDescriptorsSchema; this.getDefinition = coreSchema.getDefinition; } diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts index 83ac67ee3d..c951507b23 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts @@ -19,6 +19,7 @@ import { z } from "../zodReExport.js"; import { CoOptionalSchema } from "./CoOptionalSchema.js"; import { CoreCoValueSchema, CoreResolveQuery } from "./CoValueSchema.js"; import { withSchemaResolveQuery } from "../../schemaUtils.js"; +import { extractPlainSchema } from "./schemaValidators.js"; export interface DiscriminableCoValueSchemaDefinition { discriminatorMap: z.core.$ZodDiscriminatedUnionInternals["propValues"]; @@ -55,6 +56,26 @@ export class CoDiscriminatedUnionSchema< readonly builtin = "CoDiscriminatedUnion" as const; readonly getDefinition: () => CoDiscriminatedUnionSchemaDefinition; + #validationSchema: z.ZodType | undefined = undefined; + + getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + + const { discriminator, options } = this.getDefinition(); + this.#validationSchema = z.discriminatedUnion( + discriminator, + // @ts-expect-error + options.map((schema) => { + const validationSchema = schema.getValidationSchema(); + return extractPlainSchema(validationSchema); + }), + ); + + return this.#validationSchema; + }; + /** * Default resolve query to be used when loading instances of this schema. * This resolve query will be used when no resolve query is provided to the load method. @@ -224,6 +245,7 @@ export function createCoreCoDiscriminatedUnionSchema< return { collaborative: true as const, builtin: "CoDiscriminatedUnion" as const, + getValidationSchema: () => z.any(), getDefinition: () => ({ discriminator, get discriminatorMap() { diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts index b49ac7a379..22af1676fb 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts @@ -15,6 +15,8 @@ import { coOptionalDefiner, unstable_mergeBranchWithResolve, withSchemaPermissions, + type Schema, + CoValueCreateOptions, } from "../../../internal.js"; import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js"; import { CoFeedSchemaInit } from "../typeConverters/CoFieldSchemaInit.js"; @@ -27,6 +29,12 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; +import { z } from "../zodReExport.js"; +import { + coValueValidationSchema, + generateValidationSchemaFromItem, +} from "./schemaValidators.js"; +import { resolveSchemaField } from "../runtimeConverters/schemaFieldToCoFieldDef.js"; export class CoFeedSchema< T extends AnyZodOrCoValueSchema, @@ -35,6 +43,7 @@ export class CoFeedSchema< { collaborative = true as const; builtin = "CoFeed" as const; + #descriptorsSchema: Schema | undefined = undefined; /** * Default resolve query to be used when loading instances of this schema. @@ -52,31 +61,47 @@ export class CoFeedSchema< return this.#permissions ?? DEFAULT_SCHEMA_PERMISSIONS; } + #validationSchema: z.ZodType | undefined = undefined; + getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + + const validationSchema = z.array( + generateValidationSchemaFromItem(this.element), + ); + + this.#validationSchema = coValueValidationSchema(validationSchema, CoFeed); + return this.#validationSchema; + }; + constructor( public element: T, private coValueClass: typeof CoFeed, ) {} + getDescriptorsSchema = (): Schema => { + if (this.#descriptorsSchema) { + return this.#descriptorsSchema; + } + + this.#descriptorsSchema = resolveSchemaField(this.element as any); + + return this.#descriptorsSchema; + }; + create( init: CoFeedSchemaInit, - options?: - | { owner: Group; unique?: CoValueUniqueness["uniqueness"] } - | Group, + options?: CoValueCreateOptions, ): CoFeedInstance; /** @deprecated Creating CoValues with an Account as owner is deprecated. Use a Group instead. */ create( init: CoFeedSchemaInit, - options?: - | { owner: Account | Group; unique?: CoValueUniqueness["uniqueness"] } - | Account - | Group, + options: CoValueCreateOptions<{}, Account | Group>, ): CoFeedInstance; create( init: CoFeedSchemaInit, - options?: - | { owner: Account | Group; unique?: CoValueUniqueness["uniqueness"] } - | Account - | Group, + options?: CoValueCreateOptions<{}, Account | Group>, ): CoFeedInstance { const optionsWithPermissions = withSchemaPermissions( options, @@ -265,11 +290,23 @@ export class CoFeedSchema< export function createCoreCoFeedSchema( element: T, ): CoreCoFeedSchema { + let descriptorsSchema: Schema | undefined; + return { collaborative: true as const, builtin: "CoFeed" as const, element, + getDescriptorsSchema: () => { + if (descriptorsSchema) { + return descriptorsSchema; + } + + descriptorsSchema = resolveSchemaField(element as any); + + return descriptorsSchema; + }, resolveQuery: true as const, + getValidationSchema: () => z.any(), }; } @@ -279,6 +316,7 @@ export interface CoreCoFeedSchema< > extends CoreCoValueSchema { builtin: "CoFeed"; element: T; + getDescriptorsSchema: () => Schema; } export type CoFeedInstance = CoFeed< diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts index ab222b7f8e..b29bec8a2f 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -14,6 +14,8 @@ import { coOptionalDefiner, unstable_mergeBranchWithResolve, withSchemaPermissions, + type Schema, + CoValueCreateOptions, } from "../../../internal.js"; import { CoValueUniqueness } from "cojson"; import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js"; @@ -28,6 +30,12 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; +import { z } from "../zodReExport.js"; +import { + coValueValidationSchema, + generateValidationSchemaFromItem, +} from "./schemaValidators.js"; +import { resolveSchemaField } from "../runtimeConverters/schemaFieldToCoFieldDef.js"; export class CoListSchema< T extends AnyZodOrCoValueSchema, @@ -36,6 +44,7 @@ export class CoListSchema< { collaborative = true as const; builtin = "CoList" as const; + #descriptorsSchema: Schema | undefined = undefined; /** * Default resolve query to be used when loading instances of this schema. @@ -53,32 +62,45 @@ export class CoListSchema< return this.#permissions ?? DEFAULT_SCHEMA_PERMISSIONS; } + #validationSchema: z.ZodType | undefined = undefined; + getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + + const validationSchema = z.array( + generateValidationSchemaFromItem(this.element), + ); + + this.#validationSchema = coValueValidationSchema(validationSchema, CoList); + return this.#validationSchema; + }; + constructor( public element: T, private coValueClass: typeof CoList, ) {} + getDescriptorsSchema = (): Schema => { + if (this.#descriptorsSchema) { + return this.#descriptorsSchema; + } + + this.#descriptorsSchema = resolveSchemaField(this.element as any); + + return this.#descriptorsSchema; + }; + create( items: CoListSchemaInit, - options?: - | { owner: Group; unique?: CoValueUniqueness["uniqueness"] } - | Group, + options?: CoValueCreateOptions, ): CoListInstance; /** @deprecated Creating CoValues with an Account as owner is deprecated. Use a Group instead. */ create( items: CoListSchemaInit, - options?: - | { owner: Account | Group; unique?: CoValueUniqueness["uniqueness"] } - | Account - | Group, + options?: CoValueCreateOptions<{}, Account | Group>, ): CoListInstance; - create( - items: CoListSchemaInit, - options?: - | { owner: Account | Group; unique?: CoValueUniqueness["uniqueness"] } - | Account - | Group, - ): CoListInstance { + create(items: any, options?: any): CoListInstance { const optionsWithPermissions = withSchemaPermissions( options, this.permissions, @@ -313,11 +335,23 @@ export class CoListSchema< export function createCoreCoListSchema( element: T, ): CoreCoListSchema { + let descriptorsSchema: Schema | undefined; + return { collaborative: true as const, builtin: "CoList" as const, element, + getDescriptorsSchema: () => { + if (descriptorsSchema) { + return descriptorsSchema; + } + + descriptorsSchema = resolveSchemaField(element as any); + + return descriptorsSchema; + }, resolveQuery: true as const, + getValidationSchema: () => z.any(), }; } @@ -327,6 +361,7 @@ export interface CoreCoListSchema< > extends CoreCoValueSchema { builtin: "CoList"; element: T; + getDescriptorsSchema: () => Schema; } export type CoListInstance = CoList< diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts index ef1ab0ffeb..ed64184b37 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -19,6 +19,9 @@ import { isAnyCoValueSchema, unstable_mergeBranchWithResolve, withSchemaPermissions, + isCoValueSchema, + type Schema, + CoValueCreateOptions, } from "../../../internal.js"; import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js"; import { removeGetters, withSchemaResolveQuery } from "../../schemaUtils.js"; @@ -33,6 +36,11 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; +import { + coValueValidationSchema, + generateValidationSchemaFromItem, +} from "./schemaValidators.js"; +import { resolveSchemaField } from "../runtimeConverters/schemaFieldToCoFieldDef.js"; type CoMapSchemaInstance = Simplify< CoMapInstanceCoValuesMaybeLoaded @@ -50,8 +58,45 @@ export class CoMapSchema< builtin = "CoMap" as const; shape: Shape; catchAll?: CatchAll; + #descriptorsSchema: CoMapDescriptorsSchema | undefined = undefined; getDefinition: () => CoMapSchemaDefinition; + #validationSchema: z.ZodType | undefined = undefined; + getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + + const plainShape: Record = {}; + + for (const key in this.shape) { + const item = this.shape[key]; + if (isCoValueSchema(item)) { + // Inject as getter to avoid circularity issues + Object.defineProperty(plainShape, key, { + get: () => generateValidationSchemaFromItem(item), + enumerable: true, + configurable: true, + }); + } else { + plainShape[key] = generateValidationSchemaFromItem(item); + } + } + + let validationSchema = z.strictObject(plainShape); + if (this.catchAll) { + validationSchema = validationSchema.catchall( + generateValidationSchemaFromItem( + this.catchAll as unknown as AnyZodOrCoValueSchema, + ), + ); + } + + this.#validationSchema = coValueValidationSchema(validationSchema, CoMap); + + return this.#validationSchema; + }; + /** * Default resolve query to be used when loading instances of this schema. * This resolve query will be used when no resolve query is provided to the load method. @@ -77,30 +122,45 @@ export class CoMapSchema< this.getDefinition = coreSchema.getDefinition; } + getDescriptorsSchema = (): CoMapDescriptorsSchema => { + if (this.#descriptorsSchema) { + return this.#descriptorsSchema; + } + + const descriptorShape: Record = {}; + for (const key of Object.keys(this.shape)) { + const field = this.shape[key as keyof Shape]; + descriptorShape[key] = resolveSchemaField(field as any); + } + + const descriptorCatchall = + this.catchAll === undefined + ? undefined + : resolveSchemaField(this.catchAll as any); + + this.#descriptorsSchema = { + shape: descriptorShape, + catchall: descriptorCatchall, + }; + + return this.#descriptorsSchema; + }; + create( init: CoMapSchemaInit, - options?: - | { - owner?: Group; - unique?: CoValueUniqueness["uniqueness"]; - } - | Group, + options?: CoValueCreateOptions, ): CoMapInstanceShape & CoMap; /** @deprecated Creating CoValues with an Account as owner is deprecated. Use a Group instead. */ create( init: CoMapSchemaInit, - options?: - | { - owner?: Owner; - unique?: CoValueUniqueness["uniqueness"]; - } - | Owner, + options?: CoValueCreateOptions<{}, Account | Group>, ): CoMapInstanceShape & CoMap; create(init: any, options?: any) { const optionsWithPermissions = withSchemaPermissions( options, this.permissions, ); + return this.coValueClass.create(init, optionsWithPermissions); } @@ -466,11 +526,36 @@ export function createCoreCoMapSchema< Shape extends z.core.$ZodLooseShape, CatchAll extends AnyZodOrCoValueSchema | unknown = unknown, >(shape: Shape, catchAll?: CatchAll): CoreCoMapSchema { + let descriptorsSchema: CoMapDescriptorsSchema | undefined; + return { collaborative: true as const, builtin: "CoMap" as const, shape, catchAll, + getDescriptorsSchema: () => { + if (descriptorsSchema) { + return descriptorsSchema; + } + + const descriptorShape: Record = {}; + for (const key of Object.keys(shape)) { + const field = shape[key as keyof Shape]; + descriptorShape[key] = resolveSchemaField(field as any); + } + + const descriptorCatchall = + catchAll === undefined + ? undefined + : resolveSchemaField(catchAll as any); + + descriptorsSchema = { + shape: descriptorShape, + catchall: descriptorCatchall, + }; + + return descriptorsSchema; + }, getDefinition: () => ({ get shape() { return shape; @@ -497,6 +582,7 @@ export function createCoreCoMapSchema< }, }), resolveQuery: true as const, + getValidationSchema: () => z.any(), }; } @@ -508,6 +594,11 @@ export interface CoMapSchemaDefinition< catchall?: CatchAll; } +export type CoMapDescriptorsSchema = { + shape: Record; + catchall?: Schema; +}; + // less precise version to avoid circularity issues and allow matching against export interface CoreCoMapSchema< Shape extends z.core.$ZodLooseShape = z.core.$ZodLooseShape, @@ -516,6 +607,7 @@ export interface CoreCoMapSchema< builtin: "CoMap"; shape: Shape; catchAll?: CatchAll; + getDescriptorsSchema: () => CoMapDescriptorsSchema; getDefinition: () => CoMapSchemaDefinition; } diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoOptionalSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoOptionalSchema.ts index 6153a59ac5..a1dafac955 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoOptionalSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoOptionalSchema.ts @@ -1,5 +1,7 @@ import { CoValueSchemaFromCoreSchema } from "../zodSchema.js"; import { CoreCoValueSchema } from "./CoValueSchema.js"; +import { z } from "../zodReExport.js"; +import { CoList, CoMap } from "../../../internal.js"; type CoOptionalSchemaDefinition< Shape extends CoreCoValueSchema = CoreCoValueSchema, @@ -26,8 +28,19 @@ export class CoOptionalSchema< }); readonly resolveQuery = true as const; + #validationSchema: z.ZodType | undefined = undefined; + constructor(public readonly innerType: Shape) {} + getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + + this.#validationSchema = z.optional(this.innerType.getValidationSchema()); + return this.#validationSchema; + }; + getCoValueClass(): ReturnType< CoValueSchemaFromCoreSchema["getCoValueClass"] > { diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoRecordSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoRecordSchema.ts index 2a0bf9e916..66b0fa61a8 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoRecordSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoRecordSchema.ts @@ -12,6 +12,7 @@ import { Resolved, Simplify, SubscribeListenerOptions, + LocalValidationMode, } from "../../../internal.js"; import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js"; import { CoFieldSchemaInit } from "../typeConverters/CoFieldSchemaInit.js"; @@ -38,14 +39,24 @@ export interface CoRecordSchema< create( init: Simplify>, options?: - | { owner: Group; unique?: CoValueUniqueness["uniqueness"] } + | { + owner: Group; + unique?: CoValueUniqueness["uniqueness"]; + validation?: LocalValidationMode; + } + | { owner?: Group; validation?: LocalValidationMode } | Group, ): CoRecordInstanceShape & CoMap; /** @deprecated Creating CoValues with an Account as owner is deprecated. Use a Group instead. */ create( init: Simplify>, options?: - | { owner: Account | Group; unique?: CoValueUniqueness["uniqueness"] } + | { + owner: Account | Group; + unique?: CoValueUniqueness["uniqueness"]; + validation?: LocalValidationMode; + } + | { owner?: Account | Group; validation?: LocalValidationMode } | Account | Group, ): CoRecordInstanceShape & CoMap; diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoValueSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoValueSchema.ts index 242959fd3f..bcbe6e2433 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoValueSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoValueSchema.ts @@ -1,3 +1,5 @@ +import { z } from "../zodReExport.js"; + /** * "Core" CoValue schemas contain all data necessary to represent a CoValue schema. * Behavior is provided by CoValue schemas that extend "core" CoValue schema data structures. @@ -17,6 +19,8 @@ export interface CoreCoValueSchema { builtin: string; resolveQuery: CoreResolveQuery; + + getValidationSchema: () => z.ZodType; } /** diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoVectorSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoVectorSchema.ts index 656b4da0f7..99b70bcc9d 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoVectorSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoVectorSchema.ts @@ -14,6 +14,8 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; +import { z } from "../zodReExport.js"; +import { coValueValidationSchema } from "./schemaValidators.js"; export interface CoreCoVectorSchema extends CoreCoValueSchema { builtin: "CoVector"; @@ -28,6 +30,7 @@ export function createCoreCoVectorSchema( builtin: "CoVector" as const, dimensions, resolveQuery: true as const, + getValidationSchema: () => z.any(), }; } @@ -36,7 +39,23 @@ export class CoVectorSchema implements CoreCoVectorSchema { readonly builtin = "CoVector" as const; readonly resolveQuery = true as const; + #validationSchema: z.ZodType | undefined = undefined; #permissions: SchemaPermissions | null = null; + getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + + const validationSchema = z.instanceof(Float32Array).or(z.array(z.number())); + + this.#validationSchema = coValueValidationSchema( + validationSchema, + CoVector, + ); + + return this.#validationSchema; + }; + /** * Permissions to be used when creating or composing CoValues * @internal diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/FileStreamSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/FileStreamSchema.ts index 26e20a4238..2f0e5b43a1 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/FileStreamSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/FileStreamSchema.ts @@ -14,6 +14,7 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; +import { z } from "../zodReExport.js"; export interface CoreFileStreamSchema extends CoreCoValueSchema { builtin: "FileStream"; @@ -24,6 +25,7 @@ export function createCoreFileStreamSchema(): CoreFileStreamSchema { collaborative: true as const, builtin: "FileStream" as const, resolveQuery: true as const, + getValidationSchema: () => z.any(), }; } @@ -32,7 +34,17 @@ export class FileStreamSchema implements CoreFileStreamSchema { readonly builtin = "FileStream" as const; readonly resolveQuery = true as const; + #validationSchema: z.ZodType | undefined = undefined; #permissions: SchemaPermissions | null = null; + getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + + this.#validationSchema = z.instanceof(FileStream); + return this.#validationSchema; + }; + /** * Permissions to be used when creating or composing CoValues * @internal diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/GroupSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/GroupSchema.ts index 4ef9cb03e6..76b0406f96 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/GroupSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/GroupSchema.ts @@ -16,6 +16,7 @@ import { CoreCoValueSchema } from "./CoValueSchema.js"; import { coOptionalDefiner } from "../zodCo.js"; import { CoOptionalSchema } from "./CoOptionalSchema.js"; import type { AccountRole, InviteSecret } from "cojson"; +import { z } from "../zodReExport.js"; export interface CoreGroupSchema extends CoreCoValueSchema { builtin: "Group"; @@ -26,6 +27,7 @@ export function createCoreGroupSchema(): CoreGroupSchema { collaborative: true as const, builtin: "Group" as const, resolveQuery: true as const, + getValidationSchema: () => z.any(), }; } @@ -34,6 +36,17 @@ export class GroupSchema implements CoreGroupSchema { readonly builtin = "Group" as const; readonly resolveQuery = true as const; + #validationSchema: z.ZodType | undefined = undefined; + + getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + + this.#validationSchema = z.instanceof(Group); + return this.#validationSchema; + }; + getCoValueClass(): typeof Group { return Group; } diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts index 010892e83d..6659dd4e8f 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts @@ -16,6 +16,8 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; +import { z } from "../zodReExport.js"; +import { coValueValidationSchema } from "./schemaValidators.js"; export interface CorePlainTextSchema extends CoreCoValueSchema { builtin: "CoPlainText"; @@ -26,6 +28,7 @@ export function createCoreCoPlainTextSchema(): CorePlainTextSchema { collaborative: true as const, builtin: "CoPlainText" as const, resolveQuery: true as const, + getValidationSchema: () => z.any(), }; } @@ -35,6 +38,21 @@ export class PlainTextSchema implements CorePlainTextSchema { readonly resolveQuery = true as const; #permissions: SchemaPermissions | null = null; + #validationSchema: z.ZodType | undefined = undefined; + getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + + const validationSchema = z.string(); + + this.#validationSchema = coValueValidationSchema( + validationSchema, + CoPlainText, + ); + return this.#validationSchema; + }; + /** * Permissions to be used when creating or composing CoValues * @internal diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/RichTextSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/RichTextSchema.ts index 7c4bfea6e0..f0994f2dd7 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/RichTextSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/RichTextSchema.ts @@ -15,6 +15,8 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; +import { z } from "../zodReExport.js"; +import { coValueValidationSchema } from "./schemaValidators.js"; export interface CoreRichTextSchema extends CoreCoValueSchema { builtin: "CoRichText"; @@ -25,6 +27,7 @@ export function createCoreCoRichTextSchema(): CoreRichTextSchema { collaborative: true as const, builtin: "CoRichText" as const, resolveQuery: true as const, + getValidationSchema: () => z.any(), }; } @@ -34,6 +37,7 @@ export class RichTextSchema implements CoreRichTextSchema { readonly resolveQuery = true as const; #permissions: SchemaPermissions | null = null; + #validationSchema: z.ZodType | undefined = undefined; /** * Permissions to be used when creating or composing CoValues * @internal @@ -44,6 +48,20 @@ export class RichTextSchema implements CoreRichTextSchema { constructor(private coValueClass: typeof CoRichText) {} + getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + + const validationSchema = z.string(); + + this.#validationSchema = coValueValidationSchema( + validationSchema, + CoRichText, + ); + return this.#validationSchema; + }; + create(text: string, options?: { owner: Group } | Group): CoRichText; /** @deprecated Creating CoValues with an Account as owner is deprecated. Use a Group instead. */ create( diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts new file mode 100644 index 0000000000..9665be6671 --- /dev/null +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts @@ -0,0 +1,137 @@ +import { + Account, + Group, + isCoValue, + isCoValueSchema, +} from "../../../internal.js"; +import { z } from "../zodReExport.js"; +import type { CoreCoValueSchema } from "./CoValueSchema.js"; + +type InputSchema = + | typeof Group + | typeof Account + | CoreCoValueSchema + | z.ZodType + | z.core.$ZodType; + +export function generateValidationSchemaFromItem(item: InputSchema): z.ZodType { + // item is Group class + // This is because users can define the schema + // using Group class instead of GroupSchema + // e.g. `co.map({ group: Group })` vs `co.map({ group: co.group() })` + if (item === Group) { + return z.instanceof(Group); + } + // Same as above: `co.map({ account: Account })` vs `co.map({ account: co.account() })` + if (item === Account) { + return z.instanceof(Account); + } + + if (isCoValueSchema(item)) { + return item.getValidationSchema(); + } + + if (item instanceof z.core.$ZodType) { + // the following zod types are not supported: + if ( + // codecs are managed lower level + (item as z.ZodType).def.type === "pipe" + ) { + return z.any(); + } + + return item as z.ZodType; + } + + throw new Error(`Unsupported schema type: ${item}`); +} + +/** + * Returns a Zod schema that accepts either an instance of the given CoValue class + * or a plain value valid against the given plain schema. Validation is not used on read, + * so existing CoValue instances are accepted; for non-CoValue inputs, validation runs + * against the plain schema. The result includes `plainSchema` in meta for extraction. + */ +export function coValueValidationSchema( + plainSchema: z.ZodType, + expectedCoValueClass: new (...args: any[]) => unknown, +): z.ZodType { + return z + .unknown() + .superRefine((value, ctx) => { + if (isCoValue(value)) { + if (!(value instanceof expectedCoValueClass)) { + ctx.addIssue({ + code: "custom", + message: `Expected a ${expectedCoValueClass.name} when providing a CoValue instance`, + }); + } + return; + } + + const parsedValue = plainSchema.safeParse(value); + if (!parsedValue.success) { + for (const issue of parsedValue.error.issues) { + ctx.addIssue({ ...issue }); + } + } + }) + .meta({ + plainSchema, + }); +} + +export function extractPlainSchema(schema: z.ZodType): z.ZodType { + // plainSchema is only set on unknown schemas with superRefine + if (schema.def.type !== "unknown") { + return schema; + } + const plainSchema = schema.meta()?.plainSchema; + if (plainSchema) { + return plainSchema as z.ZodType; + } + + throw new Error("Schema does not have a plain schema"); +} + +export function expectObjectSchema(schema: z.ZodType): z.ZodObject { + if (schema.def.type === "object") { + return schema as z.ZodObject; + } + + const plainSchema = extractPlainSchema(schema); + if (plainSchema.def.type === "object") { + return plainSchema as z.ZodObject; + } + + throw new Error("Schema does not have an object schema"); +} + +export function expectArraySchema(schema: z.ZodType): z.ZodArray { + if (schema.def.type === "array") { + return schema as z.ZodArray; + } + + const plainSchema = extractPlainSchema(schema); + if (plainSchema.def.type === "array") { + return plainSchema as z.ZodArray; + } + + throw new Error("Schema does not have an array schema"); +} + +export function normalizeZodSchema(schema: z.ZodType): z.ZodType { + // ignore codecs/pipes + // even if they are nested into optional and nullable + if ( + schema.def?.type === "pipe" || + // @ts-expect-error + schema.def?.innerType?.def?.type === "pipe" || + // @ts-expect-error + schema.def?.innerType?.def?.innerType?.def?.type === "pipe" + ) { + return z.any(); + } + + return schema; +} diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/unionUtils.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/unionUtils.ts index 5c47aeece1..43367474cc 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/unionUtils.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/unionUtils.ts @@ -8,7 +8,7 @@ import { DiscriminableCoValueSchemas, DiscriminableCoreCoValueSchema, SchemaUnionDiscriminator, - coField, + createCoreCoMapSchema, } from "../../internal.js"; import { hydrateCoreCoValueSchema, @@ -91,27 +91,30 @@ export function schemaUnionDiscriminatorFor( } if (match) { - const coValueSchema = hydrateCoreCoValueSchema(option as any); - const coValueClass = coValueSchema.getCoValueClass() as typeof CoMap; - const dummyFieldNames = Array.from(allNestedRefKeys).filter( (key) => !optionDef.shape[key], ); if (dummyFieldNames.length === 0) { - return coValueClass; + const coValueSchema = hydrateCoreCoValueSchema(option as any); + return coValueSchema.getCoValueClass() as typeof CoMap; } - // inject dummy fields - return class extends coValueClass { - constructor(...args: ConstructorParameters) { - super(...args); + // Add schema-level dummy keys so deep-resolve keys shared by other union branches + // are recognized without mutating instances at runtime. + const augmentedShape = { + ...optionDef.shape, + } as Record; - for (const key of dummyFieldNames) { - (this as any)[key] = coField.null; - } - } - }; + for (const key of dummyFieldNames) { + augmentedShape[key] = z.optional(z.null()); + } + + const augmentedSchema = hydrateCoreCoValueSchema( + createCoreCoMapSchema(augmentedShape, optionDef.catchall), + ); + + return augmentedSchema.getCoValueClass() as typeof CoMap; } } diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/validationSettings.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/validationSettings.ts new file mode 100644 index 0000000000..79151d3bf3 --- /dev/null +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/validationSettings.ts @@ -0,0 +1,113 @@ +import { z } from "./zodReExport.js"; + +/** + * Global validation mode that can include "warn" for logging-only validation. + * - "strict": validate and throw on error (default) + * - "loose": skip validation entirely + * - "warn": validate but only log errors to console (don't throw) + */ +export type GlobalValidationMode = "strict" | "loose" | "warn"; + +/** + * Local validation mode for per-operation overrides. + * Only supports "strict" | "loose" - "warn" is only available as a global setting. + */ +export type LocalValidationMode = "strict" | "loose"; + +/** + * The current global validation mode. + * @default "strict" + */ +export let DEFAULT_VALIDATION_MODE: GlobalValidationMode = "warn"; + +/** + * Set the default validation mode for all CoValue operations. + * This affects create, set, push, and other mutation operations across + * CoMap, CoList, and CoFeed. + * + * @param mode - The validation mode to set: + * - "strict": validate and throw on error (default) + * - "loose": skip validation entirely + * - "warn": validate but only log errors to console + * + * @example + * ```ts + * import { setDefaultValidationMode } from "jazz-tools"; + * + * // Disable validation globally during development + * setDefaultValidationMode("loose"); + * + * // Enable warning-only mode to see validation issues without breaking + * setDefaultValidationMode("warn"); + * + * // Re-enable strict validation + * setDefaultValidationMode("strict"); + * ``` + */ +export function setDefaultValidationMode(mode: GlobalValidationMode): void { + shouldShout = false; + DEFAULT_VALIDATION_MODE = mode; +} + +/** + * Get the current default validation mode. + * + * @returns The current global validation mode + */ +export function getDefaultValidationMode(): GlobalValidationMode { + return DEFAULT_VALIDATION_MODE; +} + +let shouldShout = true; +/** + * Resolve the effective validation mode based on local override and global default. + * Local overrides take precedence over the global setting. + * + * @param localOverride - Optional local validation mode ("strict" | "loose") + * @returns The effective validation mode to use + */ +export function resolveValidationMode( + localOverride?: LocalValidationMode, +): GlobalValidationMode { + if (shouldShout) { + console.warn( + "Validation mode is %s by default, but in the next major version it will be strict", + DEFAULT_VALIDATION_MODE, + ); + shouldShout = false; + } + return localOverride ?? DEFAULT_VALIDATION_MODE; +} + +/** + * Execute validation with the specified mode. + * Centralizes validation logic to handle strict, loose, and warn modes consistently. + * + * @param schema - The Zod schema to validate against + * @param value - The value to validate + * @param mode - The validation mode to use + * @returns The validated (and possibly transformed) value + */ +export function executeValidation( + schema: z.ZodType, + value: unknown, + mode: GlobalValidationMode, +): T { + if (mode === "loose") { + return value as T; + } + + const result = z.safeParse(schema, value); + + if (!result.success) { + if (mode === "warn") { + console.warn("[Jazz] Validation warning:", result.error); + return value as T; + } + + // mode === "strict" + throw result.error; + } + + return result.data; +} diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/zodCo.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/zodCo.ts index 08d3db4dee..f98258f878 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/zodCo.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/zodCo.ts @@ -31,7 +31,7 @@ import { createCoreCoDiscriminatedUnionSchema, } from "./schemaTypes/CoDiscriminatedUnionSchema.js"; import { CoOptionalSchema } from "./schemaTypes/CoOptionalSchema.js"; -import { CoreCoValueSchema } from "./schemaTypes/CoValueSchema.js"; +import type { CoreCoValueSchema } from "./schemaTypes/CoValueSchema.js"; import { RichTextSchema, createCoreCoRichTextSchema, diff --git a/packages/jazz-tools/src/tools/internal.ts b/packages/jazz-tools/src/tools/internal.ts index 9f979b5130..ef5dcd430a 100644 --- a/packages/jazz-tools/src/tools/internal.ts +++ b/packages/jazz-tools/src/tools/internal.ts @@ -24,7 +24,7 @@ export * from "./coValues/deepLoading.js"; export * from "./implementation/anonymousJazzAgent.js"; export * from "./implementation/activeAccountContext.js"; export * from "./implementation/refs.js"; -export * from "./implementation/schema.js"; +export * from "./implementation/schemaRuntime.js"; export * from "./subscribe/SubscriptionScope.js"; export * from "./subscribe/types.js"; export * from "./subscribe/index.js"; @@ -54,10 +54,12 @@ export * from "./implementation/zodSchema/typeConverters/CoFieldSchemaInit.js"; export * from "./implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.js"; export * from "./implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.js"; export * from "./implementation/zodSchema/schemaPermissions.js"; +export * from "./implementation/zodSchema/validationSettings.js"; export * from "./coValues/extensions/imageDef.js"; export * from "./implementation/ContextManager.js"; export * from "./subscribe/JazzError.js"; +import "./implementation/zodSchema/initializeBuiltinSchemas.js"; import "./implementation/devtoolsFormatters.js"; diff --git a/packages/jazz-tools/src/tools/tests/coDiscriminatedUnion.test.ts b/packages/jazz-tools/src/tools/tests/coDiscriminatedUnion.test.ts index 1e90ddabee..1743988152 100644 --- a/packages/jazz-tools/src/tools/tests/coDiscriminatedUnion.test.ts +++ b/packages/jazz-tools/src/tools/tests/coDiscriminatedUnion.test.ts @@ -9,7 +9,7 @@ import { z, } from "../exports.js"; import { createJazzTestAccount, setupJazzTestSync } from "../testing.js"; -import { assertLoaded, waitFor } from "./utils.js"; +import { assertLoaded, expectValidationError, waitFor } from "./utils.js"; import type { Account } from "jazz-tools"; describe("co.discriminatedUnion", () => { @@ -704,8 +704,6 @@ describe("co.discriminatedUnion", () => { }, }); - console.log(loadedSpecies.$isLoaded); - assertLoaded(loadedSpecies); for (const animal of loadedSpecies) { @@ -739,7 +737,7 @@ describe("co.discriminatedUnion", () => { const Pets = co.list(Pet); expect(() => Pets.create([{ type2: "parrot", name: "Polly" }])).toThrow( - "co.discriminatedUnion() of collaborative types with no matching discriminator value found", + 'Invalid discriminated union option at index "2"', ); }); @@ -780,4 +778,379 @@ describe("co.discriminatedUnion", () => { CoValueLoadingState.UNAUTHORIZED, ); }); + + describe("Validation", () => { + test("should throw when creating with invalid field types", () => { + const Dog = co.map({ + type: z.literal("dog"), + name: z.string(), + age: z.number(), + }); + const Cat = co.map({ + type: z.literal("cat"), + name: z.string(), + weight: z.number(), + }); + const Pet = co.discriminatedUnion("type", [Dog, Cat]); + const Person = co.map({ + pet: Pet, + }); + + expectValidationError(() => + Person.create({ + pet: { + type: "dog", + name: "Rex", + // @ts-expect-error - age should be a number + age: "5", + }, + }), + ); + }); + + test("should throw when using already created CoValues", () => { + const Dog = co.map({ + type: z.literal("dog"), + name: z.string(), + age: z.number(), + }); + const Cat = co.map({ + type: z.literal("cat"), + name: z.string(), + weight: z.number(), + }); + + const Bird = co.map({ + type: z.literal("bird"), + species: z.string(), + }); + const Pet = co.discriminatedUnion("type", [Dog, Cat]); + + const Pets = co.list(Pet); + + const dog = Dog.create({ + type: "dog", + name: "Rex", + age: 5, + }); + + const cat = Cat.create({ + type: "cat", + name: "Whiskers", + weight: 10, + }); + + const bird = Bird.create({ + type: "bird", + species: "Parrot", + }); + + expectValidationError(() => + Pets.create([ + dog, + cat, + // @ts-expect-error - bird is not a valid discriminator value + bird, + ]), + ); + }); + + test("should not throw when creating with invalid field types with loose validation", () => { + const Dog = co.map({ + type: z.literal("dog"), + name: z.string(), + age: z.number(), + }); + const Cat = co.map({ + type: z.literal("cat"), + name: z.string(), + weight: z.number(), + }); + const Pet = co.discriminatedUnion("type", [Dog, Cat]); + const Person = co.map({ + pet: Pet, + }); + + expect(() => + Person.create( + { + pet: { + type: "dog", + name: "Rex", + // @ts-expect-error - age should be a number + age: "5", + }, + }, + { validation: "loose" }, + ), + ).not.toThrow(); + }); + + test("should throw when creating with wrong discriminator value", () => { + const Dog = co.map({ + type: z.literal("dog"), + name: z.string(), + }); + const Cat = co.map({ + type: z.literal("cat"), + name: z.string(), + }); + const Pet = co.discriminatedUnion("type", [Dog, Cat]); + const Person = co.map({ + pet: Pet, + }); + + expectValidationError(() => + Person.create({ + pet: { + // @ts-expect-error - "bird" is not a valid discriminator value + type: "bird", + name: "Tweety", + }, + }), + ); + }); + + test("should throw when mutating with invalid field types", () => { + const Dog = co.map({ + type: z.literal("dog"), + name: z.string(), + age: z.number(), + }); + const Cat = co.map({ + type: z.literal("cat"), + name: z.string(), + weight: z.number(), + }); + const Pet = co.discriminatedUnion("type", [Dog, Cat]); + const Person = co.map({ + pet: Pet, + }); + + const person = Person.create({ + pet: Dog.create({ + type: "dog", + name: "Rex", + age: 5, + }), + }); + + if (person.pet.type === "dog") { + const dog = person.pet; + expectValidationError(() => + dog.$jazz.set( + "age", + // @ts-expect-error - age should be a number + "6", + ), + ); + + expect(dog.age).toEqual(5); + } + }); + + test("should not throw when mutating with invalid field types with loose validation", () => { + const Dog = co.map({ + type: z.literal("dog"), + name: z.string(), + age: z.number(), + }); + const Cat = co.map({ + type: z.literal("cat"), + name: z.string(), + weight: z.number(), + }); + const Pet = co.discriminatedUnion("type", [Dog, Cat]); + const Person = co.map({ + pet: Pet, + }); + + const person = Person.create({ + pet: Dog.create({ + type: "dog", + name: "Rex", + age: 5, + }), + }); + + if (person.pet.type === "dog") { + const dog = person.pet; + expect(() => + dog.$jazz.set( + "age", + // @ts-expect-error - age should be a number + "6", + { validation: "loose" }, + ), + ).not.toThrow(); + + expect(dog.age).toEqual("6"); + } + }); + + test("should throw when mutating to wrong union member type", () => { + const Dog = co.map({ + type: z.literal("dog"), + name: z.string(), + age: z.number(), + }); + const Cat = co.map({ + type: z.literal("cat"), + name: z.string(), + weight: z.number(), + }); + const Pet = co.discriminatedUnion("type", [Dog, Cat]); + const Person = co.map({ + pet: Pet, + }); + + const person = Person.create({ + pet: Dog.create({ + type: "dog", + name: "Rex", + age: 5, + }), + }); + + if (person.pet.type === "dog") { + const dog = person.pet; + // Try to set a field that doesn't exist on Dog (weight is only on Cat) + expectValidationError(() => + // @ts-expect-error - weight doesn't exist on Dog + dog.$jazz.set("weight", 10), + ); + } + }); + + test("should throw when mutating discriminator to invalid value", () => { + const Dog = co.map({ + type: z.literal("dog"), + name: z.string(), + age: z.number(), + }); + const Cat = co.map({ + type: z.literal("cat"), + name: z.string(), + weight: z.number(), + }); + const Pet = co.discriminatedUnion("type", [Dog, Cat]); + const Person = co.map({ + pet: Pet, + }); + + const person = Person.create({ + pet: Dog.create({ + type: "dog", + name: "Rex", + age: 5, + }), + }); + + if (person.pet.type === "dog") { + const dog = person.pet; + expectValidationError(() => + dog.$jazz.set( + "type", + // @ts-expect-error - "bird" is not a valid discriminator value + "bird", + ), + ); + } + }); + + test("loaded discriminated union keeps schema validation", async () => { + const Dog = co.map({ + type: z.literal("dog"), + name: z.string(), + age: z.number(), + }); + const Cat = co.map({ + type: z.literal("cat"), + name: z.string(), + weight: z.number(), + }); + const Pet = co.discriminatedUnion("type", [Dog, Cat]); + const Person = co.map({ + pet: Pet, + }); + + const person = Person.create({ + pet: Dog.create({ + type: "dog", + name: "Rex", + age: 5, + }), + }); + + const loadedPerson = await Person.load(person.$jazz.id); + + assertLoaded(loadedPerson); + assertLoaded(loadedPerson.pet); + if (loadedPerson.pet.type === "dog") { + const dog = loadedPerson.pet; + expectValidationError( + // @ts-expect-error - string is not a number + () => dog.$jazz.set("age", "10"), + ); + } + }); + + test("should throw when creating with missing required field", () => { + const Dog = co.map({ + type: z.literal("dog"), + name: z.string(), + age: z.number(), + }); + const Cat = co.map({ + type: z.literal("cat"), + name: z.string(), + weight: z.number(), + }); + const Pet = co.discriminatedUnion("type", [Dog, Cat]); + const Person = co.map({ + pet: Pet, + }); + + expectValidationError(() => + Person.create({ + pet: { + type: "dog", + name: "Rex", + // age is required but missing + } as any, + }), + ); + }); + + test("should throw when mutating to set missing required field on different union member", () => { + const Dog = co.map({ + type: z.literal("dog"), + name: z.string(), + age: z.number(), + }); + const Cat = co.map({ + type: z.literal("cat"), + name: z.string(), + weight: z.number(), + }); + const Pet = co.discriminatedUnion("type", [Dog, Cat]); + const Person = co.map({ + pet: Pet, + }); + + const person = Person.create({ + pet: Dog.create({ + type: "dog", + name: "Rex", + age: 5, + }), + }); + + if (person.pet.type === "dog") { + const dog = person.pet; + // Try to change type to cat but without providing required weight field + expectValidationError(() => (dog.$jazz.set as any)("type", "cat")); + } + }); + }); }); diff --git a/packages/jazz-tools/src/tools/tests/coFeed.test-d.ts b/packages/jazz-tools/src/tools/tests/coFeed.test-d.ts index 7529bb193c..68fb19aff9 100644 --- a/packages/jazz-tools/src/tools/tests/coFeed.test-d.ts +++ b/packages/jazz-tools/src/tools/tests/coFeed.test-d.ts @@ -126,6 +126,28 @@ describe("CoFeed", () => { matches(feed.perAccount[Account.getMe().$jazz.id]?.value); }); + + test("create options", () => { + const StringFeed = co.feed(z.string()); + + StringFeed.create(["a"]); + StringFeed.create(["a"], Group.create()); + StringFeed.create(["a"], {}); + StringFeed.create(["a"], { owner: Group.create() }); + StringFeed.create(["a"], { owner: Group.create(), unique: "test" }); + + // @ts-expect-error - owner is required if unique is provided + StringFeed.create(["a"], { unique: "test" }); + + // this is deprecated but valid + StringFeed.create(["a"], Account.getMe()); + StringFeed.create(["a"], { owner: Account.getMe(), unique: "test" }); + + StringFeed.create(["a"], { validation: "loose" }); + StringFeed.create(["a"], { owner: Group.create(), validation: "loose" }); + // @ts-expect-error - owner is required if unique is provided + StringFeed.create(["a"], { unique: "test", validation: "loose" }); + }); }); describe("CoFeed resolution", () => { diff --git a/packages/jazz-tools/src/tools/tests/coFeed.test.ts b/packages/jazz-tools/src/tools/tests/coFeed.test.ts index 8e85f3aa5e..2e8ebe363d 100644 --- a/packages/jazz-tools/src/tools/tests/coFeed.test.ts +++ b/packages/jazz-tools/src/tools/tests/coFeed.test.ts @@ -17,7 +17,12 @@ import { z, } from "../index.js"; import { createJazzTestAccount, setupJazzTestSync } from "../testing.js"; -import { assertLoaded, setupTwoNodes, waitFor } from "./utils.js"; +import { + assertLoaded, + expectValidationError, + setupTwoNodes, + waitFor, +} from "./utils.js"; import { CoFeed, CoFeedInstanceCoValuesMaybeLoaded, @@ -25,6 +30,7 @@ import { CoValueLoadingState, TypeSym, } from "../internal.js"; +import { setDefaultValidationMode } from "../implementation/zodSchema/validationSettings.js"; const Crypto = await WasmCrypto.create(); @@ -85,6 +91,25 @@ describe("Simple CoFeed operations", async () => { const feed = Schema.create([""]); expect(feed.perAccount[me.$jazz.id]?.value?.toString()).toBe(""); }); + + test("creation input validation", () => { + const Text = z.string(); + const TextStream = co.feed(Text); + expectValidationError( + // @ts-expect-error - number is not a string + () => TextStream.create([123], { owner: me }), + ); + }); + + test("creation input with loose validation", () => { + const Text = z.string(); + const TextStream = co.feed(Text); + TextStream.create( + // @ts-expect-error - number is not a string + [123], + { owner: me, validation: "loose" }, + ); + }); }); }); @@ -154,6 +179,24 @@ describe("Simple CoFeed operations", async () => { "bread", ); }); + + test("push with validation errors", () => { + const Schema = co.feed(z.string()); + const stream = Schema.create(["milk"]); + expectValidationError( + // @ts-expect-error - number is not a string + () => stream.$jazz.push(123), + ); + }); + + test("push with validation errors with loose validation", () => { + const Schema = co.feed(z.string()); + const stream = Schema.create(["milk"]); + stream.$jazz.pushLoose( + // @ts-expect-error - number is not a string + 123, + ); + }); }); }); @@ -328,6 +371,31 @@ describe("CoFeed resolution", async () => { ?.perAccount[me.$jazz.id]?.value, ).toBe("bread"); }); + + test("Loaded CoFeed keeps validation", async () => { + const { me, stream } = await initNodeAndStream(); + const loadedStream = await TestStream.load(stream.$jazz.id, { + loadAs: me, + }); + assertLoaded(loadedStream); + expectValidationError( + // @ts-expect-error - number is not a string + () => loadedStream.$jazz.push(123), + ); + + const myTopLevelStream = loadedStream.perAccount[me.$jazz.id]; + assert(myTopLevelStream); + assertLoaded(myTopLevelStream.value); + const myNestedStream = myTopLevelStream.value.perAccount[me.$jazz.id]; + assert(myNestedStream); + assertLoaded(myNestedStream.value); + expect(myNestedStream.value.perAccount[me.$jazz.id]?.value).toEqual("milk"); + + expectValidationError( + // @ts-expect-error - number is not a string + () => myNestedStream.value.$jazz.push(123), + ); + }); }); describe("Simple FileStream operations", async () => { @@ -900,3 +968,204 @@ describe("waitForSync", async () => { ); }); }); + +describe("nested CoValue validation mode propagation", () => { + test("create with nested CoValues - loose validation should not throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const DogFeed = co.feed(Dog); + + // Should throw with default strict validation when age is a string + expectValidationError(() => + DogFeed.create([{ age: "12" as unknown as number }], { owner: me }), + ); + + // Should not throw with loose validation even though age is invalid + expect(() => + DogFeed.create([{ age: "12" as unknown as number }], { + owner: me, + validation: "loose", + }), + ).not.toThrow(); + + const feed = DogFeed.create( + [{ age: "12" as unknown as number }, { age: "13" as unknown as number }], + { owner: me, validation: "loose" }, + ); + + // Verify the nested CoValues were created with invalid data + const entries = Array.from(feed.perAccount[me.$jazz.id]?.all || []); + expect(entries.length).toBe(2); + assertLoaded(entries[0]?.value!); + expect(entries[0]?.value?.age).toBe("12"); + assertLoaded(entries[1]?.value!); + expect(entries[1]?.value?.age).toBe("13"); + }); + + test("push with nested CoValue - loose validation should not throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const DogFeed = co.feed(Dog); + + const feed = DogFeed.create([{ age: 5 }], { + owner: me, + validation: "loose", + }); + + // Should throw with default strict validation + expectValidationError(() => + feed.$jazz.push({ + age: "invalid" as unknown as number, + }), + ); + + // Should not throw with loose validation + // Note: CoFeed.push uses the global validation mode from resolveValidationMode() + // which defaults to "warn" in tests, so we need to test by creating a new feed + // with loose validation and pushing invalid data + const looseFeed = DogFeed.create([], { + owner: me, + validation: "loose", + }); + + expect(() => + looseFeed.$jazz.pushLoose({ + age: "12" as unknown as number, + }), + ).not.toThrow(); + + // Verify the nested CoValue was created with invalid data + const entries = Array.from(looseFeed.perAccount[me.$jazz.id]?.all || []); + expect(entries.length).toBe(1); + assertLoaded(entries[0]?.value!); + expect(entries[0]?.value?.age).toBe("12"); + }); + + test("create with deeply nested CoValues - loose validation should not throw", () => { + const Collar = co.map({ + size: z.number(), + }); + const Dog = co.map({ + age: z.number(), + collar: Collar, + }); + const DogFeed = co.feed(Dog); + + // Should throw with strict validation when any nested field is invalid + expectValidationError(() => + DogFeed.create( + [ + { + age: "12" as unknown as number, + collar: { size: 10 }, + }, + ], + { owner: me }, + ), + ); + + expectValidationError(() => + DogFeed.create( + [ + { + age: 12, + collar: { size: "large" as unknown as number }, + }, + ], + { owner: me }, + ), + ); + + // Should not throw with loose validation at any level + expect(() => + DogFeed.create( + [ + { + age: "12" as unknown as number, + collar: { size: "large" as unknown as number }, + }, + ], + { validation: "loose" }, + ), + ).not.toThrow(); + + const feed = DogFeed.create( + [ + { + age: "12" as unknown as number, + collar: { size: "large" as unknown as number }, + }, + ], + { owner: me, validation: "loose" }, + ); + + // Verify all levels were created with invalid data + const entries = Array.from(feed.perAccount[me.$jazz.id]?.all || []); + expect(entries.length).toBe(1); + assertLoaded(entries[0]?.value!); + expect(entries[0]?.value?.age).toBe("12"); + assertLoaded(entries[0]?.value?.collar!); + expect(entries[0]?.value?.collar.size).toBe("large"); + }); + + test("global loose validation mode propagates to nested CoValues in all mutations", () => { + const Collar = co.map({ + size: z.number(), + }); + const Dog = co.map({ + age: z.number(), + collar: Collar, + }); + const DogFeed = co.feed(Dog); + + // Set global validation mode to loose + setDefaultValidationMode("loose"); + + try { + // Test 1: Create with deeply nested invalid data + const feed = DogFeed.create( + [ + { + age: "5" as unknown as number, + collar: { size: "small" as unknown as number }, + }, + { + age: "12" as unknown as number, + collar: { size: "large" as unknown as number }, + }, + ], + { owner: me }, + ); + + // Verify all nested levels were created with invalid data + let entries = Array.from(feed.perAccount[me.$jazz.id]?.all || []); + expect(entries.length).toBe(2); + assertLoaded(entries[0]?.value!); + expect(entries[0]?.value?.age).toBe("5"); + assertLoaded(entries[0]?.value?.collar!); + expect(entries[0]?.value?.collar.size).toBe("small"); + assertLoaded(entries[1]?.value!); + expect(entries[1]?.value?.age).toBe("12"); + assertLoaded(entries[1]?.value?.collar!); + expect(entries[1]?.value?.collar.size).toBe("large"); + + // Test 2: Push with nested invalid data (uses global validation mode) + feed.$jazz.push({ + age: "20" as unknown as number, + collar: { size: "huge" as unknown as number }, + }); + + entries = Array.from(feed.perAccount[me.$jazz.id]?.all || []); + expect(entries.length).toBe(3); + assertLoaded(entries[2]?.value!); + expect(entries[2]?.value?.age).toBe("20"); + assertLoaded(entries[2]?.value?.collar!); + expect(entries[2]?.value?.collar.size).toBe("huge"); + } finally { + // Reset to strict mode + setDefaultValidationMode("strict"); + } + }); +}); diff --git a/packages/jazz-tools/src/tools/tests/coList.test-d.ts b/packages/jazz-tools/src/tools/tests/coList.test-d.ts index 6516996d83..18cd23027e 100644 --- a/packages/jazz-tools/src/tools/tests/coList.test-d.ts +++ b/packages/jazz-tools/src/tools/tests/coList.test-d.ts @@ -178,6 +178,28 @@ describe("CoList", () => { matches(list); }); + + test("create options", () => { + const StringList = co.list(z.string()); + + StringList.create(["a"]); + StringList.create(["a"], Group.create()); + StringList.create(["a"], {}); + StringList.create(["a"], { owner: Group.create() }); + StringList.create(["a"], { owner: Group.create(), unique: "test" }); + + // @ts-expect-error - owner is required if unique is provided + StringList.create(["a"], { unique: "test" }); + + // this is deprecated but valid + StringList.create(["a"], Account.getMe()); + StringList.create(["a"], { owner: Account.getMe(), unique: "test" }); + + StringList.create(["a"], { validation: "loose" }); + StringList.create(["a"], { owner: Group.create(), validation: "loose" }); + // @ts-expect-error - owner is required if unique is provided + StringList.create(["a"], { unique: "test", validation: "loose" }); + }); }); describe("CoList resolution", () => { diff --git a/packages/jazz-tools/src/tools/tests/coList.test.ts b/packages/jazz-tools/src/tools/tests/coList.test.ts index 634fa13873..bb4ffbc9b3 100644 --- a/packages/jazz-tools/src/tools/tests/coList.test.ts +++ b/packages/jazz-tools/src/tools/tests/coList.test.ts @@ -13,7 +13,13 @@ import { runWithoutActiveAccount, setupJazzTestSync, } from "../testing.js"; -import { assertLoaded, setupTwoNodes, waitFor } from "./utils.js"; +import { + assertLoaded, + expectValidationError, + setupTwoNodes, + waitFor, +} from "./utils.js"; +import { setDefaultValidationMode } from "../implementation/zodSchema/validationSettings.js"; const Crypto = await WasmCrypto.create(); @@ -109,6 +115,47 @@ describe("Simple CoList operations", async () => { }); }); + test("create CoList with validation errors", () => { + const List = co.list(z.string()); + expect( + // @ts-expect-error - number is not a string + () => List.create([2]), + ).toThrow(); + }); + + test("create CoList with validation errors with loose validation", () => { + const List = co.list(z.string()); + expect( + // @ts-expect-error - number is not a string + () => List.create([2], { validation: "loose" }), + ).not.toThrow(); + }); + + test("CoList's items keep schema validation", () => { + const Person = co.map({ + name: z.string(), + }); + const List = co.list(Person); + + expectValidationError( + // @ts-expect-error - number is not a string + () => List.create([{ name: 2 }]), + ); + + const list = List.create([{ name: "John" }]); + expect(list[0]?.name).toBe("John"); + + expectValidationError( + // @ts-expect-error - number is not a string + () => list[0]?.$jazz.set("name", 2), + [ + expect.objectContaining({ + message: "Invalid input: expected string, received number", + }), + ], + ); + }); + test("list with nullable content", () => { const List = co.list(z.string().nullable()); const list = List.create(["a", "b", "c", null]); @@ -151,6 +198,60 @@ describe("Simple CoList operations", async () => { expect(coList).toEqual(list); }); + test("loaded CoList keeps schema validation", async () => { + const Person = co.map({ + name: z.string(), + }); + const List = co.list(Person); + const list = List.create([{ name: "John" }]); + + const loadedList = await List.load(list.$jazz.id, { + resolve: { $each: true }, + }); + assertLoaded(loadedList); + expect(loadedList[0]?.name).toBe("John"); + + expectValidationError( + // @ts-expect-error - number is not a string + () => loadedList[0]?.$jazz.set("name", 2), + [ + expect.objectContaining({ + message: "Invalid input: expected string, received number", + }), + ], + ); + }); + + test("loaded CoList keeps schema validation", async () => { + const Map = co.map({ + list: co.list( + co.map({ + name: z.string(), + }), + ), + }); + + const map = Map.create({ + list: [{ name: "John" }, { name: "Jane" }], + }); + + const loadedMap = await Map.load(map.$jazz.id, { + resolve: { list: { $each: true } }, + }); + assertLoaded(loadedMap); + expect(loadedMap.list).toEqual([{ name: "John" }, { name: "Jane" }]); + + expectValidationError( + // @ts-expect-error - number is not a person + () => loadedMap.list.$jazz.push(2), + ); + + expectValidationError( + // @ts-expect-error - number is not a string + () => loadedMap.list[0]?.$jazz.set("name", 2), + ); + }); + describe("Mutation", () => { test("assignment", () => { const list = TestList.create(["bread", "butter", "onion"], { @@ -161,6 +262,37 @@ describe("Simple CoList operations", async () => { expect(list[1]).toBe("margarine"); }); + test("assignment with validation errors", () => { + const list = TestList.create(["bread", "butter", "onion"], { + owner: me, + }); + + expectValidationError( + // @ts-expect-error - number is not a string + () => list.$jazz.set(1, 2), + [ + expect.objectContaining({ + message: "Invalid input: expected string, received number", + }), + ], + ); + }); + + test("assignment with validation errors with loose validation", () => { + const list = TestList.create(["bread", "butter", "onion"], { + owner: me, + }); + + list.$jazz.set( + 1, + // @ts-expect-error - number is not a string + 2, + { validation: "loose" }, + ); + + expect(list[1]).toBe(2); + }); + test("assignment with ref using CoValue", () => { const Ingredient = co.map({ name: z.string(), @@ -200,7 +332,9 @@ describe("Simple CoList operations", async () => { ); expect(() => { - recipe.$jazz.set(1, undefined as unknown as Loaded); + recipe.$jazz.set(1, undefined as unknown as Loaded, { + validation: "loose", + }); }).toThrow("Cannot set required reference 1 to undefined"); expect(recipe[1]?.name).toBe("butter"); @@ -259,6 +393,42 @@ describe("Simple CoList operations", async () => { ]); }); + test("push with validation errors", () => { + const list = TestList.create(["bread", "butter", "onion"], { + owner: me, + }); + expectValidationError( + // @ts-expect-error - number is not a string + () => list.$jazz.push(2), + [ + expect.objectContaining({ + message: "Invalid input: expected string, received number", + }), + ], + ); + + expectValidationError( + // @ts-expect-error - number is not a string + () => list.$jazz.push("test", 2), + ); + + expect(list).toEqual(["bread", "butter", "onion"]); + }); + + test("push with validation errors with loose validation", () => { + const list = TestList.create(["bread", "butter", "onion"], { + owner: me, + }); + + // @ts-expect-error - number is not a string + list.$jazz.pushLoose(2); + + // @ts-expect-error - number is not a string + list.$jazz.pushLoose("test", 2); + + expect(list).toEqual(["bread", "butter", "onion", 2, "test", 2]); + }); + test("push CoValue into list of CoValues", () => { const Schema = co.list(co.plainText()); const list = Schema.create(["bread", "butter", "onion"]); @@ -322,6 +492,42 @@ describe("Simple CoList operations", async () => { list.$jazz.unshift("lettuce"); expect(list[0]?.toString()).toBe("lettuce"); }); + + test("unshift with validation errors", () => { + const list = TestList.create(["bread", "butter", "onion"], { + owner: me, + }); + expectValidationError( + // @ts-expect-error - number is not a string + () => list.$jazz.unshift(2), + [ + expect.objectContaining({ + message: "Invalid input: expected string, received number", + }), + ], + ); + + expectValidationError( + // @ts-expect-error - number is not a string + () => list.$jazz.unshift("test", 2), + ); + + expect(list).toEqual(["bread", "butter", "onion"]); + }); + + test("unshift with validation errors with loose validation", () => { + const list = TestList.create(["bread", "butter", "onion"], { + owner: me, + }); + + // @ts-expect-error - number is not a string + list.$jazz.unshiftLoose(2); + + // @ts-expect-error - number is not a string + list.$jazz.unshiftLoose("test", 2); + + expect(list).toEqual([2, "test", 2, "bread", "butter", "onion"]); + }); }); test("pop", () => { @@ -415,6 +621,53 @@ describe("Simple CoList operations", async () => { list.$jazz.splice(1, 0, "lettuce"); expect(list[1]?.toString()).toBe("lettuce"); }); + + test("splice with validation errors", () => { + const list = TestList.create(["bread", "butter", "onion"], { + owner: me, + }); + expectValidationError( + // @ts-expect-error - number is not a string + () => list.$jazz.splice(1, 0, 2), + [ + expect.objectContaining({ + message: "Invalid input: expected string, received number", + }), + ], + ); + + expectValidationError( + // @ts-expect-error - number is not a string + () => list.$jazz.splice(0, 1, "test", 2), + ); + + expect(list).toEqual(["bread", "butter", "onion"]); + }); + + test("spliceLoose removes and returns deleted items", () => { + const list = TestList.create(["bread", "butter", "onion"], { + owner: me, + }); + + const deleted = list.$jazz.spliceLoose(1, 1); + + expect(deleted).toEqual(["butter"]); + expect(list.$jazz.raw.asArray()).toEqual(["bread", "onion"]); + }); + + test("spliceLoose with validation errors with loose validation", () => { + const list = TestList.create(["bread", "butter", "onion"], { + owner: me, + }); + + // @ts-expect-error - number is not a string + list.$jazz.spliceLoose(1, 0, 2); + + // @ts-expect-error - number is not a string + list.$jazz.spliceLoose(0, 1, "test", 2); + + expect(list).toEqual(["test", 2, 2, "butter", "onion"]); + }); }); describe("remove", () => { @@ -597,7 +850,7 @@ describe("CoList applyDiff operations", async () => { list.$jazz.applyDiff([row1, row2, winningRow]); expect(list.length).toBe(3); expect(list[2]?.toJSON()).toEqual(["O", "O", "X"]); - // Only elements with different $jazz.id are replaced + // elements with different $jazz.id are replaced expect(list[0]?.$jazz.id).toBe(row1?.$jazz.id); expect(list[1]?.$jazz.id).toBe(row2?.$jazz.id); expect(list[2]?.$jazz.id).not.toBe(row3?.$jazz.id); @@ -691,6 +944,79 @@ describe("CoList applyDiff operations", async () => { expect(list.$jazz.raw.asArray()).toEqual(["e", "c", "new", "y", "x"]); }); + test("applyDiff respects schema validation in strict mode", () => { + const Person = co.map({ + name: z.string(), + }); + const PersonList = co.list(Person); + + const list = PersonList.create([{ name: "John" }], { owner: me }); + + expect(list.length).toBe(1); + + expectValidationError(() => + list.$jazz.applyDiff([ + { name: "John" }, + { name: 123 as unknown as string }, + ]), + ); + + // The list should remain unchanged after failed validation + expect(list.length).toBe(1); + expect(list[0]?.name).toBe("John"); + }); + + test("applyDiff respects local loose validation mode", () => { + const Person = co.map({ + name: z.string(), + }); + const PersonList = co.list(Person); + + const list = PersonList.create([{ name: "John" }], { owner: me }); + + list.$jazz.applyDiff( + [ + { name: "John" }, + { + name: 123 as unknown as string, + }, + ], + { validation: "loose" }, + ); + + // Invalid data is accepted when validation is globally loose + expect(list.length).toBe(2); + expect(list[0]?.name).toBe("John"); + expect(list[1]?.name).toBe(123 as unknown as string); + }); + + test("applyDiff respects global loose validation mode", () => { + const Person = co.map({ + name: z.string(), + }); + const PersonList = co.list(Person); + + const list = PersonList.create([{ name: "John" }], { owner: me }); + + setDefaultValidationMode("loose"); + + try { + list.$jazz.applyDiff([ + { name: "John" }, + { + name: 123 as unknown as string, + }, + ]); + + // Invalid data is accepted when validation is globally loose + expect(list.length).toBe(2); + expect(list[0]?.name).toBe("John"); + expect(list[1]?.name).toBe(123 as unknown as string); + } finally { + setDefaultValidationMode("strict"); + } + }); + test("applyDiff should emit a single update", () => { const TestMap = co.map({ type: z.string(), @@ -1142,7 +1468,7 @@ describe("CoList subscription", async () => { (update) => { spy(update); - // The update should be triggered only when the new item is loaded + // The update should be triggered when the new item is loaded for (const item of update) { expect(item).toBeDefined(); } @@ -1496,3 +1822,265 @@ describe("CoList proxy traps", () => { expect(list[2]).toBe("c"); }); }); + +describe("CoList proxy traps", () => { + test(".values() returns the same values as Object.values()", () => { + const TestList = co.list(z.string()); + const list = TestList.create([]); + list.$jazz.push("bread"); + list.$jazz.push("butter"); + list.$jazz.push("onion"); + + const valuesFromMethod = [...list.values()]; + const valuesFromObject = Object.values(list); + expect(valuesFromMethod).toEqual(valuesFromObject); + expect(valuesFromMethod).toEqual(["bread", "butter", "onion"]); + }); + + test(".values().map() returns the same values as .map()", () => { + const TestList = co.list(z.string()); + const list = TestList.create([]); + list.$jazz.push("bread"); + list.$jazz.push("butter"); + list.$jazz.push("onion"); + + const valuesFromMethod = [...list.values().map((v) => v.toUpperCase())]; + const valuesFromObject = list.map((v) => v.toUpperCase()); + + expect(valuesFromMethod).toEqual(valuesFromObject); + expect(valuesFromMethod).toEqual(["BREAD", "BUTTER", "ONION"]); + }); + + test(".keys() returns numeric indices", () => { + const TestList = co.list(z.string()); + const list = TestList.create(["bread", "butter", "onion"]); + + const keys = [...list.keys()]; + + expect(keys).toEqual([0, 1, 2]); + }); + + test(".entries() returns index-value pairs", () => { + const TestList = co.list(z.string()); + const list = TestList.create(["bread", "butter", "onion"]); + + const entries = [...list.entries()]; + + expect(entries).toEqual([ + [0, "bread"], + [1, "butter"], + [2, "onion"], + ]); + }); + + test("for...of iteration works correctly", () => { + const TestList = co.list(z.string()); + const list = TestList.create(["bread", "butter", "onion"]); + + const items: string[] = []; + for (const item of list) { + items.push(item); + } + + expect(items).toEqual(["bread", "butter", "onion"]); + }); + + test("spread operator works correctly", () => { + const TestList = co.list(z.string()); + const list = TestList.create(["bread", "butter", "onion"]); + + const items = [...list]; + + expect(items).toEqual(["bread", "butter", "onion"]); + }); + + test("Array.from works correctly", () => { + const TestList = co.list(z.string()); + const list = TestList.create(["bread", "butter", "onion"]); + + const items = Array.from(list); + + expect(items).toEqual(["bread", "butter", "onion"]); + }); + + test(".values() works with CoValue references", () => { + const Dog = co.map({ + name: z.string(), + }); + const DogList = co.list(Dog); + + const list = DogList.create([ + { name: "Rex" }, + { name: "Fido" }, + { name: "Buddy" }, + ]); + + const valuesFromMethod = [...list.values()]; + const valuesFromObject = Object.values(list); + + expect(valuesFromMethod.length).toBe(3); + expect(valuesFromMethod.map((d) => d?.name)).toEqual([ + "Rex", + "Fido", + "Buddy", + ]); + expect(valuesFromMethod).toEqual(valuesFromObject); + }); + + test(".values() works after ensureLoaded", async () => { + const Task = co.map({ + title: z.string(), + }); + const TaskList = co.list(Task); + + const list = TaskList.create([ + { title: "Task 1" }, + { title: "Task 2" }, + { title: "Task 3" }, + ]); + + const loadedList = await list.$jazz.ensureLoaded({ + resolve: { $each: true }, + }); + + const valuesFromMethod = [...loadedList.values()]; + const valuesFromObject = Object.values(loadedList); + + expect(valuesFromMethod.length).toBe(3); + expect(valuesFromMethod.map((t) => t.title)).toEqual([ + "Task 1", + "Task 2", + "Task 3", + ]); + expect(valuesFromMethod.map((t) => t.$jazz.id)).toEqual( + valuesFromObject.map((t) => t.$jazz.id), + ); + }); + + test(".values() works on remotely loaded list", async () => { + const Task = co.map({ + title: z.string(), + }); + const TaskList = co.list(Task); + + const group = Group.create(); + group.addMember("everyone", "writer"); + + const list = TaskList.create( + [{ title: "Task 1" }, { title: "Task 2" }, { title: "Task 3" }], + group, + ); + + const userB = await createJazzTestAccount(); + + const loadedList = await TaskList.load(list.$jazz.id, { + resolve: { $each: true }, + loadAs: userB, + }); + + assertLoaded(loadedList); + + const valuesFromMethod = [...loadedList.values()]; + const valuesFromObject = Object.values(loadedList); + + expect(valuesFromMethod.length).toBe(3); + expect(valuesFromMethod.map((t) => t.title)).toEqual([ + "Task 1", + "Task 2", + "Task 3", + ]); + expect(valuesFromMethod.map((t) => t.$jazz.id)).toEqual( + valuesFromObject.map((t) => t.$jazz.id), + ); + }); + + test("iterator methods work on empty list", () => { + const TestList = co.list(z.string()); + const list = TestList.create([]); + + expect([...list.values()]).toEqual([]); + expect([...list.keys()]).toEqual([]); + expect([...list.entries()]).toEqual([]); + expect([...list]).toEqual([]); + }); + + test("Object.getOwnPropertyDescriptors returns correct descriptors", () => { + const TestList = co.list(z.string()); + const list = TestList.create(["a", "b", "c"]); + + const descriptors = Object.getOwnPropertyDescriptors(list); + + // Check numeric index descriptors + expect(descriptors["0"]).toEqual({ + enumerable: true, + configurable: true, + writable: false, + value: "a", + }); + expect(descriptors["1"]).toEqual({ + enumerable: true, + configurable: true, + writable: false, + value: "b", + }); + expect(descriptors["2"]).toEqual({ + enumerable: true, + configurable: true, + writable: false, + value: "c", + }); + + // Check length descriptor + expect(descriptors["length"]).toEqual({ + enumerable: false, + configurable: false, + writable: true, + value: 3, + }); + + // Verify only expected enumerable keys + const enumerableKeys = Object.keys(descriptors).filter( + (key) => descriptors[key]?.enumerable, + ); + expect(enumerableKeys.sort()).toEqual(["0", "1", "2"]); + }); + + test("Object.getOwnPropertyDescriptors returns the resolved references", () => { + const TestList = co.list(co.map({ name: z.string() })); + const list = TestList.create([{ name: "a" }, { name: "b" }, { name: "c" }]); + + const descriptors = Object.getOwnPropertyDescriptors(list); + + // Check numeric index descriptors + expect(descriptors["0"]).toEqual({ + enumerable: true, + configurable: true, + writable: false, + value: list[0], + }); + expect(descriptors["1"]).toEqual({ + enumerable: true, + configurable: true, + writable: false, + value: list[1], + }); + expect(descriptors["2"]).toEqual({ + enumerable: true, + configurable: true, + writable: false, + value: list[2], + }); + }); + + test("setting the lenght to 0 has no effect", () => { + const TestList = co.list(z.string()); + const list = TestList.create(["a", "b", "c"]); + + list.length = 0; + + expect(list.length).toBe(3); + expect(list[0]).toBe("a"); + expect(list[1]).toBe("b"); + expect(list[2]).toBe("c"); + }); +}); diff --git a/packages/jazz-tools/src/tools/tests/coMap.record.test.ts b/packages/jazz-tools/src/tools/tests/coMap.record.test.ts index 7126132068..f12769dfb4 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.record.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.record.test.ts @@ -12,8 +12,9 @@ import { FileStream, Group, co, z } from "../exports.js"; import { Loaded } from "../implementation/zodSchema/zodSchema.js"; import { Account } from "../index.js"; import { createJazzTestAccount, setupJazzTestSync } from "../testing.js"; -import { assertLoaded, waitFor } from "./utils.js"; +import { assertLoaded, expectValidationError, waitFor } from "./utils.js"; import { CoValueLoadingState, TypeSym } from "../internal.js"; +import { setDefaultValidationMode } from "../implementation/zodSchema/validationSettings.js"; const Crypto = await WasmCrypto.create(); @@ -384,6 +385,234 @@ describe("CoMap.Record", async () => { }); }); + describe("nested CoValue validation mode propagation", () => { + test("create with nested CoValue - loose validation should not throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const PersonRecord = co.record(z.string(), Dog); + + // Should throw with default strict validation when age is a string + expectValidationError(() => + PersonRecord.create({ + john: { age: "12" as unknown as number }, + }), + ); + + // Should not throw with loose validation even though age is invalid + expect(() => + PersonRecord.create( + { + john: { age: "12" as unknown as number }, + }, + { validation: "loose" }, + ), + ).not.toThrow(); + + const personRecord = PersonRecord.create( + { + john: { age: "12" as unknown as number }, + }, + { validation: "loose" }, + ); + + // Verify the nested CoValue was created with invalid data + expect(personRecord.john).toBeDefined(); + expect(personRecord.john?.age).toBe("12"); + }); + + test("set with nested CoValue - loose validation should not throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const PersonRecord = co.record(z.string(), Dog); + + const personRecord = PersonRecord.create({ + john: { age: 5 }, + }); + + // Should throw with default strict validation + expectValidationError(() => + personRecord.$jazz.set("john", { + age: "invalid" as unknown as number, + }), + ); + + // Should not throw with loose validation + expect(() => + personRecord.$jazz.set( + "john", + { + age: "invalid" as unknown as number, + }, + { validation: "loose" }, + ), + ).not.toThrow(); + + // Verify the nested CoValue was created with invalid data + expect(personRecord.john?.age).toBe("invalid"); + }); + + test("applyDiff with nested CoValue - loose validation should not throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const PersonRecord = co.record(z.string(), Dog); + + const personRecord = PersonRecord.create({ + john: { age: 5 }, + }); + + // Should throw with default strict validation + expectValidationError(() => + personRecord.$jazz.applyDiff({ + john: { age: "string" as unknown as number }, + }), + ); + + // Should not throw with loose validation + expect(() => + personRecord.$jazz.applyDiff( + { + john: { age: "string" as unknown as number }, + }, + { validation: "loose" }, + ), + ).not.toThrow(); + + // Verify the nested CoValue was updated with invalid data + expect(personRecord.john?.age).toBe("string"); + }); + + test("create with deeply nested CoValues - loose validation should not throw", () => { + const Collar = co.map({ + size: z.number(), + }); + const Dog = co.map({ + age: z.number(), + collar: Collar, + }); + const PersonRecord = co.record(z.string(), Dog); + + // Should throw with strict validation when any nested field is invalid + expectValidationError(() => + PersonRecord.create({ + john: { + age: "12" as unknown as number, + collar: { size: 10 }, + }, + }), + ); + + expectValidationError(() => + PersonRecord.create({ + john: { + age: 12, + // @ts-expect-error - size should be number + collar: { size: "large" }, + }, + }), + ); + + // Should not throw with loose validation at any level + expect(() => + PersonRecord.create( + { + john: { + age: "12" as unknown as number, + collar: { size: "large" as unknown as number }, + }, + }, + { validation: "loose" }, + ), + ).not.toThrow(); + + const personRecord = PersonRecord.create( + { + john: { + age: "12" as unknown as number, + collar: { size: "large" as unknown as number }, + }, + }, + { validation: "loose" }, + ); + + // Verify all levels were created with invalid data + expect(personRecord.john).toBeDefined(); + expect(personRecord.john?.age).toBe("12"); + expect(personRecord.john?.collar.size).toBe("large"); + }); + + test("create with nested CoValue - strict validation explicitly set should throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const PersonRecord = co.record(z.string(), Dog); + + // Explicitly setting validation to strict should throw + expectValidationError(() => + PersonRecord.create( + { + john: { age: "12" as unknown as number }, + }, + { validation: "strict" }, + ), + ); + }); + + test("global loose validation mode propagates to nested CoValues in all mutations", () => { + const Collar = co.map({ + size: z.number(), + }); + const Dog = co.map({ + age: z.number(), + collar: Collar, + }); + const PersonRecord = co.record(z.string(), Dog); + + // Set global validation mode to loose + setDefaultValidationMode("loose"); + + try { + // Test 1: Create with deeply nested invalid data + const personRecord = PersonRecord.create({ + john: { + age: "12" as unknown as number, + collar: { size: "large" as unknown as number }, + }, + }); + + // Verify all nested levels were created with invalid data + expect(personRecord.john).toBeDefined(); + expect(personRecord.john?.age).toBe("12"); + expect(personRecord.john?.collar.size).toBe("large"); + + // Test 2: Set with nested invalid data + personRecord.$jazz.set("john", { + age: "15" as unknown as number, + collar: { size: "medium" as unknown as number }, + }); + + expect(personRecord.john?.age).toBe("15"); + expect(personRecord.john?.collar.size).toBe("medium"); + + // Test 3: ApplyDiff with nested invalid data + personRecord.$jazz.applyDiff({ + john: { + age: "20" as unknown as number, + collar: { size: "small" as unknown as number }, + }, + }); + + expect(personRecord.john?.age).toBe("20"); + expect(personRecord.john?.collar.size).toBe("small"); + } finally { + // Reset to strict mode + setDefaultValidationMode("strict"); + } + }); + }); + describe("Record Typescript validation", async () => { const me = await Account.create({ creationProps: { name: "Hermes Puggington" }, diff --git a/packages/jazz-tools/src/tools/tests/coMap.test-d.ts b/packages/jazz-tools/src/tools/tests/coMap.test-d.ts index 50b3a7d552..848ddb3798 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.test-d.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.test-d.ts @@ -274,6 +274,39 @@ describe("CoMap", async () => { // @ts-expect-error - x is not a valid property Person.create({ name: "John", age: 30, xtra: 1 }); }); + + test("create options", () => { + const Person = co.map({ + name: z.string(), + }); + + Person.create({ name: "John" }); + Person.create({ name: "John" }, Group.create()); + Person.create({ name: "John" }, {}); + Person.create({ name: "John" }, { owner: Group.create() }); + Person.create( + { name: "John" }, + { owner: Group.create(), unique: "test" }, + ); + + // @ts-expect-error - owner is required if unique is provided + Person.create({ name: "John" }, { unique: "test" }); + + // this is deprecated but valid + Person.create({ name: "John" }, Account.getMe()); + Person.create( + { name: "John" }, + { owner: Account.getMe(), unique: "test" }, + ); + + Person.create({ name: "John" }, { validation: "loose" }); + Person.create( + { name: "John" }, + { owner: Group.create(), validation: "loose" }, + ); + // @ts-expect-error - owner is required if unique is provided + Person.create({ name: "John" }, { unique: "test", validation: "loose" }); + }); }); describe("Mutation", () => { diff --git a/packages/jazz-tools/src/tools/tests/coMap.test.ts b/packages/jazz-tools/src/tools/tests/coMap.test.ts index a28da52324..107a0c3215 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.test.ts @@ -29,7 +29,13 @@ import { setActiveAccount, setupJazzTestSync, } from "../testing.js"; -import { assertLoaded, setupTwoNodes, waitFor } from "./utils.js"; +import { + assertLoaded, + expectValidationError, + setupTwoNodes, + waitFor, +} from "./utils.js"; +import { setDefaultValidationMode } from "../implementation/zodSchema/validationSettings.js"; const Crypto = await WasmCrypto.create(); @@ -173,12 +179,15 @@ describe("CoMap", async () => { const dog = Dog.create({ name: "Rex" }); - const person = Person.create({ - name: "John", - age: 20, - // @ts-expect-error - This is an hack to test the behavior - dog: { $jazz: { id: dog.$jazz.id } }, - }); + const person = Person.create( + { + name: "John", + age: 20, + // @ts-expect-error - This is an hack to test the behavior + dog: { $jazz: { id: dog.$jazz.id } }, + }, + { validation: "loose" }, + ); expect(person.dog?.name).toEqual("Rex"); }); @@ -476,8 +485,11 @@ describe("CoMap", async () => { age: z.number(), }); - // @ts-expect-error - x is not a valid property - const john = Person.create({ name: "John", age: 30, x: 1 }); + const john = Person.create( + // @ts-expect-error - x is not a valid property + { name: "John", age: 30, x: 1 }, + { validation: "loose" }, + ); expect(john.toJSON()).toEqual({ $jazz: { id: john.$jazz.id }, @@ -553,6 +565,114 @@ describe("CoMap", async () => { expect(personB.friend?.pet.name).toEqual("Rex"); }); + + it("should throw when creating with invalid properties", () => { + const Person = co.map({ + name: z.string(), + age: z.number(), + }); + + expectValidationError(() => + Person.create({ + name: "John", + // @ts-expect-error - age should be a number + age: "20", + }), + ); + }); + + it("should not throw when creating with invalid properties with loose validation", () => { + const Person = co.map({ + name: z.string(), + age: z.number(), + }); + + expect(() => + Person.create( + { + name: "John", + // @ts-expect-error - age should be a number + age: "20", + }, + { validation: "loose" }, + ), + ).not.toThrow(); + }); + + it("should throw when creating with extra properties", () => { + const Person = co.map({ + name: z.string(), + age: z.number(), + }); + + expectValidationError(() => + Person.create({ + name: "John", + age: 20, + // @ts-expect-error - extra is not a valid property + extra: "extra", + }), + ); + }); + + it("should validate Group schemas", async () => { + const Person = co.map({ + group: co.group(), + group2: Group, + }); + + expect(() => + Person.create({ group: Group.create(), group2: Group.create() }), + ).not.toThrow(); + expect(() => + // @ts-expect-error - group should be a Group + Person.create({ group: "Test", group2: Group.create() }), + ).toThrow(); + expect(() => + // @ts-expect-error - group should be a Group + Person.create({ group: Group.create(), group2: "Test" }), + ).toThrow(); + }); + + // .default() is not supported yet + it.fails("should use zod defaults for plain items", async () => { + const Person = co.map({ + name: z.string().default("John"), + age: z.number().default(20), + }); + + // @ts-expect-error - name and age are required but have defaults + const person = Person.create({}); + expect(person.name).toEqual("John"); + expect(person.age).toEqual(20); + }); + + test("CoMap validation should never validate a coValue instance as a plain object", () => { + const Dog = co.list(z.string()); + + const Person = co.map({ + pet: co.map({ + name: z.string(), + }), + }); + + const dog = Dog.create(["Rex"]); + + expectValidationError( + () => + Person.create({ + // @ts-expect-error - pet should be a CoMap + pet: dog, + }), + [ + { + code: "custom", + message: "Expected a CoMap when providing a CoValue instance", + path: ["pet"], + }, + ], + ); + }); }); describe("Mutation", () => { @@ -570,6 +690,41 @@ describe("CoMap", async () => { expect(john.age).toEqual(20); }); + test("change a primitive value should be validated", () => { + const Person = co.map({ + name: z.string(), + age: z.number(), + }); + + const john = Person.create({ name: "John", age: 20 }); + + expectValidationError(() => + john.$jazz.set("age", "21" as unknown as number), + ); + + expect(john.age).toEqual(20); + }); + + test("change a primitive value should not throw if validation is loose", () => { + const Person = co.map({ + name: z.string(), + age: z.number(), + }); + + const john = Person.create({ name: "John", age: 20 }); + + expect(() => + john.$jazz.set( + "age", + // @ts-expect-error - age should be a number + "21", + { validation: "loose" }, + ), + ).not.toThrow(); + + expect(john.age).toEqual("21"); + }); + test("delete an optional value by setting it to undefined", () => { const Person = co.map({ name: z.string(), @@ -1666,6 +1821,43 @@ describe("CoMap resolution", async () => { expect(spy).toHaveBeenCalledTimes(2); }); + + test("loading a locally available map with invalid data", async () => { + const Person1 = co.map({ + name: z.string(), + age: z.number(), + }); + + const Person2 = co.map({ + name: z.string(), + age: z.string(), + }); + + const person1 = Person1.create({ name: "John", age: 20 }); + person1.$jazz.waitForSync(); + + const person2 = await Person2.load(person1.$jazz.id); + + assertLoaded(person2); + expect(person2.age).toStrictEqual(20); + }); + + test("loaded CoMap keeps schema validation", async () => { + const Person = co.map({ + name: z.string(), + age: z.number(), + }); + + const person1 = Person.create({ name: "John", age: 20 }); + // person1.$jazz.waitForSync(); + + const person2 = await Person.load(person1.$jazz.id); + + assertLoaded(person2); + expectValidationError(() => + person2.$jazz.set("age", "20" as unknown as number), + ); + }); }); describe("CoMap applyDiff", async () => { @@ -1715,6 +1907,56 @@ describe("CoMap applyDiff", async () => { expect(map.nested?.value).toEqual("original"); }); + test("Basic applyDiff should validate", () => { + const map = TestMap.create( + { + name: "Alice", + age: 30, + isActive: true, + birthday: new Date("1990-01-01"), + nested: NestedMap.create({ value: "original" }, { owner: me }), + }, + { owner: me }, + ); + + const newValues = { + age: "35", + }; + + // @ts-expect-error - age should be a number + expect(() => map.$jazz.applyDiff(newValues)).toThrow(); + + expect(map.name).toEqual("Alice"); + expect(map.age).toEqual(30); + expect(map.isActive).toEqual(true); + expect(map.birthday).toEqual(new Date("1990-01-01")); + expect(map.nested?.value).toEqual("original"); + }); + + test("Basic applyDiff should not validate if validation is loose", () => { + const map = TestMap.create( + { + name: "Alice", + age: 30, + isActive: true, + birthday: new Date("1990-01-01"), + nested: NestedMap.create({ value: "original" }, { owner: me }), + }, + { owner: me }, + ); + + const newValues = { + age: "35", + }; + + expect(() => + // @ts-expect-error - age should be a number + map.$jazz.applyDiff(newValues, { validation: "loose" }), + ).not.toThrow(); + + expect(map.age).toEqual("35"); + }); + test("applyDiff with nested changes", () => { const originalNestedMap = NestedMap.create( { value: "original" }, @@ -1871,9 +2113,7 @@ describe("CoMap applyDiff", async () => { nested: undefined, }; - expect(() => map.$jazz.applyDiff(newValues)).toThrowError( - "Cannot set required reference nested to undefined", - ); + expect(() => map.$jazz.applyDiff(newValues)).toThrow(); }); test("applyDiff from JSON", () => { @@ -2842,3 +3082,261 @@ describe("Updating a nested reference", () => { expect(loadedGame.player1.playSelection.value).toEqual("scissors"); }); }); + +describe("nested CoValue validation mode propagation", () => { + test("create with nested CoValue - loose validation should not throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const Person = co.map({ + name: z.string(), + dog: Dog, + }); + + // Should throw with default strict validation when age is a string + expectValidationError(() => + Person.create({ + name: "john", + dog: { age: "12" as unknown as number }, + }), + ); + + // Should not throw with loose validation even though age is invalid + expect(() => + Person.create( + { + name: "john", + dog: { age: "12" as unknown as number }, + }, + { validation: "loose" }, + ), + ).not.toThrow(); + + const person = Person.create( + { + name: "john", + dog: { age: "12" as unknown as number }, + }, + { validation: "loose" }, + ); + + // Verify the nested CoValue was created with invalid data + expect(person.name).toBe("john"); + expect(person.dog).toBeDefined(); + expect(person.dog.age).toBe("12"); + }); + + test("set with nested CoValue - loose validation should not throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const Person = co.map({ + name: z.string(), + dog: Dog, + }); + + const person = Person.create({ + name: "john", + dog: { age: 5 }, + }); + + // Should throw with default strict validation + expectValidationError(() => + person.$jazz.set("dog", { + age: "invalid" as unknown as number, + }), + ); + + // Should not throw with loose validation + expect(() => + person.$jazz.set( + "dog", + { + age: "invalid" as unknown as number, + }, + { validation: "loose" }, + ), + ).not.toThrow(); + + // Verify the nested CoValue was created with invalid data + expect(person.dog.age).toBe("invalid"); + }); + + test("applyDiff with nested CoValue - loose validation should not throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const Person = co.map({ + name: z.string(), + dog: Dog, + }); + + const person = Person.create({ + name: "john", + dog: { age: 5 }, + }); + + // Should throw with default strict validation + expectValidationError(() => + person.$jazz.applyDiff({ + dog: { age: "string" as unknown as number }, + }), + ); + + // Should not throw with loose validation + expect(() => + person.$jazz.applyDiff( + { + dog: { age: "string" as unknown as number }, + }, + { validation: "loose" }, + ), + ).not.toThrow(); + + // Verify the nested CoValue was updated with invalid data + expect(person.dog.age).toBe("string"); + }); + + test("create with deeply nested CoValues - loose validation should not throw", () => { + const Collar = co.map({ + size: z.number(), + }); + const Dog = co.map({ + age: z.number(), + collar: Collar, + }); + const Person = co.map({ + name: z.string(), + dog: Dog, + }); + + // Should throw with strict validation when any nested field is invalid + expectValidationError(() => + Person.create({ + name: "john", + dog: { + age: "12" as unknown as number, + collar: { size: 10 }, + }, + }), + ); + + expectValidationError(() => + Person.create({ + name: "john", + dog: { + age: 12, + // @ts-expect-error - size should be number + collar: { size: "large" }, + }, + }), + ); + + // Should not throw with loose validation at any level + expect(() => + Person.create( + { + name: "john", + dog: { + age: "12" as unknown as number, + collar: { size: "large" as unknown as number }, + }, + }, + { validation: "loose" }, + ), + ).not.toThrow(); + + const person = Person.create( + { + name: "john", + dog: { + age: "12" as unknown as number, + collar: { size: "large" as unknown as number }, + }, + }, + { validation: "loose" }, + ); + + // Verify all levels were created with invalid data + expect(person.name).toBe("john"); + expect(person.dog.age).toBe("12"); + expect(person.dog.collar.size).toBe("large"); + }); + + test("create with nested CoValue - strict validation explicitly set should throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const Person = co.map({ + name: z.string(), + dog: Dog, + }); + + // Explicitly setting validation to strict should throw + expectValidationError(() => + Person.create( + { + name: "john", + dog: { age: "12" as unknown as number }, + }, + { validation: "strict" }, + ), + ); + }); + + test("global loose validation mode propagates to nested CoValues in all mutations", () => { + const Collar = co.map({ + size: z.number(), + }); + const Dog = co.map({ + age: z.number(), + collar: Collar, + }); + const Person = co.map({ + name: z.string(), + dog: Dog, + }); + + // Set global validation mode to loose + setDefaultValidationMode("loose"); + + try { + // Test 1: Create with deeply nested invalid data + const person = Person.create({ + name: "john", + dog: { + age: "12" as unknown as number, + collar: { size: "large" as unknown as number }, + }, + }); + + // Verify all nested levels were created with invalid data + expect(person.name).toBe("john"); + expect(person.dog.age).toBe("12"); + expect(person.dog.collar.size).toBe("large"); + + // Test 2: Set with nested invalid data + person.$jazz.set("dog", { + age: "15" as unknown as number, + collar: { size: "medium" as unknown as number }, + }); + + expect(person.dog.age).toBe("15"); + expect(person.dog.collar.size).toBe("medium"); + + // Test 3: ApplyDiff with nested invalid data + person.$jazz.applyDiff({ + dog: { + age: "20" as unknown as number, + collar: { size: "small" as unknown as number }, + }, + }); + + expect(person.dog.age).toBe("20"); + expect(person.dog.collar.size).toBe("small"); + } finally { + // Reset to strict mode + setDefaultValidationMode("strict"); + } + }); +}); diff --git a/packages/jazz-tools/src/tools/tests/coMap.unique.test.ts b/packages/jazz-tools/src/tools/tests/coMap.unique.test.ts index 7653a459ba..0302eae1db 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.unique.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.unique.test.ts @@ -14,6 +14,7 @@ import { unstable_loadUnique, } from "../internal"; import { z } from "../exports"; +import { expectValidationError } from "./utils"; beforeEach(async () => { cojsonInternals.CO_VALUE_LOADING_CONFIG.RETRY_DELAY = 1000; @@ -187,6 +188,50 @@ describe("Creating and finding unique CoMaps", async () => { }); }); + test("upserting should validate input against schema on creation", async () => { + const group = Group.create(); + const Event = co.map({ + title: z.string(), + }); + + await expectValidationError(async () => { + await Event.upsertUnique({ + value: { + // @ts-expect-error - number is not a string + title: 123, + }, + unique: "test-event-identifier", + owner: group, + }); + }); + }); + + test("upserting should validate input against schema on update", async () => { + const group = Group.create(); + const Event = co.map({ + title: z.string(), + }); + + await Event.upsertUnique({ + value: { + title: "123", + }, + unique: "test-event-identifier", + owner: group, + }); + + await expectValidationError(async () => { + await Event.upsertUnique({ + value: { + // @ts-expect-error - number is not a string + title: 456, + }, + unique: "test-event-identifier", + owner: group, + }); + }); + }); + test("upserting a existent value without enough permissions should not throw", async () => { const Event = co.map({ title: z.string(), diff --git a/packages/jazz-tools/src/tools/tests/coOptional.test.ts b/packages/jazz-tools/src/tools/tests/coOptional.test.ts index 3dbb91f3ef..448baad047 100644 --- a/packages/jazz-tools/src/tools/tests/coOptional.test.ts +++ b/packages/jazz-tools/src/tools/tests/coOptional.test.ts @@ -6,10 +6,11 @@ describe("co.optional", () => { beforeEach(async () => { await setupJazzTestSync(); - await createJazzTestAccount({ + const account = await createJazzTestAccount({ isCurrentActiveAccount: true, creationProps: { name: "Hermes Puggington" }, }); + account.$jazz.set("root", {}); }); test("can use co.optional with CoValue schemas as values", () => { @@ -123,4 +124,16 @@ describe("co.optional", () => { expect(person?.preferredName?.toString()).toEqual("John"); }); + + test("can set undefined to an optional field", () => { + const Person = co.map({ + name: co.optional(co.plainText()), + }); + + const person = Person.create({}); + + person.$jazz.set("name", undefined); + + expect(person.name).toBeUndefined(); + }); }); diff --git a/packages/jazz-tools/src/tools/tests/deepLoading.test.ts b/packages/jazz-tools/src/tools/tests/deepLoading.test.ts index 95d8dabde2..752b2708f0 100644 --- a/packages/jazz-tools/src/tools/tests/deepLoading.test.ts +++ b/packages/jazz-tools/src/tools/tests/deepLoading.test.ts @@ -1098,7 +1098,7 @@ test("throw when calling ensureLoaded on a ref that's required but missing", asy const root = JazzRoot.create( // @ts-expect-error missing required ref {}, - { owner: me }, + { owner: me, validation: "loose" }, ); await expect( diff --git a/packages/jazz-tools/src/tools/tests/patterns/quest.test.ts b/packages/jazz-tools/src/tools/tests/patterns/quest.test.ts index d55103c707..0ed37e8cb6 100644 --- a/packages/jazz-tools/src/tools/tests/patterns/quest.test.ts +++ b/packages/jazz-tools/src/tools/tests/patterns/quest.test.ts @@ -36,16 +36,21 @@ beforeEach(async () => { describe("QuestSchema", () => { test("should fill categories array with category when categories is undefined", async () => { // Create a quest without categories - // @ts-expect-error - (simulating old data) - const quest = QuestSchema.create({ - title: "Test Quest", - description: "A test quest description", - imageUrl: "https://example.com/image.jpg", - twigs: 100, - difficulty: "medium", - category: "adventure", - completed: false, - }); + const quest = QuestSchema.create( + // @ts-expect-error - (simulating old data) + { + title: "Test Quest", + description: "A test quest description", + imageUrl: "https://example.com/image.jpg", + twigs: 100, + difficulty: "medium", + category: "adventure", + completed: false, + }, + { + validation: "loose", + }, + ); // Initially categories should be undefined expect(quest.categories).toBeUndefined(); diff --git a/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts b/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts new file mode 100644 index 0000000000..2aa9834bb8 --- /dev/null +++ b/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts @@ -0,0 +1,438 @@ +import { beforeEach, describe, test, expect, vi } from "vitest"; +import { co, z } from "../exports.js"; +import { + getDefaultValidationMode, + setDefaultValidationMode, +} from "../implementation/zodSchema/validationSettings.js"; +import { createJazzTestAccount, setupJazzTestSync } from "../testing.js"; +import { expectValidationError } from "./utils.js"; + +describe("runtime validation", () => { + beforeEach(async () => { + await setupJazzTestSync(); + + await createJazzTestAccount({ + isCurrentActiveAccount: true, + creationProps: { name: "Hermes Puggington" }, + }); + }); + + test("validates numeric fields with composed constraints on create and set", () => { + const Person = co.map({ + age: z.number().int().min(0).max(120), + }); + + const john = Person.create({ age: 42 }); + expect(john.age).toEqual(42); + + expectValidationError(() => Person.create({ age: -1 })); + + expectValidationError(() => john.$jazz.set("age", 121)); + + expectValidationError(() => john.$jazz.set("age", 3.14)); + }); + + test("validates string fields with multiple Zod validators", () => { + const Person = co.map({ + username: z + .string() + .min(3) + .max(10) + .regex(/^[a-z]+$/), + }); + + const alice = Person.create({ username: "alice" }); + expect(alice.username).toEqual("alice"); + + expectValidationError(() => Person.create({ username: "ab" })); + + expectValidationError(() => Person.create({ username: "Alice123" })); + + expectValidationError(() => alice.$jazz.set("username", "bob_1")); + }); + + test("supports optional fields with composed validators", () => { + const Person = co.map({ + score: z.number().int().min(0).max(100).optional(), + }); + + const player = Person.create({}); + expect(player.score).toBeUndefined(); + + player.$jazz.set("score", 50); + expect(player.score).toEqual(50); + + expectValidationError(() => player.$jazz.set("score", 200)); + + player.$jazz.set("score", undefined); + expect(player.score).toBeUndefined(); + }); + + test("validates nested object schemas used as json fields", () => { + const Settings = z + .object({ + theme: z.enum(["light", "dark"]), + notifications: z.boolean().optional(), + }) + .strict(); + + const User = co.map({ + settings: Settings, + }); + + const user = User.create({ + settings: { theme: "dark" }, + }); + + expect(user.settings.theme).toEqual("dark"); + + expectValidationError(() => + User.create({ + // @ts-expect-error - invalid enum value + settings: { theme: "blue" }, + }), + ); + + expectValidationError(() => + // @ts-expect-error - invalid enum value at runtime + user.$jazz.set("settings", { theme: "blue" }), + ); + + user.$jazz.set("settings", { + theme: "light", + notifications: true, + }); + expect(user.settings.theme).toEqual("light"); + expect(user.settings.notifications).toEqual(true); + }); + + test("validates literal, enum, and nullish schemas", () => { + const Profile = co.map({ + mode: z.literal("auto"), + role: z.enum(["admin", "user"]), + nickname: z.string().min(2).nullish(), + }); + + const profile = Profile.create({ + mode: "auto", + role: "admin", + nickname: null, + }); + + expect(profile.mode).toEqual("auto"); + expect(profile.nickname).toBeNull(); + + expectValidationError(() => + Profile.create( + // @ts-expect-error - literal mismatch + { mode: "manual", role: "admin" }, + ), + ); + + expectValidationError(() => + profile.$jazz.set( + "role", + // @ts-expect-error - invalid enum value + "guest", + ), + ); + + profile.$jazz.set("nickname", "dj"); + expect(profile.nickname).toEqual("dj"); + + profile.$jazz.set("nickname", undefined); + expect(profile.nickname).toBeUndefined(); + }); + + // .default() is not supported yet + test.fails("applies defaults when values are omitted", () => { + const Document = co.map({ + title: z.string().min(1).default("Untitled"), + pageCount: z.number().int().min(1).default(1), + }); + + // @ts-expect-error - missing required fields + const doc = Document.create({}); + + expect(doc.title).toEqual("Untitled"); + expect(doc.pageCount).toEqual(1); + + doc.$jazz.set("title", "Specs"); + doc.$jazz.set("pageCount", 3); + expect(doc.title).toEqual("Specs"); + expect(doc.pageCount).toEqual(3); + + expectValidationError(() => doc.$jazz.set("pageCount", 0)); + }); + + test("validates string formats and identifiers", () => { + const Contact = co.map({ + email: z.email(), + website: z.url(), + userId: z.uuid(), + }); + + const contact = Contact.create({ + email: "user@example.com", + website: "https://example.com", + userId: "123e4567-e89b-12d3-a456-426614174000", + }); + + expect(contact.website).toEqual("https://example.com"); + + expectValidationError(() => + Contact.create({ + email: "not-email", + website: "https://example.com", + userId: "123", + }), + ); + + expectValidationError(() => contact.$jazz.set("website", "notaurl")); + + expectValidationError(() => contact.$jazz.set("userId", "not-a-uuid")); + }); + + test("validates arrays and tuples", () => { + const Metrics = co.map({ + tags: z.array(z.string().min(1)).min(1), + coordinates: z.tuple([z.number().int(), z.number().int()]), + }); + + const metrics = Metrics.create({ + tags: ["alpha", "beta"], + coordinates: [10, 20], + }); + + expect(metrics.tags).toEqual(["alpha", "beta"]); + + expectValidationError(() => + Metrics.create( + // @ts-expect-error - empty tags and wrong tuple length + { tags: [], coordinates: [10, 20, 30] }, + ), + ); + + expectValidationError(() => metrics.$jazz.set("tags", ["", "beta"])); + + expectValidationError(() => metrics.$jazz.set("coordinates", [10.5, 20])); + }); + + test("validates unions and discriminated unions", () => { + const Shape = co.map({ + size: z.union([z.literal("small"), z.literal("large")]), + item: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("text"), value: z.string().min(1) }), + z.object({ kind: z.literal("count"), value: z.number().int().min(1) }), + ]), + }); + + const shape = Shape.create({ + size: "small", + item: { kind: "text", value: "hello" }, + }); + + expect(shape.size).toEqual("small"); + expect(shape.item.kind).toEqual("text"); + + expectValidationError(() => + Shape.create({ + // @ts-expect-error - invalid union member + size: "medium", + item: { kind: "text", value: "hello" }, + }), + ); + + expectValidationError(() => + shape.$jazz.set("item", { kind: "count", value: 0 }), + ); + }); + + test("applies refine checks on complex schemas", () => { + const Credentials = co.map({ + password: z + .string() + .min(8) + .refine( + (value) => + /[A-Z]/.test(value) && + /[a-z]/.test(value) && + /\d/.test(value) && + /[^A-Za-z0-9]/.test(value), + ), + }); + + const credentials = Credentials.create({ + password: "GoodPa$$w0rd", + }); + + expect(credentials.password).toEqual("GoodPa$$w0rd"); + + expectValidationError(() => Credentials.create({ password: "password" })); + + expectValidationError(() => credentials.$jazz.set("password", "NoDigits!")); + }); + + test("skips runtime validation for fields when validation is loose", () => { + const Person = co.map({ + age: z.number().int().min(0), + }); + + const john = Person.create({ age: 10 }); + + expect(() => + john.$jazz.set("age", -5, { validation: "loose" }), + ).not.toThrow(); + + expect(john.age).toEqual(-5); + }); +}); + +describe("global validation mode", () => { + beforeEach(async () => { + await setupJazzTestSync(); + + await createJazzTestAccount({ + isCurrentActiveAccount: true, + creationProps: { name: "Hermes Puggington" }, + }); + + // Reset to strict mode before each test + setDefaultValidationMode("strict"); + }); + + test("getter and setter work correctly", () => { + expect(getDefaultValidationMode()).toEqual("strict"); + + setDefaultValidationMode("loose"); + expect(getDefaultValidationMode()).toEqual("loose"); + + setDefaultValidationMode("warn"); + expect(getDefaultValidationMode()).toEqual("warn"); + + setDefaultValidationMode("strict"); + expect(getDefaultValidationMode()).toEqual("strict"); + }); + + test("global loose mode skips validation on create", () => { + setDefaultValidationMode("loose"); + + const Person = co.map({ + age: z.number().int().min(0).max(120), + }); + + // Should not throw even with invalid values + expect(() => Person.create({ age: -10 })).not.toThrow(); + const person = Person.create({ age: -10 }); + expect(person.age).toEqual(-10); + }); + + test("global loose mode skips validation on set", () => { + const Person = co.map({ + age: z.number().int().min(0).max(120), + }); + + const person = Person.create({ age: 30 }); + + setDefaultValidationMode("loose"); + + // Should not throw even with invalid values + expect(() => person.$jazz.set("age", -999)).not.toThrow(); + expect(person.age).toEqual(-999); + }); + + test("global warn mode logs but does not throw", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + setDefaultValidationMode("warn"); + + const Person = co.map({ + age: z.number().int().min(0).max(120), + }); + + // Should not throw, but should log a warning + expect(() => Person.create({ age: -10 })).not.toThrow(); + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockClear(); + + const person = Person.create({ age: 30 }); + + // set with invalid value should also warn but not throw + expect(() => person.$jazz.set("age", 999)).not.toThrow(); + expect(warnSpy).toHaveBeenCalled(); + expect(person.age).toEqual(999); + + warnSpy.mockRestore(); + }); + + test("local override takes precedence over global mode", () => { + setDefaultValidationMode("loose"); + + const Person = co.map({ + age: z.number().int().min(0).max(120), + }); + + // Local strict override should still validate even when global is loose + expectValidationError(() => + Person.create({ age: -10 }, { validation: "strict" }), + ); + }); + + test("local loose override works when global is strict", () => { + setDefaultValidationMode("strict"); + + const Person = co.map({ + age: z.number().int().min(0).max(120), + }); + + const person = Person.create({ age: 30 }); + + // Local loose override should skip validation + expect(() => + person.$jazz.set("age", -999, { validation: "loose" }), + ).not.toThrow(); + expect(person.age).toEqual(-999); + }); + + test("global mode affects CoList operations", () => { + const Numbers = co.list(z.number().int().min(0)); + + // Create with valid data first + const numbers = Numbers.create([1, 2, 3]); + + setDefaultValidationMode("loose"); + + // push should respect global loose mode (through pushLoose path) + expect(() => numbers.$jazz.push(-5)).not.toThrow(); + }); + + test("global mode affects CoFeed operations", () => { + const Messages = co.feed(z.string().min(5)); + + setDefaultValidationMode("loose"); + + // Create should respect global loose mode + expect(() => Messages.create(["hi"])).not.toThrow(); + }); + + test("mode changes affect existing schemas (lazy evaluation)", () => { + const Person = co.map({ + age: z.number().int().min(0).max(120), + }); + + // Create with strict mode + const person1 = Person.create({ age: 30 }); + expectValidationError(() => person1.$jazz.set("age", -5)); + + // Change to loose mode + setDefaultValidationMode("loose"); + + // Same schema, same instance should now use loose mode + expect(() => person1.$jazz.set("age", -5)).not.toThrow(); + expect(person1.age).toEqual(-5); + + // Create new instance - also uses loose mode + expect(() => Person.create({ age: -999 })).not.toThrow(); + }); +}); diff --git a/packages/jazz-tools/src/tools/tests/schema.test.ts b/packages/jazz-tools/src/tools/tests/schema.test.ts deleted file mode 100644 index b742813685..0000000000 --- a/packages/jazz-tools/src/tools/tests/schema.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { describe, expectTypeOf, it } from "vitest"; -import { CoMap, coField } from "../index.js"; - -describe("coField.json TypeScript validation", () => { - it("should accept serializable types", async () => { - type ValidType = { str: string; num: number; bool: boolean }; - - class ValidPrimitiveMap extends CoMap { - data = coField.json(); - } - - expectTypeOf(ValidPrimitiveMap.create) - .parameter(0) - .toEqualTypeOf<{ - data: ValidType; - }>(); - }); - - it("should accept nested serializable types", async () => { - type NestedType = { - outer: { - inner: { - value: string; - }; - }; - }; - - class ValidNestedMap extends CoMap { - data = coField.json(); - } - - expectTypeOf(ValidNestedMap.create) - .parameter(0) - .toEqualTypeOf<{ - data: NestedType; - }>(); - }); - - it("should accept types with optional attributes", async () => { - type TypeWithOptional = { - value: string; - optional?: string | null; - }; - - class ValidMap extends CoMap { - data = coField.json(); - } - - expectTypeOf(ValidMap.create) - .parameter(0) - .toEqualTypeOf<{ - data: TypeWithOptional; - }>(); - }); - - it("should accept nested serializable interfaces", async () => { - interface InnerInterface { - value: string; - } - - interface NestedInterface { - outer: { - inner: InnerInterface; - }; - } - - class ValidNestedMap extends CoMap { - data = coField.json(); - } - - expectTypeOf(ValidNestedMap.create) - .parameter(0) - .toEqualTypeOf<{ - data: NestedInterface; - }>(); - }); - - it("should accept arrays of serializable types", async () => { - interface ArrayInterface { - numbers: number[]; - objects: { id: number; name: string }[]; - } - - class ValidArrayMap extends CoMap { - data = coField.json(); - } - - expectTypeOf(ValidArrayMap.create) - .parameter(0) - .toEqualTypeOf<{ - data: ArrayInterface; - }>(); - }); - - it("should flag interfaces with functions as invalid", async () => { - interface InvalidInterface { - func: () => void; - } - - class InvalidFunctionMap extends CoMap { - // @ts-expect-error Should not be considered valid - data = coField.json(); - } - - expectTypeOf(InvalidFunctionMap.create) - .parameter(0) - .toEqualTypeOf<{ - data: InvalidInterface; - }>(); - }); - - it("should flag types with functions as invalid", async () => { - type InvalidType = { func: () => void }; - - class InvalidFunctionMap extends CoMap { - // @ts-expect-error Should not be considered valid - data = coField.json(); - } - - expectTypeOf(InvalidFunctionMap.create) - .parameter(0) - .toEqualTypeOf<{ - data: InvalidType; - }>(); - }); - - it("should flag types with non-serializable constructors as invalid", async () => { - type InvalidType = { date: Date; regexp: RegExp; symbol: symbol }; - - class InvalidFunctionMap extends CoMap { - // @ts-expect-error Should not be considered valid - data = coField.json(); - } - - expectTypeOf(InvalidFunctionMap.create) - .parameter(0) - .toEqualTypeOf<{ - data: InvalidType; - }>(); - }); - - it("should flag types with symbol keys as invalid", async () => { - type InvalidType = { [key: symbol]: string }; - - class InvalidFunctionMap extends CoMap { - // @ts-expect-error Should not be considered valid - data = coField.json(); - } - }); - - it("should apply the same validation to optional json", async () => { - type ValidType = { - value: string; - }; - - type InvalidType = { - value: () => string; - }; - - class MapWithOptionalJSON extends CoMap { - data = coField.optional.json(); - // @ts-expect-error Should not be considered valid - data2 = coField.optional.json(); - } - - expectTypeOf(MapWithOptionalJSON.create) - .parameter(0) - .toEqualTypeOf<{ - data?: ValidType | null; - data2?: InvalidType | null; - }>(); - }); - - /* Special case from reported issue: - ** See: https://github.com/garden-co/jazz/issues/1496 - */ - it("should apply the same validation to optional json [JAZZ-1496]", async () => { - interface ValidInterface0 { - value: string; - } - interface ValidInterface1 { - value: string | undefined; - } - interface InterfaceWithOptionalTypes { - requiredValue: string; - value?: string; - } - - class MapWithOptionalJSON extends CoMap { - data1 = coField.optional.json(); - data2 = coField.optional.json(); - data3 = coField.optional.json(); - } - - expectTypeOf(MapWithOptionalJSON.create) - .parameter(0) - .toEqualTypeOf<{ - data1?: ValidInterface0 | null; - data2?: ValidInterface1 | null; - data3?: InterfaceWithOptionalTypes | null; - }>(); - }); - - it("should not accept functions", async () => { - class InvalidFunctionMap extends CoMap { - // @ts-expect-error Should not be considered valid - data = coField.json<() => void>(); - } - }); - - it("should not accept functions in nested properties", async () => { - class InvalidFunctionMap extends CoMap { - // @ts-expect-error Should not be considered valid - data = coField.json<{ func: () => void }>(); - } - }); - - it("should not accept RegExp", async () => { - class InvalidFunctionMap extends CoMap { - // @ts-expect-error Should not be considered valid - data = coField.json(); - } - - expectTypeOf(InvalidFunctionMap.create) - .parameter(0) - .toEqualTypeOf<{ - data: RegExp; - }>(); - }); - - it("should accept strings and numbers", async () => { - class InvalidFunctionMap extends CoMap { - str = coField.json(); - num = coField.json(); - } - - expectTypeOf(InvalidFunctionMap.create) - .parameter(0) - .toEqualTypeOf<{ - str: string; - num: number; - }>(); - }); -}); diff --git a/packages/jazz-tools/src/tools/tests/schemaInvariant.test.ts b/packages/jazz-tools/src/tools/tests/schemaInvariant.test.ts new file mode 100644 index 0000000000..401c32f870 --- /dev/null +++ b/packages/jazz-tools/src/tools/tests/schemaInvariant.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { CoFeed, CoList, co, z } from "../exports.js"; +import { createJazzTestAccount, setupJazzTestSync } from "../testing.js"; + +describe("schema invariant", () => { + beforeEach(async () => { + await setupJazzTestSync(); + await createJazzTestAccount({ + isCurrentActiveAccount: true, + creationProps: { name: "Hermes Puggington" }, + }); + }); + + test("fails fast when creating a CoList class without coValueSchema", () => { + class LegacyList extends CoList {} + + expect(() => LegacyList.create(["a"])).toThrow( + "[schema-invariant] LegacyList.create requires a coValueSchema.", + ); + }); + + test("fails fast when creating a CoFeed class without coValueSchema", () => { + class LegacyFeed extends CoFeed {} + + expect(() => LegacyFeed.create([])).toThrow( + "[schema-invariant] LegacyFeed.create requires a coValueSchema.", + ); + }); + + test("allows schema-backed list/feed classes", () => { + const Names = co.list(z.string()); + const Events = co.feed(z.string()); + + expect(() => Names.create(["alice"])).not.toThrow(); + expect(() => Events.create(["hello"])).not.toThrow(); + }); +}); diff --git a/packages/jazz-tools/src/tools/tests/schemaUnion.test.ts b/packages/jazz-tools/src/tools/tests/schemaUnion.test.ts index 6a7953a438..b6e0a5536c 100644 --- a/packages/jazz-tools/src/tools/tests/schemaUnion.test.ts +++ b/packages/jazz-tools/src/tools/tests/schemaUnion.test.ts @@ -27,7 +27,7 @@ const BlueButtonWidget = co.map({ disabled: z.boolean().optional(), }); -const ButtonWidget = co.discriminatedUnion("type", [ +const ButtonWidget = co.discriminatedUnion("color", [ RedButtonWidget, BlueButtonWidget, ]); diff --git a/packages/jazz-tools/src/tools/tests/subscribe.test.ts b/packages/jazz-tools/src/tools/tests/subscribe.test.ts index 3c31a714cd..3038e86346 100644 --- a/packages/jazz-tools/src/tools/tests/subscribe.test.ts +++ b/packages/jazz-tools/src/tools/tests/subscribe.test.ts @@ -592,7 +592,10 @@ describe("subscribeToCoValue", () => { TestMap.create({ value: "4" }, everyone), TestMap.create({ value: "5" }, everyone), ], - everyone, + { + owner: everyone, + validation: "loose", + }, ); let result = null as Loaded | null; diff --git a/packages/jazz-tools/src/tools/tests/utils.ts b/packages/jazz-tools/src/tools/tests/utils.ts index 11fc7b2916..ec55a3f8bc 100644 --- a/packages/jazz-tools/src/tools/tests/utils.ts +++ b/packages/jazz-tools/src/tools/tests/utils.ts @@ -1,4 +1,4 @@ -import { assert } from "vitest"; +import { assert, expect } from "vitest"; import { AccountClass, isControlledAccount } from "../coValues/account"; import { CoID, LocalNode, RawCoValue } from "cojson"; @@ -178,3 +178,44 @@ export async function createAccountAs>( return account; } + +function verifyValidationError(e: any, expectedIssues?: any) { + if (e?.name !== "ZodError") { + throw e; + } + + if (expectedIssues) { + expect(e.issues).toEqual(expectedIssues); + } +} + +export function expectValidationError( + fn: () => Promise, + expectedIssues?: any, +): Promise; +export function expectValidationError( + fn: () => any, + expectedIssues?: any, +): void; +export function expectValidationError( + fn: () => any, + expectedIssues?: any, +): void | Promise { + try { + const result = fn(); + + if (result instanceof Promise) { + return result + .then(() => { + throw new Error("Expected validation error, but no error was thrown"); + }) + .catch((e: any) => { + verifyValidationError(e, expectedIssues); + }); + } else { + throw new Error("Expected validation error, but no error was thrown"); + } + } catch (e: any) { + verifyValidationError(e, expectedIssues); + } +} diff --git a/packages/jazz-tools/testSetup.ts b/packages/jazz-tools/testSetup.ts index 7afe2ec547..e812a3714e 100644 --- a/packages/jazz-tools/testSetup.ts +++ b/packages/jazz-tools/testSetup.ts @@ -1,10 +1,12 @@ import { beforeEach } from "vitest"; import { cojsonInternals } from "cojson"; +import { setDefaultValidationMode } from "./src/tools/implementation/zodSchema/validationSettings.ts"; import { registerStorageCleanupRunner } from "./src/tools/tests/testStorage.js"; // Use a very high budget to avoid that slow tests fail due to the budget being exceeded. cojsonInternals.setIncomingMessagesTimeBudget(10000); // 10 seconds +setDefaultValidationMode("strict"); beforeEach(() => { registerStorageCleanupRunner(); }); diff --git a/tests/browser-integration/src/syncConflicts.test.ts b/tests/browser-integration/src/syncConflicts.test.ts index 2c0e343bb7..a719c6e728 100644 --- a/tests/browser-integration/src/syncConflicts.test.ts +++ b/tests/browser-integration/src/syncConflicts.test.ts @@ -1,11 +1,9 @@ import { commands } from "vitest/browser"; -import { AuthSecretStorage, CoMap, coField } from "jazz-tools"; +import { AuthSecretStorage, co, z } from "jazz-tools"; import { assert, afterAll, afterEach, describe, expect, test } from "vitest"; import { createAccountContext, startSyncServer, waitFor } from "./testUtils"; -class Issue extends CoMap { - estimate = coField.number; -} +const Issue = co.map({ estimate: z.number() }); afterAll(async () => { await commands.cleanup(); diff --git a/tests/vercel-functions/src/app/api/hello/route.ts b/tests/vercel-functions/src/app/api/hello/route.ts index 9688b5b6ce..79f652a0e6 100644 --- a/tests/vercel-functions/src/app/api/hello/route.ts +++ b/tests/vercel-functions/src/app/api/hello/route.ts @@ -1,6 +1,6 @@ import "jazz-tools/load-edge-wasm"; import { createWebSocketPeer } from "cojson-transport-ws"; -import { CoMap, coField, co, z } from "jazz-tools"; +import { co, z } from "jazz-tools"; import { Account } from "jazz-tools"; import { startWorker } from "jazz-tools/worker"; import { NextResponse } from "next/server";