From 02e4e9e1a60296e539322cf3e77679d49dd40c9b Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 6 Jan 2026 17:50:45 +0100 Subject: [PATCH 01/61] first iteration for schema validation on create --- .../jazz-tools/src/tools/coValues/account.ts | 3 ++ .../zodSchema/schemaTypes/AccountSchema.ts | 7 +++ .../schemaTypes/CoDiscriminatedUnionSchema.ts | 16 +++++++ .../zodSchema/schemaTypes/CoFeedSchema.ts | 15 ++++++ .../zodSchema/schemaTypes/CoListSchema.ts | 17 +++++++ .../zodSchema/schemaTypes/CoMapSchema.ts | 47 +++++++++++++++++++ .../zodSchema/schemaTypes/CoOptionalSchema.ts | 16 +++++++ .../zodSchema/schemaTypes/CoValueSchema.ts | 4 ++ .../zodSchema/schemaTypes/CoVectorSchema.ts | 8 ++++ .../zodSchema/schemaTypes/FileStreamSchema.ts | 8 ++++ .../zodSchema/schemaTypes/GroupSchema.ts | 4 ++ .../zodSchema/schemaTypes/PlainTextSchema.ts | 4 ++ .../zodSchema/schemaTypes/RichTextSchema.ts | 4 ++ .../jazz-tools/src/tools/tests/coMap.test.ts | 10 ++++ 14 files changed, 163 insertions(+) diff --git a/packages/jazz-tools/src/tools/coValues/account.ts b/packages/jazz-tools/src/tools/coValues/account.ts index 3353344af8..a19e1e0295 100644 --- a/packages/jazz-tools/src/tools/coValues/account.ts +++ b/packages/jazz-tools/src/tools/coValues/account.ts @@ -67,6 +67,9 @@ export type AccountCreationProps = { /** @category Identity & Permissions */ export class Account extends CoValueBase implements CoValue { declare [TypeSym]: "Account"; + static { + this.prototype[TypeSym] = "Account"; + } /** * Jazz methods for Accounts are inside this property. 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..c2e9e50c91 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts @@ -53,6 +53,13 @@ export class AccountSchema< shape: Shape; getDefinition: () => CoMapSchemaDefinition; + getValidationSchema = () => { + return z.object({ + profile: this.shape.profile.getValidationSchema(), + root: z.optional(this.shape.root.getValidationSchema()), + }); + }; + /** * 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. 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..2f06b3d693 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts @@ -55,6 +55,16 @@ export class CoDiscriminatedUnionSchema< readonly builtin = "CoDiscriminatedUnion" as const; readonly getDefinition: () => CoDiscriminatedUnionSchemaDefinition; + getValidationSchema = () => { + // @ts-expect-error we can't statically enforce the schema's discriminator is a valid discriminator, but in practice it is + return z.discriminatedUnion( + this.getDefinition().discriminator, + this.getDefinition().options.map((option) => + option.getValidationSchema(), + ), + ); + }; + /** * 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 +234,12 @@ export function createCoreCoDiscriminatedUnionSchema< return { collaborative: true as const, builtin: "CoDiscriminatedUnion" as const, + getValidationSchema: () => { + return z.discriminatedUnion( + discriminator, + schemas.map((option) => option.getValidationSchema()) as 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 b3025c10c3..96182718f8 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts @@ -26,6 +26,7 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; +import { z } from "../zodReExport.js"; export class CoFeedSchema< T extends AnyZodOrCoValueSchema, @@ -51,6 +52,14 @@ export class CoFeedSchema< return this.#permissions ?? DEFAULT_SCHEMA_PERMISSIONS; } + getValidationSchema = () => { + return z.array( + this.element instanceof z.core.$ZodType + ? this.element + : this.element.getValidationSchema(), + ); + }; + constructor( public element: T, private coValueClass: typeof CoFeed, @@ -225,6 +234,12 @@ export function createCoreCoFeedSchema( builtin: "CoFeed" as const, element, resolveQuery: true as const, + getValidationSchema: () => + z.array( + element instanceof z.core.$ZodType + ? element + : element.getValidationSchema(), + ), }; } 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 8643cdccb7..835a525bbb 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -28,6 +28,7 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; +import { z } from "../../../exports.js"; export class CoListSchema< T extends AnyZodOrCoValueSchema, @@ -53,6 +54,14 @@ export class CoListSchema< return this.#permissions ?? DEFAULT_SCHEMA_PERMISSIONS; } + getValidationSchema = () => { + return z.array( + this.element instanceof z.z.core.$ZodType + ? this.element + : this.element.getValidationSchema(), + ); + }; + constructor( public element: T, private coValueClass: typeof CoList, @@ -79,6 +88,8 @@ export class CoListSchema< | Account | Group, ): CoListInstance { + this.getValidationSchema().parse(items); + const optionsWithPermissions = withSchemaPermissions( options, this.permissions, @@ -278,6 +289,12 @@ export function createCoreCoListSchema( builtin: "CoList" as const, element, resolveQuery: true as const, + getValidationSchema: () => + z.array( + element instanceof z.z.core.$ZodType + ? element + : element.getValidationSchema(), + ), }; } 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 b0fd5a97fb..ff55518b40 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,8 @@ import { isAnyCoValueSchema, unstable_mergeBranchWithResolve, withSchemaPermissions, + TypeSym, + CoList, } from "../../../internal.js"; import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js"; import { removeGetters, withSchemaResolveQuery } from "../../schemaUtils.js"; @@ -52,6 +54,47 @@ export class CoMapSchema< catchAll?: CatchAll; getDefinition: () => CoMapSchemaDefinition; + getValidationSchema = () => { + const plainShape: Record = {}; + + for (const key in this.shape) { + const item = this.shape[key]; + // item is a Group + if (item?.prototype?.[TypeSym] === "Group") { + plainShape[key] = z.instanceof(Group); + } else if (item?.prototype?.[TypeSym] === "Account") { + plainShape[key] = z.instanceof(Account); + } else if (item.builtin === "CoMap") { + // Inject as getter to avoid circularity issues + Object.defineProperty(plainShape, key, { + get: () => z.instanceof(CoMap).or(item.getValidationSchema()), + enumerable: true, + configurable: true, + }); + } else if (item.builtin === "CoList") { + // Inject as getter to avoid circularity issues + Object.defineProperty(plainShape, key, { + get: () => z.instanceof(CoList).or(item.getValidationSchema()), + enumerable: true, + configurable: true, + }); + } else if (item?.getValidationSchema) { + // Inject as getter to avoid circularity issues + Object.defineProperty(plainShape, key, { + get: () => item.getValidationSchema(), + enumerable: true, + configurable: true, + }); + } else if ((item as any) instanceof z.core.$ZodType) { + plainShape[key] = item; + } else { + throw new Error(`Unsupported schema type: ${item}`); + } + } + + return z.object(plainShape); + }; + /** * 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. @@ -101,6 +144,9 @@ export class CoMapSchema< options, this.permissions, ); + + this.getValidationSchema().parse(init); + return this.coValueClass.create(init, optionsWithPermissions); } @@ -449,6 +495,7 @@ export function createCoreCoMapSchema< }, }), resolveQuery: true as const, + getValidationSchema: () => z.object(shape), }; } 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..5399a6a01a 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, @@ -28,6 +30,20 @@ export class CoOptionalSchema< constructor(public readonly innerType: Shape) {} + getValidationSchema = () => { + if (this.innerType.builtin === "CoMap") { + return z.optional( + z.instanceof(CoMap).or(this.innerType.getValidationSchema()), + ); + } else if (this.innerType.builtin === "CoList") { + return z.optional( + z.instanceof(CoList).or(this.innerType.getValidationSchema()), + ); + } + + return z.optional(this.innerType.getValidationSchema()); + }; + getCoValueClass(): ReturnType< CoValueSchemaFromCoreSchema["getCoValueClass"] > { 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..e5c99d3ca9 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 type { AnyZodSchema } from "../zodSchema.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: () => AnyZodSchema; } /** 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..27fb6ddef9 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,7 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; +import { z } from "../zodReExport.js"; export interface CoreCoVectorSchema extends CoreCoValueSchema { builtin: "CoVector"; @@ -28,6 +29,9 @@ export function createCoreCoVectorSchema( builtin: "CoVector" as const, dimensions, resolveQuery: true as const, + getValidationSchema: () => { + return z.instanceof(CoVector).or(z.array(z.number())); + }, }; } @@ -37,6 +41,10 @@ export class CoVectorSchema implements CoreCoVectorSchema { readonly resolveQuery = true as const; #permissions: SchemaPermissions | null = null; + getValidationSchema = () => { + return z.instanceof(CoVector).or(z.array(z.number())); + }; + /** * 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..38de9909c8 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,9 @@ export function createCoreFileStreamSchema(): CoreFileStreamSchema { collaborative: true as const, builtin: "FileStream" as const, resolveQuery: true as const, + getValidationSchema: () => { + return z.instanceof(FileStream); + }, }; } @@ -33,6 +37,10 @@ export class FileStreamSchema implements CoreFileStreamSchema { readonly resolveQuery = true as const; #permissions: SchemaPermissions | null = null; + getValidationSchema = () => { + return z.instanceof(FileStream); + }; + /** * 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..2d1334478a 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.instanceof(Group), }; } @@ -34,6 +36,8 @@ export class GroupSchema implements CoreGroupSchema { readonly builtin = "Group" as const; readonly resolveQuery = true as const; + getValidationSchema = () => z.instanceof(Group); + 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..4c1b14fc72 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,7 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; +import { z } from "../zodReExport.js"; export interface CorePlainTextSchema extends CoreCoValueSchema { builtin: "CoPlainText"; @@ -26,6 +27,7 @@ export function createCoreCoPlainTextSchema(): CorePlainTextSchema { collaborative: true as const, builtin: "CoPlainText" as const, resolveQuery: true as const, + getValidationSchema: () => z.string(), }; } @@ -35,6 +37,8 @@ export class PlainTextSchema implements CorePlainTextSchema { readonly resolveQuery = true as const; #permissions: SchemaPermissions | null = null; + getValidationSchema = () => z.string().or(z.instanceof(CoPlainText)); + /** * 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..20dc8f339e 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,7 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; +import { z } from "../zodReExport.js"; export interface CoreRichTextSchema extends CoreCoValueSchema { builtin: "CoRichText"; @@ -25,6 +26,7 @@ export function createCoreCoRichTextSchema(): CoreRichTextSchema { collaborative: true as const, builtin: "CoRichText" as const, resolveQuery: true as const, + getValidationSchema: () => z.string().or(z.instanceof(CoRichText)), }; } @@ -44,6 +46,8 @@ export class RichTextSchema implements CoreRichTextSchema { constructor(private coValueClass: typeof CoRichText) {} + getValidationSchema = () => z.string().or(z.instanceof(CoRichText)); + 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/tests/coMap.test.ts b/packages/jazz-tools/src/tools/tests/coMap.test.ts index 8521182b8b..7c89241786 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.test.ts @@ -515,6 +515,16 @@ 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(), + }); + + // @ts-expect-error - age should be a number + expect(() => Person.create({ name: "John", age: "20" })).toThrow(); + }); }); describe("Mutation", () => { From 55f73fe53515d15e88dc7550f68b853592f45859 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 6 Jan 2026 18:34:23 +0100 Subject: [PATCH 02/61] fixup! first iteration for schema validation on create --- .../zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2f06b3d693..ee04cc03c7 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts @@ -56,9 +56,9 @@ export class CoDiscriminatedUnionSchema< readonly getDefinition: () => CoDiscriminatedUnionSchemaDefinition; getValidationSchema = () => { - // @ts-expect-error we can't statically enforce the schema's discriminator is a valid discriminator, but in practice it is return z.discriminatedUnion( this.getDefinition().discriminator, + // @ts-expect-error we can't statically enforce the schema's discriminator is a valid discriminator, but in practice it is this.getDefinition().options.map((option) => option.getValidationSchema(), ), From cf1e7197794b26000c2da4d6e7a0ceac4fd3b551 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 12 Jan 2026 15:11:43 +0100 Subject: [PATCH 03/61] validation on creation finished + init comap.set --- .../zodSchema/schemaTypes/AccountSchema.ts | 10 +- .../schemaTypes/CoDiscriminatedUnionSchema.ts | 14 +-- .../zodSchema/schemaTypes/CoListSchema.ts | 55 +++++++--- .../zodSchema/schemaTypes/CoMapSchema.ts | 58 ++++++---- .../zodSchema/schemaTypes/CoVectorSchema.ts | 10 +- .../zodSchema/schemaTypes/schemaValidators.ts | 22 ++++ .../jazz-tools/src/tools/tests/coMap.test.ts | 103 +++++++++++++++++- .../src/tools/tests/deepLoading.test.ts | 2 +- .../src/tools/tests/patterns/quest.test.ts | 23 ++-- .../src/tools/tests/subscribe.test.ts | 5 +- 10 files changed, 235 insertions(+), 67 deletions(-) create mode 100644 packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts 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 c2e9e50c91..8bf0343e5b 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts @@ -54,10 +54,12 @@ export class AccountSchema< getDefinition: () => CoMapSchemaDefinition; getValidationSchema = () => { - return z.object({ - profile: this.shape.profile.getValidationSchema(), - root: z.optional(this.shape.root.getValidationSchema()), - }); + return z.instanceof(Account).or( + z.object({ + profile: this.shape.profile.getValidationSchema(), + root: z.optional(this.shape.root.getValidationSchema()), + }), + ); }; /** 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 ee04cc03c7..7ab8b479bc 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts @@ -56,13 +56,8 @@ export class CoDiscriminatedUnionSchema< readonly getDefinition: () => CoDiscriminatedUnionSchemaDefinition; getValidationSchema = () => { - return z.discriminatedUnion( - this.getDefinition().discriminator, - // @ts-expect-error we can't statically enforce the schema's discriminator is a valid discriminator, but in practice it is - this.getDefinition().options.map((option) => - option.getValidationSchema(), - ), - ); + // Discriminated union schema can apply only if data are plain objects. + return z.any(); }; /** @@ -235,10 +230,7 @@ export function createCoreCoDiscriminatedUnionSchema< collaborative: true as const, builtin: "CoDiscriminatedUnion" as const, getValidationSchema: () => { - return z.discriminatedUnion( - discriminator, - schemas.map((option) => option.getValidationSchema()) as any, - ); + return z.any(); }, getDefinition: () => ({ discriminator, 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 835a525bbb..61ad110820 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -28,7 +28,7 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; -import { z } from "../../../exports.js"; +import { z } from "../zodReExport.js"; export class CoListSchema< T extends AnyZodOrCoValueSchema, @@ -55,11 +55,15 @@ export class CoListSchema< } getValidationSchema = () => { - return z.array( - this.element instanceof z.z.core.$ZodType - ? this.element - : this.element.getValidationSchema(), - ); + return z + .instanceof(CoList) + .or( + z.array( + this.element instanceof z.core.$ZodType + ? this.element + : this.element.getValidationSchema(), + ), + ); }; constructor( @@ -70,25 +74,40 @@ export class CoListSchema< create( items: CoListSchemaInit, options?: - | { owner: Group; unique?: CoValueUniqueness["uniqueness"] } + | { + owner: Group; + unique?: CoValueUniqueness["uniqueness"]; + validation?: "strict" | "loose"; + } | Group, ): 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"] } + | { + owner: Account | Group; + unique?: CoValueUniqueness["uniqueness"]; + validation?: "strict" | "loose"; + } | Account | Group, ): CoListInstance; create( items: CoListSchemaInit, options?: - | { owner: Account | Group; unique?: CoValueUniqueness["uniqueness"] } + | { + owner: Account | Group; + unique?: CoValueUniqueness["uniqueness"]; + validation?: "strict" | "loose"; + } | Account | Group, - ): CoListInstance { - this.getValidationSchema().parse(items); + ): CoListInstance; + create(items: any, options?: any): CoListInstance { + if (options?.validation !== "loose") { + items = this.getValidationSchema().parse(items) as CoListSchemaInit; + } const optionsWithPermissions = withSchemaPermissions( options, @@ -290,11 +309,15 @@ export function createCoreCoListSchema( element, resolveQuery: true as const, getValidationSchema: () => - z.array( - element instanceof z.z.core.$ZodType - ? element - : element.getValidationSchema(), - ), + z + .instanceof(CoList) + .or( + z.array( + element instanceof z.core.$ZodType + ? element + : element.getValidationSchema(), + ), + ), }; } 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 ff55518b40..ff632ae9e9 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -20,7 +20,6 @@ import { unstable_mergeBranchWithResolve, withSchemaPermissions, TypeSym, - CoList, } from "../../../internal.js"; import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js"; import { removeGetters, withSchemaResolveQuery } from "../../schemaUtils.js"; @@ -35,6 +34,7 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; +import { isAnyCoValue, isCoValueSchema } from "./schemaValidators.js"; type CoMapSchemaInstance = Simplify< CoMapInstanceCoValuesMaybeLoaded @@ -62,37 +62,47 @@ export class CoMapSchema< // item is a Group if (item?.prototype?.[TypeSym] === "Group") { plainShape[key] = z.instanceof(Group); - } else if (item?.prototype?.[TypeSym] === "Account") { - plainShape[key] = z.instanceof(Account); - } else if (item.builtin === "CoMap") { + } else if ( + item?.prototype?.[TypeSym] === "Account" || + (isCoValueSchema(item) && item.builtin === "Account") + ) { + plainShape[key] = isAnyCoValue; + } else if (isCoValueSchema(item)) { // Inject as getter to avoid circularity issues Object.defineProperty(plainShape, key, { - get: () => z.instanceof(CoMap).or(item.getValidationSchema()), - enumerable: true, - configurable: true, - }); - } else if (item.builtin === "CoList") { - // Inject as getter to avoid circularity issues - Object.defineProperty(plainShape, key, { - get: () => z.instanceof(CoList).or(item.getValidationSchema()), - enumerable: true, - configurable: true, - }); - } else if (item?.getValidationSchema) { - // Inject as getter to avoid circularity issues - Object.defineProperty(plainShape, key, { - get: () => item.getValidationSchema(), + get: () => isAnyCoValue.or(item.getValidationSchema()), enumerable: true, configurable: true, }); } else if ((item as any) instanceof z.core.$ZodType) { - plainShape[key] = item; + // the following zod types are not supported: + if ( + // codecs are managed lower level + item._def.type === "pipe" + ) { + plainShape[key] = z.any(); + } else { + plainShape[key] = item; + } } else { throw new Error(`Unsupported schema type: ${item}`); } } - return z.object(plainShape); + let validationSchema = z.strictObject(plainShape); + if (this.catchAll) { + if (isCoValueSchema(this.catchAll)) { + validationSchema = validationSchema.catchall( + this.catchAll.getValidationSchema(), + ); + } else if ((this.catchAll as any) instanceof z.core.$ZodType) { + validationSchema = validationSchema.catchall( + this.catchAll as unknown as z.core.$ZodType, + ); + } + } + + return z.instanceof(CoMap).or(validationSchema); }; /** @@ -126,6 +136,7 @@ export class CoMapSchema< | { owner?: Group; unique?: CoValueUniqueness["uniqueness"]; + validation?: "strict" | "loose"; } | Group, ): CoMapInstanceShape & CoMap; @@ -136,6 +147,7 @@ export class CoMapSchema< | { owner?: Owner; unique?: CoValueUniqueness["uniqueness"]; + validation?: "strict" | "loose"; } | Owner, ): CoMapInstanceShape & CoMap; @@ -145,7 +157,9 @@ export class CoMapSchema< this.permissions, ); - this.getValidationSchema().parse(init); + if (options?.validation !== "loose") { + init = this.getValidationSchema().parse(init); + } return this.coValueClass.create(init, optionsWithPermissions); } 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 27fb6ddef9..2bda110194 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoVectorSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoVectorSchema.ts @@ -30,7 +30,10 @@ export function createCoreCoVectorSchema( dimensions, resolveQuery: true as const, getValidationSchema: () => { - return z.instanceof(CoVector).or(z.array(z.number())); + return z + .instanceof(CoVector) + .or(z.instanceof(Float32Array)) + .or(z.array(z.number())); }, }; } @@ -42,7 +45,10 @@ export class CoVectorSchema implements CoreCoVectorSchema { #permissions: SchemaPermissions | null = null; getValidationSchema = () => { - return z.instanceof(CoVector).or(z.array(z.number())); + return z + .instanceof(CoVector) + .or(z.instanceof(Float32Array)) + .or(z.array(z.number())); }; /** 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..49b71a23cb --- /dev/null +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts @@ -0,0 +1,22 @@ +import { Account, Group } from "../../../internal.js"; +import { z } from "../zodReExport.js"; +import type { CoreCoValueSchema } from "./CoValueSchema.js"; + +export const isCoValueSchema = (item: any): item is CoreCoValueSchema => { + return ( + typeof item === "object" && + item !== null && + "collaborative" in item && + item.collaborative === true + ); +}; + +export const isAnyCoValue = z.object({ + $jazz: z.object({ + id: z.string(), + }), +}); + +// any $jazz.id can be a valid account during validation +// TODO: improve this, validating some corner cases +export const isAccount = isAnyCoValue; diff --git a/packages/jazz-tools/src/tools/tests/coMap.test.ts b/packages/jazz-tools/src/tools/tests/coMap.test.ts index 7c89241786..7df325009b 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.test.ts @@ -439,7 +439,10 @@ describe("CoMap", async () => { }); // @ts-expect-error - x is not a valid property - const john = Person.create({ name: "John", age: 30, x: 1 }); + const john = Person.create( + { name: "John", age: 30, x: 1 }, + { validation: "loose" }, + ); expect(john.toJSON()).toEqual({ $jazz: { id: john.$jazz.id }, @@ -525,6 +528,54 @@ describe("CoMap", async () => { // @ts-expect-error - age should be a number expect(() => Person.create({ name: "John", age: "20" })).toThrow(); }); + + 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 validate Group schemas", async () => { + const Person = co.map({ + group: co.group(), + group2: Group, + }); + + expect(() => + Person.create({ group: Group.create(), group2: Group.create() }), + ).not.toThrow(); + // @ts-expect-error - group should be a Group + expect(() => + Person.create({ group: "Test", group2: Group.create() }), + ).toThrow(); + // @ts-expect-error - group should be a Group + expect(() => + Person.create({ group: Group.create(), group2: "Test" }), + ).toThrow(); + }); + + it("should use zod defaults for plain items", async () => { + const Person = co.map({ + name: z.string().default("John"), + age: z.number().default(20), + }); + + const person = Person.create({}); + expect(person.name).toEqual("John"); + expect(person.age).toEqual(20); + }); }); describe("Mutation", () => { @@ -542,6 +593,36 @@ 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 }); + + // @ts-expect-error - age should be a number + expect(() => john.$jazz.set("age", "21")).toThrow(); + + 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 }); + + // @ts-expect-error - age should be a number + expect(() => + john.$jazz.set("age", "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(), @@ -1543,6 +1624,26 @@ 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); + }); }); describe("CoMap applyDiff", async () => { diff --git a/packages/jazz-tools/src/tools/tests/deepLoading.test.ts b/packages/jazz-tools/src/tools/tests/deepLoading.test.ts index a1c9696ada..d1e2b701da 100644 --- a/packages/jazz-tools/src/tools/tests/deepLoading.test.ts +++ b/packages/jazz-tools/src/tools/tests/deepLoading.test.ts @@ -1096,7 +1096,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..34ea16b8a1 100644 --- a/packages/jazz-tools/src/tools/tests/patterns/quest.test.ts +++ b/packages/jazz-tools/src/tools/tests/patterns/quest.test.ts @@ -37,15 +37,20 @@ 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( + { + 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/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; From dfca8c093caa6c6792eb3fd2a98b2cee4b941710 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 12 Jan 2026 15:32:13 +0100 Subject: [PATCH 04/61] fixup! validation on creation finished + init comap.set --- packages/jazz-tools/src/tools/tests/coMap.test.ts | 9 +++++---- .../jazz-tools/src/tools/tests/patterns/quest.test.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/jazz-tools/src/tools/tests/coMap.test.ts b/packages/jazz-tools/src/tools/tests/coMap.test.ts index 7df325009b..544509f8b2 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.test.ts @@ -438,8 +438,8 @@ describe("CoMap", async () => { age: z.number(), }); - // @ts-expect-error - x is not a valid property const john = Person.create( + // @ts-expect-error - x is not a valid property { name: "John", age: 30, x: 1 }, { validation: "loose" }, ); @@ -556,12 +556,12 @@ describe("CoMap", async () => { expect(() => Person.create({ group: Group.create(), group2: Group.create() }), ).not.toThrow(); - // @ts-expect-error - group should be a Group expect(() => + // @ts-expect-error - group should be a Group Person.create({ group: "Test", group2: Group.create() }), ).toThrow(); - // @ts-expect-error - group should be a Group expect(() => + // @ts-expect-error - group should be a Group Person.create({ group: Group.create(), group2: "Test" }), ).toThrow(); }); @@ -572,6 +572,7 @@ describe("CoMap", async () => { 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); @@ -615,8 +616,8 @@ describe("CoMap", async () => { const john = Person.create({ name: "John", age: 20 }); - // @ts-expect-error - age should be a number expect(() => + // @ts-expect-error - age should be a number john.$jazz.set("age", "21", { validation: "loose" }), ).not.toThrow(); 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 34ea16b8a1..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,8 +36,8 @@ 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( + // @ts-expect-error - (simulating old data) { title: "Test Quest", description: "A test quest description", From 3fd01ddf8eb499c8cc37462f43da9aa2a5b04880 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 13 Jan 2026 17:31:06 +0100 Subject: [PATCH 05/61] feat: validation on coMap.set and applyDiff --- .../jazz-tools/src/tools/coValues/coMap.ts | 79 +++++++++++++++++-- .../coValueSchemaTransformation.ts | 14 +++- .../zodSchema/schemaTypes/CoMapSchema.ts | 5 +- .../jazz-tools/src/tools/tests/coMap.test.ts | 54 ++++++++++++- 4 files changed, 140 insertions(+), 12 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index a20a9cfad4..f0a970916a 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -54,7 +54,10 @@ import { parseSubscribeRestArgs, subscribeToCoValueWithoutMe, subscribeToExistingCoValue, + AnyCoreCoValueSchema, + CoreCoMapSchema, } from "../internal.js"; +import { z } from "../implementation/zodSchema/zodReExport.js"; export type CoMapEdit = { value?: V; @@ -110,6 +113,7 @@ type CoMapFieldSchema = { export class CoMap extends CoValueBase implements CoValue { /** @category Type Helpers */ declare [TypeSym]: "CoMap"; + coMapSchema: CoreCoMapSchema | undefined; static { this.prototype[TypeSym] = "CoMap"; } @@ -127,7 +131,9 @@ export class CoMap extends CoValueBase implements CoValue { static _schema: CoMapFieldSchema; /** @internal */ - constructor(options: { fromRaw: RawCoMap } | undefined) { + constructor( + options: { fromRaw: RawCoMap; coMapSchema?: CoreCoMapSchema } | undefined, + ) { super(); const proxy = new Proxy(this, CoMapProxyHandler as ProxyHandler); @@ -136,7 +142,11 @@ export class CoMap extends CoValueBase implements CoValue { if ("fromRaw" in options) { Object.defineProperties(this, { $jazz: { - value: new CoMapJazzApi(proxy, () => options.fromRaw), + value: new CoMapJazzApi( + proxy, + () => options.fromRaw, + options.coMapSchema, + ), enumerable: false, }, }); @@ -175,6 +185,7 @@ export class CoMap extends CoValueBase implements CoValue { | { owner?: Account | Group; unique?: CoValueUniqueness["uniqueness"]; + coMapSchema?: CoreCoMapSchema; } | Account | Group, @@ -248,6 +259,7 @@ export class CoMap extends CoValueBase implements CoValue { | { owner?: Account | Group; unique?: CoValueUniqueness["uniqueness"]; + coMapSchema?: CoreCoMapSchema | undefined; } | Account | Group, @@ -256,7 +268,11 @@ export class CoMap extends CoValueBase implements CoValue { Object.defineProperties(instance, { $jazz: { - value: new CoMapJazzApi(instance, () => raw), + value: new CoMapJazzApi( + instance, + () => raw, + options && "coMapSchema" in options ? options.coMapSchema : undefined, + ), enumerable: false, }, }); @@ -513,6 +529,7 @@ export class CoMap extends CoValueBase implements CoValue { (this as any).create(options.value, { owner: options.owner, unique: options.unique, + coMapSchema: this, }); }, onUpdateWhenFound(value) { @@ -562,17 +579,48 @@ 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 cachedSchema: z.core.$ZodTypeDef | undefined; constructor( private coMap: M, private getRaw: () => RawCoMap, + private coMapSchema?: CoreCoMapSchema, ) { super(coMap); + this.cachedSchema = this.coMapSchema?.getValidationSchema?.()._zod.def; } get owner(): Group { return getCoValueOwner(this.coMap); } + private getPropertySchema(key: string): z.core.$ZodTypes { + if (this.cachedSchema === undefined) { + return z.any(); + } + + if (this.cachedSchema?.type !== "union") { + throw new Error("Cached schema is not a union"); + } + + // @ts-expect-error as union, it has options fields and 2nd is the plain shape + const fieldSchema = this.cachedSchema.options[1]?.shape?.[key] as + | z.core.$ZodTypes + | undefined; + + // ignore codecs/pipes + // even if they are optional and nullable + // @ts-expect-error + if ( + fieldSchema?._def?.type === "pipe" || + fieldSchema?._def?.innerType?._def?.type === "pipe" || + fieldSchema?._def?.innerType?._def?.innerType?._def?.type === "pipe" + ) { + return z.any(); + } + + return fieldSchema ?? z.any(); + } + /** * Check if a key is defined in the CoMap. * @@ -592,16 +640,28 @@ 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?: "strict" | "loose" }, + ): void { const descriptor = this.getDescriptor(key as string); if (!descriptor) { throw Error(`Cannot set unknown key ${key}`); } + // Validate the value if validation is not loose and we have a schema + if (options?.validation !== "loose" && this.coMapSchema) { + // Get the field schema for this specific key from the shape + const fieldSchema = this.getPropertySchema(key); + value = z.parse(fieldSchema, value) as CoFieldInit; + } + let refId = (value as CoValue)?.$jazz?.id; if (descriptor === "json") { this.raw.set(key, value as JsonValue | undefined); @@ -656,11 +716,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?: "strict" | "loose" }, + ): M { for (const key in newValues) { if (Object.prototype.hasOwnProperty.call(newValues, key)) { const tKey = key as keyof typeof newValues & keyof this; @@ -673,13 +738,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); } } } 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 acb755ae31..1d65ca9604 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts @@ -20,6 +20,7 @@ import { isCoValueClass, Group, CoVector, + CoreCoMapSchema, } from "../../../internal.js"; import { coField } from "../../schema.js"; @@ -81,7 +82,11 @@ export function hydrateCoreCoValueSchema( const ClassToExtend = schema.builtin === "Account" ? Account : CoMap; const coValueClass = class ZCoMap extends ClassToExtend { - constructor(options: { fromRaw: RawCoMap } | undefined) { + constructor( + options: + | { fromRaw: RawCoMap; validationSchema?: CoreCoMapSchema } + | undefined, + ) { super(options); for (const [fieldName, fieldType] of Object.entries(def.shape)) { (this as any)[fieldName] = schemaFieldToCoFieldDef( @@ -94,6 +99,13 @@ export function hydrateCoreCoValueSchema( ); } } + + static fromRaw(raw: RawCoMap) { + return new this({ + fromRaw: raw, + validationSchema: schema as CoreCoMapSchema, + }); + } }; const coValueSchema = 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 ff632ae9e9..c39d35b085 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -161,7 +161,10 @@ export class CoMapSchema< init = this.getValidationSchema().parse(init); } - return this.coValueClass.create(init, optionsWithPermissions); + return this.coValueClass.create(init, { + ...optionsWithPermissions, + coMapSchema: this, + }); } load< diff --git a/packages/jazz-tools/src/tools/tests/coMap.test.ts b/packages/jazz-tools/src/tools/tests/coMap.test.ts index 544509f8b2..8d5a057a99 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.test.ts @@ -1694,6 +1694,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", + }; + + // @ts-expect-error - age should be a number + expect(() => + map.$jazz.applyDiff(newValues, { validation: "loose" }), + ).not.toThrow(); + + expect(map.age).toEqual("35"); + }); + test("applyDiff with nested changes", () => { const originalNestedMap = NestedMap.create( { value: "original" }, @@ -1850,9 +1900,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", () => { From f38e1f51c6884be51776b86cf50bb98a4b607ddf Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 13 Jan 2026 18:21:30 +0100 Subject: [PATCH 06/61] fixup! feat: validation on coMap.set and applyDiff --- packages/jazz-tools/src/tools/coValues/coMap.ts | 4 +++- packages/jazz-tools/src/tools/tests/coMap.test.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index f0a970916a..676b119d35 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -609,10 +609,12 @@ class CoMapJazzApi extends CoValueJazzApi { // ignore codecs/pipes // even if they are optional and nullable - // @ts-expect-error if ( + // @ts-expect-error fieldSchema?._def?.type === "pipe" || + // @ts-expect-error fieldSchema?._def?.innerType?._def?.type === "pipe" || + // @ts-expect-error fieldSchema?._def?.innerType?._def?.innerType?._def?.type === "pipe" ) { return z.any(); diff --git a/packages/jazz-tools/src/tools/tests/coMap.test.ts b/packages/jazz-tools/src/tools/tests/coMap.test.ts index 8d5a057a99..ca3009bc74 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.test.ts @@ -1736,8 +1736,8 @@ describe("CoMap applyDiff", async () => { age: "35", }; - // @ts-expect-error - age should be a number expect(() => + // @ts-expect-error - age should be a number map.$jazz.applyDiff(newValues, { validation: "loose" }), ).not.toThrow(); From c00d7893baeabf2a6ca6d2f14f7c1dadcec0bbfb Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 19 Jan 2026 12:56:44 +0100 Subject: [PATCH 07/61] update better-auth test with validation --- .../database-adapter/repository/generic.ts | 12 +++++++++--- .../database-adapter/repository/session.ts | 14 ++++++++++---- .../database-adapter/repository/user.ts | 9 ++++++++- .../database-adapter/tests/index.test.ts | 2 +- 4 files changed, 28 insertions(+), 9 deletions(-) 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 () => { From 559b35994e78113da99e492fd209d91188e5c6d2 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 19 Jan 2026 14:16:50 +0100 Subject: [PATCH 08/61] update inspector tests for runtime validation --- examples/inspector/tests/lib/data.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/examples/inspector/tests/lib/data.ts b/examples/inspector/tests/lib/data.ts index 756a1eff1b..8835f02048 100644 --- a/examples/inspector/tests/lib/data.ts +++ b/examples/inspector/tests/lib/data.ts @@ -72,19 +72,25 @@ export const createFile = () => { }; export const createImage = () => { - return ImageDefinition.create({ - originalSize: [1920, 1080], - placeholderDataURL: "data:image/jpeg;base64,...", - }); + return ImageDefinition.create( + { + originalSize: [1920, 1080], + placeholderDataURL: "data:image/jpeg;base64,...", + }, + { validation: "loose" }, + ); }; export const createOrganization = () => { return Organization.create({ name: "Garden Computing", - image: ImageDefinition.create({ - originalSize: [1920, 1080], - placeholderDataURL: "data:image/jpeg;base64,...", - }), + image: ImageDefinition.create( + { + originalSize: [1920, 1080], + placeholderDataURL: "data:image/jpeg;base64,...", + }, + { validation: "loose" }, + ), projects: co.list(Project).create( projectsData.map((project) => Project.create({ From 6c6e20b45e33ed3131d23c256ec401e47ccf1832 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 19 Jan 2026 14:26:31 +0100 Subject: [PATCH 09/61] fix co.richText usage --- packages/jazz-tools/src/react-core/tests/useCoState.test.ts | 2 +- .../implementation/zodSchema/schemaTypes/PlainTextSchema.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 930ffd45c9..103134b49b 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/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts index 4c1b14fc72..2cbc51efc4 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts @@ -27,7 +27,7 @@ export function createCoreCoPlainTextSchema(): CorePlainTextSchema { collaborative: true as const, builtin: "CoPlainText" as const, resolveQuery: true as const, - getValidationSchema: () => z.string(), + getValidationSchema: () => z.string().or(z.instanceof(CoPlainText)), }; } From ebe63875da2a75281971209a39f696e724273a15 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 19 Jan 2026 16:04:10 +0100 Subject: [PATCH 10/61] colist creation and mutation validation --- .../jazz-tools/src/tools/coValues/coList.ts | 85 ++++++++++++++++- .../zodSchema/schemaTypes/CoListSchema.ts | 8 +- .../jazz-tools/src/tools/tests/coList.test.ts | 92 ++++++++++++++++++- packages/jazz-tools/src/tools/tests/utils.ts | 25 ++++- 4 files changed, 199 insertions(+), 11 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 6d1f9dae27..5d9810ec7e 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -43,7 +43,10 @@ import { parseSubscribeRestArgs, subscribeToCoValueWithoutMe, subscribeToExistingCoValue, + CoListSchema, + AnyZodOrCoValueSchema, } from "../internal.js"; +import { z } from "../implementation/zodSchema/zodReExport.js"; /** * CoLists are collaborative versions of plain arrays. @@ -109,6 +112,8 @@ export class CoList this.prototype[TypeSym] = "CoList"; } + coListSchema: CoListSchema | undefined; + /** @internal This is only a marker type and doesn't exist at runtime */ [ItemsSym]!: Item; /** @internal */ @@ -119,7 +124,14 @@ export class CoList return Array; } - constructor(options: { fromRaw: RawCoList } | undefined) { + constructor( + options: + | { + fromRaw: RawCoList; + coListSchema?: CoListSchema; + } + | undefined, + ) { super(); const proxy = new Proxy(this, CoListProxyHandler as ProxyHandler); @@ -127,7 +139,11 @@ export class CoList if (options && "fromRaw" in options) { Object.defineProperties(this, { $jazz: { - value: new CoListJazzApi(proxy, () => options.fromRaw), + value: new CoListJazzApi( + proxy, + () => options.fromRaw, + options.coListSchema, + ), enumerable: false, }, $isLoaded: { value: true, enumerable: false }, @@ -166,6 +182,8 @@ export class CoList | { owner: Account | Group; unique?: CoValueUniqueness["uniqueness"]; + validation?: "strict" | "loose"; + coListSchema?: CoListSchema; } | Account | Group, @@ -175,7 +193,13 @@ export class CoList Object.defineProperties(instance, { $jazz: { - value: new CoListJazzApi(instance, () => raw), + value: new CoListJazzApi( + instance, + () => raw, + options && "coListSchema" in options + ? options.coListSchema + : undefined, + ), enumerable: false, }, $isLoaded: { value: true, enumerable: false }, @@ -518,16 +542,53 @@ export class CoListJazzApi extends CoValueJazzApi { constructor( private coList: L, private getRaw: () => RawCoList, + private coListSchema?: CoListSchema, ) { super(coList); } + private getItemSchema(): z.core.$ZodTypes { + const listSchema = this.coListSchema?.getValidationSchema(); + + if (listSchema?.type !== "union") { + throw new Error("List schema is not a union"); + } + + const fieldSchema = listSchema.options[1]?.element as + | z.core.$ZodTypes + | undefined; + + // ignore codecs/pipes + // even if they are optional and nullable + if ( + // @ts-expect-error + fieldSchema?._def?.type === "pipe" || + // @ts-expect-error + fieldSchema?._def?.innerType?._def?.type === "pipe" || + // @ts-expect-error + fieldSchema?._def?.innerType?._def?.innerType?._def?.type === "pipe" + ) { + return z.any(); + } + + return fieldSchema ?? z.any(); + } + /** @category Collaboration */ get owner(): Group { return getCoValueOwner(this.coList); } - set(index: number, value: CoFieldInit>): void { + set( + index: number, + value: CoFieldInit>, + options?: { validation?: "strict" | "loose" }, + ): void { + if (options?.validation !== "loose" && this.coListSchema) { + const fieldSchema = this.getItemSchema(); + value = z.parse(fieldSchema, value) as CoFieldInit>; + } + const itemDescriptor = this.schema[ItemsSym]; const rawValue = toRawItems([value], itemDescriptor, this.owner)[0]!; if (rawValue === null && !itemDescriptor.optional) { @@ -543,6 +604,22 @@ export class CoListJazzApi extends CoValueJazzApi { * @category Content */ push(...items: CoFieldInit>[]): number { + if (this.coListSchema) { + const schema = z.array(this.getItemSchema()); + + items = z.parse(schema, items) as CoFieldInit>[]; + } + 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), undefined, 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 61ad110820..ae4c6dde7f 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -113,10 +113,10 @@ export class CoListSchema< options, this.permissions, ); - return this.coValueClass.create( - items as any, - optionsWithPermissions, - ) as CoListInstance; + return this.coValueClass.create(items as any, { + ...optionsWithPermissions, + coListSchema: this, + }) as CoListInstance; } load< diff --git a/packages/jazz-tools/src/tools/tests/coList.test.ts b/packages/jazz-tools/src/tools/tests/coList.test.ts index 80fcf00ffe..1c36697f31 100644 --- a/packages/jazz-tools/src/tools/tests/coList.test.ts +++ b/packages/jazz-tools/src/tools/tests/coList.test.ts @@ -13,7 +13,12 @@ import { runWithoutActiveAccount, setupJazzTestSync, } from "../testing.js"; -import { assertLoaded, setupTwoNodes, waitFor } from "./utils.js"; +import { + assertLoaded, + expectValidationError, + setupTwoNodes, + waitFor, +} from "./utils.js"; const Crypto = await WasmCrypto.create(); @@ -100,6 +105,22 @@ 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("list with nullable content", () => { const List = co.list(z.string().nullable()); const list = List.create(["a", "b", "c", null]); @@ -152,6 +173,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(), @@ -191,7 +243,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"); @@ -250,6 +304,40 @@ 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), + ); + }); + + 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"]); diff --git a/packages/jazz-tools/src/tools/tests/utils.ts b/packages/jazz-tools/src/tools/tests/utils.ts index 11fc7b2916..ef2551662d 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,26 @@ export async function createAccountAs>( return account; } + +export async function expectValidationError( + fn: () => any | Promise, + expectedIssues?: any, +) { + let thrown = false; + try { + await fn(); + } catch (e: any) { + thrown = true; + if (e?.name !== "ZodError") { + throw e; + } + + if (expectedIssues) { + expect(e.issues).toEqual(expectedIssues); + } + } + + if (!thrown) { + throw new Error("Expected validation error, but no error was thrown"); + } +} From 6e6c3adbaad674937e711b99fda190386f574a4f Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 19 Jan 2026 19:14:21 +0100 Subject: [PATCH 11/61] validation on loading --- .../src/tools/coValues/CoValueBase.ts | 9 +- .../jazz-tools/src/tools/coValues/coList.ts | 26 ++---- .../jazz-tools/src/tools/coValues/coMap.ts | 42 ++++------ .../src/tools/coValues/interfaces.ts | 2 + .../coValueSchemaTransformation.ts | 28 +++---- .../zodSchema/schemaTypes/CoListSchema.ts | 8 +- .../zodSchema/schemaTypes/CoMapSchema.ts | 5 +- .../zodSchema/schemaTypes/CoValueSchema.ts | 4 +- .../jazz-tools/src/tools/tests/coList.test.ts | 83 ++++++++++++++++++- .../jazz-tools/src/tools/tests/coMap.test.ts | 25 +++++- packages/jazz-tools/src/tools/tests/utils.ts | 4 +- 11 files changed, 162 insertions(+), 74 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/CoValueBase.ts b/packages/jazz-tools/src/tools/coValues/CoValueBase.ts index 3f73af5276..0ed5e713dc 100644 --- a/packages/jazz-tools/src/tools/coValues/CoValueBase.ts +++ b/packages/jazz-tools/src/tools/coValues/CoValueBase.ts @@ -15,6 +15,7 @@ import { unstable_mergeBranch, } from "../internal.js"; import { Group, TypeSym } from "../internal.js"; +import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; /** @internal */ export abstract class CoValueBase implements CoValue { @@ -30,8 +31,12 @@ export abstract class CoValueBase implements CoValue { } /** @category Internals */ - static fromRaw(this: CoValueClass, raw: RawCoValue): V { - return new this({ fromRaw: raw }); + static fromRaw( + this: CoValueClass, + raw: RawCoValue, + schema?: CoreCoValueSchema, + ): V { + return new this({ fromRaw: raw, schema }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 5d9810ec7e..96379f1fc1 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -47,6 +47,7 @@ import { AnyZodOrCoValueSchema, } from "../internal.js"; import { z } from "../implementation/zodSchema/zodReExport.js"; +import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; /** * CoLists are collaborative versions of plain arrays. @@ -112,8 +113,6 @@ export class CoList this.prototype[TypeSym] = "CoList"; } - coListSchema: CoListSchema | undefined; - /** @internal This is only a marker type and doesn't exist at runtime */ [ItemsSym]!: Item; /** @internal */ @@ -125,24 +124,19 @@ export class CoList } constructor( - options: - | { - fromRaw: RawCoList; - coListSchema?: CoListSchema; - } - | undefined, + options: { fromRaw?: RawCoList; schema?: CoreCoValueSchema } | undefined, ) { super(); const proxy = new Proxy(this, CoListProxyHandler as ProxyHandler); - if (options && "fromRaw" in options) { + if (options && "fromRaw" in options && options.fromRaw) { Object.defineProperties(this, { $jazz: { value: new CoListJazzApi( proxy, - () => options.fromRaw, - options.coListSchema, + () => options.fromRaw!, + options.schema as CoListSchema, ), enumerable: false, }, @@ -183,7 +177,6 @@ export class CoList owner: Account | Group; unique?: CoValueUniqueness["uniqueness"]; validation?: "strict" | "loose"; - coListSchema?: CoListSchema; } | Account | Group, @@ -196,9 +189,7 @@ export class CoList value: new CoListJazzApi( instance, () => raw, - options && "coListSchema" in options - ? options.coListSchema - : undefined, + this.coValueSchema as CoListSchema, ), enumerable: false, }, @@ -542,7 +533,7 @@ export class CoListJazzApi extends CoValueJazzApi { constructor( private coList: L, private getRaw: () => RawCoList, - private coListSchema?: CoListSchema, + private coListSchema?: CoreCoValueSchema, ) { super(coList); } @@ -550,10 +541,11 @@ export class CoListJazzApi extends CoValueJazzApi { private getItemSchema(): z.core.$ZodTypes { const listSchema = this.coListSchema?.getValidationSchema(); - if (listSchema?.type !== "union") { + if (!listSchema || ("type" in listSchema && listSchema.type !== "union")) { throw new Error("List schema is not a union"); } + // @ts-expect-error as union, it has options fields and 2nd is the plain shape const fieldSchema = listSchema.options[1]?.element as | z.core.$ZodTypes | undefined; diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 676b119d35..1f78221d83 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -113,7 +113,6 @@ type CoMapFieldSchema = { export class CoMap extends CoValueBase implements CoValue { /** @category Type Helpers */ declare [TypeSym]: "CoMap"; - coMapSchema: CoreCoMapSchema | undefined; static { this.prototype[TypeSym] = "CoMap"; } @@ -132,27 +131,19 @@ export class CoMap extends CoValueBase implements CoValue { /** @internal */ constructor( - options: { fromRaw: RawCoMap; coMapSchema?: CoreCoMapSchema } | undefined, + options: { fromRaw: RawCoMap; schema?: CoreCoMapSchema } | undefined, ) { super(); const proxy = new Proxy(this, CoMapProxyHandler as ProxyHandler); - if (options) { - if ("fromRaw" in options) { - Object.defineProperties(this, { - $jazz: { - value: new CoMapJazzApi( - proxy, - () => options.fromRaw, - options.coMapSchema, - ), - enumerable: false, - }, - }); - } else { - throw new Error("Invalid CoMap constructor arguments"); - } + if (options && "fromRaw" in options) { + Object.defineProperties(this, { + $jazz: { + value: new CoMapJazzApi(proxy, () => options.fromRaw, options.schema), + enumerable: false, + }, + }); } return proxy; @@ -185,14 +176,17 @@ export class CoMap extends CoValueBase implements CoValue { | { owner?: Account | Group; unique?: CoValueUniqueness["uniqueness"]; - coMapSchema?: CoreCoMapSchema; } | Account | Group, ) { const instance = new this(); - - return CoMap._createCoMap(instance, init, options); + return CoMap._createCoMap( + instance, + this.coValueSchema as CoreCoMapSchema, + init, + options, + ); } /** @@ -254,12 +248,12 @@ export class CoMap extends CoValueBase implements CoValue { */ static _createCoMap( instance: M, + schema: CoreCoMapSchema, init: Simplify>, options?: | { owner?: Account | Group; unique?: CoValueUniqueness["uniqueness"]; - coMapSchema?: CoreCoMapSchema | undefined; } | Account | Group, @@ -268,11 +262,7 @@ export class CoMap extends CoValueBase implements CoValue { Object.defineProperties(instance, { $jazz: { - value: new CoMapJazzApi( - instance, - () => raw, - options && "coMapSchema" in options ? options.coMapSchema : undefined, - ), + value: new CoMapJazzApi(instance, () => raw, schema), enumerable: false, }, }); diff --git a/packages/jazz-tools/src/tools/coValues/interfaces.ts b/packages/jazz-tools/src/tools/coValues/interfaces.ts index 6f327361e8..427295cbf2 100644 --- a/packages/jazz-tools/src/tools/coValues/interfaces.ts +++ b/packages/jazz-tools/src/tools/coValues/interfaces.ts @@ -33,10 +33,12 @@ import { import type { BranchDefinition } 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 { + coValueSchema?: CoreCoValueSchema; /** @ignore */ // eslint-disable-next-line @typescript-eslint/no-explicit-any new (...args: any[]): Value; 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 1d65ca9604..17435d7ba4 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts @@ -82,12 +82,12 @@ export function hydrateCoreCoValueSchema( const ClassToExtend = schema.builtin === "Account" ? Account : CoMap; const coValueClass = class ZCoMap extends ClassToExtend { - constructor( - options: - | { fromRaw: RawCoMap; validationSchema?: CoreCoMapSchema } - | undefined, - ) { - super(options); + static coValueSchema: CoreCoValueSchema; + constructor(options: { fromRaw: RawCoMap } | undefined) { + super({ + ...options, + schema: ZCoMap.coValueSchema, + }); for (const [fieldName, fieldType] of Object.entries(def.shape)) { (this as any)[fieldName] = schemaFieldToCoFieldDef( fieldType as SchemaField, @@ -99,13 +99,6 @@ export function hydrateCoreCoValueSchema( ); } } - - static fromRaw(raw: RawCoMap) { - return new this({ - fromRaw: raw, - validationSchema: schema as CoreCoMapSchema, - }); - } }; const coValueSchema = @@ -113,12 +106,18 @@ export function hydrateCoreCoValueSchema( ? new AccountSchema(schema as any, coValueClass as any) : new CoMapSchema(schema as any, coValueClass as any); + coValueClass.coValueSchema = coValueSchema; + return coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoList") { const element = schema.element; const coValueClass = class ZCoList extends CoList { + static coValueSchema: CoreCoValueSchema; constructor(options: { fromRaw: RawCoList } | undefined) { - super(options); + super({ + ...options, + schema: ZCoList.coValueSchema, + }); (this as any)[coField.items] = schemaFieldToCoFieldDef( element as SchemaField, ); @@ -126,6 +125,7 @@ export function hydrateCoreCoValueSchema( }; const coValueSchema = new CoListSchema(element, coValueClass as any); + coValueClass.coValueSchema = coValueSchema; return coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "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 ae4c6dde7f..61ad110820 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -113,10 +113,10 @@ export class CoListSchema< options, this.permissions, ); - return this.coValueClass.create(items as any, { - ...optionsWithPermissions, - coListSchema: this, - }) as CoListInstance; + return this.coValueClass.create( + items as any, + optionsWithPermissions, + ) as CoListInstance; } load< 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 c39d35b085..ff632ae9e9 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -161,10 +161,7 @@ export class CoMapSchema< init = this.getValidationSchema().parse(init); } - return this.coValueClass.create(init, { - ...optionsWithPermissions, - coMapSchema: this, - }); + return this.coValueClass.create(init, optionsWithPermissions); } load< 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 e5c99d3ca9..5558bf6e6d 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoValueSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoValueSchema.ts @@ -1,4 +1,4 @@ -import type { AnyZodSchema } from "../zodSchema.js"; +import { z } from "../../../exports.js"; /** * "Core" CoValue schemas contain all data necessary to represent a CoValue schema. @@ -20,7 +20,7 @@ export interface CoreCoValueSchema { resolveQuery: CoreResolveQuery; - getValidationSchema: () => AnyZodSchema; + getValidationSchema: () => z.z.core.$ZodTypes; } /** diff --git a/packages/jazz-tools/src/tools/tests/coList.test.ts b/packages/jazz-tools/src/tools/tests/coList.test.ts index 1c36697f31..c94ec7c402 100644 --- a/packages/jazz-tools/src/tools/tests/coList.test.ts +++ b/packages/jazz-tools/src/tools/tests/coList.test.ts @@ -121,6 +121,31 @@ describe("Simple CoList operations", async () => { ).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]); @@ -163,6 +188,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"], { @@ -676,7 +755,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); @@ -1221,7 +1300,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(); } diff --git a/packages/jazz-tools/src/tools/tests/coMap.test.ts b/packages/jazz-tools/src/tools/tests/coMap.test.ts index ca3009bc74..6e973334d5 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.test.ts @@ -29,7 +29,12 @@ import { setActiveAccount, setupJazzTestSync, } from "../testing.js"; -import { assertLoaded, setupTwoNodes, waitFor } from "./utils.js"; +import { + assertLoaded, + expectValidationError, + setupTwoNodes, + waitFor, +} from "./utils.js"; const Crypto = await WasmCrypto.create(); @@ -1645,6 +1650,24 @@ describe("CoMap resolution", async () => { 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( + // @ts-expect-error - string is not a number + () => person2.$jazz.set("age", "20"), + ); + }); }); describe("CoMap applyDiff", async () => { diff --git a/packages/jazz-tools/src/tools/tests/utils.ts b/packages/jazz-tools/src/tools/tests/utils.ts index ef2551662d..2e639a1e8b 100644 --- a/packages/jazz-tools/src/tools/tests/utils.ts +++ b/packages/jazz-tools/src/tools/tests/utils.ts @@ -179,13 +179,13 @@ export async function createAccountAs>( return account; } -export async function expectValidationError( +export function expectValidationError( fn: () => any | Promise, expectedIssues?: any, ) { let thrown = false; try { - await fn(); + fn(); } catch (e: any) { thrown = true; if (e?.name !== "ZodError") { From 4e52fe051c34da98c56ada3273ded904a803234a Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 19 Jan 2026 20:13:32 +0100 Subject: [PATCH 12/61] cleanups --- .../tests/repository/user.test.ts | 55 ------------------- .../jazz-tools/src/tools/coValues/coList.ts | 8 +-- .../jazz-tools/src/tools/coValues/coMap.ts | 1 - .../zodSchema/schemaTypes/CoValueSchema.ts | 4 +- 4 files changed, 4 insertions(+), 64 deletions(-) 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/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 96379f1fc1..cd92a9799b 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -136,7 +136,7 @@ export class CoList value: new CoListJazzApi( proxy, () => options.fromRaw!, - options.schema as CoListSchema, + options.schema, ), enumerable: false, }, @@ -186,11 +186,7 @@ export class CoList Object.defineProperties(instance, { $jazz: { - value: new CoListJazzApi( - instance, - () => raw, - this.coValueSchema as CoListSchema, - ), + value: new CoListJazzApi(instance, () => raw, this.coValueSchema), enumerable: false, }, $isLoaded: { value: true, enumerable: false }, diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 1f78221d83..1b1f4f7714 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -54,7 +54,6 @@ import { parseSubscribeRestArgs, subscribeToCoValueWithoutMe, subscribeToExistingCoValue, - AnyCoreCoValueSchema, CoreCoMapSchema, } from "../internal.js"; import { z } from "../implementation/zodSchema/zodReExport.js"; 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 5558bf6e6d..e3a5cdac96 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoValueSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoValueSchema.ts @@ -1,4 +1,4 @@ -import { z } from "../../../exports.js"; +import { AnyZodSchema } from "../zodSchema.js"; /** * "Core" CoValue schemas contain all data necessary to represent a CoValue schema. @@ -20,7 +20,7 @@ export interface CoreCoValueSchema { resolveQuery: CoreResolveQuery; - getValidationSchema: () => z.z.core.$ZodTypes; + getValidationSchema: () => AnyZodSchema; } /** From de93f372ede5369e939566b681e59a1e9de72dd7 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 20 Jan 2026 13:42:47 +0100 Subject: [PATCH 13/61] feedback addressed --- packages/jazz-tools/src/tools/coValues/CoValueBase.ts | 9 ++------- packages/jazz-tools/src/tools/coValues/coList.ts | 10 ++++------ packages/jazz-tools/src/tools/coValues/coMap.ts | 11 +++++++---- .../runtimeConverters/coValueSchemaTransformation.ts | 10 ++-------- .../schemaTypes/CoDiscriminatedUnionSchema.ts | 4 +--- .../zodSchema/schemaTypes/CoFeedSchema.ts | 7 +------ .../zodSchema/schemaTypes/CoListSchema.ts | 11 +---------- .../zodSchema/schemaTypes/CoMapSchema.ts | 7 +++++-- .../zodSchema/schemaTypes/CoOptionalSchema.ts | 10 ---------- .../zodSchema/schemaTypes/CoVectorSchema.ts | 7 +------ .../zodSchema/schemaTypes/FileStreamSchema.ts | 4 +--- .../zodSchema/schemaTypes/GroupSchema.ts | 2 +- .../zodSchema/schemaTypes/PlainTextSchema.ts | 2 +- .../zodSchema/schemaTypes/RichTextSchema.ts | 2 +- 14 files changed, 28 insertions(+), 68 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/CoValueBase.ts b/packages/jazz-tools/src/tools/coValues/CoValueBase.ts index 0ed5e713dc..3f73af5276 100644 --- a/packages/jazz-tools/src/tools/coValues/CoValueBase.ts +++ b/packages/jazz-tools/src/tools/coValues/CoValueBase.ts @@ -15,7 +15,6 @@ import { unstable_mergeBranch, } from "../internal.js"; import { Group, TypeSym } from "../internal.js"; -import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; /** @internal */ export abstract class CoValueBase implements CoValue { @@ -31,12 +30,8 @@ export abstract class CoValueBase implements CoValue { } /** @category Internals */ - static fromRaw( - this: CoValueClass, - raw: RawCoValue, - schema?: CoreCoValueSchema, - ): V { - return new this({ fromRaw: raw, schema }); + static fromRaw(this: CoValueClass, raw: RawCoValue): V { + return new this({ fromRaw: raw }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index cd92a9799b..141de2b38d 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -123,20 +123,18 @@ export class CoList return Array; } - constructor( - options: { fromRaw?: RawCoList; schema?: CoreCoValueSchema } | undefined, - ) { + constructor(options: { fromRaw: RawCoList } | undefined) { super(); const proxy = new Proxy(this, CoListProxyHandler as ProxyHandler); - if (options && "fromRaw" in options && options.fromRaw) { + if (options && "fromRaw" in options) { Object.defineProperties(this, { $jazz: { value: new CoListJazzApi( proxy, - () => options.fromRaw!, - options.schema, + () => options.fromRaw, + (this.constructor as any).coValueSchema, ), enumerable: false, }, diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 1b1f4f7714..a2317574b5 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -129,9 +129,7 @@ export class CoMap extends CoValueBase implements CoValue { static _schema: CoMapFieldSchema; /** @internal */ - constructor( - options: { fromRaw: RawCoMap; schema?: CoreCoMapSchema } | undefined, - ) { + constructor(options: { fromRaw: RawCoMap } | undefined) { super(); const proxy = new Proxy(this, CoMapProxyHandler as ProxyHandler); @@ -139,7 +137,12 @@ export class CoMap extends CoValueBase implements CoValue { if (options && "fromRaw" in options) { Object.defineProperties(this, { $jazz: { - value: new CoMapJazzApi(proxy, () => options.fromRaw, options.schema), + value: new CoMapJazzApi( + proxy, + () => options.fromRaw, + // coValueSchema is defined in /implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts + (this.constructor as any).coValueSchema, + ), enumerable: false, }, }); 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 17435d7ba4..18525a0f6b 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts @@ -84,10 +84,7 @@ export function hydrateCoreCoValueSchema( const coValueClass = class ZCoMap extends ClassToExtend { static coValueSchema: CoreCoValueSchema; constructor(options: { fromRaw: RawCoMap } | undefined) { - super({ - ...options, - schema: ZCoMap.coValueSchema, - }); + super(options); for (const [fieldName, fieldType] of Object.entries(def.shape)) { (this as any)[fieldName] = schemaFieldToCoFieldDef( fieldType as SchemaField, @@ -114,10 +111,7 @@ export function hydrateCoreCoValueSchema( const coValueClass = class ZCoList extends CoList { static coValueSchema: CoreCoValueSchema; constructor(options: { fromRaw: RawCoList } | undefined) { - super({ - ...options, - schema: ZCoList.coValueSchema, - }); + super(options); (this as any)[coField.items] = schemaFieldToCoFieldDef( element as SchemaField, ); 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 7ab8b479bc..5de7ccf204 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts @@ -229,9 +229,7 @@ export function createCoreCoDiscriminatedUnionSchema< return { collaborative: true as const, builtin: "CoDiscriminatedUnion" as const, - getValidationSchema: () => { - return z.any(); - }, + 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 96182718f8..7829602a2e 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts @@ -234,12 +234,7 @@ export function createCoreCoFeedSchema( builtin: "CoFeed" as const, element, resolveQuery: true as const, - getValidationSchema: () => - z.array( - element instanceof z.core.$ZodType - ? element - : element.getValidationSchema(), - ), + getValidationSchema: () => z.any(), }; } 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 61ad110820..c927d68fc0 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -308,16 +308,7 @@ export function createCoreCoListSchema( builtin: "CoList" as const, element, resolveQuery: true as const, - getValidationSchema: () => - z - .instanceof(CoList) - .or( - z.array( - element instanceof z.core.$ZodType - ? element - : element.getValidationSchema(), - ), - ), + getValidationSchema: () => z.any(), }; } 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 ff632ae9e9..04656961d8 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -59,7 +59,10 @@ export class CoMapSchema< for (const key in this.shape) { const item = this.shape[key]; - // item is a Group + // 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?.prototype?.[TypeSym] === "Group") { plainShape[key] = z.instanceof(Group); } else if ( @@ -509,7 +512,7 @@ export function createCoreCoMapSchema< }, }), resolveQuery: true as const, - getValidationSchema: () => z.object(shape), + getValidationSchema: () => z.any(), }; } 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 5399a6a01a..a6b40aa05d 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoOptionalSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoOptionalSchema.ts @@ -31,16 +31,6 @@ export class CoOptionalSchema< constructor(public readonly innerType: Shape) {} getValidationSchema = () => { - if (this.innerType.builtin === "CoMap") { - return z.optional( - z.instanceof(CoMap).or(this.innerType.getValidationSchema()), - ); - } else if (this.innerType.builtin === "CoList") { - return z.optional( - z.instanceof(CoList).or(this.innerType.getValidationSchema()), - ); - } - return z.optional(this.innerType.getValidationSchema()); }; 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 2bda110194..7ae5b12df5 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoVectorSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoVectorSchema.ts @@ -29,12 +29,7 @@ export function createCoreCoVectorSchema( builtin: "CoVector" as const, dimensions, resolveQuery: true as const, - getValidationSchema: () => { - return z - .instanceof(CoVector) - .or(z.instanceof(Float32Array)) - .or(z.array(z.number())); - }, + getValidationSchema: () => z.any(), }; } 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 38de9909c8..86ac0f22e9 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/FileStreamSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/FileStreamSchema.ts @@ -25,9 +25,7 @@ export function createCoreFileStreamSchema(): CoreFileStreamSchema { collaborative: true as const, builtin: "FileStream" as const, resolveQuery: true as const, - getValidationSchema: () => { - return z.instanceof(FileStream); - }, + getValidationSchema: () => z.any(), }; } 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 2d1334478a..6fee7b9571 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/GroupSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/GroupSchema.ts @@ -27,7 +27,7 @@ export function createCoreGroupSchema(): CoreGroupSchema { collaborative: true as const, builtin: "Group" as const, resolveQuery: true as const, - getValidationSchema: () => z.instanceof(Group), + getValidationSchema: () => z.any(), }; } 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 2cbc51efc4..b6ec9f611f 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts @@ -27,7 +27,7 @@ export function createCoreCoPlainTextSchema(): CorePlainTextSchema { collaborative: true as const, builtin: "CoPlainText" as const, resolveQuery: true as const, - getValidationSchema: () => z.string().or(z.instanceof(CoPlainText)), + getValidationSchema: () => z.any(), }; } 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 20dc8f339e..645590a507 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/RichTextSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/RichTextSchema.ts @@ -26,7 +26,7 @@ export function createCoreCoRichTextSchema(): CoreRichTextSchema { collaborative: true as const, builtin: "CoRichText" as const, resolveQuery: true as const, - getValidationSchema: () => z.string().or(z.instanceof(CoRichText)), + getValidationSchema: () => z.any(), }; } From 7fa442cce6315d922277a88d46d5f464c7f0ac2d Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 20 Jan 2026 17:20:14 +0100 Subject: [PATCH 14/61] refactor: zodSchema from CoValueSchema --- .../zodSchema/schemaTypes/CoFeedSchema.ts | 9 ++-- .../zodSchema/schemaTypes/CoListSchema.ts | 9 +--- .../zodSchema/schemaTypes/CoMapSchema.ts | 47 ++++------------ .../zodSchema/schemaTypes/CoValueSchema.ts | 4 +- .../zodSchema/schemaTypes/schemaValidators.ts | 54 +++++++++++++------ .../jazz-tools/src/tools/tests/coMap.test.ts | 15 +++--- 6 files changed, 65 insertions(+), 73 deletions(-) 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 7829602a2e..d18ddc9361 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts @@ -27,6 +27,7 @@ import { SchemaPermissions, } from "../schemaPermissions.js"; import { z } from "../zodReExport.js"; +import { generateValidationSchemaFromItem } from "./schemaValidators.js"; export class CoFeedSchema< T extends AnyZodOrCoValueSchema, @@ -53,11 +54,9 @@ export class CoFeedSchema< } getValidationSchema = () => { - return z.array( - this.element instanceof z.core.$ZodType - ? this.element - : this.element.getValidationSchema(), - ); + return z + .instanceof(CoFeed) + .or(z.array(generateValidationSchemaFromItem(this.element))); }; constructor( 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 c927d68fc0..2a021762e4 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -29,6 +29,7 @@ import { SchemaPermissions, } from "../schemaPermissions.js"; import { z } from "../zodReExport.js"; +import { generateValidationSchemaFromItem } from "./schemaValidators.js"; export class CoListSchema< T extends AnyZodOrCoValueSchema, @@ -57,13 +58,7 @@ export class CoListSchema< getValidationSchema = () => { return z .instanceof(CoList) - .or( - z.array( - this.element instanceof z.core.$ZodType - ? this.element - : this.element.getValidationSchema(), - ), - ); + .or(z.array(generateValidationSchemaFromItem(this.element))); }; constructor( 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 04656961d8..746a23d355 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -19,7 +19,7 @@ import { isAnyCoValueSchema, unstable_mergeBranchWithResolve, withSchemaPermissions, - TypeSym, + isCoValueSchema, } from "../../../internal.js"; import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js"; import { removeGetters, withSchemaResolveQuery } from "../../schemaUtils.js"; @@ -34,7 +34,7 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; -import { isAnyCoValue, isCoValueSchema } from "./schemaValidators.js"; +import { generateValidationSchemaFromItem } from "./schemaValidators.js"; type CoMapSchemaInstance = Simplify< CoMapInstanceCoValuesMaybeLoaded @@ -55,54 +55,29 @@ export class CoMapSchema< getDefinition: () => CoMapSchemaDefinition; getValidationSchema = () => { - const plainShape: Record = {}; + const plainShape: Record = {}; for (const key in this.shape) { const item = this.shape[key]; - // 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?.prototype?.[TypeSym] === "Group") { - plainShape[key] = z.instanceof(Group); - } else if ( - item?.prototype?.[TypeSym] === "Account" || - (isCoValueSchema(item) && item.builtin === "Account") - ) { - plainShape[key] = isAnyCoValue; - } else if (isCoValueSchema(item)) { + if (isCoValueSchema(item)) { // Inject as getter to avoid circularity issues Object.defineProperty(plainShape, key, { - get: () => isAnyCoValue.or(item.getValidationSchema()), + get: () => generateValidationSchemaFromItem(item), enumerable: true, configurable: true, }); - } else if ((item as any) instanceof z.core.$ZodType) { - // the following zod types are not supported: - if ( - // codecs are managed lower level - item._def.type === "pipe" - ) { - plainShape[key] = z.any(); - } else { - plainShape[key] = item; - } } else { - throw new Error(`Unsupported schema type: ${item}`); + plainShape[key] = generateValidationSchemaFromItem(this.shape[key]); } } let validationSchema = z.strictObject(plainShape); if (this.catchAll) { - if (isCoValueSchema(this.catchAll)) { - validationSchema = validationSchema.catchall( - this.catchAll.getValidationSchema(), - ); - } else if ((this.catchAll as any) instanceof z.core.$ZodType) { - validationSchema = validationSchema.catchall( - this.catchAll as unknown as z.core.$ZodType, - ); - } + validationSchema = validationSchema.catchall( + generateValidationSchemaFromItem( + this.catchAll as unknown as AnyZodOrCoValueSchema, + ), + ); } return z.instanceof(CoMap).or(validationSchema); 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 e3a5cdac96..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,4 +1,4 @@ -import { AnyZodSchema } from "../zodSchema.js"; +import { z } from "../zodReExport.js"; /** * "Core" CoValue schemas contain all data necessary to represent a CoValue schema. @@ -20,7 +20,7 @@ export interface CoreCoValueSchema { resolveQuery: CoreResolveQuery; - getValidationSchema: () => AnyZodSchema; + getValidationSchema: () => z.ZodType; } /** diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts index 49b71a23cb..37b2d2da83 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts @@ -1,22 +1,42 @@ -import { Account, Group } from "../../../internal.js"; +import { Account, Group, isCoValueSchema, TypeSym } from "../../../internal.js"; import { z } from "../zodReExport.js"; import type { CoreCoValueSchema } from "./CoValueSchema.js"; -export const isCoValueSchema = (item: any): item is CoreCoValueSchema => { - return ( - typeof item === "object" && - item !== null && - "collaborative" in item && - item.collaborative === true - ); -}; +type InputSchema = + | typeof Group + | typeof Account + | CoreCoValueSchema + | z.ZodType + | z.core.$ZodType; -export const isAnyCoValue = z.object({ - $jazz: z.object({ - id: z.string(), - }), -}); +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 ("prototype" in item && item.prototype?.[TypeSym] === "Group") { + return z.instanceof(Group); + } + // Same as above: `co.map({ account: Account })` vs `co.map({ account: co.account() })` + if ("prototype" in item && item.prototype?.[TypeSym] === "Account") { + return z.instanceof(Account); + } -// any $jazz.id can be a valid account during validation -// TODO: improve this, validating some corner cases -export const isAccount = isAnyCoValue; + 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}`); +} diff --git a/packages/jazz-tools/src/tools/tests/coMap.test.ts b/packages/jazz-tools/src/tools/tests/coMap.test.ts index 6e973334d5..48d453df08 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.test.ts @@ -177,12 +177,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"); }); From c6b6ed7c14afa50983f72204b1e9c69aa76f3d17 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 20 Jan 2026 18:34:51 +0100 Subject: [PATCH 15/61] coFeed runtime validation --- .../jazz-tools/src/tools/coValues/coFeed.ts | 68 +++++++++++++++++- .../jazz-tools/src/tools/coValues/coList.ts | 13 ++-- .../jazz-tools/src/tools/coValues/coMap.ts | 11 ++- .../coValueSchemaTransformation.ts | 1 + .../jazz-tools/src/tools/tests/coFeed.test.ts | 70 ++++++++++++++++++- 5 files changed, 145 insertions(+), 18 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index 2cae31c23f..f67e9f1d47 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -50,6 +50,8 @@ import { subscribeToCoValueWithoutMe, subscribeToExistingCoValue, } from "../internal.js"; +import { z } from "../implementation/zodSchema/zodReExport.js"; +import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; /** @deprecated Use CoFeedEntry instead */ export type CoStreamEntry = CoFeedEntry; @@ -93,6 +95,7 @@ export { CoFeed as CoStream }; * @category CoValues */ export class CoFeed extends CoValueBase implements CoValue { + static coValueSchema?: CoreCoValueSchema; declare $jazz: CoFeedJazzApi; /** @@ -203,7 +206,12 @@ export class CoFeed extends CoValueBase implements CoValue { Object.defineProperties(this, { $jazz: { - value: new CoFeedJazzApi(this, options.fromRaw), + value: new CoFeedJazzApi( + this, + options.fromRaw, + // coValueSchema is defined in /implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts + (this.constructor as typeof CoFeed).coValueSchema, + ), enumerable: false, }, }); @@ -219,14 +227,28 @@ export class CoFeed extends CoValueBase implements CoValue { static create( this: CoValueClass, init: S extends CoFeed ? Item[] : never, - options?: { owner: Account | Group } | Account | Group, + options?: + | { + owner?: Account | Group; + validation?: "strict" | "loose"; + } + | Account + | Group, ) { const { owner } = parseCoValueCreateOptions(options); const raw = owner.$jazz.raw.createStream(); const instance = new this({ fromRaw: raw }); if (init) { - instance.$jazz.push(...init); + const validation = + options && typeof options === "object" && "validation" in options + ? options.validation + : undefined; + if (validation !== "loose") { + instance.$jazz.push(...init); + } else { + instance.$jazz.pushLoose(...init); + } } return instance; } @@ -333,10 +355,36 @@ export class CoFeedJazzApi extends CoValueJazzApi { constructor( private coFeed: F, public raw: RawCoStream, + private coFeedSchema?: CoreCoValueSchema, ) { super(coFeed); } + private getItemSchema(): z.ZodType { + const feedSchema = this.coFeedSchema?.getValidationSchema(); + + if (!feedSchema || ("type" in feedSchema && feedSchema.type !== "union")) { + throw new Error("Feed schema is not a union"); + } + + // @ts-expect-error as union, it has options fields and 2nd is the plain shape + const fieldSchema = feedSchema.options[1]?.element as z.ZodType | undefined; + + // ignore codecs/pipes + // even if they are optional and nullable + if ( + fieldSchema?.def?.type === "pipe" || + // @ts-expect-error + fieldSchema?.def?.innerType?.def?.type === "pipe" || + // @ts-expect-error + fieldSchema?.def?.innerType?.def?.innerType?.def?.type === "pipe" + ) { + return z.any(); + } + + return fieldSchema ?? z.any(); + } + get owner(): Group { return getCoValueOwner(this.coFeed); } @@ -362,6 +410,20 @@ export class CoFeedJazzApi extends CoValueJazzApi { * @category Content */ push(...items: CoFieldInit>[]): void { + if (this.coFeedSchema) { + const schema = z.array(this.getItemSchema()); + + items = z.parse(schema, items) as CoFieldInit>[]; + } + 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); } diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 141de2b38d..90985d0598 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -532,7 +532,7 @@ export class CoListJazzApi extends CoValueJazzApi { super(coList); } - private getItemSchema(): z.core.$ZodTypes { + private getItemSchema(): z.ZodType { const listSchema = this.coListSchema?.getValidationSchema(); if (!listSchema || ("type" in listSchema && listSchema.type !== "union")) { @@ -540,19 +540,16 @@ export class CoListJazzApi extends CoValueJazzApi { } // @ts-expect-error as union, it has options fields and 2nd is the plain shape - const fieldSchema = listSchema.options[1]?.element as - | z.core.$ZodTypes - | undefined; + const fieldSchema = listSchema.options[1]?.element as z.ZodType | undefined; // ignore codecs/pipes // even if they are optional and nullable if ( + fieldSchema?.def?.type === "pipe" || // @ts-expect-error - fieldSchema?._def?.type === "pipe" || + fieldSchema?.def?.innerType?.def?.type === "pipe" || // @ts-expect-error - fieldSchema?._def?.innerType?._def?.type === "pipe" || - // @ts-expect-error - fieldSchema?._def?.innerType?._def?.innerType?._def?.type === "pipe" + fieldSchema?.def?.innerType?.def?.innerType?.def?.type === "pipe" ) { return z.any(); } diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index a2317574b5..97dd49af1e 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -585,7 +585,7 @@ class CoMapJazzApi extends CoValueJazzApi { return getCoValueOwner(this.coMap); } - private getPropertySchema(key: string): z.core.$ZodTypes { + private getPropertySchema(key: string): z.ZodType { if (this.cachedSchema === undefined) { return z.any(); } @@ -596,18 +596,17 @@ class CoMapJazzApi extends CoValueJazzApi { // @ts-expect-error as union, it has options fields and 2nd is the plain shape const fieldSchema = this.cachedSchema.options[1]?.shape?.[key] as - | z.core.$ZodTypes + | z.ZodType | undefined; // ignore codecs/pipes // even if they are optional and nullable if ( + fieldSchema?.def?.type === "pipe" || // @ts-expect-error - fieldSchema?._def?.type === "pipe" || + fieldSchema?.def?.innerType?.def?.type === "pipe" || // @ts-expect-error - fieldSchema?._def?.innerType?._def?.type === "pipe" || - // @ts-expect-error - fieldSchema?._def?.innerType?._def?.innerType?._def?.type === "pipe" + fieldSchema?.def?.innerType?.def?.innerType?.def?.type === "pipe" ) { return z.any(); } 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 18525a0f6b..5fb8465ef2 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts @@ -127,6 +127,7 @@ export function hydrateCoreCoValueSchema( schemaFieldToCoFieldDef(schema.element as SchemaField), ); const coValueSchema = new CoFeedSchema(schema.element, coValueClass); + coValueClass.coValueSchema = coValueSchema; return coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "FileStream") { const coValueClass = FileStream; diff --git a/packages/jazz-tools/src/tools/tests/coFeed.test.ts b/packages/jazz-tools/src/tools/tests/coFeed.test.ts index 8e85f3aa5e..05fc1cada5 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, @@ -85,6 +90,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 +178,25 @@ 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, + { validation: "loose" }, + ); + }); }); }); @@ -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 () => { From 1d80d8fac95616c3ba89db588bb50dbdeae6ee45 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 20 Jan 2026 18:50:22 +0100 Subject: [PATCH 16/61] typos --- packages/jazz-tools/src/tools/coValues/coList.ts | 1 - packages/jazz-tools/src/tools/tests/coFeed.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 90985d0598..07194f9e94 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -174,7 +174,6 @@ export class CoList | { owner: Account | Group; unique?: CoValueUniqueness["uniqueness"]; - validation?: "strict" | "loose"; } | Account | Group, diff --git a/packages/jazz-tools/src/tools/tests/coFeed.test.ts b/packages/jazz-tools/src/tools/tests/coFeed.test.ts index 05fc1cada5..2a8ab1d06b 100644 --- a/packages/jazz-tools/src/tools/tests/coFeed.test.ts +++ b/packages/jazz-tools/src/tools/tests/coFeed.test.ts @@ -194,7 +194,6 @@ describe("Simple CoFeed operations", async () => { stream.$jazz.pushLoose( // @ts-expect-error - number is not a string 123, - { validation: "loose" }, ); }); }); From b00ae535f652e71d0274fdd7b2960eee79b30394 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Wed, 21 Jan 2026 10:31:11 +0100 Subject: [PATCH 17/61] coFeed.create validation option type --- .../zodSchema/schemaTypes/CoFeedSchema.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 d18ddc9361..304118c4fb 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts @@ -66,16 +66,22 @@ export class CoFeedSchema< create( init: CoFeedSchemaInit, - options?: { owner: Group } | Group, + options?: { owner?: Group; validation?: "strict" | "loose" } | Group, ): CoFeedInstance; /** @deprecated Creating CoValues with an Account as owner is deprecated. Use a Group instead. */ create( init: CoFeedSchemaInit, - options?: { owner: Account | Group } | Account | Group, + options?: + | { owner?: Account | Group; validation?: "strict" | "loose" } + | Account + | Group, ): CoFeedInstance; create( init: CoFeedSchemaInit, - options?: { owner: Account | Group } | Account | Group, + options?: + | { owner?: Account | Group; validation?: "strict" | "loose" } + | Account + | Group, ): CoFeedInstance { const optionsWithPermissions = withSchemaPermissions( options, From 4dea1aa50b3c8cce42a9fbea7f438f380c8449e4 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Wed, 21 Jan 2026 11:56:59 +0100 Subject: [PATCH 18/61] add more zod validation tests --- .../src/tools/tests/runtimeValidation.test.ts | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts 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..5262ea751f --- /dev/null +++ b/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts @@ -0,0 +1,284 @@ +import { beforeEach, describe, test, expect } from "vitest"; +import { co, z } from "../exports.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"]).default("light"), + 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(); + }); + + test("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); + }); +}); From 58c23cdd233f5633bef7d379a5e3c2bf32d2209d Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Wed, 21 Jan 2026 14:04:14 +0100 Subject: [PATCH 19/61] chore: formatting --- .../jazz-tools/src/tools/tests/coMap.test.ts | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/jazz-tools/src/tools/tests/coMap.test.ts b/packages/jazz-tools/src/tools/tests/coMap.test.ts index 48d453df08..e1d7533cf6 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.test.ts @@ -533,8 +533,13 @@ describe("CoMap", async () => { age: z.number(), }); - // @ts-expect-error - age should be a number - expect(() => Person.create({ name: "John", age: "20" })).toThrow(); + 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", () => { @@ -555,6 +560,22 @@ describe("CoMap", async () => { ).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(), @@ -610,8 +631,13 @@ describe("CoMap", async () => { const john = Person.create({ name: "John", age: 20 }); - // @ts-expect-error - age should be a number - expect(() => john.$jazz.set("age", "21")).toThrow(); + expectValidationError(() => + john.$jazz.set( + "age", + // @ts-expect-error - age should be a number + "21", + ), + ); expect(john.age).toEqual(20); }); @@ -625,8 +651,12 @@ describe("CoMap", async () => { const john = Person.create({ name: "John", age: 20 }); expect(() => - // @ts-expect-error - age should be a number - john.$jazz.set("age", "21", { validation: "loose" }), + john.$jazz.set( + "age", + // @ts-expect-error - age should be a number + "21", + { validation: "loose" }, + ), ).not.toThrow(); expect(john.age).toEqual("21"); From f04ff0699e631907b776249c97865f87e1bfc91a Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Wed, 21 Jan 2026 16:57:59 +0100 Subject: [PATCH 20/61] global default validation mode --- .../jazz-tools/src/tools/coValues/coFeed.ts | 32 +++- .../jazz-tools/src/tools/coValues/coList.ts | 24 ++- .../jazz-tools/src/tools/coValues/coMap.ts | 20 ++- packages/jazz-tools/src/tools/exports.ts | 2 + .../zodSchema/schemaTypes/CoFeedSchema.ts | 35 +++- .../zodSchema/schemaTypes/CoListSchema.ts | 18 +- .../zodSchema/schemaTypes/CoMapSchema.ts | 18 +- .../zodSchema/validationSettings.ts | 111 ++++++++++++ packages/jazz-tools/src/tools/internal.ts | 1 + .../src/tools/tests/runtimeValidation.test.ts | 158 +++++++++++++++++- packages/jazz-tools/testSetup.ts | 3 + 11 files changed, 386 insertions(+), 36 deletions(-) create mode 100644 packages/jazz-tools/src/tools/implementation/zodSchema/validationSettings.ts diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index f67e9f1d47..948bfece8c 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -52,6 +52,11 @@ import { } from "../internal.js"; import { z } from "../implementation/zodSchema/zodReExport.js"; import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; +import { + executeValidation, + resolveValidationMode, + type LocalValidationMode, +} from "../implementation/zodSchema/validationSettings.js"; /** @deprecated Use CoFeedEntry instead */ export type CoStreamEntry = CoFeedEntry; @@ -230,7 +235,7 @@ export class CoFeed extends CoValueBase implements CoValue { options?: | { owner?: Account | Group; - validation?: "strict" | "loose"; + validation?: LocalValidationMode; } | Account | Group, @@ -244,11 +249,20 @@ export class CoFeed extends CoValueBase implements CoValue { options && typeof options === "object" && "validation" in options ? options.validation : undefined; - if (validation !== "loose") { - instance.$jazz.push(...init); - } else { - instance.$jazz.pushLoose(...init); + 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) + const coValueSchema = (this as unknown as typeof CoFeed).coValueSchema; + if (validationMode !== "loose" && coValueSchema) { + const fullSchema = coValueSchema.getValidationSchema(); + init = executeValidation( + fullSchema, + init, + validationMode, + ) as typeof init; } + instance.$jazz.pushLoose(...init); } return instance; } @@ -410,10 +424,12 @@ export class CoFeedJazzApi extends CoValueJazzApi { * @category Content */ push(...items: CoFieldInit>[]): void { - if (this.coFeedSchema) { + const validationMode = resolveValidationMode(); + if (validationMode !== "loose" && this.coFeedSchema) { const schema = z.array(this.getItemSchema()); - - items = z.parse(schema, items) as CoFieldInit>[]; + items = executeValidation(schema, items, validationMode) as CoFieldInit< + CoFeedItem + >[]; } this.pushLoose(...items); } diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 07194f9e94..33b1605a03 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -48,6 +48,11 @@ import { } from "../internal.js"; import { z } from "../implementation/zodSchema/zodReExport.js"; import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; +import { + executeValidation, + resolveValidationMode, + type LocalValidationMode, +} from "../implementation/zodSchema/validationSettings.js"; /** * CoLists are collaborative versions of plain arrays. @@ -564,11 +569,16 @@ export class CoListJazzApi extends CoValueJazzApi { set( index: number, value: CoFieldInit>, - options?: { validation?: "strict" | "loose" }, + options?: { validation?: LocalValidationMode }, ): void { - if (options?.validation !== "loose" && this.coListSchema) { + const validationMode = resolveValidationMode(options?.validation); + if (validationMode !== "loose" && this.coListSchema) { const fieldSchema = this.getItemSchema(); - value = z.parse(fieldSchema, value) as CoFieldInit>; + value = executeValidation( + fieldSchema, + value, + validationMode, + ) as CoFieldInit>; } const itemDescriptor = this.schema[ItemsSym]; @@ -586,10 +596,12 @@ export class CoListJazzApi extends CoValueJazzApi { * @category Content */ push(...items: CoFieldInit>[]): number { - if (this.coListSchema) { + const validationMode = resolveValidationMode(); + if (validationMode !== "loose" && this.coListSchema) { const schema = z.array(this.getItemSchema()); - - items = z.parse(schema, items) as CoFieldInit>[]; + items = executeValidation(schema, items, validationMode) as CoFieldInit< + CoListItem + >[]; } return this.pushLoose(...items); } diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 97dd49af1e..076da9336e 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -57,6 +57,11 @@ import { CoreCoMapSchema, } from "../internal.js"; import { z } from "../implementation/zodSchema/zodReExport.js"; +import { + executeValidation, + resolveValidationMode, + type LocalValidationMode, +} from "../implementation/zodSchema/validationSettings.js"; export type CoMapEdit = { value?: V; @@ -640,7 +645,7 @@ class CoMapJazzApi extends CoValueJazzApi { set>( key: K, value: CoFieldInit, - options?: { validation?: "strict" | "loose" }, + options?: { validation?: LocalValidationMode }, ): void { const descriptor = this.getDescriptor(key as string); @@ -648,11 +653,16 @@ class CoMapJazzApi extends CoValueJazzApi { throw Error(`Cannot set unknown key ${key}`); } - // Validate the value if validation is not loose and we have a schema - if (options?.validation !== "loose" && this.coMapSchema) { + // 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); - value = z.parse(fieldSchema, value) as CoFieldInit; + value = executeValidation( + fieldSchema, + value, + validationMode, + ) as CoFieldInit; } let refId = (value as CoValue)?.$jazz?.id; @@ -717,7 +727,7 @@ class CoMapJazzApi extends CoValueJazzApi { */ applyDiff( newValues: Partial>, - options?: { validation?: "strict" | "loose" }, + options?: { validation?: LocalValidationMode }, ): M { for (const key in newValues) { if (Object.prototype.hasOwnProperty.call(newValues, key)) { diff --git a/packages/jazz-tools/src/tools/exports.ts b/packages/jazz-tools/src/tools/exports.ts index 0862060c55..80cf958eba 100644 --- a/packages/jazz-tools/src/tools/exports.ts +++ b/packages/jazz-tools/src/tools/exports.ts @@ -67,6 +67,8 @@ export { unstable_loadUnique, getUnloadedCoValueWithoutId, setDefaultSchemaPermissions, + setDefaultValidationMode, + getDefaultValidationMode, deleteCoValues, getJazzErrorType, } from "./internal.js"; 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 304118c4fb..93e5b48946 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts @@ -28,6 +28,11 @@ import { } from "../schemaPermissions.js"; import { z } from "../zodReExport.js"; import { generateValidationSchemaFromItem } from "./schemaValidators.js"; +import { + executeValidation, + resolveValidationMode, + type LocalValidationMode, +} from "../validationSettings.js"; export class CoFeedSchema< T extends AnyZodOrCoValueSchema, @@ -66,20 +71,20 @@ export class CoFeedSchema< create( init: CoFeedSchemaInit, - options?: { owner?: Group; validation?: "strict" | "loose" } | Group, + options?: { owner?: Group; validation?: LocalValidationMode } | Group, ): CoFeedInstance; /** @deprecated Creating CoValues with an Account as owner is deprecated. Use a Group instead. */ create( init: CoFeedSchemaInit, options?: - | { owner?: Account | Group; validation?: "strict" | "loose" } + | { owner?: Account | Group; validation?: LocalValidationMode } | Account | Group, ): CoFeedInstance; create( init: CoFeedSchemaInit, options?: - | { owner?: Account | Group; validation?: "strict" | "loose" } + | { owner?: Account | Group; validation?: LocalValidationMode } | Account | Group, ): CoFeedInstance { @@ -87,10 +92,26 @@ export class CoFeedSchema< options, this.permissions, ); - return this.coValueClass.create( - init as any, - optionsWithPermissions, - ) as CoFeedInstance; + + // Handle validation directly using the schema + const validation = + options && typeof options === "object" && "validation" in options + ? options.validation + : undefined; + const validationMode = resolveValidationMode(validation); + if (validationMode !== "loose") { + init = executeValidation( + this.getValidationSchema(), + init, + validationMode, + ) as CoFeedSchemaInit; + } + + // Pass validation: "loose" to avoid double validation in CoFeed.create + return this.coValueClass.create(init as any, { + ...optionsWithPermissions, + validation: "loose" as const, + }) as CoFeedInstance; } load< 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 2a021762e4..52e5e998de 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -30,6 +30,11 @@ import { } from "../schemaPermissions.js"; import { z } from "../zodReExport.js"; import { generateValidationSchemaFromItem } from "./schemaValidators.js"; +import { + executeValidation, + resolveValidationMode, + type LocalValidationMode, +} from "../validationSettings.js"; export class CoListSchema< T extends AnyZodOrCoValueSchema, @@ -83,7 +88,7 @@ export class CoListSchema< | { owner: Account | Group; unique?: CoValueUniqueness["uniqueness"]; - validation?: "strict" | "loose"; + validation?: LocalValidationMode; } | Account | Group, @@ -94,14 +99,19 @@ export class CoListSchema< | { owner: Account | Group; unique?: CoValueUniqueness["uniqueness"]; - validation?: "strict" | "loose"; + validation?: LocalValidationMode; } | Account | Group, ): CoListInstance; create(items: any, options?: any): CoListInstance { - if (options?.validation !== "loose") { - items = this.getValidationSchema().parse(items) as CoListSchemaInit; + const validationMode = resolveValidationMode(options?.validation); + if (validationMode !== "loose") { + items = executeValidation( + this.getValidationSchema(), + items, + validationMode, + ) as CoListSchemaInit; } const optionsWithPermissions = withSchemaPermissions( 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 746a23d355..e8f4005e45 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -35,6 +35,11 @@ import { SchemaPermissions, } from "../schemaPermissions.js"; import { generateValidationSchemaFromItem } from "./schemaValidators.js"; +import { + executeValidation, + resolveValidationMode, + type LocalValidationMode, +} from "../validationSettings.js"; type CoMapSchemaInstance = Simplify< CoMapInstanceCoValuesMaybeLoaded @@ -114,7 +119,7 @@ export class CoMapSchema< | { owner?: Group; unique?: CoValueUniqueness["uniqueness"]; - validation?: "strict" | "loose"; + validation?: LocalValidationMode; } | Group, ): CoMapInstanceShape & CoMap; @@ -125,7 +130,7 @@ export class CoMapSchema< | { owner?: Owner; unique?: CoValueUniqueness["uniqueness"]; - validation?: "strict" | "loose"; + validation?: LocalValidationMode; } | Owner, ): CoMapInstanceShape & CoMap; @@ -135,8 +140,13 @@ export class CoMapSchema< this.permissions, ); - if (options?.validation !== "loose") { - init = this.getValidationSchema().parse(init); + const validationMode = resolveValidationMode(options?.validation); + if (validationMode !== "loose") { + init = executeValidation( + this.getValidationSchema(), + init, + validationMode, + ); } return this.coValueClass.create(init, optionsWithPermissions); 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..40c610eb3c --- /dev/null +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/validationSettings.ts @@ -0,0 +1,111 @@ +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; + } + + if (mode === "warn") { + try { + return z.parse(schema, value); + } catch (error) { + console.warn("[Jazz] Validation warning:", error); + return value as T; + } + } + + // mode === "strict" + return z.parse(schema, value); +} diff --git a/packages/jazz-tools/src/tools/internal.ts b/packages/jazz-tools/src/tools/internal.ts index 9f979b5130..31de52f1c7 100644 --- a/packages/jazz-tools/src/tools/internal.ts +++ b/packages/jazz-tools/src/tools/internal.ts @@ -54,6 +54,7 @@ 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"; diff --git a/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts b/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts index 5262ea751f..65625ae765 100644 --- a/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts +++ b/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts @@ -1,5 +1,10 @@ -import { beforeEach, describe, test, expect } from "vitest"; -import { co, z } from "../exports.js"; +import { beforeEach, describe, test, expect, vi } from "vitest"; +import { + co, + z, + setDefaultValidationMode, + getDefaultValidationMode, +} from "../exports.js"; import { createJazzTestAccount, setupJazzTestSync } from "../testing.js"; import { expectValidationError } from "./utils.js"; @@ -282,3 +287,152 @@ describe("runtime validation", () => { 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/testSetup.ts b/packages/jazz-tools/testSetup.ts index e25ba59294..7bec231db2 100644 --- a/packages/jazz-tools/testSetup.ts +++ b/packages/jazz-tools/testSetup.ts @@ -1,4 +1,7 @@ import { cojsonInternals } from "cojson"; +import { setDefaultValidationMode } from "./src/tools/implementation/zodSchema/validationSettings.ts"; // Use a very high budget to avoid that slow tests fail due to the budget being exceeded. cojsonInternals.setIncomingMessagesTimeBudget(10000); // 10 seconds + +setDefaultValidationMode("strict"); From 83c23637cddeb8baeafc030820b097d162365447 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Wed, 21 Jan 2026 17:02:31 +0100 Subject: [PATCH 21/61] strict validation for examples --- examples/chat-rn-expo/src/schema.ts | 4 +++- examples/chat-rn/src/schema.ts | 4 +++- examples/chat/src/schema.ts | 4 +++- examples/form/src/schema.ts | 4 +++- examples/inspector/tests/lib/data.ts | 10 +++++++++- examples/jazz-nextjs/src/schema.ts | 4 +++- examples/multi-cursors/src/schema.ts | 4 +++- examples/music-player/src/1_schema.ts | 9 ++++++++- examples/organization/src/schema.ts | 4 +++- examples/passkey-rn/src/schema.ts | 4 +++- examples/todo/src/1_schema.ts | 4 +++- examples/vector-search/src/schema.ts | 4 +++- 12 files changed, 47 insertions(+), 12 deletions(-) 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 8835f02048..8f4565623b 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; 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 986b032039..502e2ea770 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).withPermissions({ onInlineCreate: "sameAsContainer", }); 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(); From f3f01d3dcacf581ae36793b144a8ebeb7287700b Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Thu, 22 Jan 2026 12:30:07 +0100 Subject: [PATCH 22/61] refactoring from feedback --- examples/inspector/tests/lib/data.ts | 26 ++++---- .../jazz-tools/src/tools/coValues/coFeed.ts | 34 +++++----- .../jazz-tools/src/tools/coValues/coList.ts | 34 +++++----- .../jazz-tools/src/tools/coValues/coMap.ts | 39 ++++++----- .../zodSchema/schemaTypes/CoFeedSchema.ts | 8 ++- .../zodSchema/schemaTypes/CoListSchema.ts | 8 ++- .../zodSchema/schemaTypes/CoMapSchema.ts | 8 ++- .../zodSchema/schemaTypes/schemaValidators.ts | 64 +++++++++++++++++++ 8 files changed, 145 insertions(+), 76 deletions(-) diff --git a/examples/inspector/tests/lib/data.ts b/examples/inspector/tests/lib/data.ts index 8f4565623b..562b64342f 100644 --- a/examples/inspector/tests/lib/data.ts +++ b/examples/inspector/tests/lib/data.ts @@ -80,25 +80,23 @@ export const createFile = () => { }; export const createImage = () => { - return ImageDefinition.create( - { - originalSize: [1920, 1080], - placeholderDataURL: "data:image/jpeg;base64,...", - }, - { validation: "loose" }, - ); + return ImageDefinition.create({ + original: FileStream.create(), + originalSize: [1920, 1080], + progressive: false, + placeholderDataURL: "data:image/jpeg;base64,...", + }); }; export const createOrganization = () => { return Organization.create({ name: "Garden Computing", - image: ImageDefinition.create( - { - originalSize: [1920, 1080], - placeholderDataURL: "data:image/jpeg;base64,...", - }, - { validation: "loose" }, - ), + image: ImageDefinition.create({ + original: FileStream.create(), + progressive: false, + originalSize: [1920, 1080], + placeholderDataURL: "data:image/jpeg;base64,...", + }), projects: co.list(Project).create( projectsData.map((project) => Project.create({ diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index 948bfece8c..16af104f83 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -57,6 +57,10 @@ import { resolveValidationMode, type LocalValidationMode, } from "../implementation/zodSchema/validationSettings.js"; +import { + extractFieldElementFromUnionSchema, + normalizeZodSchema, +} from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; /** @deprecated Use CoFeedEntry instead */ export type CoStreamEntry = CoFeedEntry; @@ -375,28 +379,20 @@ export class CoFeedJazzApi extends CoValueJazzApi { } private getItemSchema(): z.ZodType { - const feedSchema = this.coFeedSchema?.getValidationSchema(); - - if (!feedSchema || ("type" in feedSchema && feedSchema.type !== "union")) { - throw new Error("Feed schema is not a union"); - } - - // @ts-expect-error as union, it has options fields and 2nd is the plain shape - const fieldSchema = feedSchema.options[1]?.element as z.ZodType | undefined; - - // ignore codecs/pipes - // even if they are optional and nullable - if ( - fieldSchema?.def?.type === "pipe" || - // @ts-expect-error - fieldSchema?.def?.innerType?.def?.type === "pipe" || - // @ts-expect-error - fieldSchema?.def?.innerType?.def?.innerType?.def?.type === "pipe" - ) { + /** + * coFeedSchema may be undefined if the CoFeed is created directly with its constructor, + * without using a co.feed().create() to create it. + * In that case, we can't validate the values. + */ + if (this.coFeedSchema === undefined) { return z.any(); } - return fieldSchema ?? z.any(); + const fieldSchema = extractFieldElementFromUnionSchema( + this.coFeedSchema.getValidationSchema(), + ); + + return normalizeZodSchema(fieldSchema); } get owner(): Group { diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 33b1605a03..4d26784d5b 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -53,6 +53,10 @@ import { resolveValidationMode, type LocalValidationMode, } from "../implementation/zodSchema/validationSettings.js"; +import { + extractFieldElementFromUnionSchema, + normalizeZodSchema, +} from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; /** * CoLists are collaborative versions of plain arrays. @@ -537,28 +541,20 @@ export class CoListJazzApi extends CoValueJazzApi { } private getItemSchema(): z.ZodType { - const listSchema = this.coListSchema?.getValidationSchema(); - - if (!listSchema || ("type" in listSchema && listSchema.type !== "union")) { - throw new Error("List schema is not a union"); - } - - // @ts-expect-error as union, it has options fields and 2nd is the plain shape - const fieldSchema = listSchema.options[1]?.element as z.ZodType | undefined; - - // ignore codecs/pipes - // even if they are optional and nullable - if ( - fieldSchema?.def?.type === "pipe" || - // @ts-expect-error - fieldSchema?.def?.innerType?.def?.type === "pipe" || - // @ts-expect-error - fieldSchema?.def?.innerType?.def?.innerType?.def?.type === "pipe" - ) { + /** + * coMapSchema may be undefined if the CoMap is created directly with its constructor, + * without using a co.list().create() to create it. + * In that case, we can't validate the values. + */ + if (this.coListSchema === undefined) { return z.any(); } - return fieldSchema ?? z.any(); + const fieldSchema = extractFieldElementFromUnionSchema( + this.coListSchema.getValidationSchema(), + ); + + return normalizeZodSchema(fieldSchema); } /** @category Collaboration */ diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 076da9336e..76f436cb96 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -62,6 +62,10 @@ import { resolveValidationMode, type LocalValidationMode, } from "../implementation/zodSchema/validationSettings.js"; +import { + extractFieldShapeFromUnionSchema, + normalizeZodSchema, +} from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; export type CoMapEdit = { value?: V; @@ -576,14 +580,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 cachedSchema: z.core.$ZodTypeDef | undefined; constructor( private coMap: M, private getRaw: () => RawCoMap, private coMapSchema?: CoreCoMapSchema, ) { super(coMap); - this.cachedSchema = this.coMapSchema?.getValidationSchema?.()._zod.def; } get owner(): Group { @@ -591,32 +593,27 @@ class CoMapJazzApi extends CoValueJazzApi { } private getPropertySchema(key: string): z.ZodType { - if (this.cachedSchema === undefined) { + /** + * coMapSchema may be undefined if the CoMap is created directly with its constructor, + * without using a co.map().create() to create it. + * In that case, we can't validate the values. + */ + if (this.coMapSchema === undefined) { return z.any(); } - if (this.cachedSchema?.type !== "union") { - throw new Error("Cached schema is not a union"); - } + const objectValidation = extractFieldShapeFromUnionSchema( + this.coMapSchema.getValidationSchema(), + ); - // @ts-expect-error as union, it has options fields and 2nd is the plain shape - const fieldSchema = this.cachedSchema.options[1]?.shape?.[key] as - | z.ZodType - | undefined; + const fieldSchema = + objectValidation.shape[key] ?? objectValidation.def.catchall; - // ignore codecs/pipes - // even if they are optional and nullable - if ( - fieldSchema?.def?.type === "pipe" || - // @ts-expect-error - fieldSchema?.def?.innerType?.def?.type === "pipe" || - // @ts-expect-error - fieldSchema?.def?.innerType?.def?.innerType?.def?.type === "pipe" - ) { - return z.any(); + if (fieldSchema === undefined) { + throw new Error(`Field ${key} is not defined in the CoMap schema`); } - return fieldSchema ?? z.any(); + return normalizeZodSchema(fieldSchema); } /** 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 93e5b48946..23861aaefa 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts @@ -58,10 +58,16 @@ export class CoFeedSchema< return this.#permissions ?? DEFAULT_SCHEMA_PERMISSIONS; } + #validationSchema: z.ZodType | undefined = undefined; getValidationSchema = () => { - return z + if (this.#validationSchema) { + return this.#validationSchema; + } + + this.#validationSchema = z .instanceof(CoFeed) .or(z.array(generateValidationSchemaFromItem(this.element))); + return this.#validationSchema; }; constructor( 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 52e5e998de..8de85b43ac 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -60,10 +60,16 @@ export class CoListSchema< return this.#permissions ?? DEFAULT_SCHEMA_PERMISSIONS; } + #validationSchema: z.ZodType | undefined = undefined; getValidationSchema = () => { - return z + if (this.#validationSchema) { + return this.#validationSchema; + } + + this.#validationSchema = z .instanceof(CoList) .or(z.array(generateValidationSchemaFromItem(this.element))); + return this.#validationSchema; }; constructor( 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 e8f4005e45..8519da0e7a 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -59,7 +59,12 @@ export class CoMapSchema< catchAll?: CatchAll; getDefinition: () => CoMapSchemaDefinition; + #validationSchema: z.ZodType | undefined = undefined; getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + const plainShape: Record = {}; for (const key in this.shape) { @@ -85,7 +90,8 @@ export class CoMapSchema< ); } - return z.instanceof(CoMap).or(validationSchema); + this.#validationSchema = z.instanceof(CoMap).or(validationSchema); + return this.#validationSchema; }; /** diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts index 37b2d2da83..be740f7eac 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts @@ -40,3 +40,67 @@ export function generateValidationSchemaFromItem(item: InputSchema): z.ZodType { throw new Error(`Unsupported schema type: ${item}`); } + +function isUnionSchema(schema: unknown): schema is z.ZodUnion { + if (typeof schema !== "object" || schema === null) { + return false; + } + + if ("type" in schema && schema.type === "union") { + return true; + } + + return false; +} + +export function extractFieldShapeFromUnionSchema(schema: unknown): z.ZodObject { + if (!isUnionSchema(schema)) { + throw new Error("Schema is not a union"); + } + + const unionElement = schema.options[1]; + + if (typeof unionElement !== "object" || unionElement === null) { + throw new Error("Union element is not an object"); + } + + if ("shape" in unionElement) { + return unionElement as z.ZodObject; + } + + throw new Error("Union element is not an object with shape"); +} + +export function extractFieldElementFromUnionSchema(schema: unknown): z.ZodType { + if (!isUnionSchema(schema)) { + throw new Error("Schema is not a union"); + } + + const unionElement = schema.options[1]; + + if (typeof unionElement !== "object" || unionElement === null) { + throw new Error("Union element is not an object"); + } + + if ("element" in unionElement) { + return unionElement.element as z.ZodType; + } + + throw new Error("Union element is not an object with element"); +} + +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; +} From 9c658cc8974eb211b98e11b2ece5fa647e6f7b66 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Thu, 22 Jan 2026 13:00:12 +0100 Subject: [PATCH 23/61] push down data validation on coValue creation --- .../jazz-tools/src/tools/coValues/coList.ts | 13 ++++++ .../jazz-tools/src/tools/coValues/coMap.ts | 20 ++++++++- .../zodSchema/schemaTypes/CoFeedSchema.ts | 30 +++---------- .../zodSchema/schemaTypes/CoListSchema.ts | 15 +------ .../zodSchema/schemaTypes/CoMapSchema.ts | 15 +------ .../src/tools/tests/coMap.unique.test.ts | 45 +++++++++++++++++++ packages/jazz-tools/src/tools/tests/utils.ts | 4 +- 7 files changed, 86 insertions(+), 56 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 4d26784d5b..6a4ead3e5f 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -183,10 +183,23 @@ export class CoList | { owner: Account | Group; unique?: CoValueUniqueness["uniqueness"]; + validation?: LocalValidationMode; } | Account | Group, ) { + const validationMode = resolveValidationMode( + options && "validation" in options ? options.validation : undefined, + ); + + if (this.coValueSchema && validationMode !== "loose") { + items = executeValidation( + this.coValueSchema.getValidationSchema(), + items, + validationMode, + ) as typeof items; + } + const instance = new this(); const { owner, uniqueness } = parseCoValueCreateOptions(options); diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 76f436cb96..29da25796f 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -187,6 +187,7 @@ export class CoMap extends CoValueBase implements CoValue { | { owner?: Account | Group; unique?: CoValueUniqueness["uniqueness"]; + validation?: LocalValidationMode; } | Account | Group, @@ -265,10 +266,23 @@ export class CoMap extends CoValueBase implements CoValue { | { owner?: Account | Group; unique?: CoValueUniqueness["uniqueness"]; + validation?: LocalValidationMode; } | Account | Group, ): M { + const validationMode = resolveValidationMode( + options && "validation" in options ? options.validation : undefined, + ); + + if (schema && validationMode !== "loose") { + init = executeValidation( + schema.getValidationSchema(), + init, + validationMode, + ) as typeof init; + } + const { owner, uniqueness } = parseCoValueCreateOptions(options); Object.defineProperties(instance, { @@ -515,6 +529,7 @@ export class CoMap extends CoValueBase implements CoValue { unique: CoValueUniqueness["uniqueness"]; owner: Account | Group; resolve?: RefsToResolveStrict; + validation?: LocalValidationMode; }, ): Promise>> { const header = CoMap._getUniqueHeader( @@ -531,10 +546,13 @@ export class CoMap extends CoValueBase implements CoValue { owner: options.owner, unique: options.unique, coMapSchema: this, + validation: options.validation, }); }, onUpdateWhenFound(value) { - value.$jazz.applyDiff(options.value); + value.$jazz.applyDiff(options.value, { + validation: options.validation, + }); }, }); } 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 23861aaefa..b300e39a4b 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts @@ -28,11 +28,7 @@ import { } from "../schemaPermissions.js"; import { z } from "../zodReExport.js"; import { generateValidationSchemaFromItem } from "./schemaValidators.js"; -import { - executeValidation, - resolveValidationMode, - type LocalValidationMode, -} from "../validationSettings.js"; +import { type LocalValidationMode } from "../validationSettings.js"; export class CoFeedSchema< T extends AnyZodOrCoValueSchema, @@ -98,26 +94,10 @@ export class CoFeedSchema< options, this.permissions, ); - - // Handle validation directly using the schema - const validation = - options && typeof options === "object" && "validation" in options - ? options.validation - : undefined; - const validationMode = resolveValidationMode(validation); - if (validationMode !== "loose") { - init = executeValidation( - this.getValidationSchema(), - init, - validationMode, - ) as CoFeedSchemaInit; - } - - // Pass validation: "loose" to avoid double validation in CoFeed.create - return this.coValueClass.create(init as any, { - ...optionsWithPermissions, - validation: "loose" as const, - }) as CoFeedInstance; + return this.coValueClass.create( + init as any, + optionsWithPermissions, + ) as CoFeedInstance; } load< 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 8de85b43ac..659c2f482b 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -30,11 +30,7 @@ import { } from "../schemaPermissions.js"; import { z } from "../zodReExport.js"; import { generateValidationSchemaFromItem } from "./schemaValidators.js"; -import { - executeValidation, - resolveValidationMode, - type LocalValidationMode, -} from "../validationSettings.js"; +import type { LocalValidationMode } from "../validationSettings.js"; export class CoListSchema< T extends AnyZodOrCoValueSchema, @@ -111,15 +107,6 @@ export class CoListSchema< | Group, ): CoListInstance; create(items: any, options?: any): CoListInstance { - const validationMode = resolveValidationMode(options?.validation); - if (validationMode !== "loose") { - items = executeValidation( - this.getValidationSchema(), - items, - validationMode, - ) as CoListSchemaInit; - } - const optionsWithPermissions = withSchemaPermissions( options, this.permissions, 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 8519da0e7a..0b93c0cded 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -35,11 +35,7 @@ import { SchemaPermissions, } from "../schemaPermissions.js"; import { generateValidationSchemaFromItem } from "./schemaValidators.js"; -import { - executeValidation, - resolveValidationMode, - type LocalValidationMode, -} from "../validationSettings.js"; +import type { LocalValidationMode } from "../validationSettings.js"; type CoMapSchemaInstance = Simplify< CoMapInstanceCoValuesMaybeLoaded @@ -146,15 +142,6 @@ export class CoMapSchema< this.permissions, ); - const validationMode = resolveValidationMode(options?.validation); - if (validationMode !== "loose") { - init = executeValidation( - this.getValidationSchema(), - init, - validationMode, - ); - } - return this.coValueClass.create(init, optionsWithPermissions); } 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..92c2a5268e 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(), + }); + + 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, + }); + + 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/utils.ts b/packages/jazz-tools/src/tools/tests/utils.ts index 2e639a1e8b..ef2551662d 100644 --- a/packages/jazz-tools/src/tools/tests/utils.ts +++ b/packages/jazz-tools/src/tools/tests/utils.ts @@ -179,13 +179,13 @@ export async function createAccountAs>( return account; } -export function expectValidationError( +export async function expectValidationError( fn: () => any | Promise, expectedIssues?: any, ) { let thrown = false; try { - fn(); + await fn(); } catch (e: any) { thrown = true; if (e?.name !== "ZodError") { From f985395633a8511c5b06bc53a727c6253125f90c Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Thu, 22 Jan 2026 13:18:59 +0100 Subject: [PATCH 24/61] fixup! push down data validation on coValue creation --- packages/jazz-tools/src/tools/coValues/coMap.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 29da25796f..f57cbdae05 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -545,7 +545,6 @@ export class CoMap extends CoValueBase implements CoValue { (this as any).create(options.value, { owner: options.owner, unique: options.unique, - coMapSchema: this, validation: options.validation, }); }, From 14d5628d24da5b23ae0a41e8ef2e55f8477c6fa9 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Thu, 22 Jan 2026 13:27:13 +0100 Subject: [PATCH 25/61] coList validationm for unshift and splice --- .../jazz-tools/src/tools/coValues/coList.ts | 26 +++++++++ .../jazz-tools/src/tools/tests/coList.test.ts | 54 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 6a4ead3e5f..a19be341c2 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -639,6 +639,24 @@ 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()); + items = 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], @@ -702,6 +720,14 @@ export class CoListJazzApi extends CoValueJazzApi { this.raw.delete(idxToDelete); } + const validationMode = resolveValidationMode(); + if (validationMode !== "loose" && this.coListSchema) { + const schema = z.array(this.getItemSchema()); + items = executeValidation(schema, items, validationMode) as CoFieldInit< + CoListItem + >[]; + } + const rawItems = toRawItems( items as CoListItem[], this.schema[ItemsSym], diff --git a/packages/jazz-tools/src/tools/tests/coList.test.ts b/packages/jazz-tools/src/tools/tests/coList.test.ts index c94ec7c402..8ae727f07b 100644 --- a/packages/jazz-tools/src/tools/tests/coList.test.ts +++ b/packages/jazz-tools/src/tools/tests/coList.test.ts @@ -480,6 +480,40 @@ 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), + ); + }); + + 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", () => { @@ -573,6 +607,26 @@ 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(1, 0, "test", 2), + ); + }); }); describe("remove", () => { From 038fa14204cc82f96541696c02b5fb3a57ad94e6 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 26 Jan 2026 12:00:01 +0100 Subject: [PATCH 26/61] Update packages/jazz-tools/src/tools/coValues/coList.ts Co-authored-by: Nico Rainhart --- packages/jazz-tools/src/tools/coValues/coList.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index a19be341c2..98fe60e355 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -555,7 +555,7 @@ export class CoListJazzApi extends CoValueJazzApi { private getItemSchema(): z.ZodType { /** - * coMapSchema may be undefined if the CoMap is created directly with its constructor, + * coListSchema may be undefined if the CoList is created directly with its constructor, * without using a co.list().create() to create it. * In that case, we can't validate the values. */ From 0ebfe36d50e27bf7c49b9e95d67b6c1f73235d42 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Thu, 22 Jan 2026 14:19:18 +0100 Subject: [PATCH 27/61] fixup! coList validationm for unshift and splice --- packages/jazz-tools/src/tools/coValues/coList.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 98fe60e355..426e6fb38f 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -712,14 +712,6 @@ export class CoListJazzApi extends CoValueJazzApi { ): CoListItem[] { const deleted = this.coList.slice(start, start + deleteCount); - for ( - let idxToDelete = start + deleteCount - 1; - idxToDelete >= start; - idxToDelete-- - ) { - this.raw.delete(idxToDelete); - } - const validationMode = resolveValidationMode(); if (validationMode !== "loose" && this.coListSchema) { const schema = z.array(this.getItemSchema()); @@ -728,6 +720,14 @@ export class CoListJazzApi extends CoValueJazzApi { >[]; } + for ( + let idxToDelete = start + deleteCount - 1; + idxToDelete >= start; + idxToDelete-- + ) { + this.raw.delete(idxToDelete); + } + const rawItems = toRawItems( items as CoListItem[], this.schema[ItemsSym], From 6e67485ccf445cc19caaba97465ad10d5e51ab94 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 26 Jan 2026 13:27:52 +0100 Subject: [PATCH 28/61] removed getDefaultValidationMode from exports --- packages/jazz-tools/src/tools/exports.ts | 1 - .../jazz-tools/src/tools/tests/runtimeValidation.test.ts | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/jazz-tools/src/tools/exports.ts b/packages/jazz-tools/src/tools/exports.ts index 80cf958eba..c2ec49f82e 100644 --- a/packages/jazz-tools/src/tools/exports.ts +++ b/packages/jazz-tools/src/tools/exports.ts @@ -68,7 +68,6 @@ export { getUnloadedCoValueWithoutId, setDefaultSchemaPermissions, setDefaultValidationMode, - getDefaultValidationMode, deleteCoValues, getJazzErrorType, } from "./internal.js"; diff --git a/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts b/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts index 65625ae765..07762f975f 100644 --- a/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts +++ b/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts @@ -1,10 +1,9 @@ import { beforeEach, describe, test, expect, vi } from "vitest"; +import { co, z } from "../exports.js"; import { - co, - z, - setDefaultValidationMode, getDefaultValidationMode, -} from "../exports.js"; + setDefaultValidationMode, +} from "../implementation/zodSchema/validationSettings.js"; import { createJazzTestAccount, setupJazzTestSync } from "../testing.js"; import { expectValidationError } from "./utils.js"; From bbb626d250e5b053ed12869246b635d39ca31542 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 26 Jan 2026 13:28:43 +0100 Subject: [PATCH 29/61] strict equality checks for Account and Group class in schema validation --- packages/jazz-tools/src/tools/coValues/account.ts | 3 --- .../zodSchema/schemaTypes/schemaValidators.ts | 6 +++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/account.ts b/packages/jazz-tools/src/tools/coValues/account.ts index a19e1e0295..3353344af8 100644 --- a/packages/jazz-tools/src/tools/coValues/account.ts +++ b/packages/jazz-tools/src/tools/coValues/account.ts @@ -67,9 +67,6 @@ export type AccountCreationProps = { /** @category Identity & Permissions */ export class Account extends CoValueBase implements CoValue { declare [TypeSym]: "Account"; - static { - this.prototype[TypeSym] = "Account"; - } /** * Jazz methods for Accounts are inside this property. diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts index be740f7eac..8e8d69ad8a 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts @@ -1,4 +1,4 @@ -import { Account, Group, isCoValueSchema, TypeSym } from "../../../internal.js"; +import { Account, Group, isCoValueSchema } from "../../../internal.js"; import { z } from "../zodReExport.js"; import type { CoreCoValueSchema } from "./CoValueSchema.js"; @@ -14,11 +14,11 @@ export function generateValidationSchemaFromItem(item: InputSchema): z.ZodType { // 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 ("prototype" in item && item.prototype?.[TypeSym] === "Group") { + if (item === Group) { return z.instanceof(Group); } // Same as above: `co.map({ account: Account })` vs `co.map({ account: co.account() })` - if ("prototype" in item && item.prototype?.[TypeSym] === "Account") { + if (item === Account) { return z.instanceof(Account); } From 38a5ddd35c8716e2d2d95045c7ebcba8b1d54c60 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 26 Jan 2026 14:50:31 +0100 Subject: [PATCH 30/61] ensure validation before coList manipulations --- packages/jazz-tools/src/tools/coValues/coList.ts | 3 +-- packages/jazz-tools/src/tools/tests/coList.test.ts | 8 +++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 426e6fb38f..24e21a13fa 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -710,8 +710,6 @@ export class CoListJazzApi extends CoValueJazzApi { deleteCount: number, ...items: CoFieldInit>[] ): CoListItem[] { - const deleted = this.coList.slice(start, start + deleteCount); - const validationMode = resolveValidationMode(); if (validationMode !== "loose" && this.coListSchema) { const schema = z.array(this.getItemSchema()); @@ -719,6 +717,7 @@ export class CoListJazzApi extends CoValueJazzApi { CoListItem >[]; } + const deleted = this.coList.slice(start, start + deleteCount); for ( let idxToDelete = start + deleteCount - 1; diff --git a/packages/jazz-tools/src/tools/tests/coList.test.ts b/packages/jazz-tools/src/tools/tests/coList.test.ts index 8ae727f07b..dc08dcfc69 100644 --- a/packages/jazz-tools/src/tools/tests/coList.test.ts +++ b/packages/jazz-tools/src/tools/tests/coList.test.ts @@ -401,6 +401,8 @@ describe("Simple CoList operations", async () => { // @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", () => { @@ -499,6 +501,8 @@ describe("Simple CoList operations", async () => { // @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", () => { @@ -624,8 +628,10 @@ describe("Simple CoList operations", async () => { expectValidationError( // @ts-expect-error - number is not a string - () => list.$jazz.splice(1, 0, "test", 2), + () => list.$jazz.splice(0, 1, "test", 2), ); + + expect(list).toEqual(["bread", "butter", "onion"]); }); }); From 25f78a056b29becb174f270a75bb7bdc6e2ccefb Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 26 Jan 2026 16:20:38 +0100 Subject: [PATCH 31/61] expectValidationError overload for async calls --- .../src/tools/tests/coMap.unique.test.ts | 4 +- packages/jazz-tools/src/tools/tests/utils.ts | 52 +++++++++++++------ 2 files changed, 37 insertions(+), 19 deletions(-) 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 92c2a5268e..0302eae1db 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.unique.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.unique.test.ts @@ -194,7 +194,7 @@ describe("Creating and finding unique CoMaps", async () => { title: z.string(), }); - expectValidationError(async () => { + await expectValidationError(async () => { await Event.upsertUnique({ value: { // @ts-expect-error - number is not a string @@ -220,7 +220,7 @@ describe("Creating and finding unique CoMaps", async () => { owner: group, }); - expectValidationError(async () => { + await expectValidationError(async () => { await Event.upsertUnique({ value: { // @ts-expect-error - number is not a string diff --git a/packages/jazz-tools/src/tools/tests/utils.ts b/packages/jazz-tools/src/tools/tests/utils.ts index ef2551662d..ec55a3f8bc 100644 --- a/packages/jazz-tools/src/tools/tests/utils.ts +++ b/packages/jazz-tools/src/tools/tests/utils.ts @@ -179,25 +179,43 @@ export async function createAccountAs>( return account; } -export async function expectValidationError( - fn: () => any | Promise, - expectedIssues?: any, -) { - let thrown = false; - try { - await fn(); - } catch (e: any) { - thrown = true; - if (e?.name !== "ZodError") { - throw e; - } +function verifyValidationError(e: any, expectedIssues?: any) { + if (e?.name !== "ZodError") { + throw e; + } - if (expectedIssues) { - expect(e.issues).toEqual(expectedIssues); - } + if (expectedIssues) { + expect(e.issues).toEqual(expectedIssues); } +} - if (!thrown) { - throw new Error("Expected validation error, but no error was thrown"); +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); } } From b41745ea674ed5fbe7ee99c520a581bdc9bb8f86 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Mon, 26 Jan 2026 16:22:12 +0100 Subject: [PATCH 32/61] Pass down localValidationmode when instantiateRefEncodedWithInit is called --- .../jazz-tools/src/tools/coValues/coFeed.ts | 8 +++++-- .../jazz-tools/src/tools/coValues/coList.ts | 9 +++++++- .../jazz-tools/src/tools/coValues/coMap.ts | 23 +++++++++++++------ .../src/tools/implementation/schema.ts | 7 +++++- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index 16af104f83..1a869a6ce2 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -437,11 +437,14 @@ export class CoFeedJazzApi extends CoValueJazzApi { */ pushLoose(...items: CoFieldInit>[]): void { for (const item of items) { - this.pushItem(item); + this.pushItem(item, { validationMode: "loose" }); } } - private pushItem(item: CoFieldInit>) { + private pushItem( + item: CoFieldInit>, + { validationMode }: { validationMode?: LocalValidationMode }, + ) { const itemDescriptor = this.schema[ItemsSym] as Schema; if (itemDescriptor === "json") { @@ -460,6 +463,7 @@ export class CoFeedJazzApi extends CoValueJazzApi { this.owner, newOwnerStrategy, onCreate, + validationMode, ); refId = coValue.$jazz.id; } diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 24e21a13fa..be5076c8cc 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -212,7 +212,12 @@ export class CoList }); const raw = owner.$jazz.raw.createList( - toRawItems(items, instance.$jazz.schema[ItemsSym], owner), + toRawItems( + items, + instance.$jazz.schema[ItemsSym], + owner, + options && "validation" in options ? options.validation : undefined, + ), null, "private", uniqueness, @@ -1008,6 +1013,7 @@ function toRawItems( items: Item[], itemDescriptor: Schema, owner: Group, + validationMode?: LocalValidationMode, ): JsonValue[] { let rawItems: JsonValue[] = []; if (itemDescriptor === "json") { @@ -1030,6 +1036,7 @@ function toRawItems( owner, newOwnerStrategy, onCreate, + validationMode, ); refId = coValue.$jazz.id; } diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index f57cbdae05..0cd4e7d765 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -292,7 +292,13 @@ export class CoMap extends CoValueBase implements CoValue { }, }); - const raw = CoMap.rawFromInit(instance, init, owner, uniqueness); + const raw = CoMap.rawFromInit( + instance, + init, + owner, + uniqueness, + options && "validation" in options ? options.validation : undefined, + ); return instance; } @@ -306,6 +312,7 @@ export class CoMap extends CoValueBase implements CoValue { init: Simplify> | undefined, owner: Group, uniqueness?: CoValueUniqueness, + validationMode?: LocalValidationMode, ) { const rawOwner = owner.$jazz.raw; @@ -338,6 +345,7 @@ export class CoMap extends CoValueBase implements CoValue { owner, newOwnerStrategy, onCreate, + validationMode, ); refId = coValue.$jazz.id; } @@ -661,12 +669,6 @@ class CoMapJazzApi extends CoValueJazzApi { value: CoFieldInit, options?: { validation?: LocalValidationMode }, ): void { - const descriptor = this.getDescriptor(key as string); - - if (!descriptor) { - throw Error(`Cannot set unknown key ${key}`); - } - // Validate the value based on the resolved validation mode const validationMode = resolveValidationMode(options?.validation); if (validationMode !== "loose" && this.coMapSchema) { @@ -679,6 +681,12 @@ class CoMapJazzApi extends CoValueJazzApi { ) as CoFieldInit; } + const descriptor = this.getDescriptor(key as string); + + if (!descriptor) { + throw Error(`Cannot set unknown key ${key}`); + } + let refId = (value as CoValue)?.$jazz?.id; if (descriptor === "json") { this.raw.set(key, value as JsonValue | undefined); @@ -701,6 +709,7 @@ class CoMapJazzApi extends CoValueJazzApi { this.owner, newOwnerStrategy, onCreate, + options?.validation, ); refId = coValue.$jazz.id; } diff --git a/packages/jazz-tools/src/tools/implementation/schema.ts b/packages/jazz-tools/src/tools/implementation/schema.ts index 8c1382d200..43ac6fb7f9 100644 --- a/packages/jazz-tools/src/tools/implementation/schema.ts +++ b/packages/jazz-tools/src/tools/implementation/schema.ts @@ -15,6 +15,7 @@ import { type RefPermissions, SchemaInit, isCoValueClass, + LocalValidationMode, } from "../internal.js"; /** @category Schema definition */ @@ -178,6 +179,7 @@ export function instantiateRefEncodedWithInit( containerOwner: Group, newOwnerStrategy: NewInlineOwnerStrategy = extendContainerOwner, onCreate?: RefOnCreateCallback, + validationMode?: LocalValidationMode, ): V { if (!isCoValueClass(schema.ref)) { throw Error( @@ -187,7 +189,10 @@ export function instantiateRefEncodedWithInit( const owner = newOwnerStrategy(() => Group.create(), containerOwner, init); onCreate?.(owner, init); // @ts-expect-error - create is a static method in all CoValue classes - return schema.ref.create(init, owner); + return schema.ref.create(init, { + owner, + validation: validationMode, + }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any From 398d92890f8f89ec0acd76614e6a2c9832512244 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 27 Jan 2026 12:20:19 +0100 Subject: [PATCH 33/61] coDiscriminatedUnion validation --- .../jazz-tools/src/tools/coValues/coFeed.ts | 6 +- .../jazz-tools/src/tools/coValues/coList.ts | 16 +- .../jazz-tools/src/tools/coValues/coMap.ts | 10 +- .../schemaTypes/CoDiscriminatedUnionSchema.ts | 25 +- .../tools/tests/coDiscriminatedUnion.test.ts | 379 +++++++++++++++++- .../jazz-tools/src/tools/tests/coMap.test.ts | 3 +- .../src/tools/tests/runtimeValidation.test.ts | 3 +- .../src/tools/tests/schemaUnion.test.ts | 2 +- 8 files changed, 417 insertions(+), 27 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index 1a869a6ce2..040c98aa0b 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -260,11 +260,7 @@ export class CoFeed extends CoValueBase implements CoValue { const coValueSchema = (this as unknown as typeof CoFeed).coValueSchema; if (validationMode !== "loose" && coValueSchema) { const fullSchema = coValueSchema.getValidationSchema(); - init = executeValidation( - fullSchema, - init, - validationMode, - ) as typeof init; + executeValidation(fullSchema, init, validationMode) as typeof init; } instance.$jazz.pushLoose(...init); } diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index be5076c8cc..0fbf34785b 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -193,7 +193,7 @@ export class CoList ); if (this.coValueSchema && validationMode !== "loose") { - items = executeValidation( + executeValidation( this.coValueSchema.getValidationSchema(), items, validationMode, @@ -588,11 +588,9 @@ export class CoListJazzApi extends CoValueJazzApi { const validationMode = resolveValidationMode(options?.validation); if (validationMode !== "loose" && this.coListSchema) { const fieldSchema = this.getItemSchema(); - value = executeValidation( - fieldSchema, - value, - validationMode, - ) as CoFieldInit>; + executeValidation(fieldSchema, value, validationMode) as CoFieldInit< + CoListItem + >; } const itemDescriptor = this.schema[ItemsSym]; @@ -613,7 +611,7 @@ export class CoListJazzApi extends CoValueJazzApi { const validationMode = resolveValidationMode(); if (validationMode !== "loose" && this.coListSchema) { const schema = z.array(this.getItemSchema()); - items = executeValidation(schema, items, validationMode) as CoFieldInit< + executeValidation(schema, items, validationMode) as CoFieldInit< CoListItem >[]; } @@ -647,7 +645,7 @@ export class CoListJazzApi extends CoValueJazzApi { const validationMode = resolveValidationMode(); if (validationMode !== "loose" && this.coListSchema) { const schema = z.array(this.getItemSchema()); - items = executeValidation(schema, items, validationMode) as CoFieldInit< + executeValidation(schema, items, validationMode) as CoFieldInit< CoListItem >[]; } @@ -718,7 +716,7 @@ export class CoListJazzApi extends CoValueJazzApi { const validationMode = resolveValidationMode(); if (validationMode !== "loose" && this.coListSchema) { const schema = z.array(this.getItemSchema()); - items = executeValidation(schema, items, validationMode) as CoFieldInit< + executeValidation(schema, items, validationMode) as CoFieldInit< CoListItem >[]; } diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 0cd4e7d765..024ea1cb6d 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -276,7 +276,7 @@ export class CoMap extends CoValueBase implements CoValue { ); if (schema && validationMode !== "loose") { - init = executeValidation( + executeValidation( schema.getValidationSchema(), init, validationMode, @@ -674,11 +674,9 @@ class CoMapJazzApi extends CoValueJazzApi { if (validationMode !== "loose" && this.coMapSchema) { // Get the field schema for this specific key from the shape const fieldSchema = this.getPropertySchema(key); - value = executeValidation( - fieldSchema, - value, - validationMode, - ) as CoFieldInit; + executeValidation(fieldSchema, value, validationMode) as CoFieldInit< + M[K] + >; } const descriptor = this.getDescriptor(key as string); 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 5de7ccf204..499fba229d 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts @@ -56,8 +56,29 @@ export class CoDiscriminatedUnionSchema< readonly getDefinition: () => CoDiscriminatedUnionSchemaDefinition; getValidationSchema = () => { - // Discriminated union schema can apply only if data are plain objects. - return z.any(); + const { discriminator, options } = this.getDefinition(); + return z.discriminatedUnion( + discriminator, + // @ts-expect-error + options.map((schema) => { + const validationSchema = schema.getValidationSchema(); + + if (validationSchema.def.type === "union") { + const def = validationSchema.def as + | z.core.$ZodUnionDef + | z.core.$ZodDiscriminatedUnionDef; + // in case of nested co.discriminatedUnion + // the nested `validationSchema` is already a z.discriminatedUnion + if ("discriminator" in def) { + return validationSchema; + } + + return def.options[1]; + } + + throw new Error("Invalid schema type"); + }), + ); }; /** diff --git a/packages/jazz-tools/src/tools/tests/coDiscriminatedUnion.test.ts b/packages/jazz-tools/src/tools/tests/coDiscriminatedUnion.test.ts index 1e90ddabee..4fa4fee177 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", () => { @@ -739,7 +739,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 +780,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/coMap.test.ts b/packages/jazz-tools/src/tools/tests/coMap.test.ts index e1d7533cf6..ba77705742 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.test.ts @@ -595,7 +595,8 @@ describe("CoMap", async () => { ).toThrow(); }); - it("should use zod defaults for plain items", async () => { + // .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), diff --git a/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts b/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts index 07762f975f..9222907c96 100644 --- a/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts +++ b/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts @@ -144,7 +144,8 @@ describe("runtime validation", () => { expect(profile.nickname).toBeUndefined(); }); - test("applies defaults when values are omitted", () => { + // .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), 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, ]); From c84ab34fc17915ac00a10c7b38e493cd929a28b6 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 27 Jan 2026 12:35:52 +0100 Subject: [PATCH 34/61] add unsafeSplice and rename unsafe methods --- .../jazz-tools/src/tools/coValues/coList.ts | 29 +++++++++++++--- .../jazz-tools/src/tools/tests/coList.test.ts | 33 ++++++++++++++++--- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 0fbf34785b..778654c86c 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -615,7 +615,7 @@ export class CoListJazzApi extends CoValueJazzApi { CoListItem >[]; } - return this.pushLoose(...items); + return this.unsafePush(...items); } /** @@ -625,7 +625,7 @@ export class CoListJazzApi extends CoValueJazzApi { * * @category Content */ - pushLoose(...items: CoFieldInit>[]): number { + unsafePush(...items: CoFieldInit>[]): number { this.raw.appendItems( toRawItems(items, this.schema[ItemsSym], this.owner), undefined, @@ -649,7 +649,7 @@ export class CoListJazzApi extends CoValueJazzApi { CoListItem >[]; } - return this.unshiftLoose(...items); + return this.unsafeUnshift(...items); } /** @@ -659,7 +659,7 @@ export class CoListJazzApi extends CoValueJazzApi { * * @category Content */ - unshiftLoose(...items: CoFieldInit>[]): number { + unsafeUnshift(...items: CoFieldInit>[]): number { for (const item of toRawItems( items as CoFieldInit>[], this.schema[ItemsSym], @@ -701,6 +701,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. @@ -720,6 +721,24 @@ export class CoListJazzApi extends CoValueJazzApi { CoListItem >[]; } + + return this.unsafeSplice(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 + */ + unsafeSplice( + start: number, + deleteCount: number, + ...items: CoFieldInit>[] + ): CoListItem[] { const deleted = this.coList.slice(start, start + deleteCount); for ( @@ -862,7 +881,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.unsafeSplice(from, to - from, ...insert); } this.raw.core.resumeNotifyUpdate(); diff --git a/packages/jazz-tools/src/tools/tests/coList.test.ts b/packages/jazz-tools/src/tools/tests/coList.test.ts index dc08dcfc69..cd77c0e51a 100644 --- a/packages/jazz-tools/src/tools/tests/coList.test.ts +++ b/packages/jazz-tools/src/tools/tests/coList.test.ts @@ -411,10 +411,10 @@ describe("Simple CoList operations", async () => { }); // @ts-expect-error - number is not a string - list.$jazz.pushLoose(2); + list.$jazz.unsafePush(2); // @ts-expect-error - number is not a string - list.$jazz.pushLoose("test", 2); + list.$jazz.unsafePush("test", 2); expect(list).toEqual(["bread", "butter", "onion", 2, "test", 2]); }); @@ -511,10 +511,10 @@ describe("Simple CoList operations", async () => { }); // @ts-expect-error - number is not a string - list.$jazz.unshiftLoose(2); + list.$jazz.unsafeUnshift(2); // @ts-expect-error - number is not a string - list.$jazz.unshiftLoose("test", 2); + list.$jazz.unsafeUnshift("test", 2); expect(list).toEqual([2, "test", 2, "bread", "butter", "onion"]); }); @@ -633,6 +633,31 @@ describe("Simple CoList operations", async () => { expect(list).toEqual(["bread", "butter", "onion"]); }); + + test("unsafeSplice removes and returns deleted items", () => { + const list = TestList.create(["bread", "butter", "onion"], { + owner: me, + }); + + const deleted = list.$jazz.unsafeSplice(1, 1); + + expect(deleted).toEqual(["butter"]); + expect(list.$jazz.raw.asArray()).toEqual(["bread", "onion"]); + }); + + test("unsafeSplice 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.unsafeSplice(1, 0, 2); + + // @ts-expect-error - number is not a string + list.$jazz.unsafeSplice(0, 1, "test", 2); + + expect(list).toEqual(["test", 2, 2, "butter", "onion"]); + }); }); describe("remove", () => { From e6ef5fd846e9ad6aaaf2d653699fc6f12c81ddd5 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 27 Jan 2026 15:50:31 +0100 Subject: [PATCH 35/61] ensure validationMode is pass down for nested coValues --- .../jazz-tools/src/tools/coValues/coList.ts | 11 +- .../zodSchema/schemaTypes/CoListSchema.ts | 2 +- .../jazz-tools/src/tools/tests/coFeed.test.ts | 202 ++++++++++++ .../jazz-tools/src/tools/tests/coList.test.ts | 306 ++++++++++++++++++ .../jazz-tools/src/tools/tests/coMap.test.ts | 259 +++++++++++++++ 5 files changed, 777 insertions(+), 3 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 778654c86c..c781f1eec8 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -594,7 +594,12 @@ export class CoListJazzApi extends CoValueJazzApi { } const itemDescriptor = this.schema[ItemsSym]; - const rawValue = toRawItems([value], itemDescriptor, this.owner)[0]!; + const rawValue = toRawItems( + [value], + itemDescriptor, + this.owner, + options?.validation, + )[0]!; if (rawValue === null && !itemDescriptor.optional) { throw new Error(`Cannot set required reference ${index} to undefined`); } @@ -627,7 +632,7 @@ export class CoListJazzApi extends CoValueJazzApi { */ unsafePush(...items: CoFieldInit>[]): number { this.raw.appendItems( - toRawItems(items, this.schema[ItemsSym], this.owner), + toRawItems(items, this.schema[ItemsSym], this.owner, "loose"), undefined, "private", ); @@ -664,6 +669,7 @@ export class CoListJazzApi extends CoValueJazzApi { items as CoFieldInit>[], this.schema[ItemsSym], this.owner, + "loose", )) { this.raw.prepend(item); } @@ -753,6 +759,7 @@ export class CoListJazzApi extends CoValueJazzApi { items as CoListItem[], this.schema[ItemsSym], this.owner, + "loose", ); // If there are no items to insert, return the deleted items 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 659c2f482b..8ea4bc0cb9 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -77,7 +77,7 @@ export class CoListSchema< items: CoListSchemaInit, options?: | { - owner: Group; + owner?: Group; unique?: CoValueUniqueness["uniqueness"]; validation?: "strict" | "loose"; } diff --git a/packages/jazz-tools/src/tools/tests/coFeed.test.ts b/packages/jazz-tools/src/tools/tests/coFeed.test.ts index 2a8ab1d06b..0ab6393130 100644 --- a/packages/jazz-tools/src/tools/tests/coFeed.test.ts +++ b/packages/jazz-tools/src/tools/tests/coFeed.test.ts @@ -30,6 +30,7 @@ import { CoValueLoadingState, TypeSym, } from "../internal.js"; +import { setDefaultValidationMode } from "../implementation/zodSchema/validationSettings.js"; const Crypto = await WasmCrypto.create(); @@ -967,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 }, + }, + ], + { owner: me, 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.ts b/packages/jazz-tools/src/tools/tests/coList.test.ts index cd77c0e51a..06822999b2 100644 --- a/packages/jazz-tools/src/tools/tests/coList.test.ts +++ b/packages/jazz-tools/src/tools/tests/coList.test.ts @@ -19,6 +19,7 @@ import { setupTwoNodes, waitFor, } from "./utils.js"; +import { setDefaultValidationMode } from "../implementation/zodSchema/validationSettings.js"; const Crypto = await WasmCrypto.create(); @@ -1477,3 +1478,308 @@ describe("lastUpdatedAt", () => { expect(list.$jazz.lastUpdatedAt).not.toEqual(updatedAt); }); }); + +describe("nested CoValue validation mode propagation", () => { + test("create with nested CoValues - loose validation should not throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const DogList = co.list(Dog); + + // Should throw with default strict validation when age is a string + expectValidationError(() => + DogList.create([{ age: "12" as unknown as number }]), + ); + + // Should not throw with loose validation even though age is invalid + expect(() => + DogList.create( + [ + // @ts-expect-error - age should be number + { age: "12" }, + ], + { validation: "loose" }, + ), + ).not.toThrow(); + + const list = DogList.create( + [{ age: "12" as unknown as number }, { age: "13" as unknown as number }], + { validation: "loose" }, + ); + + // Verify the nested CoValues were created with invalid data + expect(list.length).toBe(2); + expect(list[0]?.age).toBe("12"); + expect(list[1]?.age).toBe("13"); + }); + + test("set with nested CoValue - loose validation should not throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const DogList = co.list(Dog); + + const list = DogList.create([{ age: 5 }]); + + // Should throw with default strict validation + expectValidationError(() => + list.$jazz.set(0, { + age: "invalid" as unknown as number, + }), + ); + + // Should not throw with loose validation + expect(() => + list.$jazz.set( + 0, + { + age: "invalid" as unknown as number, + }, + { validation: "loose" }, + ), + ).not.toThrow(); + + // Verify the nested CoValue was created with invalid data + expect(list[0]?.age).toBe("invalid"); + }); + + test("push with nested CoValues - loose validation should not throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const DogList = co.list(Dog); + + const list = DogList.create([{ age: 5 }], { validation: "loose" }); + + // Should throw with default strict validation + expectValidationError(() => + list.$jazz.push({ + // @ts-expect-error - age should be number + age: "12", + }), + ); + + // Should not throw with unsafePush (which uses loose validation internally) + expect(() => + list.$jazz.unsafePush( + { + age: "12" as unknown as number, + }, + { + age: "13" as unknown as number, + }, + ), + ).not.toThrow(); + + // Verify the nested CoValues were created with invalid data + expect(list.length).toBe(3); + expect(list[1]?.age).toBe("12"); + expect(list[2]?.age).toBe("13"); + }); + + test("unshift with nested CoValues - loose validation should not throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const DogList = co.list(Dog); + + const list = DogList.create([{ age: 5 }], { validation: "loose" }); + + // Should throw with default strict validation + expectValidationError(() => + list.$jazz.unshift({ + age: "invalid" as unknown as number, + }), + ); + + // Should not throw with unsafeUnshift (which uses loose validation internally) + expect(() => + list.$jazz.unsafeUnshift( + { + age: "invalid" as unknown as number, + }, + { + age: "another" as unknown as number, + }, + ), + ).not.toThrow(); + + // Verify the nested CoValues were created with invalid data + // Note: unshift prepends items one by one, so ["invalid", "another"] becomes ["another", "invalid", ...] + expect(list.length).toBe(3); + expect(list[0]?.age).toBe("another"); + expect(list[1]?.age).toBe("invalid"); + }); + + test("splice with nested CoValues - loose validation should not throw", () => { + const Dog = co.map({ + age: z.number(), + }); + const DogList = co.list(Dog); + + const list = DogList.create([{ age: 5 }, { age: 6 }], { + validation: "loose", + }); + + // Should throw with default strict validation + expectValidationError(() => + list.$jazz.splice(0, 1, { + // @ts-expect-error - age should be number + age: "new", + }), + ); + + // Should not throw with unsafeSplice (which uses loose validation internally) + expect(() => + list.$jazz.unsafeSplice( + 0, + 1, + { + age: "new" as unknown as number, + }, + { + age: "another" as unknown as number, + }, + ), + ).not.toThrow(); + + // Verify the nested CoValues were created with invalid data + expect(list.length).toBe(3); + expect(list[0]?.age).toBe("new"); + expect(list[1]?.age).toBe("another"); + }); + + 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 DogList = co.list(Dog); + + // Should throw with strict validation when any nested field is invalid + expectValidationError(() => + DogList.create([ + { + age: "12" as unknown as number, + collar: { size: 10 }, + }, + ]), + ); + + expectValidationError(() => + DogList.create([ + { + age: 12, + collar: { size: "large" as unknown as number }, + }, + ]), + ); + + // Should not throw with loose validation at any level + expect(() => + DogList.create( + [ + { + age: "12" as unknown as number, + collar: { size: "large" as unknown as number }, + }, + ], + { validation: "loose" }, + ), + ).not.toThrow(); + + const list = DogList.create( + [ + { + age: "12" as unknown as number, + collar: { size: "large" as unknown as number }, + }, + ], + { validation: "loose" }, + ); + + // Verify all levels were created with invalid data + expect(list[0]?.age).toBe("12"); + expect(list[0]?.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 DogList = co.list(Dog); + + // Set global validation mode to loose + setDefaultValidationMode("loose"); + + try { + // Test 1: Create with deeply nested invalid data + const list = DogList.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 }, + }, + ]); + + // Verify all nested levels were created with invalid data + expect(list.length).toBe(2); + expect(list[0]?.age).toBe("5"); + expect(list[0]?.collar.size).toBe("small"); + expect(list[1]?.age).toBe("12"); + expect(list[1]?.collar.size).toBe("large"); + + // Test 2: Set with nested invalid data + list.$jazz.set(0, { + age: "8" as unknown as number, + collar: { size: "tiny" as unknown as number }, + }); + + expect(list[0]?.age).toBe("8"); + expect(list[0]?.collar.size).toBe("tiny"); + + // Test 3: Push with nested invalid data (uses global validation mode) + list.$jazz.push({ + age: "15" as unknown as number, + collar: { size: "huge" as unknown as number }, + }); + + expect(list.length).toBe(3); + expect(list[2]?.age).toBe("15"); + expect(list[2]?.collar.size).toBe("huge"); + + // Test 4: Unshift with nested invalid data (uses global validation mode) + list.$jazz.unshift({ + age: "3" as unknown as number, + collar: { size: "micro" as unknown as number }, + }); + + expect(list.length).toBe(4); + expect(list[0]?.age).toBe("3"); + expect(list[0]?.collar.size).toBe("micro"); + + // Test 5: Splice with nested invalid data (uses global validation mode) + list.$jazz.splice(1, 1, { + age: "10" as unknown as number, + collar: { size: "medium" as unknown as number }, + }); + + expect(list.length).toBe(4); + expect(list[1]?.age).toBe("10"); + expect(list[1]?.collar.size).toBe("medium"); + } finally { + // Reset to strict mode + setDefaultValidationMode("strict"); + } + }); +}); diff --git a/packages/jazz-tools/src/tools/tests/coMap.test.ts b/packages/jazz-tools/src/tools/tests/coMap.test.ts index ba77705742..63b6d7aeee 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.test.ts @@ -35,6 +35,7 @@ import { setupTwoNodes, waitFor, } from "./utils.js"; +import { setDefaultValidationMode } from "../implementation/zodSchema/validationSettings.js"; const Crypto = await WasmCrypto.create(); @@ -2918,3 +2919,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"); + } + }); +}); From 7b28e9709bd0ab49c835197b7d8c6631544271ea Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 27 Jan 2026 15:57:07 +0100 Subject: [PATCH 36/61] fixup! coDiscriminatedUnion validation --- packages/jazz-tools/src/tools/coValues/coFeed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index 040c98aa0b..45edc3f80a 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -419,7 +419,7 @@ export class CoFeedJazzApi extends CoValueJazzApi { const validationMode = resolveValidationMode(); if (validationMode !== "loose" && this.coFeedSchema) { const schema = z.array(this.getItemSchema()); - items = executeValidation(schema, items, validationMode) as CoFieldInit< + executeValidation(schema, items, validationMode) as CoFieldInit< CoFeedItem >[]; } From d8962f6f3be397760cdfc9b124de9827b315b9b3 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 10 Feb 2026 12:32:34 +0100 Subject: [PATCH 37/61] addressing perf feedbacks --- .../jazz-tools/src/tools/coValues/coList.ts | 14 +++++++------- .../zodSchema/schemaTypes/AccountSchema.ts | 10 +++++++++- .../schemaTypes/CoDiscriminatedUnionSchema.ts | 10 +++++++++- .../zodSchema/schemaTypes/CoOptionalSchema.ts | 9 ++++++++- .../zodSchema/schemaTypes/CoVectorSchema.ts | 9 ++++++++- .../zodSchema/schemaTypes/FileStreamSchema.ts | 8 +++++++- .../zodSchema/schemaTypes/GroupSchema.ts | 11 ++++++++++- .../zodSchema/schemaTypes/PlainTextSchema.ts | 10 +++++++++- .../zodSchema/schemaTypes/RichTextSchema.ts | 11 ++++++++++- .../zodSchema/validationSettings.ts | 16 +++++++++------- .../jazz-tools/src/tools/tests/coList.test.ts | 18 +++++++++--------- 11 files changed, 95 insertions(+), 31 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 051816b0db..16b2f1b6a3 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -620,7 +620,7 @@ export class CoListJazzApi extends CoValueJazzApi { CoListItem >[]; } - return this.unsafePush(...items); + return this.pushLoose(...items); } /** @@ -630,7 +630,7 @@ export class CoListJazzApi extends CoValueJazzApi { * * @category Content */ - unsafePush(...items: CoFieldInit>[]): number { + pushLoose(...items: CoFieldInit>[]): number { this.raw.appendItems( toRawItems(items, this.schema[ItemsSym], this.owner, "loose"), undefined, @@ -654,7 +654,7 @@ export class CoListJazzApi extends CoValueJazzApi { CoListItem >[]; } - return this.unsafeUnshift(...items); + return this.unshiftLoose(...items); } /** @@ -664,7 +664,7 @@ export class CoListJazzApi extends CoValueJazzApi { * * @category Content */ - unsafeUnshift(...items: CoFieldInit>[]): number { + unshiftLoose(...items: CoFieldInit>[]): number { for (const item of toRawItems( items as CoFieldInit>[], this.schema[ItemsSym], @@ -728,7 +728,7 @@ export class CoListJazzApi extends CoValueJazzApi { >[]; } - return this.unsafeSplice(start, deleteCount, ...items); + return this.spliceLoose(start, deleteCount, ...items); } /** @@ -740,7 +740,7 @@ export class CoListJazzApi extends CoValueJazzApi { * * @category Content */ - unsafeSplice( + spliceLoose( start: number, deleteCount: number, ...items: CoFieldInit>[] @@ -888,7 +888,7 @@ export class CoListJazzApi extends CoValueJazzApi { this.raw.core.pauseNotifyUpdate(); for (const [from, to, insert] of patches.reverse()) { - this.unsafeSplice(from, to - from, ...insert); + this.spliceLoose(from, to - from, ...insert); } this.raw.core.resumeNotifyUpdate(); 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 8bf0343e5b..19fcdb3f44 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/AccountSchema.ts @@ -53,13 +53,21 @@ export class AccountSchema< shape: Shape; getDefinition: () => CoMapSchemaDefinition; + #validationSchema: z.ZodType | undefined = undefined; + getValidationSchema = () => { - return z.instanceof(Account).or( + 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; }; /** 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 499fba229d..8d68f21bbb 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoDiscriminatedUnionSchema.ts @@ -55,9 +55,15 @@ 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(); - return z.discriminatedUnion( + this.#validationSchema = z.discriminatedUnion( discriminator, // @ts-expect-error options.map((schema) => { @@ -79,6 +85,8 @@ export class CoDiscriminatedUnionSchema< throw new Error("Invalid schema type"); }), ); + + return this.#validationSchema; }; /** 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 a6b40aa05d..a1dafac955 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoOptionalSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoOptionalSchema.ts @@ -28,10 +28,17 @@ export class CoOptionalSchema< }); readonly resolveQuery = true as const; + #validationSchema: z.ZodType | undefined = undefined; + constructor(public readonly innerType: Shape) {} getValidationSchema = () => { - return z.optional(this.innerType.getValidationSchema()); + if (this.#validationSchema) { + return this.#validationSchema; + } + + this.#validationSchema = z.optional(this.innerType.getValidationSchema()); + return this.#validationSchema; }; getCoValueClass(): ReturnType< 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 7ae5b12df5..2c80a4e9ce 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoVectorSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoVectorSchema.ts @@ -38,12 +38,19 @@ 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 = () => { - return z + if (this.#validationSchema) { + return this.#validationSchema; + } + + this.#validationSchema = z .instanceof(CoVector) .or(z.instanceof(Float32Array)) .or(z.array(z.number())); + + return this.#validationSchema; }; /** 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 86ac0f22e9..2f0e5b43a1 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/FileStreamSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/FileStreamSchema.ts @@ -34,9 +34,15 @@ 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 = () => { - return z.instanceof(FileStream); + if (this.#validationSchema) { + return this.#validationSchema; + } + + this.#validationSchema = z.instanceof(FileStream); + return this.#validationSchema; }; /** 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 6fee7b9571..76b0406f96 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/GroupSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/GroupSchema.ts @@ -36,7 +36,16 @@ export class GroupSchema implements CoreGroupSchema { readonly builtin = "Group" as const; readonly resolveQuery = true as const; - getValidationSchema = () => z.instanceof(Group); + #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 b6ec9f611f..66acca3534 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts @@ -37,7 +37,15 @@ export class PlainTextSchema implements CorePlainTextSchema { readonly resolveQuery = true as const; #permissions: SchemaPermissions | null = null; - getValidationSchema = () => z.string().or(z.instanceof(CoPlainText)); + #validationSchema: z.ZodType | undefined = undefined; + getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + + this.#validationSchema = z.string().or(z.instanceof(CoPlainText)); + return this.#validationSchema; + }; /** * Permissions to be used when creating or composing CoValues 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 645590a507..a578b32268 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/RichTextSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/RichTextSchema.ts @@ -46,7 +46,16 @@ export class RichTextSchema implements CoreRichTextSchema { constructor(private coValueClass: typeof CoRichText) {} - getValidationSchema = () => z.string().or(z.instanceof(CoRichText)); + #validationSchema: z.ZodType | undefined = undefined; + + getValidationSchema = () => { + if (this.#validationSchema) { + return this.#validationSchema; + } + + this.#validationSchema = z.string().or(z.instanceof(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. */ diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/validationSettings.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/validationSettings.ts index 40c610eb3c..79151d3bf3 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/validationSettings.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/validationSettings.ts @@ -97,15 +97,17 @@ export function executeValidation( return value as T; } - if (mode === "warn") { - try { - return z.parse(schema, value); - } catch (error) { - console.warn("[Jazz] Validation warning:", error); + 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; } - // mode === "strict" - return z.parse(schema, value); + return result.data; } diff --git a/packages/jazz-tools/src/tools/tests/coList.test.ts b/packages/jazz-tools/src/tools/tests/coList.test.ts index fad30c6a7a..510e812aeb 100644 --- a/packages/jazz-tools/src/tools/tests/coList.test.ts +++ b/packages/jazz-tools/src/tools/tests/coList.test.ts @@ -412,10 +412,10 @@ describe("Simple CoList operations", async () => { }); // @ts-expect-error - number is not a string - list.$jazz.unsafePush(2); + list.$jazz.pushLoose(2); // @ts-expect-error - number is not a string - list.$jazz.unsafePush("test", 2); + list.$jazz.pushLoose("test", 2); expect(list).toEqual(["bread", "butter", "onion", 2, "test", 2]); }); @@ -512,10 +512,10 @@ describe("Simple CoList operations", async () => { }); // @ts-expect-error - number is not a string - list.$jazz.unsafeUnshift(2); + list.$jazz.unshiftLoose(2); // @ts-expect-error - number is not a string - list.$jazz.unsafeUnshift("test", 2); + list.$jazz.unshiftLoose("test", 2); expect(list).toEqual([2, "test", 2, "bread", "butter", "onion"]); }); @@ -635,27 +635,27 @@ describe("Simple CoList operations", async () => { expect(list).toEqual(["bread", "butter", "onion"]); }); - test("unsafeSplice removes and returns deleted items", () => { + test("spliceLoose removes and returns deleted items", () => { const list = TestList.create(["bread", "butter", "onion"], { owner: me, }); - const deleted = list.$jazz.unsafeSplice(1, 1); + const deleted = list.$jazz.spliceLoose(1, 1); expect(deleted).toEqual(["butter"]); expect(list.$jazz.raw.asArray()).toEqual(["bread", "onion"]); }); - test("unsafeSplice with validation errors with loose validation", () => { + 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.unsafeSplice(1, 0, 2); + list.$jazz.spliceLoose(1, 0, 2); // @ts-expect-error - number is not a string - list.$jazz.unsafeSplice(0, 1, "test", 2); + list.$jazz.spliceLoose(0, 1, "test", 2); expect(list).toEqual(["test", 2, 2, "butter", "onion"]); }); From 2e7223a47a5787fa57472aaee2d8ae1fd97bd3b4 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 10 Feb 2026 16:10:28 +0100 Subject: [PATCH 38/61] fixup! Merge branch 'main' into feat/runtime-validation-write --- packages/jazz-tools/src/tools/coValues/coList.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index c5f73e8d67..8b56c4a3cd 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -632,6 +632,7 @@ export class CoListJazzApi extends CoValueJazzApi { itemDescriptor, this.owner, undefined, + undefined, options?.validation, )[0]!; if (rawValue === null && !itemDescriptor.optional) { @@ -666,7 +667,14 @@ export class CoListJazzApi extends CoValueJazzApi { */ pushLoose(...items: CoFieldInit>[]): number { this.raw.appendItems( - toRawItems(items, this.schema[ItemsSym], this.owner, undefined, "loose"), + toRawItems( + items, + this.schema[ItemsSym], + this.owner, + undefined, + undefined, + "loose", + ), undefined, "private", ); @@ -704,6 +712,7 @@ export class CoListJazzApi extends CoValueJazzApi { this.schema[ItemsSym], this.owner, undefined, + undefined, "loose", )) { this.raw.prepend(item); @@ -795,6 +804,7 @@ export class CoListJazzApi extends CoValueJazzApi { this.schema[ItemsSym], this.owner, undefined, + undefined, "loose", ); From 9eb7440858b008234aaebd52d9e573346d728053 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 10 Feb 2026 17:21:13 +0100 Subject: [PATCH 39/61] fixup! addressing perf feedbacks --- .../tools/implementation/zodSchema/schemaTypes/CoListSchema.ts | 2 ++ .../tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts | 2 ++ packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) 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 3746f9d3de..033bdc8ddd 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -62,6 +62,8 @@ export class CoListSchema< return this.#validationSchema; } + // since validation is not used on read, we can't validate already existing CoValues + // so we accept every CoList instance this.#validationSchema = z .instanceof(CoList) .or(z.array(generateValidationSchemaFromItem(this.element))); 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 cef6412862..7c291972a5 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -86,6 +86,8 @@ export class CoMapSchema< ); } + // since validation is not used on read, we can't validate already existing CoValues + // so we accept every CoMap instance this.#validationSchema = z.instanceof(CoMap).or(validationSchema); return this.#validationSchema; }; diff --git a/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts b/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts index 9222907c96..2aa9834bb8 100644 --- a/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts +++ b/packages/jazz-tools/src/tools/tests/runtimeValidation.test.ts @@ -71,7 +71,7 @@ describe("runtime validation", () => { test("validates nested object schemas used as json fields", () => { const Settings = z .object({ - theme: z.enum(["light", "dark"]).default("light"), + theme: z.enum(["light", "dark"]), notifications: z.boolean().optional(), }) .strict(); From 85a525d0ad28a6f79773a9d9fed78c1540c004d0 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 10 Feb 2026 17:39:28 +0100 Subject: [PATCH 40/61] fixup! fixup! addressing perf feedbacks --- .../tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7c291972a5..c77a4acc8d 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -73,7 +73,7 @@ export class CoMapSchema< configurable: true, }); } else { - plainShape[key] = generateValidationSchemaFromItem(this.shape[key]); + plainShape[key] = generateValidationSchemaFromItem(item); } } From a6120d4e5316ca30671fc0fe1e71ef5288764ca8 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Tue, 10 Feb 2026 17:48:34 +0100 Subject: [PATCH 41/61] more tests --- .../zodSchema/schemaTypes/CoFeedSchema.ts | 18 +- .../zodSchema/schemaTypes/CoRecordSchema.ts | 15 +- .../jazz-tools/src/tools/tests/coFeed.test.ts | 2 +- .../src/tools/tests/coMap.record.test.ts | 231 +++++++++++++++++- 4 files changed, 259 insertions(+), 7 deletions(-) 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 067a8b8bf5..dcaad28521 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts @@ -75,7 +75,11 @@ export class CoFeedSchema< create( init: CoFeedSchemaInit, options?: - | { owner: Group; unique?: CoValueUniqueness["uniqueness"] } + | { + owner: Group; + unique?: CoValueUniqueness["uniqueness"]; + validation?: LocalValidationMode; + } | { owner?: Group; validation?: LocalValidationMode } | Group, ): CoFeedInstance; @@ -83,7 +87,11 @@ export class CoFeedSchema< create( init: CoFeedSchemaInit, options?: - | { owner: Account | Group; unique?: CoValueUniqueness["uniqueness"] } + | { + owner: Account | Group; + unique?: CoValueUniqueness["uniqueness"]; + validation?: LocalValidationMode; + } | { owner?: Account | Group; validation?: LocalValidationMode } | Account | Group, @@ -91,7 +99,11 @@ export class CoFeedSchema< create( init: CoFeedSchemaInit, options?: - | { owner: Account | Group; unique?: CoValueUniqueness["uniqueness"] } + | { + owner: Account | Group; + unique?: CoValueUniqueness["uniqueness"]; + validation?: LocalValidationMode; + } | { owner?: Account | Group; validation?: LocalValidationMode } | Account | Group, 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/tests/coFeed.test.ts b/packages/jazz-tools/src/tools/tests/coFeed.test.ts index 0ab6393130..2e8ebe363d 100644 --- a/packages/jazz-tools/src/tools/tests/coFeed.test.ts +++ b/packages/jazz-tools/src/tools/tests/coFeed.test.ts @@ -1087,7 +1087,7 @@ describe("nested CoValue validation mode propagation", () => { collar: { size: "large" as unknown as number }, }, ], - { owner: me, validation: "loose" }, + { validation: "loose" }, ), ).not.toThrow(); 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 a733b4002d..c5a8080d6e 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(); @@ -378,6 +379,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" }, From 78b0bae1d50fd37be197f440e3e09d67ad1cce8a Mon Sep 17 00:00:00 2001 From: Guido D'Orsi Date: Tue, 10 Feb 2026 19:50:06 +0100 Subject: [PATCH 42/61] feat: enforce coValueSchema to be available --- .../jazz-tools/src/tools/coValues/account.ts | 14 +++++++++ .../jazz-tools/src/tools/coValues/coFeed.ts | 26 ++++++++++------ .../jazz-tools/src/tools/coValues/coList.ts | 21 +++++++------ .../jazz-tools/src/tools/coValues/coMap.ts | 31 +++++++++++++++---- .../jazz-tools/src/tools/coValues/profile.ts | 11 ++++++- .../zodSchema/schemaInvariant.ts | 22 +++++++++++++ 6 files changed, 100 insertions(+), 25 deletions(-) create mode 100644 packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts diff --git a/packages/jazz-tools/src/tools/coValues/account.ts b/packages/jazz-tools/src/tools/coValues/account.ts index 3353344af8..b2ba2f5875 100644 --- a/packages/jazz-tools/src/tools/coValues/account.ts +++ b/packages/jazz-tools/src/tools/coValues/account.ts @@ -58,6 +58,9 @@ import { InstanceOfSchemaCoValuesMaybeLoaded, LoadedAndRequired, } from "../internal.js"; +import { z } from "../implementation/zodSchema/zodReExport.js"; +import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; +import { createCoreCoMapSchema } from "../implementation/zodSchema/schemaTypes/CoMapSchema.js"; export type AccountCreationProps = { name: string; @@ -67,6 +70,17 @@ export type AccountCreationProps = { /** @category Identity & Permissions */ export class Account extends CoValueBase implements CoValue { declare [TypeSym]: "Account"; + static coValueSchema: CoreCoValueSchema = { + ...createCoreCoMapSchema({ + profile: createCoreCoMapSchema({ + name: z.string(), + inbox: z.optional(z.string()), + inboxInvite: z.optional(z.string()), + }), + root: createCoreCoMapSchema({}), + }), + builtin: "Account" as const, + }; /** * Jazz methods for Accounts are inside this property. diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index 5a51c8837e..9ca7b26ef3 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -65,6 +65,7 @@ import { extractFieldElementFromUnionSchema, normalizeZodSchema, } from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; +import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; /** @deprecated Use CoFeedEntry instead */ export type CoStreamEntry = CoFeedEntry; @@ -216,15 +217,14 @@ export class CoFeed extends CoValueBase implements CoValue { /** @internal */ constructor(options: { fromRaw: RawCoStream }) { super(); + const coFeedSchema = assertCoValueSchema( + this.constructor as typeof CoFeed, + "load", + ); Object.defineProperties(this, { $jazz: { - value: new CoFeedJazzApi( - this, - options.fromRaw, - // coValueSchema is defined in /implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts - (this.constructor as typeof CoFeed).coValueSchema, - ), + value: new CoFeedJazzApi(this, options.fromRaw, coFeedSchema), enumerable: false, }, }); @@ -250,6 +250,10 @@ export class CoFeed extends CoValueBase implements CoValue { | Account | Group, ) { + const coFeedSchema = assertCoValueSchema( + this as unknown as typeof CoFeed, + "create", + ); const { owner, uniqueness, firstComesWins } = parseCoValueCreateOptions(options); const initMeta = firstComesWins ? { fww: "init" } : undefined; @@ -265,12 +269,16 @@ export class CoFeed extends CoValueBase implements CoValue { // Validate using the full schema - init is an array, so it will match the array branch // of the union (instanceof CoFeed | array of items) - const coValueSchema = (this as unknown as typeof CoFeed).coValueSchema; - if (validationMode !== "loose" && coValueSchema) { - const fullSchema = coValueSchema.getValidationSchema(); + if (validationMode !== "loose") { + const fullSchema = coFeedSchema.getValidationSchema(); executeValidation(fullSchema, init, validationMode) as typeof init; } + if (coFeedSchema.builtin !== "CoFeed") { + throw new Error( + `[schema-invariant] ${this.name || "CoFeed"}.create expected CoFeed schema, got ${coFeedSchema.builtin}.`, + ); + } // @ts-expect-error - _schema is not defined on the class const itemDescriptor = this._schema[ItemsSym] as Schema; diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 8b56c4a3cd..bbc9213703 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -40,8 +40,6 @@ import { parseSubscribeRestArgs, subscribeToCoValueWithoutMe, subscribeToExistingCoValue, - CoListSchema, - AnyZodOrCoValueSchema, } from "../internal.js"; import { z } from "../implementation/zodSchema/zodReExport.js"; import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; @@ -54,6 +52,7 @@ import { extractFieldElementFromUnionSchema, normalizeZodSchema, } from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; +import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; /** * CoLists are collaborative versions of plain arrays. @@ -135,13 +134,13 @@ export class CoList const proxy = new Proxy(this, CoListProxyHandler as ProxyHandler); if (options && "fromRaw" in options) { + const coListSchema = assertCoValueSchema( + this.constructor as typeof CoList, + "load", + ); Object.defineProperties(this, { $jazz: { - value: new CoListJazzApi( - proxy, - () => options.fromRaw, - (this.constructor as any).coValueSchema, - ), + value: new CoListJazzApi(proxy, () => options.fromRaw, coListSchema), enumerable: false, }, $isLoaded: { value: true, enumerable: false }, @@ -186,13 +185,17 @@ export class CoList | Account | Group, ) { + const coListSchema = assertCoValueSchema( + this as unknown as typeof CoList, + "create", + ); const validationMode = resolveValidationMode( options && "validation" in options ? options.validation : undefined, ); - if (this.coValueSchema && validationMode !== "loose") { + if (validationMode !== "loose") { executeValidation( - this.coValueSchema.getValidationSchema(), + coListSchema.getValidationSchema(), items, validationMode, ) as typeof items; diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 0fd74c41aa..d157ac2a4f 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -65,6 +65,8 @@ import { extractFieldShapeFromUnionSchema, normalizeZodSchema, } from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; +import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; +import { createCoreCoMapSchema } from "../implementation/zodSchema/schemaTypes/CoMapSchema.js"; export type CoMapEdit = { value?: V; @@ -136,6 +138,9 @@ export class CoMap extends CoValueBase implements CoValue { /** @internal */ static _schema: CoMapFieldSchema; + // TODO: we are keeping this default to avoid breaking too many tests, but it should be removed in the future + static coValueSchema: CoreCoMapSchema = createCoreCoMapSchema({}); + /** @internal */ constructor(options: { fromRaw: RawCoMap } | undefined) { super(); @@ -143,13 +148,16 @@ export class CoMap extends CoValueBase implements CoValue { const proxy = new Proxy(this, CoMapProxyHandler as ProxyHandler); if (options && "fromRaw" in options) { + const coMapSchema = assertCoValueSchema( + this.constructor as typeof CoMap, + "load", + ); Object.defineProperties(this, { $jazz: { value: new CoMapJazzApi( proxy, () => options.fromRaw, - // coValueSchema is defined in /implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts - (this.constructor as any).coValueSchema, + coMapSchema as CoreCoMapSchema, ), enumerable: false, }, @@ -191,10 +199,14 @@ export class CoMap extends CoValueBase implements CoValue { | Account | Group, ) { + const coMapSchema = assertCoValueSchema( + this as unknown as typeof CoMap, + "create", + ); const instance = new this(); return CoMap._createCoMap( instance, - this.coValueSchema as CoreCoMapSchema, + coMapSchema as CoreCoMapSchema, init, options, ); @@ -663,9 +675,16 @@ class CoMapJazzApi extends CoValueJazzApi { return z.any(); } - const objectValidation = extractFieldShapeFromUnionSchema( - this.coMapSchema.getValidationSchema(), - ); + const fullSchema = this.coMapSchema.getValidationSchema(); + let objectValidation: z.ZodObject | undefined; + + try { + objectValidation = extractFieldShapeFromUnionSchema(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; diff --git a/packages/jazz-tools/src/tools/coValues/profile.ts b/packages/jazz-tools/src/tools/coValues/profile.ts index 4f474d2695..403f6877e7 100644 --- a/packages/jazz-tools/src/tools/coValues/profile.ts +++ b/packages/jazz-tools/src/tools/coValues/profile.ts @@ -1,16 +1,25 @@ import { Account, + coField, CoMap, CoMapInit_DEPRECATED, CoValueClass, Group, Simplify, TypeSym, - coField, } from "../internal.js"; +import { z } from "../implementation/zodSchema/zodReExport.js"; +import { createCoreCoMapSchema } from "../implementation/zodSchema/schemaTypes/CoMapSchema.js"; +import type { CoreCoMapSchema } from "../implementation/zodSchema/schemaTypes/CoMapSchema.js"; /** @category Identity & Permissions */ export class Profile extends CoMap { + static coValueSchema: CoreCoMapSchema = createCoreCoMapSchema({ + name: z.string(), + inbox: z.optional(z.string()), + inboxInvite: z.optional(z.string()), + }); + readonly name = coField.string; readonly inbox? = coField.optional.string; readonly inboxInvite? = coField.optional.string; 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..a5bc00c011 --- /dev/null +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts @@ -0,0 +1,22 @@ +import type { CoreCoValueSchema } from "./schemaTypes/CoValueSchema.js"; + +export function assertCoValueSchema( + constructor: unknown, + operation: "create" | "load" | "resolve", +): CoreCoValueSchema { + const constructorLike = constructor as { + name?: string; + coValueSchema?: CoreCoValueSchema; + }; + const schema = constructorLike.coValueSchema; + + if (!schema) { + const className = constructorLike.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.`, + ); + } + + return schema; +} From 3bce7de2d5fcc27d6f78d93886f99e376da115e4 Mon Sep 17 00:00:00 2001 From: Guido D'Orsi Date: Tue, 10 Feb 2026 20:09:40 +0100 Subject: [PATCH 43/61] feat: makeCodecCoField --> makeCodecSchema --- .../jazz-tools/src/tools/coValues/account.ts | 20 +++ .../jazz-tools/src/tools/coValues/coFeed.ts | 7 +- .../jazz-tools/src/tools/coValues/coList.ts | 27 ++++ .../jazz-tools/src/tools/coValues/coMap.ts | 14 ++ .../src/tools/implementation/schema.ts | 17 +++ .../coValueSchemaTransformation.ts | 12 +- .../schemaFieldToCoFieldDef.ts | 133 ++++++++---------- 7 files changed, 150 insertions(+), 80 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/account.ts b/packages/jazz-tools/src/tools/coValues/account.ts index b2ba2f5875..2410976ba3 100644 --- a/packages/jazz-tools/src/tools/coValues/account.ts +++ b/packages/jazz-tools/src/tools/coValues/account.ts @@ -50,6 +50,7 @@ import { ensureCoValueLoaded, inspect, instantiateRefEncodedWithInit, + isSchemaDescriptorValue, loadCoValue, loadCoValueWithoutMe, parseSubscribeRestArgs, @@ -653,6 +654,16 @@ export const AccountAndGroupProxyHandler: ProxyHandler = { } }, set(target, key, value, receiver) { + if ( + target instanceof Account && + (key === "profile" || key === "root") && + isSchemaDescriptorValue(value) + ) { + (target.constructor as typeof Account)._schema ||= {}; + (target.constructor as typeof Account)._schema[key] = value; + return true; + } + if ( target instanceof Account && (key === "profile" || key === "root") && @@ -676,6 +687,15 @@ export const AccountAndGroupProxyHandler: ProxyHandler = { } }, defineProperty(target, key, descriptor) { + if ( + (key === "profile" || key === "root") && + isSchemaDescriptorValue(descriptor.value) + ) { + (target.constructor as typeof Account)._schema ||= {}; + (target.constructor as typeof Account)._schema[key] = descriptor.value; + return true; + } + if ( (key === "profile" || key === "root") && typeof descriptor.value === "object" && diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index 9ca7b26ef3..7f474f4fbc 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -46,6 +46,7 @@ import { ensureCoValueLoaded, inspect, instantiateRefEncodedWithInit, + isSchemaDescriptorValue, isRefEncoded, loadCoValueWithoutMe, parseCoValueCreateOptions, @@ -129,7 +130,11 @@ export class CoFeed extends CoValueBase implements CoValue { }; cls._schema ||= {}; - cls._schema[ItemsSym] = (item as any)[SchemaInit]; + if (isSchemaDescriptorValue(item)) { + cls._schema[ItemsSym] = item; + } else { + cls._schema[ItemsSym] = (item as any)[SchemaInit]; + } return cls; } diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index bbc9213703..de8dd5bf0f 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -33,6 +33,7 @@ import { ensureCoValueLoaded, inspect, instantiateRefEncodedWithInit, + isSchemaDescriptorValue, isRefEncoded, loadCoValueWithoutMe, makeRefs, @@ -1169,6 +1170,17 @@ const CoListProxyHandler: ProxyHandler = { return Reflect.set(target, key, value, receiver); } + if (key === ItemsSym && isSchemaDescriptorValue(value)) { + const constructor = target.constructor as typeof CoList; + + if (!constructor._schema) { + constructor._schema = {}; + } + + constructor._schema[ItemsSym] = value; + return true; + } + if (key === ItemsSym && typeof value === "object" && SchemaInit in value) { const constructor = target.constructor as typeof CoList; @@ -1187,6 +1199,21 @@ const CoListProxyHandler: ProxyHandler = { return Reflect.set(target, key, value, receiver); }, defineProperty(target, key, descriptor) { + if ( + descriptor.value && + key === ItemsSym && + isSchemaDescriptorValue(descriptor.value) + ) { + const constructor = target.constructor as typeof CoList; + + if (!constructor._schema) { + constructor._schema = {}; + } + + constructor._schema[ItemsSym] = descriptor.value; + return true; + } + if ( descriptor.value && key === ItemsSym && diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index d157ac2a4f..0e5462671f 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -46,6 +46,7 @@ import { ensureCoValueLoaded, inspect, instantiateRefEncodedWithInit, + isSchemaDescriptorValue, isRefEncoded, loadCoValueWithoutMe, makeRefs, @@ -1091,6 +1092,12 @@ const CoMapProxyHandler: ProxyHandler = { } }, set(target, key, value, receiver) { + if (typeof key === "string" && isSchemaDescriptorValue(value)) { + (target.constructor as typeof CoMap)._schema ||= {}; + (target.constructor as typeof CoMap)._schema[key] = value; + return true; + } + if ( typeof key === "string" && typeof value === "object" && @@ -1113,6 +1120,13 @@ const CoMapProxyHandler: ProxyHandler = { throw Error("Cannot update a CoMap directly. Use `$jazz.set` instead."); }, defineProperty(target, key, attributes) { + if ("value" in attributes && isSchemaDescriptorValue(attributes.value)) { + (target.constructor as typeof CoMap)._schema ||= {}; + (target.constructor as typeof CoMap)._schema[key as string] = + attributes.value; + return true; + } + if ( "value" in attributes && typeof attributes.value === "object" && diff --git a/packages/jazz-tools/src/tools/implementation/schema.ts b/packages/jazz-tools/src/tools/implementation/schema.ts index 6c6d5c8621..e18bf2a6bb 100644 --- a/packages/jazz-tools/src/tools/implementation/schema.ts +++ b/packages/jazz-tools/src/tools/implementation/schema.ts @@ -258,6 +258,23 @@ export function instantiateRefEncodedWithInit( // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Schema = JsonEncoded | RefEncoded | EncodedAs; +export function isSchemaDescriptorValue(value: unknown): value is Schema { + if (value === "json") { + return true; + } + if (typeof value !== "object" || value === null) { + return false; + } + + return ( + ("encoded" in value && + typeof (value as { encoded?: unknown }).encoded === "object") || + ("ref" in value && + "optional" in value && + typeof (value as { ref?: unknown }).ref === "function") + ); +} + export type SchemaFor = LoadedAndRequired extends CoValue ? RefEncoded> : LoadedAndRequired extends JsonValue 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 75aef67000..3404871420 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts @@ -15,14 +15,12 @@ import { FileStream, FileStreamSchema, CoVectorSchema, + ItemsSym, PlainTextSchema, SchemaUnion, isCoValueClass, - Group, CoVector, - CoreCoMapSchema, } from "../../../internal.js"; -import { coField } from "../../schema.js"; import { CoreCoValueSchema } from "../schemaTypes/CoValueSchema.js"; import { RichTextSchema } from "../schemaTypes/RichTextSchema.js"; @@ -104,7 +102,7 @@ export function hydrateCoreCoValueSchema( ); } if (def.catchall) { - (this as any)[coField.items] = schemaFieldToCoFieldDef( + (this as any)[ItemsSym] = schemaFieldToCoFieldDef( def.catchall as SchemaField, ); } @@ -125,7 +123,7 @@ export function hydrateCoreCoValueSchema( static coValueSchema: CoreCoValueSchema; constructor(options: { fromRaw: RawCoList } | undefined) { super(options); - (this as any)[coField.items] = schemaFieldToCoFieldDef( + (this as any)[ItemsSym] = schemaFieldToCoFieldDef( element as SchemaField, ); } @@ -137,8 +135,8 @@ export function hydrateCoreCoValueSchema( return coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoFeed") { const coValueClass = CoFeed.Of( - schemaFieldToCoFieldDef(schema.element as SchemaField), - ); + schemaFieldToCoFieldDef(schema.element as SchemaField) as any, + ) as typeof CoFeed; const coValueSchema = new CoFeedSchema(schema.element, coValueClass); coValueClass.coValueSchema = coValueSchema; return coValueSchema as unknown as CoValueSchemaFromCoreSchema; 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..ebd58cd5da 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.ts @@ -1,6 +1,8 @@ import type { JsonValue } from "cojson"; import { + type Schema, CoValueClass, + Encoders, isCoValueClass, schemaToRefPermissions, getDefaultRefPermissions, @@ -11,7 +13,6 @@ import { type DiscriminableCoValueSchemas, type RefOnCreateCallback, } from "../../../internal.js"; -import { coField } from "../../schema.js"; import { CoreCoValueSchema } from "../schemaTypes/CoValueSchema.js"; import { isUnionOfPrimitivesDeeply, @@ -56,64 +57,60 @@ 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; +): 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: [] }); + }, }, - 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 { +export function resolveSchemaField(schema: SchemaField): Schema { const cachedCoFieldDef = schemaFieldCache.get(schema); if (cachedCoFieldDef !== undefined) { return cachedCoFieldDef; } if (isCoValueClass(schema)) { - return cacheSchemaField( - schema, - coField.ref(schema, { - permissions: getDefaultRefPermissions(), - }), - ); + return cacheSchemaField(schema, { + ref: schema, + optional: false, + 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(), { + return cacheSchemaField(schema, { + ref: schema.getCoValueClass(), + optional: true, permissions: schemaFieldPermissions(schema), - }), - ); + }); + } + return cacheSchemaField(schema, { + ref: schema.getCoValueClass(), + optional: false, + permissions: schemaFieldPermissions(schema), + }); } else { if ("_zod" in schema) { const zodSchemaDef = schema._zod.def; @@ -122,45 +119,41 @@ export function schemaFieldToCoFieldDef(schema: SchemaField): CoFieldDef { zodSchemaDef.type === "nullable" ) { const inner = zodSchemaDef.innerType as SchemaField; - const coFieldDef: any = schemaFieldToCoFieldDef(inner); + const resolved = resolveSchemaField(inner); + const innerZodType = inner as unknown as z.ZodTypeAny; if ( zodSchemaDef.type === "nullable" && - coFieldDef === coField.optional.Date + innerZodType?._zod?.def?.type === "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); + return cacheSchemaField(schema, resolved); } else if (zodSchemaDef.type === "string") { - return cacheSchemaField(schema, coField.string); + return cacheSchemaField(schema, "json"); } else if (zodSchemaDef.type === "number") { - return cacheSchemaField(schema, coField.number); + return cacheSchemaField(schema, "json"); } else if (zodSchemaDef.type === "boolean") { - return cacheSchemaField(schema, coField.boolean); + return cacheSchemaField(schema, "json"); } else if (zodSchemaDef.type === "null") { - return cacheSchemaField(schema, coField.null); + return cacheSchemaField(schema, "json"); } else if (zodSchemaDef.type === "enum") { - return cacheSchemaField(schema, coField.string); + return cacheSchemaField(schema, "json"); } else if (zodSchemaDef.type === "readonly") { return cacheSchemaField( schema, - schemaFieldToCoFieldDef( + resolveSchemaField( (schema as unknown as ZodReadonly).def.innerType as SchemaField, ), ); } else if (zodSchemaDef.type === "date") { - return cacheSchemaField(schema, coField.optional.Date); + return cacheSchemaField(schema, { encoded: Encoders.OptionalDate }); } else if (zodSchemaDef.type === "template_literal") { - return cacheSchemaField(schema, coField.string); + return cacheSchemaField(schema, "json"); } else if (zodSchemaDef.type === "lazy") { // Mostly to support z.json() return cacheSchemaField( schema, - schemaFieldToCoFieldDef( + resolveSchemaField( (schema as unknown as ZodLazy).unwrap() as SchemaField, ), ); @@ -174,7 +167,7 @@ export function schemaFieldToCoFieldDef(schema: SchemaField): CoFieldDef { return cacheSchemaField( schema, - schemaFieldToCoFieldDef( + resolveSchemaField( (schema as unknown as ZodDefault | ZodCatch).def .innerType as SchemaField, ), @@ -193,15 +186,7 @@ export function schemaFieldToCoFieldDef(schema: SchemaField): CoFieldDef { ) { 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 - >[]), - ), - ); + return cacheSchemaField(schema, "json"); } else if ( zodSchemaDef.type === "object" || zodSchemaDef.type === "record" || @@ -209,10 +194,10 @@ export function schemaFieldToCoFieldDef(schema: SchemaField): CoFieldDef { zodSchemaDef.type === "tuple" || zodSchemaDef.type === "intersection" ) { - return cacheSchemaField(schema, coField.json()); + return cacheSchemaField(schema, "json"); } else if (zodSchemaDef.type === "union") { if (isUnionOfPrimitivesDeeply(schema)) { - return cacheSchemaField(schema, coField.json()); + return cacheSchemaField(schema, "json"); } else { throw new Error( "z.union()/z.discriminatedUnion() of collaborative types is not supported. Use co.discriminatedUnion() instead.", @@ -230,7 +215,7 @@ export function schemaFieldToCoFieldDef(schema: SchemaField): CoFieldDef { } try { - schemaFieldToCoFieldDef(zodSchemaDef.in as SchemaField); + 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}`; @@ -241,7 +226,7 @@ export function schemaFieldToCoFieldDef(schema: SchemaField): CoFieldDef { return cacheSchemaField( schema, - makeCodecCoField( + makeCodecSchema( schema as z.core.$ZodCodec, ), ); @@ -256,6 +241,10 @@ export function schemaFieldToCoFieldDef(schema: SchemaField): CoFieldDef { } } +export function schemaFieldToCoFieldDef(schema: SchemaField): Schema { + return resolveSchemaField(schema); +} + function schemaFieldPermissions(schema: CoreCoValueSchema): RefPermissions { if (schema.builtin === "CoOptional") { return schemaFieldPermissions((schema as any).innerType); From 12b4f92b5c0fb1b57ae87f2cd0e454dc0e238a51 Mon Sep 17 00:00:00 2001 From: Guido D'Orsi Date: Wed, 11 Feb 2026 12:07:59 +0100 Subject: [PATCH 44/61] feat: remove legacy _schema --- .../browser/tests/createInviteLink.test.ts | 2 +- .../src/react/tests/useAcceptInvite.test.ts | 2 +- .../jazz-tools/src/tools/coValues/account.ts | 129 ++++---- .../jazz-tools/src/tools/coValues/coFeed.ts | 88 +++--- .../jazz-tools/src/tools/coValues/coList.ts | 153 ++++----- .../jazz-tools/src/tools/coValues/coMap.ts | 134 +++----- .../jazz-tools/src/tools/coValues/profile.ts | 7 +- packages/jazz-tools/src/tools/exports.ts | 2 - .../src/tools/implementation/schema.ts | 293 ------------------ .../src/tools/implementation/schemaRuntime.ts | 140 +++++++++ .../coValueSchemaTransformation.ts | 32 +- .../schemaFieldToCoFieldDef.ts | 283 ++++++++--------- .../implementation/zodSchema/unionUtils.ts | 26 +- packages/jazz-tools/src/tools/internal.ts | 2 +- .../src/tools/tests/coOptional.test.ts | 3 +- .../jazz-tools/src/tools/tests/schema.test.ts | 244 --------------- .../src/tools/tests/schemaInvariant.test.ts | 37 +++ .../src/syncConflicts.test.ts | 6 +- .../src/app/api/hello/route.ts | 2 +- 19 files changed, 561 insertions(+), 1024 deletions(-) delete mode 100644 packages/jazz-tools/src/tools/implementation/schema.ts create mode 100644 packages/jazz-tools/src/tools/implementation/schemaRuntime.ts delete mode 100644 packages/jazz-tools/src/tools/tests/schema.test.ts create mode 100644 packages/jazz-tools/src/tools/tests/schemaInvariant.test.ts 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/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 2410976ba3..36e331308f 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,9 +48,10 @@ import { coValuesCache, createInboxRoot, ensureCoValueLoaded, + hydrateCoreCoValueSchema, inspect, instantiateRefEncodedWithInit, - isSchemaDescriptorValue, + isRefEncoded, loadCoValue, loadCoValueWithoutMe, parseSubscribeRestArgs, @@ -62,6 +63,9 @@ import { import { z } from "../implementation/zodSchema/zodReExport.js"; import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; import { createCoreCoMapSchema } from "../implementation/zodSchema/schemaTypes/CoMapSchema.js"; +import type { CoreAccountSchema } from "../implementation/zodSchema/schemaTypes/AccountSchema.js"; +import { resolveSchemaField } from "../implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.js"; +import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; export type AccountCreationProps = { name: string; @@ -91,18 +95,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; @@ -117,10 +109,15 @@ export class Account extends CoValueBase implements CoValue { AccountAndGroupProxyHandler as ProxyHandler, ); + const accountSchema = assertCoValueSchema( + this.constructor as unknown as typeof Account, + "create", + ) as CoreAccountSchema; + Object.defineProperties(this, { [TypeSym]: { value: "Account", enumerable: false }, $jazz: { - value: new AccountJazzApi(proxy, options.fromRaw), + value: new AccountJazzApi(proxy, options.fromRaw, accountSchema), enumerable: false, }, }); @@ -444,6 +441,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. * @@ -456,6 +455,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; @@ -488,7 +488,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( @@ -513,13 +516,39 @@ 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; + + if ( + accountSchema.builtin !== "Account" && + accountSchema.builtin !== "CoMap" + ) { + throw new Error( + `[schema-invariant] ${this.account.constructor.name || "Account"}.resolve expected Account/CoMap schema, got ${accountSchema.builtin}.`, + ); } + const definition = accountSchema.getDefinition(); + const field = definition.shape[key as keyof typeof definition.shape]; + if (field) { + const normalizedField = + typeof field === "object" && + field !== null && + "collaborative" in field && + !("getCoValueClass" in field) + ? hydrateCoreCoValueSchema(field as any) + : field; + + const descriptor = resolveSchemaField(normalizedField as any); + this.descriptorCache.set(key, descriptor); + return descriptor; + } + + this.descriptorCache.set(key, undefined); return undefined; } @@ -547,7 +576,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, @@ -557,7 +586,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, @@ -616,14 +645,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; @@ -654,29 +675,7 @@ export const AccountAndGroupProxyHandler: ProxyHandler = { } }, set(target, key, value, receiver) { - if ( - target instanceof Account && - (key === "profile" || key === "root") && - isSchemaDescriptorValue(value) - ) { - (target.constructor as typeof Account)._schema ||= {}; - (target.constructor as typeof Account)._schema[key] = value; - return true; - } - - 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); } @@ -687,27 +686,7 @@ export const AccountAndGroupProxyHandler: ProxyHandler = { } }, defineProperty(target, key, descriptor) { - if ( - (key === "profile" || key === "root") && - isSchemaDescriptorValue(descriptor.value) - ) { - (target.constructor as typeof Account)._schema ||= {}; - (target.constructor as typeof Account)._schema[key] = descriptor.value; - return true; - } - - 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 7f474f4fbc..279f49f0db 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,13 +39,10 @@ import { CoValueJazzApi, ItemsSym, Ref, - SchemaInit, accessChildById, - coField, ensureCoValueLoaded, inspect, instantiateRefEncodedWithInit, - isSchemaDescriptorValue, isRefEncoded, loadCoValueWithoutMe, parseCoValueCreateOptions, @@ -56,6 +52,10 @@ import { } from "../internal.js"; import { z } from "../implementation/zodSchema/zodReExport.js"; import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; +import { + CoreCoFeedSchema, + createCoreCoFeedSchema, +} from "../implementation/zodSchema/schemaTypes/CoFeedSchema.js"; import { executeValidation, GlobalValidationMode, @@ -67,6 +67,7 @@ import { normalizeZodSchema, } from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; +import { resolveSchemaField } from "../implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.js"; /** @deprecated Use CoFeedEntry instead */ export type CoStreamEntry = CoFeedEntry; @@ -114,29 +115,23 @@ export class CoFeed extends CoValueBase implements CoValue { declare $jazz: CoFeedJazzApi; /** - * Declare a `CoFeed` by subclassing `CoFeed.Of(...)` and passing the item schema using a `co` primitive or a `coField.ref`. + * Declare a `CoFeed` by subclassing `CoFeed.Of(...)` and passing the item schema. * * @example * ```ts - * class ColorFeed extends CoFeed.Of(coField.string) {} - * class AnimalFeed extends CoFeed.Of(coField.ref(Animal)) {} + * const Animal = co.map({ name: z.string() }); + * class ColorFeed extends CoFeed.Of(z.string()) {} + * class AnimalFeed extends CoFeed.Of(Animal) {} * ``` * * @category Declaration */ static Of(item: Item): typeof CoFeed { - const cls = class CoFeedOf extends CoFeed { - [coField.items] = item; + return class CoFeedOf extends CoFeed { + static override coValueSchema = createCoreCoFeedSchema( + item as any, + ) as CoreCoFeedSchema; }; - - cls._schema ||= {}; - if (isSchemaDescriptorValue(item)) { - cls._schema[ItemsSym] = item; - } else { - cls._schema[ItemsSym] = (item as any)[SchemaInit]; - } - - return cls; } /** @category Type Helpers */ @@ -147,10 +142,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 @@ -229,7 +220,11 @@ export class CoFeed extends CoValueBase implements CoValue { Object.defineProperties(this, { $jazz: { - value: new CoFeedJazzApi(this, options.fromRaw, coFeedSchema), + value: new CoFeedJazzApi( + this, + options.fromRaw, + coFeedSchema as CoreCoFeedSchema, + ), enumerable: false, }, }); @@ -284,8 +279,9 @@ export class CoFeed extends CoValueBase implements CoValue { `[schema-invariant] ${this.name || "CoFeed"}.create expected CoFeed schema, got ${coFeedSchema.builtin}.`, ); } - // @ts-expect-error - _schema is not defined on the class - const itemDescriptor = this._schema[ItemsSym] as Schema; + const itemDescriptor = resolveSchemaField( + (coFeedSchema as CoreCoFeedSchema).element as any, + ); for (let index = 0; index < init.length; index++) { const item = init[index]; @@ -383,7 +379,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 @@ -421,10 +417,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]); } /** @@ -515,7 +512,7 @@ export class CoFeedJazzApi extends CoValueJazzApi { constructor( private coFeed: F, public raw: RawCoStream, - private coFeedSchema?: CoreCoValueSchema, + private coFeedSchema?: CoreCoFeedSchema, ) { super(coFeed); } @@ -587,7 +584,7 @@ export class CoFeedJazzApi extends CoValueJazzApi { item: CoFieldInit>, { validationMode }: { validationMode?: LocalValidationMode }, ) { - const itemDescriptor = this.schema[ItemsSym] as Schema; + const itemDescriptor = this.getItemsDescriptor(); this.raw.push( processCoFeedItem( @@ -655,15 +652,20 @@ export class CoFeedJazzApi extends CoValueJazzApi { * Get the descriptor for the items in the `CoFeed` * @internal */ - getItemsDescriptor(): Schema | undefined { - return this.schema[ItemsSym]; - } + getItemsDescriptor(): Schema { + if (!this.coFeedSchema) { + throw new Error( + `[schema-invariant] ${this.coFeed.constructor.name || "CoFeed"} is missing coValueSchema.`, + ); + } - /** @internal */ - get schema(): { - [ItemsSym]: SchemaFor> | any; - } { - return (this.coFeed.constructor as typeof CoFeed)._schema; + if (this.coFeedSchema.builtin !== "CoFeed") { + throw new Error( + `[schema-invariant] ${this.coFeed.constructor.name || "CoFeed"}.resolve expected CoFeed schema, got ${this.coFeedSchema.builtin}.`, + ); + } + + return resolveSchemaField(this.coFeedSchema.element as any); } } @@ -756,7 +758,7 @@ export const CoStreamPerAccountProxyHandler = ( rawEntry, innerTarget.$jazz.loadedAs, key as unknown as ID, - innerTarget.$jazz.schema[ItemsSym], + innerTarget.$jazz.getItemsDescriptor(), ); Object.defineProperty(entry, "all", { @@ -773,7 +775,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 @@ -825,7 +827,7 @@ const CoStreamPerSessionProxyHandler = ( cojsonInternals.isAccountID(by) ? (by as unknown as ID) : undefined, - innerTarget.$jazz.schema[ItemsSym], + innerTarget.$jazz.getItemsDescriptor(), ); Object.defineProperty(entry, "all", { @@ -842,7 +844,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 diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index de8dd5bf0f..ad9937243f 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,13 +26,10 @@ import { AnonymousJazzAgent, ItemsSym, Ref, - SchemaInit, accessChildByKey, - coField, ensureCoValueLoaded, inspect, instantiateRefEncodedWithInit, - isSchemaDescriptorValue, isRefEncoded, loadCoValueWithoutMe, makeRefs, @@ -44,6 +40,10 @@ import { } from "../internal.js"; import { z } from "../implementation/zodSchema/zodReExport.js"; import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; +import { + CoreCoListSchema, + createCoreCoListSchema, +} from "../implementation/zodSchema/schemaTypes/CoListSchema.js"; import { executeValidation, resolveValidationMode, @@ -54,6 +54,7 @@ import { normalizeZodSchema, } from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; +import { resolveSchemaField } from "../implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.js"; /** * CoLists are collaborative versions of plain arrays. @@ -81,20 +82,18 @@ export class CoList extends Array implements ReadonlyArray, CoValue { + static coValueSchema?: CoreCoValueSchema; declare $jazz: CoListJazzApi; declare $isLoaded: true; /** - * Declare a `CoList` by subclassing `CoList.Of(...)` and passing the item schema using `co`. + * Declare a `CoList` by subclassing `CoList.Of(...)` and passing the item schema. * * @example * ```ts - * class ColorList extends CoList.Of( - * coField.string - * ) {} - * class AnimalList extends CoList.Of( - * coField.ref(Animal) - * ) {} + * const Animal = co.map({ name: z.string() }); + * class ColorList extends CoList.Of(z.string()) {} + * class AnimalList extends CoList.Of(Animal) {} * ``` * * @category Declaration @@ -102,7 +101,9 @@ export class CoList static Of(item: Item): typeof CoList { // TODO: cache superclass for item class return class CoListOf extends CoList { - [coField.items] = item; + static override coValueSchema = createCoreCoListSchema( + item as any, + ) as CoreCoListSchema; }; } @@ -121,10 +122,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; } @@ -141,7 +138,11 @@ export class CoList ); Object.defineProperties(this, { $jazz: { - value: new CoListJazzApi(proxy, () => options.fromRaw, coListSchema), + value: new CoListJazzApi( + proxy, + () => options.fromRaw, + coListSchema as CoreCoListSchema, + ), enumerable: false, }, $isLoaded: { value: true, enumerable: false }, @@ -189,7 +190,7 @@ export class CoList const coListSchema = assertCoValueSchema( this as unknown as typeof CoList, "create", - ); + ) as CoreCoListSchema; const validationMode = resolveValidationMode( options && "validation" in options ? options.validation : undefined, ); @@ -208,17 +209,18 @@ export class CoList Object.defineProperties(instance, { $jazz: { - value: new CoListJazzApi(instance, () => raw, this.coValueSchema), + value: new CoListJazzApi(instance, () => raw, coListSchema), enumerable: false, }, $isLoaded: { value: true, enumerable: false }, }); 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, @@ -235,7 +237,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) { @@ -272,10 +274,9 @@ export class CoList static schema( // eslint-disable-next-line @typescript-eslint/no-explicit-any this: { new (...args: any): V } & typeof CoList, - def: { [ItemsSym]: V["$jazz"]["schema"][ItemsSym] }, + def: { [ItemsSym]: CoFieldInit }, ) { - this._schema ||= {}; - Object.assign(this._schema, def); + this.coValueSchema = createCoreCoListSchema(def[ItemsSym] as any); } /** @@ -590,7 +591,7 @@ export class CoListJazzApi extends CoValueJazzApi { constructor( private coList: L, private getRaw: () => RawCoList, - private coListSchema?: CoreCoValueSchema, + private coListSchema?: CoreCoListSchema, ) { super(coList); } @@ -630,7 +631,7 @@ export class CoListJazzApi extends CoValueJazzApi { >; } - const itemDescriptor = this.schema[ItemsSym]; + const itemDescriptor = this.getItemsDescriptor(); const rawValue = toRawItems( [value], itemDescriptor, @@ -639,7 +640,11 @@ export class CoListJazzApi extends CoValueJazzApi { undefined, options?.validation, )[0]!; - if (rawValue === null && !itemDescriptor.optional) { + if ( + rawValue === null && + isRefEncoded(itemDescriptor) && + !itemDescriptor.optional + ) { throw new Error(`Cannot set required reference ${index} to undefined`); } this.raw.replace(index, rawValue); @@ -673,7 +678,7 @@ export class CoListJazzApi extends CoValueJazzApi { this.raw.appendItems( toRawItems( items, - this.schema[ItemsSym], + this.getItemsDescriptor(), this.owner, undefined, undefined, @@ -713,7 +718,7 @@ export class CoListJazzApi extends CoValueJazzApi { unshiftLoose(...items: CoFieldInit>[]): number { for (const item of toRawItems( items as CoFieldInit>[], - this.schema[ItemsSym], + this.getItemsDescriptor(), this.owner, undefined, undefined, @@ -805,7 +810,7 @@ export class CoListJazzApi extends CoValueJazzApi { const rawItems = toRawItems( items as CoListItem[], - this.schema[ItemsSym], + this.getItemsDescriptor(), this.owner, undefined, undefined, @@ -918,7 +923,7 @@ export class CoListJazzApi extends CoValueJazzApi { */ applyDiff(result: CoFieldInit>[]): L { 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; @@ -1005,8 +1010,20 @@ export class CoListJazzApi extends CoValueJazzApi { * Get the descriptor for the items in the `CoList` * @internal */ - getItemsDescriptor(): Schema | undefined { - return this.schema[ItemsSym]; + getItemsDescriptor(): Schema { + if (!this.coListSchema) { + throw new Error( + `[schema-invariant] ${this.coList.constructor.name || "CoList"} is missing coValueSchema.`, + ); + } + + if (this.coListSchema.builtin !== "CoList") { + throw new Error( + `[schema-invariant] ${this.coList.constructor.name || "CoList"}.resolve expected CoList schema, got ${this.coListSchema.builtin}.`, + ); + } + + return resolveSchemaField(this.coListSchema.element as any); } /** @@ -1042,7 +1059,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; } @@ -1067,13 +1084,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; - } } /** @@ -1134,7 +1144,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; @@ -1170,28 +1184,6 @@ const CoListProxyHandler: ProxyHandler = { return Reflect.set(target, key, value, receiver); } - if (key === ItemsSym && isSchemaDescriptorValue(value)) { - const constructor = target.constructor as typeof CoList; - - if (!constructor._schema) { - constructor._schema = {}; - } - - constructor._schema[ItemsSym] = value; - return true; - } - - 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."); } @@ -1199,38 +1191,7 @@ const CoListProxyHandler: ProxyHandler = { return Reflect.set(target, key, value, receiver); }, defineProperty(target, key, descriptor) { - if ( - descriptor.value && - key === ItemsSym && - isSchemaDescriptorValue(descriptor.value) - ) { - const constructor = target.constructor as typeof CoList; - - if (!constructor._schema) { - constructor._schema = {}; - } - - constructor._schema[ItemsSym] = descriptor.value; - return true; - } - - 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 0e5462671f..02965fe84b 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -37,16 +37,13 @@ import { Account, CoValueBase, CoValueJazzApi, - ItemsSym, Ref, RegisteredSchemas, - SchemaInit, accessChildById, accessChildByKey, ensureCoValueLoaded, inspect, instantiateRefEncodedWithInit, - isSchemaDescriptorValue, isRefEncoded, loadCoValueWithoutMe, makeRefs, @@ -68,6 +65,7 @@ import { } from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; import { createCoreCoMapSchema } from "../implementation/zodSchema/schemaTypes/CoMapSchema.js"; +import { resolveSchemaField } from "../implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.js"; export type CoMapEdit = { value?: V; @@ -83,27 +81,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 @@ -136,9 +131,6 @@ export class CoMap extends CoValueBase implements CoValue { */ declare $jazz: CoMapJazzApi; - /** @internal */ - static _schema: CoMapFieldSchema; - // TODO: we are keeping this default to avoid breaking too many tests, but it should be removed in the future static coValueSchema: CoreCoMapSchema = createCoreCoMapSchema({}); @@ -386,19 +378,19 @@ export class CoMap extends CoValueBase implements CoValue { } /** - * Declare a Record-like CoMap schema, by extending `CoMap.Record(...)` and passing the value schema using `co`. Keys are always `string`. + * Declare a Record-like CoMap schema by extending `CoMap.Record(...)` and + * passing the catchall value schema. Keys are always `string`. * * @example * ```ts - * import { coField, CoMap } from "jazz-tools"; + * import { co, z, CoMap } from "jazz-tools"; * - * class ColorToFruitMap extends CoMap.Record( - * coField.ref(Fruit) - * ) {} + * const Fruit = co.map({ name: z.string() }); + * const ColorToFruitMap = co.record(z.string(), Fruit) * * // assume we have map: ColorToFruitMap * // and strawberry: Fruit - * map["red"] = strawberry; + * map.$jazz.set("red", strawberry); * ``` * * @category Declaration @@ -406,7 +398,10 @@ export class CoMap extends CoValueBase implements CoValue { static Record(value: Value) { // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging class RecordLikeCoMap extends CoMap { - [ItemsSym] = value; + static override coValueSchema: CoreCoMapSchema = createCoreCoMapSchema( + {}, + value, + ); } // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging interface RecordLikeCoMap extends Record {} @@ -654,10 +649,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, + private coMapSchema: CoreCoMapSchema, ) { super(coMap); } @@ -667,15 +664,6 @@ class CoMapJazzApi extends CoValueJazzApi { } private getPropertySchema(key: string): z.ZodType { - /** - * coMapSchema may be undefined if the CoMap is created directly with its constructor, - * without using a co.map().create() to create it. - * In that case, we can't validate the values. - */ - if (this.coMapSchema === undefined) { - return z.any(); - } - const fullSchema = this.coMapSchema.getValidationSchema(); let objectValidation: z.ZodObject | undefined; @@ -893,11 +881,29 @@ 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); + } + + if ( + this.coMapSchema.builtin !== "CoMap" && + this.coMapSchema.builtin !== "Account" + ) { + throw new Error( + `[schema-invariant] ${this.coMap.constructor.name || "CoMap"}.resolve expected CoMap/Account schema, got ${this.coMapSchema.builtin}.`, + ); + } + + const definition = this.coMapSchema.getDefinition(); + const schemaField = definition.shape[key] ?? definition.catchall; + if (schemaField) { + const descriptor = resolveSchemaField(schemaField as any); + this.descriptorCache.set(key, descriptor); + return descriptor; + } + + this.descriptorCache.set(key, undefined); + return undefined; } /** @@ -993,11 +999,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< @@ -1063,9 +1064,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") { @@ -1092,23 +1091,6 @@ const CoMapProxyHandler: ProxyHandler = { } }, set(target, key, value, receiver) { - if (typeof key === "string" && isSchemaDescriptorValue(value)) { - (target.constructor as typeof CoMap)._schema ||= {}; - (target.constructor as typeof CoMap)._schema[key] = value; - return true; - } - - 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); } @@ -1120,28 +1102,10 @@ const CoMapProxyHandler: ProxyHandler = { throw Error("Cannot update a CoMap directly. Use `$jazz.set` instead."); }, defineProperty(target, key, attributes) { - if ("value" in attributes && isSchemaDescriptorValue(attributes.value)) { - (target.constructor as typeof CoMap)._schema ||= {}; - (target.constructor as typeof CoMap)._schema[key as string] = - attributes.value; - return true; - } - - 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/profile.ts b/packages/jazz-tools/src/tools/coValues/profile.ts index 403f6877e7..0f3544ed5c 100644 --- a/packages/jazz-tools/src/tools/coValues/profile.ts +++ b/packages/jazz-tools/src/tools/coValues/profile.ts @@ -1,6 +1,5 @@ import { Account, - coField, CoMap, CoMapInit_DEPRECATED, CoValueClass, @@ -20,9 +19,9 @@ export class Profile extends CoMap { inboxInvite: z.optional(z.string()), }); - 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/exports.ts b/packages/jazz-tools/src/tools/exports.ts index 45fcd42215..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"; 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 e18bf2a6bb..0000000000 --- a/packages/jazz-tools/src/tools/implementation/schema.ts +++ /dev/null @@ -1,293 +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, - GlobalValidationMode, -} 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; - }, - validationMode?: GlobalValidationMode, -): 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, - validation: validationMode, - unique: childUniqueness, - firstComesWins: unique?.firstComesWins, - }); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type Schema = JsonEncoded | RefEncoded | EncodedAs; - -export function isSchemaDescriptorValue(value: unknown): value is Schema { - if (value === "json") { - return true; - } - if (typeof value !== "object" || value === null) { - return false; - } - - return ( - ("encoded" in value && - typeof (value as { encoded?: unknown }).encoded === "object") || - ("ref" in value && - "optional" in value && - typeof (value as { ref?: unknown }).ref === "function") - ); -} - -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/runtimeConverters/coValueSchemaTransformation.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts index 3404871420..d022f05649 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts @@ -1,4 +1,4 @@ -import { RawCoList, RawCoMap } from "cojson"; +import { RawCoList, RawCoMap, RawCoStream } from "cojson"; import { Account, AccountSchema, @@ -15,7 +15,6 @@ import { FileStream, FileStreamSchema, CoVectorSchema, - ItemsSym, PlainTextSchema, SchemaUnion, isCoValueClass, @@ -33,11 +32,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 * @@ -96,16 +90,6 @@ export function hydrateCoreCoValueSchema( static coValueSchema: CoreCoValueSchema; 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)[ItemsSym] = schemaFieldToCoFieldDef( - def.catchall as SchemaField, - ); - } } }; @@ -123,9 +107,6 @@ export function hydrateCoreCoValueSchema( static coValueSchema: CoreCoValueSchema; constructor(options: { fromRaw: RawCoList } | undefined) { super(options); - (this as any)[ItemsSym] = schemaFieldToCoFieldDef( - element as SchemaField, - ); } }; @@ -134,10 +115,13 @@ export function hydrateCoreCoValueSchema( return coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoFeed") { - const coValueClass = CoFeed.Of( - schemaFieldToCoFieldDef(schema.element as SchemaField) as any, - ) as typeof CoFeed; - const coValueSchema = new CoFeedSchema(schema.element, coValueClass); + const coValueClass = class ZCoFeed extends CoFeed { + static coValueSchema: CoreCoValueSchema; + constructor(options: { fromRaw: RawCoStream }) { + super(options); + } + }; + const coValueSchema = new CoFeedSchema(schema.element, coValueClass as any); coValueClass.coValueSchema = coValueSchema; return coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "FileStream") { 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 ebd58cd5da..7398ff50ec 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.ts @@ -2,7 +2,6 @@ import type { JsonValue } from "cojson"; import { type Schema, CoValueClass, - Encoders, isCoValueClass, schemaToRefPermissions, getDefaultRefPermissions, @@ -30,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 */ @@ -86,161 +91,159 @@ function cacheSchemaField(schema: SchemaField, value: Schema): Schema { return value; } -export function resolveSchemaField(schema: SchemaField): Schema { - 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, { - ref: schema, - optional: false, - permissions: getDefaultRefPermissions(), - }); - } else if (isCoValueSchema(schema)) { - if (schema.builtin === "CoOptional") { - return cacheSchemaField(schema, { - ref: schema.getCoValueClass(), - optional: true, - permissions: schemaFieldPermissions(schema), - }); - } - return cacheSchemaField(schema, { - ref: schema.getCoValueClass(), - optional: false, - 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 resolved = resolveSchemaField(inner); - const innerZodType = inner as unknown as z.ZodTypeAny; - if ( - zodSchemaDef.type === "nullable" && - innerZodType?._zod?.def?.type === "date" - ) { - throw new Error("Nullable z.date() is not supported"); - } - return cacheSchemaField(schema, resolved); - } else if (zodSchemaDef.type === "string") { - return cacheSchemaField(schema, "json"); - } else if (zodSchemaDef.type === "number") { - return cacheSchemaField(schema, "json"); - } else if (zodSchemaDef.type === "boolean") { - return cacheSchemaField(schema, "json"); - } else if (zodSchemaDef.type === "null") { - return cacheSchemaField(schema, "json"); - } else if (zodSchemaDef.type === "enum") { - return cacheSchemaField(schema, "json"); - } else if (zodSchemaDef.type === "readonly") { - return cacheSchemaField( - schema, - resolveSchemaField( - (schema as unknown as ZodReadonly).def.innerType as SchemaField, - ), - ); - } else if (zodSchemaDef.type === "date") { - return cacheSchemaField(schema, { encoded: Encoders.OptionalDate }); - } else if (zodSchemaDef.type === "template_literal") { - return cacheSchemaField(schema, "json"); - } else if (zodSchemaDef.type === "lazy") { - // Mostly to support z.json() - return cacheSchemaField( - schema, - resolveSchemaField( - (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, - resolveSchemaField( - (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, "json"); - } else if ( - zodSchemaDef.type === "object" || - zodSchemaDef.type === "record" || - zodSchemaDef.type === "array" || - zodSchemaDef.type === "tuple" || - zodSchemaDef.type === "intersection" - ) { - return cacheSchemaField(schema, "json"); - } else if (zodSchemaDef.type === "union") { - if (isUnionOfPrimitivesDeeply(schema)) { - return cacheSchemaField(schema, "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 { - 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}`; - } + 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, - makeCodecSchema( - 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); } diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/unionUtils.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/unionUtils.ts index 5c47aeece1..79471c8469 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, @@ -102,15 +102,23 @@ export function schemaUnionDiscriminatorFor( return coValueClass; } - // 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 = createCoreCoMapSchema( + augmentedShape, + optionDef.catchall, + ); + + return class extends coValueClass { + static override coValueSchema = augmentedSchema; }; } } diff --git a/packages/jazz-tools/src/tools/internal.ts b/packages/jazz-tools/src/tools/internal.ts index 31de52f1c7..75382a5f43 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"; diff --git a/packages/jazz-tools/src/tools/tests/coOptional.test.ts b/packages/jazz-tools/src/tools/tests/coOptional.test.ts index 3dbb91f3ef..b41384771c 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", () => { 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/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"; From 0791cf45a5df56bd449a5bb04ed63823a051010c Mon Sep 17 00:00:00 2001 From: Guido D'Orsi Date: Wed, 11 Feb 2026 12:35:07 +0100 Subject: [PATCH 45/61] feat: initialize built-in schemas for CoMap and Account, update coValueSchema handling --- .../jazz-tools/src/tools/coValues/account.ts | 26 ++----------- .../jazz-tools/src/tools/coValues/coList.ts | 38 +------------------ .../jazz-tools/src/tools/coValues/coMap.ts | 36 +----------------- .../zodSchema/initializeBuiltinSchemas.ts | 7 ++++ .../tools/implementation/zodSchema/zodCo.ts | 2 +- packages/jazz-tools/src/tools/internal.ts | 1 + 6 files changed, 15 insertions(+), 95 deletions(-) create mode 100644 packages/jazz-tools/src/tools/implementation/zodSchema/initializeBuiltinSchemas.ts diff --git a/packages/jazz-tools/src/tools/coValues/account.ts b/packages/jazz-tools/src/tools/coValues/account.ts index 36e331308f..3dc8047311 100644 --- a/packages/jazz-tools/src/tools/coValues/account.ts +++ b/packages/jazz-tools/src/tools/coValues/account.ts @@ -60,10 +60,8 @@ import { InstanceOfSchemaCoValuesMaybeLoaded, LoadedAndRequired, } from "../internal.js"; -import { z } from "../implementation/zodSchema/zodReExport.js"; -import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; -import { createCoreCoMapSchema } from "../implementation/zodSchema/schemaTypes/CoMapSchema.js"; import type { CoreAccountSchema } from "../implementation/zodSchema/schemaTypes/AccountSchema.js"; +import type { AccountSchema as HydratedAccountSchema } from "../implementation/zodSchema/schemaTypes/AccountSchema.js"; import { resolveSchemaField } from "../implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.js"; import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; @@ -75,17 +73,7 @@ export type AccountCreationProps = { /** @category Identity & Permissions */ export class Account extends CoValueBase implements CoValue { declare [TypeSym]: "Account"; - static coValueSchema: CoreCoValueSchema = { - ...createCoreCoMapSchema({ - profile: createCoreCoMapSchema({ - name: z.string(), - inbox: z.optional(z.string()), - inboxInvite: z.optional(z.string()), - }), - root: createCoreCoMapSchema({}), - }), - builtin: "Account" as const, - }; + static coValueSchema?: HydratedAccountSchema; /** * Jazz methods for Accounts are inside this property. @@ -535,15 +523,7 @@ class AccountJazzApi extends CoValueJazzApi { const definition = accountSchema.getDefinition(); const field = definition.shape[key as keyof typeof definition.shape]; if (field) { - const normalizedField = - typeof field === "object" && - field !== null && - "collaborative" in field && - !("getCoValueClass" in field) - ? hydrateCoreCoValueSchema(field as any) - : field; - - const descriptor = resolveSchemaField(normalizedField as any); + const descriptor = resolveSchemaField(field); this.descriptorCache.set(key, descriptor); return descriptor; } diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index ad9937243f..48e8c9bf13 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -39,11 +39,7 @@ import { subscribeToExistingCoValue, } from "../internal.js"; import { z } from "../implementation/zodSchema/zodReExport.js"; -import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; -import { - CoreCoListSchema, - createCoreCoListSchema, -} from "../implementation/zodSchema/schemaTypes/CoListSchema.js"; +import { CoreCoListSchema } from "../implementation/zodSchema/schemaTypes/CoListSchema.js"; import { executeValidation, resolveValidationMode, @@ -82,31 +78,10 @@ export class CoList extends Array implements ReadonlyArray, CoValue { - static coValueSchema?: CoreCoValueSchema; + static coValueSchema?: CoreCoListSchema; declare $jazz: CoListJazzApi; declare $isLoaded: true; - /** - * Declare a `CoList` by subclassing `CoList.Of(...)` and passing the item schema. - * - * @example - * ```ts - * const Animal = co.map({ name: z.string() }); - * class ColorList extends CoList.Of(z.string()) {} - * class AnimalList extends CoList.Of(Animal) {} - * ``` - * - * @category Declaration - */ - static Of(item: Item): typeof CoList { - // TODO: cache superclass for item class - return class CoListOf extends CoList { - static override coValueSchema = createCoreCoListSchema( - item as any, - ) as CoreCoListSchema; - }; - } - /** * @ignore * @deprecated Use UPPERCASE `CoList.Of` instead! */ @@ -270,15 +245,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]: CoFieldInit }, - ) { - this.coValueSchema = createCoreCoListSchema(def[ItemsSym] as any); - } - /** * Load a `CoList` with a given ID, as a given account. * diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 02965fe84b..067b861a5d 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -64,7 +64,6 @@ import { normalizeZodSchema, } from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; -import { createCoreCoMapSchema } from "../implementation/zodSchema/schemaTypes/CoMapSchema.js"; import { resolveSchemaField } from "../implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.js"; export type CoMapEdit = { @@ -131,8 +130,7 @@ export class CoMap extends CoValueBase implements CoValue { */ declare $jazz: CoMapJazzApi; - // TODO: we are keeping this default to avoid breaking too many tests, but it should be removed in the future - static coValueSchema: CoreCoMapSchema = createCoreCoMapSchema({}); + static coValueSchema?: CoreCoMapSchema; /** @internal */ constructor(options: { fromRaw: RawCoMap } | undefined) { @@ -377,38 +375,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 catchall value schema. Keys are always `string`. - * - * @example - * ```ts - * import { co, z, CoMap } from "jazz-tools"; - * - * const Fruit = co.map({ name: z.string() }); - * const ColorToFruitMap = co.record(z.string(), Fruit) - * - * // assume we have map: ColorToFruitMap - * // and strawberry: Fruit - * map.$jazz.set("red", strawberry); - * ``` - * - * @category Declaration - */ - static Record(value: Value) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging - class RecordLikeCoMap extends CoMap { - static override coValueSchema: CoreCoMapSchema = createCoreCoMapSchema( - {}, - 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. * 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..b98ea2fd38 --- /dev/null +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/initializeBuiltinSchemas.ts @@ -0,0 +1,7 @@ +import { CoMap } from "../../coValues/coMap.js"; +import { Account } from "../../coValues/account.js"; +import { coMapDefiner } from "./zodCo.js"; +import { coAccountDefiner } from "./zodCo.js"; + +CoMap.coValueSchema = coMapDefiner({}); +Account.coValueSchema = coAccountDefiner(); 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 75382a5f43..ef5dcd430a 100644 --- a/packages/jazz-tools/src/tools/internal.ts +++ b/packages/jazz-tools/src/tools/internal.ts @@ -61,4 +61,5 @@ export * from "./implementation/ContextManager.js"; export * from "./subscribe/JazzError.js"; +import "./implementation/zodSchema/initializeBuiltinSchemas.js"; import "./implementation/devtoolsFormatters.js"; From fd80743f0028232c72f4b10e759abefbb4438ff3 Mon Sep 17 00:00:00 2001 From: Guido D'Orsi Date: Wed, 11 Feb 2026 13:19:38 +0100 Subject: [PATCH 46/61] refactor: replace resolveSchemaField with cached getDescriptorsSchema in CoList, CoFeed, CoMap, and Account schemas --- .../jazz-tools/src/tools/coValues/account.ts | 7 +-- .../jazz-tools/src/tools/coValues/coFeed.ts | 9 ++- .../jazz-tools/src/tools/coValues/coList.ts | 3 +- .../jazz-tools/src/tools/coValues/coMap.ts | 9 ++- .../coValueSchemaTransformation.ts | 6 +- .../zodSchema/schemaTypes/AccountSchema.ts | 3 + .../zodSchema/schemaTypes/CoFeedSchema.ts | 25 ++++++++ .../zodSchema/schemaTypes/CoListSchema.ts | 25 ++++++++ .../zodSchema/schemaTypes/CoMapSchema.ts | 58 +++++++++++++++++++ 9 files changed, 126 insertions(+), 19 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/account.ts b/packages/jazz-tools/src/tools/coValues/account.ts index 3dc8047311..40df9d6750 100644 --- a/packages/jazz-tools/src/tools/coValues/account.ts +++ b/packages/jazz-tools/src/tools/coValues/account.ts @@ -62,7 +62,6 @@ import { } from "../internal.js"; import type { CoreAccountSchema } from "../implementation/zodSchema/schemaTypes/AccountSchema.js"; import type { AccountSchema as HydratedAccountSchema } from "../implementation/zodSchema/schemaTypes/AccountSchema.js"; -import { resolveSchemaField } from "../implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.js"; import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; export type AccountCreationProps = { @@ -520,10 +519,8 @@ class AccountJazzApi extends CoValueJazzApi { ); } - const definition = accountSchema.getDefinition(); - const field = definition.shape[key as keyof typeof definition.shape]; - if (field) { - const descriptor = resolveSchemaField(field); + const descriptor = accountSchema.getDescriptorsSchema().shape[key]; + if (descriptor) { this.descriptorCache.set(key, descriptor); return descriptor; } diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index 279f49f0db..b917c7c11b 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -67,7 +67,6 @@ import { normalizeZodSchema, } from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; -import { resolveSchemaField } from "../implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.js"; /** @deprecated Use CoFeedEntry instead */ export type CoStreamEntry = CoFeedEntry; @@ -279,9 +278,9 @@ export class CoFeed extends CoValueBase implements CoValue { `[schema-invariant] ${this.name || "CoFeed"}.create expected CoFeed schema, got ${coFeedSchema.builtin}.`, ); } - const itemDescriptor = resolveSchemaField( - (coFeedSchema as CoreCoFeedSchema).element as any, - ); + const itemDescriptor = ( + coFeedSchema as CoreCoFeedSchema + ).getDescriptorsSchema(); for (let index = 0; index < init.length; index++) { const item = init[index]; @@ -665,7 +664,7 @@ export class CoFeedJazzApi extends CoValueJazzApi { ); } - return resolveSchemaField(this.coFeedSchema.element as any); + return this.coFeedSchema.getDescriptorsSchema(); } } diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 48e8c9bf13..9c2f88ad3a 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -50,7 +50,6 @@ import { normalizeZodSchema, } from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; -import { resolveSchemaField } from "../implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.js"; /** * CoLists are collaborative versions of plain arrays. @@ -989,7 +988,7 @@ export class CoListJazzApi extends CoValueJazzApi { ); } - return resolveSchemaField(this.coListSchema.element as any); + return this.coListSchema.getDescriptorsSchema(); } /** diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 067b861a5d..d3c328e995 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -64,7 +64,6 @@ import { normalizeZodSchema, } from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; -import { resolveSchemaField } from "../implementation/zodSchema/runtimeConverters/schemaFieldToCoFieldDef.js"; export type CoMapEdit = { value?: V; @@ -860,10 +859,10 @@ class CoMapJazzApi extends CoValueJazzApi { ); } - const definition = this.coMapSchema.getDefinition(); - const schemaField = definition.shape[key] ?? definition.catchall; - if (schemaField) { - const descriptor = resolveSchemaField(schemaField as any); + const descriptorsSchema = this.coMapSchema.getDescriptorsSchema(); + const descriptor = + descriptorsSchema.shape[key] ?? descriptorsSchema.catchall; + if (descriptor) { this.descriptorCache.set(key, descriptor); return descriptor; } 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 d022f05649..8080b330f5 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts @@ -32,6 +32,8 @@ import { CoValueClassOrSchema, CoValueSchemaFromCoreSchema, } from "../zodSchema.js"; +import { CoreCoListSchema } from "../schemaTypes/CoListSchema.js"; +import { CoreCoFeedSchema } from "../schemaTypes/CoFeedSchema.js"; /** * A platform agnostic way to check if we're in development mode * @@ -104,7 +106,7 @@ export function hydrateCoreCoValueSchema( } else if (schema.builtin === "CoList") { const element = schema.element; const coValueClass = class ZCoList extends CoList { - static coValueSchema: CoreCoValueSchema; + static coValueSchema: CoreCoListSchema; constructor(options: { fromRaw: RawCoList } | undefined) { super(options); } @@ -116,7 +118,7 @@ export function hydrateCoreCoValueSchema( return coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoFeed") { const coValueClass = class ZCoFeed extends CoFeed { - static coValueSchema: CoreCoValueSchema; + static coValueSchema: CoreCoFeedSchema; constructor(options: { fromRaw: RawCoStream }) { super(options); } 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 19fcdb3f44..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,6 +52,7 @@ export class AccountSchema< collaborative = true as const; builtin = "Account" as const; shape: Shape; + getDescriptorsSchema: () => CoMapDescriptorsSchema; getDefinition: () => CoMapSchemaDefinition; #validationSchema: z.ZodType | undefined = undefined; @@ -82,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/CoFeedSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts index dcaad28521..3dd33c03e3 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,7 @@ import { coOptionalDefiner, unstable_mergeBranchWithResolve, withSchemaPermissions, + type Schema, } from "../../../internal.js"; import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js"; import { CoFeedSchemaInit } from "../typeConverters/CoFieldSchemaInit.js"; @@ -30,6 +31,7 @@ import { import { z } from "../zodReExport.js"; import { generateValidationSchemaFromItem } from "./schemaValidators.js"; import { type LocalValidationMode } from "../validationSettings.js"; +import { resolveSchemaField } from "../runtimeConverters/schemaFieldToCoFieldDef.js"; export class CoFeedSchema< T extends AnyZodOrCoValueSchema, @@ -38,6 +40,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. @@ -72,6 +75,16 @@ export class CoFeedSchema< 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?: @@ -295,10 +308,21 @@ 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(), }; @@ -310,6 +334,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 033bdc8ddd..76fee516d8 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,7 @@ import { coOptionalDefiner, unstable_mergeBranchWithResolve, withSchemaPermissions, + type Schema, } from "../../../internal.js"; import { CoValueUniqueness } from "cojson"; import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js"; @@ -31,6 +32,7 @@ import { import { z } from "../zodReExport.js"; import { generateValidationSchemaFromItem } from "./schemaValidators.js"; import type { LocalValidationMode } from "../validationSettings.js"; +import { resolveSchemaField } from "../runtimeConverters/schemaFieldToCoFieldDef.js"; export class CoListSchema< T extends AnyZodOrCoValueSchema, @@ -39,6 +41,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. @@ -75,6 +78,16 @@ export class CoListSchema< 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?: @@ -343,10 +356,21 @@ 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(), }; @@ -358,6 +382,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 c77a4acc8d..a4bbd9070c 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -20,6 +20,7 @@ import { unstable_mergeBranchWithResolve, withSchemaPermissions, isCoValueSchema, + type Schema, } from "../../../internal.js"; import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js"; import { removeGetters, withSchemaResolveQuery } from "../../schemaUtils.js"; @@ -36,6 +37,7 @@ import { } from "../schemaPermissions.js"; import { generateValidationSchemaFromItem } from "./schemaValidators.js"; import type { LocalValidationMode } from "../validationSettings.js"; +import { resolveSchemaField } from "../runtimeConverters/schemaFieldToCoFieldDef.js"; type CoMapSchemaInstance = Simplify< CoMapInstanceCoValuesMaybeLoaded @@ -53,6 +55,7 @@ export class CoMapSchema< builtin = "CoMap" as const; shape: Shape; catchAll?: CatchAll; + #descriptorsSchema: CoMapDescriptorsSchema | undefined = undefined; getDefinition: () => CoMapSchemaDefinition; #validationSchema: z.ZodType | undefined = undefined; @@ -117,6 +120,30 @@ 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?: @@ -509,11 +536,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; @@ -552,6 +604,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, @@ -560,6 +617,7 @@ export interface CoreCoMapSchema< builtin: "CoMap"; shape: Shape; catchAll?: CatchAll; + getDescriptorsSchema: () => CoMapDescriptorsSchema; getDefinition: () => CoMapSchemaDefinition; } From cca09692e75472b9a97aea60c0ac3bd4996927be Mon Sep 17 00:00:00 2001 From: Guido D'Orsi Date: Wed, 11 Feb 2026 14:04:07 +0100 Subject: [PATCH 47/61] refactor: remove deprecated methods and streamline SchemaUnion handling in CoFeed, CoList, and schemaUnion --- .../jazz-tools/src/tools/coValues/coFeed.ts | 20 --- .../jazz-tools/src/tools/coValues/coList.ts | 7 - .../src/tools/coValues/schemaUnion.ts | 124 +++++------------- .../coValueSchemaTransformation.ts | 6 +- 4 files changed, 37 insertions(+), 120 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index b917c7c11b..9be954e7b4 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -113,26 +113,6 @@ export class CoFeed extends CoValueBase implements CoValue { static coValueSchema?: CoreCoValueSchema; declare $jazz: CoFeedJazzApi; - /** - * Declare a `CoFeed` by subclassing `CoFeed.Of(...)` and passing the item schema. - * - * @example - * ```ts - * const Animal = co.map({ name: z.string() }); - * class ColorFeed extends CoFeed.Of(z.string()) {} - * class AnimalFeed extends CoFeed.Of(Animal) {} - * ``` - * - * @category Declaration - */ - static Of(item: Item): typeof CoFeed { - return class CoFeedOf extends CoFeed { - static override coValueSchema = createCoreCoFeedSchema( - item as any, - ) as CoreCoFeedSchema; - }; - } - /** @category Type Helpers */ declare [TypeSym]: "CoStream"; static { diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 9c2f88ad3a..4c99982a95 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -81,13 +81,6 @@ export class CoList declare $jazz: CoListJazzApi; declare $isLoaded: true; - /** - * @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 { 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/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts index 8080b330f5..3f9c61051a 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts @@ -16,7 +16,7 @@ import { FileStreamSchema, CoVectorSchema, PlainTextSchema, - SchemaUnion, + schemaUnionClassFromDiscriminator, isCoValueClass, CoVector, } from "../../../internal.js"; @@ -147,7 +147,9 @@ export function hydrateCoreCoValueSchema( const coValueClass = CoRichText; return new RichTextSchema(coValueClass) as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoDiscriminatedUnion") { - const coValueClass = SchemaUnion.Of(schemaUnionDiscriminatorFor(schema)); + const coValueClass = schemaUnionClassFromDiscriminator( + schemaUnionDiscriminatorFor(schema), + ); const coValueSchema = new CoDiscriminatedUnionSchema(schema, coValueClass); return coValueSchema as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "Group") { From ef4fe051144d1ae5b0f1d9c397c1c109aa1edbb0 Mon Sep 17 00:00:00 2001 From: Guido D'Orsi Date: Wed, 11 Feb 2026 14:21:24 +0100 Subject: [PATCH 48/61] fix: clean up type castings for schema --- .../jazz-tools/src/tools/coValues/account.ts | 2 +- .../jazz-tools/src/tools/coValues/coFeed.ts | 16 ++-- .../jazz-tools/src/tools/coValues/coList.ts | 8 +- .../jazz-tools/src/tools/coValues/coMap.ts | 13 +-- .../src/tools/coValues/coPlainText.ts | 3 + .../jazz-tools/src/tools/coValues/coVector.ts | 3 + .../src/tools/coValues/interfaces.ts | 7 +- .../coValueSchemaTransformation.ts | 90 +++++++++---------- .../zodSchema/schemaInvariant.ts | 23 +++-- 9 files changed, 75 insertions(+), 90 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/account.ts b/packages/jazz-tools/src/tools/coValues/account.ts index 40df9d6750..5799319718 100644 --- a/packages/jazz-tools/src/tools/coValues/account.ts +++ b/packages/jazz-tools/src/tools/coValues/account.ts @@ -99,7 +99,7 @@ export class Account extends CoValueBase implements CoValue { const accountSchema = assertCoValueSchema( this.constructor as unknown as typeof Account, "create", - ) as CoreAccountSchema; + ); Object.defineProperties(this, { [TypeSym]: { value: "Account", enumerable: false }, diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index 9be954e7b4..495221aafb 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -49,9 +49,9 @@ import { parseSubscribeRestArgs, subscribeToCoValueWithoutMe, subscribeToExistingCoValue, + CoreFileStreamSchema, } from "../internal.js"; import { z } from "../implementation/zodSchema/zodReExport.js"; -import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoValueSchema.js"; import { CoreCoFeedSchema, createCoreCoFeedSchema, @@ -110,7 +110,7 @@ export { CoFeed as CoStream }; * @category CoValues */ export class CoFeed extends CoValueBase implements CoValue { - static coValueSchema?: CoreCoValueSchema; + static coValueSchema?: CoreCoFeedSchema; declare $jazz: CoFeedJazzApi; /** @category Type Helpers */ @@ -199,11 +199,7 @@ export class CoFeed extends CoValueBase implements CoValue { Object.defineProperties(this, { $jazz: { - value: new CoFeedJazzApi( - this, - options.fromRaw, - coFeedSchema as CoreCoFeedSchema, - ), + value: new CoFeedJazzApi(this, options.fromRaw, coFeedSchema), enumerable: false, }, }); @@ -258,9 +254,7 @@ export class CoFeed extends CoValueBase implements CoValue { `[schema-invariant] ${this.name || "CoFeed"}.create expected CoFeed schema, got ${coFeedSchema.builtin}.`, ); } - const itemDescriptor = ( - coFeedSchema as CoreCoFeedSchema - ).getDescriptorsSchema(); + const itemDescriptor = coFeedSchema.getDescriptorsSchema(); for (let index = 0; index < init.length; index++) { const item = init[index]; @@ -877,6 +871,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 4c99982a95..745856b3b7 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -105,11 +105,7 @@ export class CoList ); Object.defineProperties(this, { $jazz: { - value: new CoListJazzApi( - proxy, - () => options.fromRaw, - coListSchema as CoreCoListSchema, - ), + value: new CoListJazzApi(proxy, () => options.fromRaw, coListSchema), enumerable: false, }, $isLoaded: { value: true, enumerable: false }, @@ -157,7 +153,7 @@ export class CoList const coListSchema = assertCoValueSchema( this as unknown as typeof CoList, "create", - ) as CoreCoListSchema; + ); const validationMode = resolveValidationMode( options && "validation" in options ? options.validation : undefined, ); diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index d3c328e995..4c63af0a6a 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -144,11 +144,7 @@ export class CoMap extends CoValueBase implements CoValue { ); Object.defineProperties(this, { $jazz: { - value: new CoMapJazzApi( - proxy, - () => options.fromRaw, - coMapSchema as CoreCoMapSchema, - ), + value: new CoMapJazzApi(proxy, () => options.fromRaw, coMapSchema), enumerable: false, }, }); @@ -194,12 +190,7 @@ export class CoMap extends CoValueBase implements CoValue { "create", ); const instance = new this(); - return CoMap._createCoMap( - instance, - coMapSchema as CoreCoMapSchema, - init, - options, - ); + return CoMap._createCoMap(instance, coMapSchema, init, options); } /** diff --git a/packages/jazz-tools/src/tools/coValues/coPlainText.ts b/packages/jazz-tools/src/tools/coValues/coPlainText.ts index 5de8d2f94d..b73620b29a 100644 --- a/packages/jazz-tools/src/tools/coValues/coPlainText.ts +++ b/packages/jazz-tools/src/tools/coValues/coPlainText.ts @@ -19,13 +19,16 @@ import { parseSubscribeRestArgs, subscribeToCoValueWithoutMe, subscribeToExistingCoValue, + CorePlainTextSchema, } 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; diff --git a/packages/jazz-tools/src/tools/coValues/coVector.ts b/packages/jazz-tools/src/tools/coValues/coVector.ts index 1567184e3d..fc7557d1b8 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 aa2cd3f9ab..1ad728cfd1 100644 --- a/packages/jazz-tools/src/tools/coValues/interfaces.ts +++ b/packages/jazz-tools/src/tools/coValues/interfaces.ts @@ -41,8 +41,11 @@ import { CoreCoValueSchema } from "../implementation/zodSchema/schemaTypes/CoVal /** @category Abstract interfaces */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export interface CoValueClass { - coValueSchema?: CoreCoValueSchema; +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; 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 3f9c61051a..6d108732be 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts @@ -84,74 +84,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 { - static coValueSchema: CoreCoValueSchema; - constructor(options: { fromRaw: RawCoMap } | undefined) { - super(options); - } - }; - - const coValueSchema = - ClassToExtend === Account - ? new AccountSchema(schema as any, coValueClass as any) - : new CoMapSchema(schema as any, coValueClass as any); + } else if (schema.builtin === "CoMap") { + const coValueClass = class ZCoMap extends CoMap {}; + coValueClass.coValueSchema = new CoMapSchema( + schema as any, + coValueClass as any, + ); - coValueClass.coValueSchema = coValueSchema; + return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; + } else if (schema.builtin === "Account") { + const coValueClass = class ZAccount extends Account {}; + 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 { - static coValueSchema: CoreCoListSchema; - constructor(options: { fromRaw: RawCoList } | undefined) { - super(options); - } - }; + const coValueClass = class ZCoList extends CoList {}; + coValueClass.coValueSchema = new CoListSchema(element, coValueClass as any); - const coValueSchema = new CoListSchema(element, coValueClass as any); - coValueClass.coValueSchema = coValueSchema; - - return coValueSchema as unknown as CoValueSchemaFromCoreSchema; + return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoFeed") { - const coValueClass = class ZCoFeed extends CoFeed { - static coValueSchema: CoreCoFeedSchema; - constructor(options: { fromRaw: RawCoStream }) { - super(options); - } - }; - const coValueSchema = new CoFeedSchema(schema.element, coValueClass as any); - coValueClass.coValueSchema = coValueSchema; - return coValueSchema as unknown as CoValueSchemaFromCoreSchema; + const coValueClass = class ZCoFeed extends CoFeed {}; + coValueClass.coValueSchema = new CoFeedSchema( + schema.element, + coValueClass as any, + ); + return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "FileStream") { - const coValueClass = FileStream; - return new FileStreamSchema(coValueClass) as CoValueSchemaFromCoreSchema; + const coValueClass = class ZFileStream extends FileStream {}; + 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 { 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 ZCoPlainText extends CoPlainText {}; + 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 ZCoRichText extends CoRichText {}; + coValueClass.coValueSchema = new RichTextSchema(coValueClass as any); + return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoDiscriminatedUnion") { const coValueClass = schemaUnionClassFromDiscriminator( schemaUnionDiscriminatorFor(schema), ); - const coValueSchema = new CoDiscriminatedUnionSchema(schema, coValueClass); - return coValueSchema as CoValueSchemaFromCoreSchema; + 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/schemaInvariant.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts index a5bc00c011..c2370da56d 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts @@ -1,22 +1,21 @@ import type { CoreCoValueSchema } from "./schemaTypes/CoValueSchema.js"; -export function assertCoValueSchema( - constructor: unknown, - operation: "create" | "load" | "resolve", -): CoreCoValueSchema { - const constructorLike = constructor as { - name?: string; - coValueSchema?: CoreCoValueSchema; - }; - const schema = constructorLike.coValueSchema; +type ConstructorWithSchema = { + name?: string; + coValueSchema?: S; +}; +export function assertCoValueSchema( + constructor: C, + operation: "create" | "load" | "resolve", +): NonNullable { + const schema = constructor.coValueSchema; if (!schema) { - const className = constructorLike.name || "AnonymousCoValue"; + 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.`, ); } - - return schema; + return schema as NonNullable; } From dcd2fc7acb45defd5935a10d4a62e7e46a71f8bc Mon Sep 17 00:00:00 2001 From: Guido D'Orsi Date: Wed, 11 Feb 2026 14:31:20 +0100 Subject: [PATCH 49/61] refactor: optimize schemaUnionDiscriminatorFor by consolidating coValueSchema retrieval and enhancing return structure --- .../tools/implementation/zodSchema/unionUtils.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/unionUtils.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/unionUtils.ts index 79471c8469..43367474cc 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/unionUtils.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/unionUtils.ts @@ -91,15 +91,13 @@ 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; } // Add schema-level dummy keys so deep-resolve keys shared by other union branches @@ -112,14 +110,11 @@ export function schemaUnionDiscriminatorFor( augmentedShape[key] = z.optional(z.null()); } - const augmentedSchema = createCoreCoMapSchema( - augmentedShape, - optionDef.catchall, + const augmentedSchema = hydrateCoreCoValueSchema( + createCoreCoMapSchema(augmentedShape, optionDef.catchall), ); - return class extends coValueClass { - static override coValueSchema = augmentedSchema; - }; + return augmentedSchema.getCoValueClass() as typeof CoMap; } } From d6fa4349211f8b2b0b27adabaa9ccfa463fc5b55 Mon Sep 17 00:00:00 2001 From: Guido D'Orsi Date: Wed, 11 Feb 2026 15:40:02 +0100 Subject: [PATCH 50/61] refactor: rename imported classes for clarity and consistency in coValueSchemaTransformation --- .../coValueSchemaTransformation.ts | 47 +++++++------------ 1 file changed, 16 insertions(+), 31 deletions(-) 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 6d108732be..a706bfcc25 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts @@ -1,24 +1,23 @@ -import { RawCoList, RawCoMap, RawCoStream } 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, schemaUnionClassFromDiscriminator, isCoValueClass, - CoVector, + CoVector as CoVectorClass, } from "../../../internal.js"; import { CoreCoValueSchema } from "../schemaTypes/CoValueSchema.js"; @@ -32,20 +31,6 @@ import { CoValueClassOrSchema, CoValueSchemaFromCoreSchema, } from "../zodSchema.js"; -import { CoreCoListSchema } from "../schemaTypes/CoListSchema.js"; -import { CoreCoFeedSchema } from "../schemaTypes/CoFeedSchema.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 @@ -85,7 +70,7 @@ export function hydrateCoreCoValueSchema( `co.optional() of collaborative types is not supported as top-level schema: ${JSON.stringify(schema)}`, ); } else if (schema.builtin === "CoMap") { - const coValueClass = class ZCoMap extends CoMap {}; + const coValueClass = class CoMap extends CoMapClass {}; coValueClass.coValueSchema = new CoMapSchema( schema as any, coValueClass as any, @@ -93,7 +78,7 @@ export function hydrateCoreCoValueSchema( return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "Account") { - const coValueClass = class ZAccount extends Account {}; + const coValueClass = class Account extends AccountClass {}; coValueClass.coValueSchema = new AccountSchema( schema as any, coValueClass as any, @@ -102,25 +87,25 @@ export function hydrateCoreCoValueSchema( return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoList") { const element = schema.element; - const coValueClass = class ZCoList extends CoList {}; + const coValueClass = class CoList extends CoListClass {}; coValueClass.coValueSchema = new CoListSchema(element, coValueClass as any); return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoFeed") { - const coValueClass = class ZCoFeed extends CoFeed {}; + const coValueClass = class CoFeed extends CoFeedClass {}; coValueClass.coValueSchema = new CoFeedSchema( schema.element, coValueClass as any, ); return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "FileStream") { - const coValueClass = class ZFileStream extends FileStream {}; + 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; }; coValueClass.coValueSchema = new CoVectorSchema( @@ -130,11 +115,11 @@ export function hydrateCoreCoValueSchema( return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoPlainText") { - const coValueClass = class ZCoPlainText extends CoPlainText {}; + 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 = class ZCoRichText extends CoRichText {}; + 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") { From 8eb0b7512f7dba668f2e10bf88907db7011ff2c2 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Wed, 11 Feb 2026 15:05:25 +0100 Subject: [PATCH 51/61] chore: assertCoValueSchema typed --- .../jazz-tools/src/tools/coValues/account.ts | 3 +- .../jazz-tools/src/tools/coValues/coFeed.ts | 15 +++----- .../jazz-tools/src/tools/coValues/coList.ts | 10 +++--- .../jazz-tools/src/tools/coValues/coMap.ts | 8 ++--- .../zodSchema/schemaInvariant.ts | 34 ++++++++++++++++++- 5 files changed, 46 insertions(+), 24 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/account.ts b/packages/jazz-tools/src/tools/coValues/account.ts index 5799319718..0889143c2f 100644 --- a/packages/jazz-tools/src/tools/coValues/account.ts +++ b/packages/jazz-tools/src/tools/coValues/account.ts @@ -97,7 +97,8 @@ export class Account extends CoValueBase implements CoValue { ); const accountSchema = assertCoValueSchema( - this.constructor as unknown as typeof Account, + this.constructor, + "Account", "create", ); diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index 495221aafb..eba0199d4e 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -193,7 +193,8 @@ export class CoFeed extends CoValueBase implements CoValue { constructor(options: { fromRaw: RawCoStream }) { super(); const coFeedSchema = assertCoValueSchema( - this.constructor as typeof CoFeed, + this.constructor, + "CoFeed", "load", ); @@ -225,10 +226,7 @@ export class CoFeed extends CoValueBase implements CoValue { | Account | Group, ) { - const coFeedSchema = assertCoValueSchema( - this as unknown as typeof CoFeed, - "create", - ); + const coFeedSchema = assertCoValueSchema(this, "CoFeed", "create"); const { owner, uniqueness, firstComesWins } = parseCoValueCreateOptions(options); const initMeta = firstComesWins ? { fww: "init" } : undefined; @@ -249,11 +247,6 @@ export class CoFeed extends CoValueBase implements CoValue { executeValidation(fullSchema, init, validationMode) as typeof init; } - if (coFeedSchema.builtin !== "CoFeed") { - throw new Error( - `[schema-invariant] ${this.name || "CoFeed"}.create expected CoFeed schema, got ${coFeedSchema.builtin}.`, - ); - } const itemDescriptor = coFeedSchema.getDescriptorsSchema(); for (let index = 0; index < init.length; index++) { @@ -485,7 +478,7 @@ export class CoFeedJazzApi extends CoValueJazzApi { constructor( private coFeed: F, public raw: RawCoStream, - private coFeedSchema?: CoreCoFeedSchema, + private coFeedSchema: CoreCoFeedSchema, ) { super(coFeed); } diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 745856b3b7..c4f0499ee2 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -100,7 +100,8 @@ export class CoList if (options && "fromRaw" in options) { const coListSchema = assertCoValueSchema( - this.constructor as typeof CoList, + this.constructor, + "CoList", "load", ); Object.defineProperties(this, { @@ -150,10 +151,7 @@ export class CoList | Account | Group, ) { - const coListSchema = assertCoValueSchema( - this as unknown as typeof CoList, - "create", - ); + const coListSchema = assertCoValueSchema(this, "CoList", "create"); const validationMode = resolveValidationMode( options && "validation" in options ? options.validation : undefined, ); @@ -545,7 +543,7 @@ export class CoListJazzApi extends CoValueJazzApi { constructor( private coList: L, private getRaw: () => RawCoList, - private coListSchema?: CoreCoListSchema, + private coListSchema: CoreCoListSchema, ) { super(coList); } diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 4c63af0a6a..f27a1d166e 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -139,7 +139,8 @@ export class CoMap extends CoValueBase implements CoValue { if (options && "fromRaw" in options) { const coMapSchema = assertCoValueSchema( - this.constructor as typeof CoMap, + this.constructor, + "CoMap", "load", ); Object.defineProperties(this, { @@ -185,10 +186,7 @@ export class CoMap extends CoValueBase implements CoValue { | Account | Group, ) { - const coMapSchema = assertCoValueSchema( - this as unknown as typeof CoMap, - "create", - ); + const coMapSchema = assertCoValueSchema(this, "CoMap", "create"); const instance = new this(); return CoMap._createCoMap(instance, coMapSchema, init, options); } diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts index c2370da56d..4f9d825b9e 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts @@ -1,3 +1,7 @@ +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 = { @@ -5,7 +9,7 @@ type ConstructorWithSchema = { coValueSchema?: S; }; -export function assertCoValueSchema( +export function assertCoreCoValueSchema( constructor: C, operation: "create" | "load" | "resolve", ): NonNullable { @@ -19,3 +23,31 @@ export function assertCoValueSchema( } 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, operation); + + if (schema.builtin !== type) { + const className = constructor.name || "AnonymousCoValue"; + + throw new Error( + `[schema-invariant] ${className}.${operation} requires a ${type} schema. ` + + `Attached schema is ${schema.builtin}.`, + ); + } + + return schema as Extract; +} From 5b880c1bba8ba76cb67411d1db17ec89698747d4 Mon Sep 17 00:00:00 2001 From: Guido D'Orsi Date: Wed, 11 Feb 2026 16:36:18 +0100 Subject: [PATCH 52/61] fix: clean up schema builtin checks --- .../jazz-tools/src/tools/coValues/account.ts | 9 -------- .../jazz-tools/src/tools/coValues/coFeed.ts | 12 ---------- .../jazz-tools/src/tools/coValues/coList.ts | 12 ---------- .../jazz-tools/src/tools/coValues/coMap.ts | 9 -------- .../zodSchema/schemaInvariant.ts | 23 ++++++++++--------- 5 files changed, 12 insertions(+), 53 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/account.ts b/packages/jazz-tools/src/tools/coValues/account.ts index 0889143c2f..4875dc825a 100644 --- a/packages/jazz-tools/src/tools/coValues/account.ts +++ b/packages/jazz-tools/src/tools/coValues/account.ts @@ -511,15 +511,6 @@ class AccountJazzApi extends CoValueJazzApi { const accountSchema = this.coValueSchema; - if ( - accountSchema.builtin !== "Account" && - accountSchema.builtin !== "CoMap" - ) { - throw new Error( - `[schema-invariant] ${this.account.constructor.name || "Account"}.resolve expected Account/CoMap schema, got ${accountSchema.builtin}.`, - ); - } - const descriptor = accountSchema.getDescriptorsSchema().shape[key]; if (descriptor) { this.descriptorCache.set(key, descriptor); diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index eba0199d4e..32de939961 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -619,18 +619,6 @@ export class CoFeedJazzApi extends CoValueJazzApi { * @internal */ getItemsDescriptor(): Schema { - if (!this.coFeedSchema) { - throw new Error( - `[schema-invariant] ${this.coFeed.constructor.name || "CoFeed"} is missing coValueSchema.`, - ); - } - - if (this.coFeedSchema.builtin !== "CoFeed") { - throw new Error( - `[schema-invariant] ${this.coFeed.constructor.name || "CoFeed"}.resolve expected CoFeed schema, got ${this.coFeedSchema.builtin}.`, - ); - } - return this.coFeedSchema.getDescriptorsSchema(); } } diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index c4f0499ee2..b36544fd04 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -963,18 +963,6 @@ export class CoListJazzApi extends CoValueJazzApi { * @internal */ getItemsDescriptor(): Schema { - if (!this.coListSchema) { - throw new Error( - `[schema-invariant] ${this.coList.constructor.name || "CoList"} is missing coValueSchema.`, - ); - } - - if (this.coListSchema.builtin !== "CoList") { - throw new Error( - `[schema-invariant] ${this.coList.constructor.name || "CoList"}.resolve expected CoList schema, got ${this.coListSchema.builtin}.`, - ); - } - return this.coListSchema.getDescriptorsSchema(); } diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index f27a1d166e..0ce3757175 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -839,15 +839,6 @@ class CoMapJazzApi extends CoValueJazzApi { return this.descriptorCache.get(key); } - if ( - this.coMapSchema.builtin !== "CoMap" && - this.coMapSchema.builtin !== "Account" - ) { - throw new Error( - `[schema-invariant] ${this.coMap.constructor.name || "CoMap"}.resolve expected CoMap/Account schema, got ${this.coMapSchema.builtin}.`, - ); - } - const descriptorsSchema = this.coMapSchema.getDescriptorsSchema(); const descriptor = descriptorsSchema.shape[key] ?? descriptorsSchema.catchall; diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts index 4f9d825b9e..d736121472 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaInvariant.ts @@ -9,8 +9,9 @@ type ConstructorWithSchema = { coValueSchema?: S; }; -export function assertCoreCoValueSchema( +function assertCoreCoValueSchema( constructor: C, + expectedSchemaType: string, operation: "create" | "load" | "resolve", ): NonNullable { const schema = constructor.coValueSchema; @@ -21,6 +22,15 @@ export function assertCoreCoValueSchema( `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; } @@ -38,16 +48,7 @@ export function assertCoValueSchema< type: T, operation: "create" | "load" | "resolve", ): Extract { - const schema = assertCoreCoValueSchema(constructor, operation); - - if (schema.builtin !== type) { - const className = constructor.name || "AnonymousCoValue"; - - throw new Error( - `[schema-invariant] ${className}.${operation} requires a ${type} schema. ` + - `Attached schema is ${schema.builtin}.`, - ); - } + const schema = assertCoreCoValueSchema(constructor, type, operation); return schema as Extract; } From 554f0596ddcbd44a0924396f8fd919a09d9cd7f1 Mon Sep 17 00:00:00 2001 From: Guido D'Orsi Date: Wed, 11 Feb 2026 16:40:38 +0100 Subject: [PATCH 53/61] refactor: rename coValueClass subclasses as workaround for bundling bug --- .../coValueSchemaTransformation.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 a706bfcc25..1c0a9f8f29 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/runtimeConverters/coValueSchemaTransformation.ts @@ -70,7 +70,7 @@ export function hydrateCoreCoValueSchema( `co.optional() of collaborative types is not supported as top-level schema: ${JSON.stringify(schema)}`, ); } else if (schema.builtin === "CoMap") { - const coValueClass = class CoMap extends CoMapClass {}; + const coValueClass = class _CoMap extends CoMapClass {}; coValueClass.coValueSchema = new CoMapSchema( schema as any, coValueClass as any, @@ -78,7 +78,7 @@ export function hydrateCoreCoValueSchema( return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "Account") { - const coValueClass = class Account extends AccountClass {}; + const coValueClass = class _Account extends AccountClass {}; coValueClass.coValueSchema = new AccountSchema( schema as any, coValueClass as any, @@ -87,25 +87,25 @@ export function hydrateCoreCoValueSchema( return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoList") { const element = schema.element; - const coValueClass = class CoList extends CoListClass {}; + const coValueClass = class _CoList extends CoListClass {}; coValueClass.coValueSchema = new CoListSchema(element, coValueClass as any); return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoFeed") { - const coValueClass = class CoFeed extends CoFeedClass {}; + const coValueClass = class _CoFeed extends CoFeedClass {}; coValueClass.coValueSchema = new CoFeedSchema( schema.element, coValueClass as any, ); return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "FileStream") { - const coValueClass = class FileStream extends FileStreamClass {}; + 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 CoVector extends CoVectorClass { + const coValueClass = class _CoVector extends CoVectorClass { protected static requiredDimensionsCount = dimensions; }; coValueClass.coValueSchema = new CoVectorSchema( @@ -115,11 +115,11 @@ export function hydrateCoreCoValueSchema( return coValueClass.coValueSchema as unknown as CoValueSchemaFromCoreSchema; } else if (schema.builtin === "CoPlainText") { - const coValueClass = class CoPlainText extends CoPlainTextClass {}; + 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 = class CoRichText extends CoRichTextClass {}; + 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") { From 1317e90a87883cd15127511c1748eeff237f8cd6 Mon Sep 17 00:00:00 2001 From: Guido D'Orsi Date: Wed, 11 Feb 2026 16:53:35 +0100 Subject: [PATCH 54/61] chore: changelog --- .changeset/smooth-lions-joke.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/smooth-lions-joke.md 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. From 734c671fb321d83abd89ac83e9e2a9c542b0efbb Mon Sep 17 00:00:00 2001 From: Guido D'Orsi Date: Thu, 12 Feb 2026 11:20:51 +0100 Subject: [PATCH 55/61] fix: profile default schema using an hydrated schema def --- packages/jazz-tools/src/tools/coValues/profile.ts | 9 --------- .../implementation/zodSchema/initializeBuiltinSchemas.ts | 4 +++- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/profile.ts b/packages/jazz-tools/src/tools/coValues/profile.ts index 0f3544ed5c..23320c5a97 100644 --- a/packages/jazz-tools/src/tools/coValues/profile.ts +++ b/packages/jazz-tools/src/tools/coValues/profile.ts @@ -7,18 +7,9 @@ import { Simplify, TypeSym, } from "../internal.js"; -import { z } from "../implementation/zodSchema/zodReExport.js"; -import { createCoreCoMapSchema } from "../implementation/zodSchema/schemaTypes/CoMapSchema.js"; -import type { CoreCoMapSchema } from "../implementation/zodSchema/schemaTypes/CoMapSchema.js"; /** @category Identity & Permissions */ export class Profile extends CoMap { - static coValueSchema: CoreCoMapSchema = createCoreCoMapSchema({ - name: z.string(), - inbox: z.optional(z.string()), - inboxInvite: z.optional(z.string()), - }); - declare readonly name: string; declare readonly inbox?: string; declare readonly inboxInvite?: string; diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/initializeBuiltinSchemas.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/initializeBuiltinSchemas.ts index b98ea2fd38..0a01271cdc 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/initializeBuiltinSchemas.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/initializeBuiltinSchemas.ts @@ -1,7 +1,9 @@ import { CoMap } from "../../coValues/coMap.js"; import { Account } from "../../coValues/account.js"; -import { coMapDefiner } from "./zodCo.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(); From b641d1a28b6b459a112d54b6963cbb7adb25a5bb Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Thu, 12 Feb 2026 11:26:20 +0100 Subject: [PATCH 56/61] doc warning about schema validation --- .../homepage/content/docs/core-concepts/covalues/overview.mdx | 4 ++++ 1 file changed, 4 insertions(+) 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). From 5b15ff339f9e195d179f04f9b93794150e658e93 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Thu, 12 Feb 2026 11:37:23 +0100 Subject: [PATCH 57/61] fix verify schema.def.type in isUnionSchema --- .../zodSchema/schemaTypes/schemaValidators.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts index 8e8d69ad8a..e51743ed88 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts @@ -46,7 +46,13 @@ function isUnionSchema(schema: unknown): schema is z.ZodUnion { return false; } - if ("type" in schema && schema.type === "union") { + if ( + "def" in schema && + typeof schema.def === "object" && + schema.def !== null && + "type" in schema.def && + schema.def.type === "union" + ) { return true; } From d692a249bf1f878e4f72799c973fc572ce9c6e53 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Thu, 12 Feb 2026 11:44:15 +0100 Subject: [PATCH 58/61] feat: added local validation mode to coList.applyDiff --- .../jazz-tools/src/tools/coValues/coList.ts | 12 ++- .../jazz-tools/src/tools/tests/coList.test.ts | 73 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index b36544fd04..80906469dd 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -873,7 +873,17 @@ 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.getItemsDescriptor()) ? (aIdx: number, bIdx: number) => { diff --git a/packages/jazz-tools/src/tools/tests/coList.test.ts b/packages/jazz-tools/src/tools/tests/coList.test.ts index d9cb6c9aa3..bb4ffbc9b3 100644 --- a/packages/jazz-tools/src/tools/tests/coList.test.ts +++ b/packages/jazz-tools/src/tools/tests/coList.test.ts @@ -944,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(), From cdcdad13154fd88ff1a4263a0750489a5b6a721c Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Thu, 12 Feb 2026 11:54:03 +0100 Subject: [PATCH 59/61] changeset --- .changeset/runtime-validation-improvements.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/runtime-validation-improvements.md 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. From 1d90cb3968e6833506afe1fd108d6ce4aa5af570 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Thu, 12 Feb 2026 14:27:17 +0100 Subject: [PATCH 60/61] chore: refactoring coValueCreateOption (#3457) --- .../jazz-tools/src/tools/coValues/coFeed.ts | 11 +---- .../jazz-tools/src/tools/coValues/coList.ts | 11 +---- .../jazz-tools/src/tools/coValues/coMap.ts | 20 ++------- .../src/tools/coValues/coPlainText.ts | 3 +- .../src/tools/coValues/interfaces.ts | 41 ++++++++++++++----- .../zodSchema/schemaPermissions.ts | 9 +++- .../zodSchema/schemaTypes/CoFeedSchema.ts | 30 ++------------ .../zodSchema/schemaTypes/CoListSchema.ts | 29 ++----------- .../zodSchema/schemaTypes/CoMapSchema.ts | 17 ++------ .../src/tools/tests/coFeed.test-d.ts | 22 ++++++++++ .../src/tools/tests/coList.test-d.ts | 22 ++++++++++ .../src/tools/tests/coMap.test-d.ts | 33 +++++++++++++++ 12 files changed, 135 insertions(+), 113 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index 32de939961..7f256f24df 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -50,6 +50,7 @@ import { subscribeToCoValueWithoutMe, subscribeToExistingCoValue, CoreFileStreamSchema, + CoValueCreateOptionsInternal, } from "../internal.js"; import { z } from "../implementation/zodSchema/zodReExport.js"; import { @@ -216,15 +217,7 @@ export class CoFeed extends CoValueBase implements CoValue { static create( this: CoValueClass, init: S extends CoFeed ? Item[] : never, - options?: - | { - owner?: Account | Group; - validation?: LocalValidationMode; - unique?: CoValueUniqueness["uniqueness"]; - firstComesWins?: boolean; - } - | Account - | Group, + options?: CoValueCreateOptionsInternal, ) { const coFeedSchema = assertCoValueSchema(this, "CoFeed", "create"); const { owner, uniqueness, firstComesWins } = diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 80906469dd..60749711ac 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -37,6 +37,7 @@ import { parseSubscribeRestArgs, subscribeToCoValueWithoutMe, subscribeToExistingCoValue, + CoValueCreateOptionsInternal, } from "../internal.js"; import { z } from "../implementation/zodSchema/zodReExport.js"; import { CoreCoListSchema } from "../implementation/zodSchema/schemaTypes/CoListSchema.js"; @@ -141,15 +142,7 @@ export class CoList static create( this: CoValueClass, items: L[number][], - options?: - | { - owner: Account | Group; - unique?: CoValueUniqueness["uniqueness"]; - firstComesWins?: boolean; - validation?: LocalValidationMode; - } - | Account - | Group, + options?: CoValueCreateOptionsInternal, ) { const coListSchema = assertCoValueSchema(this, "CoList", "create"); const validationMode = resolveValidationMode( diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 0ce3757175..928789d1ca 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -52,6 +52,7 @@ import { subscribeToCoValueWithoutMe, subscribeToExistingCoValue, CoreCoMapSchema, + CoValueCreateOptionsInternal, } from "../internal.js"; import { z } from "../implementation/zodSchema/zodReExport.js"; import { @@ -177,14 +178,7 @@ export class CoMap extends CoValueBase implements CoValue { static create( this: CoValueClass, init: Simplify>, - options?: - | { - owner?: Account | Group; - unique?: CoValueUniqueness["uniqueness"]; - validation?: LocalValidationMode; - } - | Account - | Group, + options?: CoValueCreateOptionsInternal, ) { const coMapSchema = assertCoValueSchema(this, "CoMap", "create"); const instance = new this(); @@ -251,15 +245,7 @@ export class CoMap extends CoValueBase implements CoValue { instance: M, schema: CoreCoMapSchema, init: Simplify>, - options?: - | { - owner?: Account | Group; - unique?: CoValueUniqueness["uniqueness"]; - validation?: LocalValidationMode; - firstComesWins?: boolean; - } - | Account - | Group, + options?: CoValueCreateOptionsInternal, ): M { const validationMode = resolveValidationMode( options && "validation" in options ? options.validation : undefined, diff --git a/packages/jazz-tools/src/tools/coValues/coPlainText.ts b/packages/jazz-tools/src/tools/coValues/coPlainText.ts index b73620b29a..67c4ad1cec 100644 --- a/packages/jazz-tools/src/tools/coValues/coPlainText.ts +++ b/packages/jazz-tools/src/tools/coValues/coPlainText.ts @@ -20,6 +20,7 @@ import { subscribeToCoValueWithoutMe, subscribeToExistingCoValue, CorePlainTextSchema, + CoValueCreateOptionsInternal, } from "../internal.js"; import { Account } from "./account.js"; import { getCoValueOwner, Group } from "./group.js"; @@ -95,7 +96,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/interfaces.ts b/packages/jazz-tools/src/tools/coValues/interfaces.ts index 1ad728cfd1..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, @@ -470,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/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/CoFeedSchema.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts index 3dd33c03e3..184f873c9f 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts @@ -16,6 +16,7 @@ import { unstable_mergeBranchWithResolve, withSchemaPermissions, type Schema, + CoValueCreateOptions, } from "../../../internal.js"; import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js"; import { CoFeedSchemaInit } from "../typeConverters/CoFieldSchemaInit.js"; @@ -87,39 +88,16 @@ export class CoFeedSchema< create( init: CoFeedSchemaInit, - options?: - | { - owner: Group; - unique?: CoValueUniqueness["uniqueness"]; - validation?: LocalValidationMode; - } - | { owner?: Group; validation?: LocalValidationMode } - | 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"]; - validation?: LocalValidationMode; - } - | { owner?: Account | Group; validation?: LocalValidationMode } - | Account - | Group, + options: CoValueCreateOptions<{}, Account | Group>, ): CoFeedInstance; create( init: CoFeedSchemaInit, - options?: - | { - owner: Account | Group; - unique?: CoValueUniqueness["uniqueness"]; - validation?: LocalValidationMode; - } - | { owner?: Account | Group; validation?: LocalValidationMode } - | Account - | Group, + options?: CoValueCreateOptions<{}, Account | Group>, ): CoFeedInstance { const optionsWithPermissions = withSchemaPermissions( options, 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 76fee516d8..d6bc9348e4 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -15,6 +15,7 @@ import { unstable_mergeBranchWithResolve, withSchemaPermissions, type Schema, + CoValueCreateOptions, } from "../../../internal.js"; import { CoValueUniqueness } from "cojson"; import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js"; @@ -90,36 +91,12 @@ export class CoListSchema< create( items: CoListSchemaInit, - options?: - | { - owner?: Group; - unique?: CoValueUniqueness["uniqueness"]; - validation?: "strict" | "loose"; - } - | 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"]; - validation?: LocalValidationMode; - } - | Account - | Group, - ): CoListInstance; - create( - items: CoListSchemaInit, - options?: - | { - owner: Account | Group; - unique?: CoValueUniqueness["uniqueness"]; - validation?: LocalValidationMode; - } - | Account - | Group, + options?: CoValueCreateOptions<{}, Account | Group>, ): CoListInstance; create(items: any, options?: any): CoListInstance { const optionsWithPermissions = withSchemaPermissions( 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 a4bbd9070c..ffaefa04d2 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -21,6 +21,7 @@ import { withSchemaPermissions, isCoValueSchema, type Schema, + CoValueCreateOptions, } from "../../../internal.js"; import { AnonymousJazzAgent } from "../../anonymousJazzAgent.js"; import { removeGetters, withSchemaResolveQuery } from "../../schemaUtils.js"; @@ -146,24 +147,12 @@ export class CoMapSchema< create( init: CoMapSchemaInit, - options?: - | { - owner?: Group; - unique?: CoValueUniqueness["uniqueness"]; - validation?: LocalValidationMode; - } - | 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"]; - validation?: LocalValidationMode; - } - | Owner, + options?: CoValueCreateOptions<{}, Account | Group>, ): CoMapInstanceShape & CoMap; create(init: any, options?: any) { const optionsWithPermissions = withSchemaPermissions( 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/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/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", () => { From 507f3258b0b4d06be9be5692b7040ec6f6faee15 Mon Sep 17 00:00:00 2001 From: Matteo Manchi Date: Fri, 13 Feb 2026 14:50:52 +0100 Subject: [PATCH 61/61] Schema validation with superRefine (#3460) * chore: replaced union with superRefine in schema validation * fixup! chore: replaced union with superRefine in schema validation --- .../jazz-tools/src/tools/coValues/coFeed.ts | 15 +-- .../jazz-tools/src/tools/coValues/coList.ts | 15 +-- .../jazz-tools/src/tools/coValues/coMap.ts | 4 +- .../schemaTypes/CoDiscriminatedUnionSchema.ts | 17 +-- .../zodSchema/schemaTypes/CoFeedSchema.ts | 14 ++- .../zodSchema/schemaTypes/CoListSchema.ts | 16 +-- .../zodSchema/schemaTypes/CoMapSchema.ts | 11 +- .../zodSchema/schemaTypes/CoVectorSchema.ts | 11 +- .../zodSchema/schemaTypes/PlainTextSchema.ts | 8 +- .../zodSchema/schemaTypes/RichTextSchema.ts | 11 +- .../zodSchema/schemaTypes/schemaValidators.ts | 101 +++++++++++------- .../tools/tests/coDiscriminatedUnion.test.ts | 2 - .../jazz-tools/src/tools/tests/coMap.test.ts | 38 +++++-- .../src/tools/tests/coOptional.test.ts | 12 +++ 14 files changed, 161 insertions(+), 114 deletions(-) diff --git a/packages/jazz-tools/src/tools/coValues/coFeed.ts b/packages/jazz-tools/src/tools/coValues/coFeed.ts index 7f256f24df..1c1045cfcc 100644 --- a/packages/jazz-tools/src/tools/coValues/coFeed.ts +++ b/packages/jazz-tools/src/tools/coValues/coFeed.ts @@ -64,7 +64,7 @@ import { type LocalValidationMode, } from "../implementation/zodSchema/validationSettings.js"; import { - extractFieldElementFromUnionSchema, + expectArraySchema, normalizeZodSchema, } from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; @@ -477,18 +477,9 @@ export class CoFeedJazzApi extends CoValueJazzApi { } private getItemSchema(): z.ZodType { - /** - * coFeedSchema may be undefined if the CoFeed is created directly with its constructor, - * without using a co.feed().create() to create it. - * In that case, we can't validate the values. - */ - if (this.coFeedSchema === undefined) { - return z.any(); - } - - const fieldSchema = extractFieldElementFromUnionSchema( + const fieldSchema = expectArraySchema( this.coFeedSchema.getValidationSchema(), - ); + ).element; return normalizeZodSchema(fieldSchema); } diff --git a/packages/jazz-tools/src/tools/coValues/coList.ts b/packages/jazz-tools/src/tools/coValues/coList.ts index 60749711ac..766a823cfe 100644 --- a/packages/jazz-tools/src/tools/coValues/coList.ts +++ b/packages/jazz-tools/src/tools/coValues/coList.ts @@ -47,7 +47,7 @@ import { type LocalValidationMode, } from "../implementation/zodSchema/validationSettings.js"; import { - extractFieldElementFromUnionSchema, + expectArraySchema, normalizeZodSchema, } from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; @@ -542,18 +542,9 @@ export class CoListJazzApi extends CoValueJazzApi { } private getItemSchema(): z.ZodType { - /** - * coListSchema may be undefined if the CoList is created directly with its constructor, - * without using a co.list().create() to create it. - * In that case, we can't validate the values. - */ - if (this.coListSchema === undefined) { - return z.any(); - } - - const fieldSchema = extractFieldElementFromUnionSchema( + const fieldSchema = expectArraySchema( this.coListSchema.getValidationSchema(), - ); + ).element; return normalizeZodSchema(fieldSchema); } diff --git a/packages/jazz-tools/src/tools/coValues/coMap.ts b/packages/jazz-tools/src/tools/coValues/coMap.ts index 928789d1ca..eefdffe38f 100644 --- a/packages/jazz-tools/src/tools/coValues/coMap.ts +++ b/packages/jazz-tools/src/tools/coValues/coMap.ts @@ -61,7 +61,7 @@ import { type LocalValidationMode, } from "../implementation/zodSchema/validationSettings.js"; import { - extractFieldShapeFromUnionSchema, + expectObjectSchema, normalizeZodSchema, } from "../implementation/zodSchema/schemaTypes/schemaValidators.js"; import { assertCoValueSchema } from "../implementation/zodSchema/schemaInvariant.js"; @@ -608,7 +608,7 @@ class CoMapJazzApi extends CoValueJazzApi { let objectValidation: z.ZodObject | undefined; try { - objectValidation = extractFieldShapeFromUnionSchema(fullSchema); + 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. 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 8d68f21bbb..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"]; @@ -68,21 +69,7 @@ export class CoDiscriminatedUnionSchema< // @ts-expect-error options.map((schema) => { const validationSchema = schema.getValidationSchema(); - - if (validationSchema.def.type === "union") { - const def = validationSchema.def as - | z.core.$ZodUnionDef - | z.core.$ZodDiscriminatedUnionDef; - // in case of nested co.discriminatedUnion - // the nested `validationSchema` is already a z.discriminatedUnion - if ("discriminator" in def) { - return validationSchema; - } - - return def.options[1]; - } - - throw new Error("Invalid schema type"); + return extractPlainSchema(validationSchema); }), ); 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 184f873c9f..22af1676fb 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoFeedSchema.ts @@ -30,8 +30,10 @@ import { SchemaPermissions, } from "../schemaPermissions.js"; import { z } from "../zodReExport.js"; -import { generateValidationSchemaFromItem } from "./schemaValidators.js"; -import { type LocalValidationMode } from "../validationSettings.js"; +import { + coValueValidationSchema, + generateValidationSchemaFromItem, +} from "./schemaValidators.js"; import { resolveSchemaField } from "../runtimeConverters/schemaFieldToCoFieldDef.js"; export class CoFeedSchema< @@ -65,9 +67,11 @@ export class CoFeedSchema< return this.#validationSchema; } - this.#validationSchema = z - .instanceof(CoFeed) - .or(z.array(generateValidationSchemaFromItem(this.element))); + const validationSchema = z.array( + generateValidationSchemaFromItem(this.element), + ); + + this.#validationSchema = coValueValidationSchema(validationSchema, CoFeed); return this.#validationSchema; }; 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 d6bc9348e4..b29bec8a2f 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoListSchema.ts @@ -31,8 +31,10 @@ import { SchemaPermissions, } from "../schemaPermissions.js"; import { z } from "../zodReExport.js"; -import { generateValidationSchemaFromItem } from "./schemaValidators.js"; -import type { LocalValidationMode } from "../validationSettings.js"; +import { + coValueValidationSchema, + generateValidationSchemaFromItem, +} from "./schemaValidators.js"; import { resolveSchemaField } from "../runtimeConverters/schemaFieldToCoFieldDef.js"; export class CoListSchema< @@ -66,11 +68,11 @@ export class CoListSchema< return this.#validationSchema; } - // since validation is not used on read, we can't validate already existing CoValues - // so we accept every CoList instance - this.#validationSchema = z - .instanceof(CoList) - .or(z.array(generateValidationSchemaFromItem(this.element))); + const validationSchema = z.array( + generateValidationSchemaFromItem(this.element), + ); + + this.#validationSchema = coValueValidationSchema(validationSchema, CoList); return this.#validationSchema; }; 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 ffaefa04d2..ed64184b37 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoMapSchema.ts @@ -36,8 +36,10 @@ import { DEFAULT_SCHEMA_PERMISSIONS, SchemaPermissions, } from "../schemaPermissions.js"; -import { generateValidationSchemaFromItem } from "./schemaValidators.js"; -import type { LocalValidationMode } from "../validationSettings.js"; +import { + coValueValidationSchema, + generateValidationSchemaFromItem, +} from "./schemaValidators.js"; import { resolveSchemaField } from "../runtimeConverters/schemaFieldToCoFieldDef.js"; type CoMapSchemaInstance = Simplify< @@ -90,9 +92,8 @@ export class CoMapSchema< ); } - // since validation is not used on read, we can't validate already existing CoValues - // so we accept every CoMap instance - this.#validationSchema = z.instanceof(CoMap).or(validationSchema); + this.#validationSchema = coValueValidationSchema(validationSchema, CoMap); + return this.#validationSchema; }; 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 2c80a4e9ce..99b70bcc9d 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoVectorSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/CoVectorSchema.ts @@ -15,6 +15,7 @@ import { SchemaPermissions, } from "../schemaPermissions.js"; import { z } from "../zodReExport.js"; +import { coValueValidationSchema } from "./schemaValidators.js"; export interface CoreCoVectorSchema extends CoreCoValueSchema { builtin: "CoVector"; @@ -45,10 +46,12 @@ export class CoVectorSchema implements CoreCoVectorSchema { return this.#validationSchema; } - this.#validationSchema = z - .instanceof(CoVector) - .or(z.instanceof(Float32Array)) - .or(z.array(z.number())); + const validationSchema = z.instanceof(Float32Array).or(z.array(z.number())); + + this.#validationSchema = coValueValidationSchema( + validationSchema, + CoVector, + ); return this.#validationSchema; }; 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 66acca3534..6659dd4e8f 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/PlainTextSchema.ts @@ -17,6 +17,7 @@ import { SchemaPermissions, } from "../schemaPermissions.js"; import { z } from "../zodReExport.js"; +import { coValueValidationSchema } from "./schemaValidators.js"; export interface CorePlainTextSchema extends CoreCoValueSchema { builtin: "CoPlainText"; @@ -43,7 +44,12 @@ export class PlainTextSchema implements CorePlainTextSchema { return this.#validationSchema; } - this.#validationSchema = z.string().or(z.instanceof(CoPlainText)); + const validationSchema = z.string(); + + this.#validationSchema = coValueValidationSchema( + validationSchema, + CoPlainText, + ); return this.#validationSchema; }; 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 a578b32268..f0994f2dd7 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/RichTextSchema.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/RichTextSchema.ts @@ -16,6 +16,7 @@ import { SchemaPermissions, } from "../schemaPermissions.js"; import { z } from "../zodReExport.js"; +import { coValueValidationSchema } from "./schemaValidators.js"; export interface CoreRichTextSchema extends CoreCoValueSchema { builtin: "CoRichText"; @@ -36,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 @@ -46,14 +48,17 @@ export class RichTextSchema implements CoreRichTextSchema { constructor(private coValueClass: typeof CoRichText) {} - #validationSchema: z.ZodType | undefined = undefined; - getValidationSchema = () => { if (this.#validationSchema) { return this.#validationSchema; } - this.#validationSchema = z.string().or(z.instanceof(CoRichText)); + const validationSchema = z.string(); + + this.#validationSchema = coValueValidationSchema( + validationSchema, + CoRichText, + ); return this.#validationSchema; }; diff --git a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts index e51743ed88..9665be6671 100644 --- a/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts +++ b/packages/jazz-tools/src/tools/implementation/zodSchema/schemaTypes/schemaValidators.ts @@ -1,4 +1,9 @@ -import { Account, Group, isCoValueSchema } from "../../../internal.js"; +import { + Account, + Group, + isCoValue, + isCoValueSchema, +} from "../../../internal.js"; import { z } from "../zodReExport.js"; import type { CoreCoValueSchema } from "./CoValueSchema.js"; @@ -41,58 +46,78 @@ export function generateValidationSchemaFromItem(item: InputSchema): z.ZodType { throw new Error(`Unsupported schema type: ${item}`); } -function isUnionSchema(schema: unknown): schema is z.ZodUnion { - if (typeof schema !== "object" || schema === null) { - return false; - } - - if ( - "def" in schema && - typeof schema.def === "object" && - schema.def !== null && - "type" in schema.def && - schema.def.type === "union" - ) { - return true; - } - - return false; +/** + * 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 extractFieldShapeFromUnionSchema(schema: unknown): z.ZodObject { - if (!isUnionSchema(schema)) { - throw new Error("Schema is not a union"); +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; } - const unionElement = schema.options[1]; + throw new Error("Schema does not have a plain schema"); +} - if (typeof unionElement !== "object" || unionElement === null) { - throw new Error("Union element is not an object"); +export function expectObjectSchema(schema: z.ZodType): z.ZodObject { + if (schema.def.type === "object") { + return schema as z.ZodObject; } - if ("shape" in unionElement) { - return unionElement as z.ZodObject; + const plainSchema = extractPlainSchema(schema); + if (plainSchema.def.type === "object") { + return plainSchema as z.ZodObject; } - throw new Error("Union element is not an object with shape"); + throw new Error("Schema does not have an object schema"); } -export function extractFieldElementFromUnionSchema(schema: unknown): z.ZodType { - if (!isUnionSchema(schema)) { - throw new Error("Schema is not a union"); - } - - const unionElement = schema.options[1]; - - if (typeof unionElement !== "object" || unionElement === null) { - throw new Error("Union element is not an object"); +export function expectArraySchema(schema: z.ZodType): z.ZodArray { + if (schema.def.type === "array") { + return schema as z.ZodArray; } - if ("element" in unionElement) { - return unionElement.element as z.ZodType; + const plainSchema = extractPlainSchema(schema); + if (plainSchema.def.type === "array") { + return plainSchema as z.ZodArray; } - throw new Error("Union element is not an object with element"); + throw new Error("Schema does not have an array schema"); } export function normalizeZodSchema(schema: z.ZodType): z.ZodType { diff --git a/packages/jazz-tools/src/tools/tests/coDiscriminatedUnion.test.ts b/packages/jazz-tools/src/tools/tests/coDiscriminatedUnion.test.ts index 4fa4fee177..1743988152 100644 --- a/packages/jazz-tools/src/tools/tests/coDiscriminatedUnion.test.ts +++ b/packages/jazz-tools/src/tools/tests/coDiscriminatedUnion.test.ts @@ -704,8 +704,6 @@ describe("co.discriminatedUnion", () => { }, }); - console.log(loadedSpecies.$isLoaded); - assertLoaded(loadedSpecies); for (const animal of loadedSpecies) { diff --git a/packages/jazz-tools/src/tools/tests/coMap.test.ts b/packages/jazz-tools/src/tools/tests/coMap.test.ts index 82c87c1bf0..3f22f25919 100644 --- a/packages/jazz-tools/src/tools/tests/coMap.test.ts +++ b/packages/jazz-tools/src/tools/tests/coMap.test.ts @@ -645,6 +645,33 @@ describe("CoMap", async () => { 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", () => { @@ -671,11 +698,7 @@ describe("CoMap", async () => { const john = Person.create({ name: "John", age: 20 }); expectValidationError(() => - john.$jazz.set( - "age", - // @ts-expect-error - age should be a number - "21", - ), + john.$jazz.set("age", "21" as unknown as number), ); expect(john.age).toEqual(20); @@ -1735,9 +1758,8 @@ describe("CoMap resolution", async () => { const person2 = await Person.load(person1.$jazz.id); assertLoaded(person2); - expectValidationError( - // @ts-expect-error - string is not a number - () => person2.$jazz.set("age", "20"), + expectValidationError(() => + person2.$jazz.set("age", "20" as unknown as number), ); }); }); diff --git a/packages/jazz-tools/src/tools/tests/coOptional.test.ts b/packages/jazz-tools/src/tools/tests/coOptional.test.ts index b41384771c..448baad047 100644 --- a/packages/jazz-tools/src/tools/tests/coOptional.test.ts +++ b/packages/jazz-tools/src/tools/tests/coOptional.test.ts @@ -124,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(); + }); });