From 29297269937c6b9f630ac180c0a8f9ace95072f8 Mon Sep 17 00:00:00 2001 From: front_depiction Date: Sun, 6 Jul 2025 12:33:42 +0200 Subject: [PATCH 01/10] feat(tests): add CRUD operations for union and complex object schemas - Implemented CRUD operations for a new union table schema with user, admin, and guest types. - Added CRUD operations for a complex object schema with nested structures and optional fields. - Enhanced test coverage with various scenarios for union and complex object CRUD operations, including creation, reading, updating, and deletion. - Updated the `crud` function to support optional system fields in the schema validation process. --- packages/convex-helpers/server/crud.test.ts | 397 ++++++++++++++++++++ packages/convex-helpers/server/crud.ts | 51 ++- 2 files changed, 429 insertions(+), 19 deletions(-) diff --git a/packages/convex-helpers/server/crud.test.ts b/packages/convex-helpers/server/crud.test.ts index fa3a25e3..887126c7 100644 --- a/packages/convex-helpers/server/crud.test.ts +++ b/packages/convex-helpers/server/crud.test.ts @@ -20,9 +20,58 @@ const ExampleFields = { }; const CrudTable = "crud_example"; +// Union table test schema +const UnionTable = "union_example"; +const UnionFields = v.union( + v.object({ + type: v.literal("user"), + name: v.string(), + email: v.string(), + }), + v.object({ + type: v.literal("admin"), + name: v.string(), + permissions: v.array(v.string()), + }), + v.object({ + type: v.literal("guest"), + sessionId: v.string(), + }), +); + +// Complex object test schema +const ComplexTable = "complex_example"; +const ComplexFields = { + profile: v.object({ + name: v.string(), + age: v.optional(v.number()), + address: v.object({ + street: v.string(), + city: v.string(), + country: v.string(), + }), + }), + tags: v.array(v.string()), + metadata: v.record(v.string(), v.any()), + nested: v.object({ + level1: v.object({ + level2: v.object({ + deep: v.boolean(), + }), + }), + }), + optionalArray: v.optional(v.array(v.object({ + id: v.string(), + value: v.number(), + }))), +}; + const schema = defineSchema({ [CrudTable]: defineTable(ExampleFields), + [UnionTable]: defineTable(UnionFields), + [ComplexTable]: defineTable(ComplexFields), }); + type DataModel = DataModelFromSchemaDefinition; const internalQuery = internalQueryGeneric as QueryBuilder< DataModel, @@ -38,6 +87,24 @@ export const { create, read, paginate, update, destroy } = crud( CrudTable, ); +// Union table CRUD +export const { + create: unionCreate, + read: unionRead, + paginate: unionPaginate, + update: unionUpdate, + destroy: unionDestroy, +} = crud(schema, UnionTable); + +// Complex object CRUD +export const { + create: complexCreate, + read: complexRead, + paginate: complexPaginate, + update: complexUpdate, + destroy: complexDestroy, +} = crud(schema, ComplexTable); + const testApi: ApiFromModules<{ fns: { create: typeof create; @@ -48,6 +115,26 @@ const testApi: ApiFromModules<{ }; }>["fns"] = anyApi["crud.test"] as any; +const unionTestApi: ApiFromModules<{ + fns: { + unionCreate: typeof unionCreate; + unionRead: typeof unionRead; + unionUpdate: typeof unionUpdate; + unionPaginate: typeof unionPaginate; + unionDestroy: typeof unionDestroy; + }; +}>["fns"] = anyApi["crud.test"] as any; + +const complexTestApi: ApiFromModules<{ + fns: { + complexCreate: typeof complexCreate; + complexRead: typeof complexRead; + complexUpdate: typeof complexUpdate; + complexPaginate: typeof complexPaginate; + complexDestroy: typeof complexDestroy; + }; +}>["fns"] = anyApi["crud.test"] as any; + test("crud for table", async () => { const t = convexTest(schema, modules); const doc = await t.mutation(testApi.create, { foo: "", bar: null }); @@ -67,6 +154,316 @@ test("crud for table", async () => { expect(await t.query(testApi.read, { id: doc._id })).toBe(null); }); +test("union table - user type", async () => { + const t = convexTest(schema, modules); + const userDoc = await t.mutation(unionTestApi.unionCreate, { + type: "user", + name: "John Doe", + email: "john@example.com", + }); + expect(userDoc).toMatchObject({ + type: "user", + name: "John Doe", + email: "john@example.com", + }); + + const readUser = await t.query(unionTestApi.unionRead, { id: userDoc._id }); + expect(readUser).toMatchObject(userDoc); + + await t.mutation(unionTestApi.unionUpdate, { + id: userDoc._id, + patch: { name: "Jane Doe", email: "jane@example.com" }, + }); + + const updatedUser = await t.query(unionTestApi.unionRead, { id: userDoc._id }); + expect(updatedUser).toMatchObject({ + type: "user", + name: "Jane Doe", + email: "jane@example.com", + }); + + await t.mutation(unionTestApi.unionDestroy, { id: userDoc._id }); + expect(await t.query(unionTestApi.unionRead, { id: userDoc._id })).toBe(null); +}); + +test("union table - admin type", async () => { + const t = convexTest(schema, modules); + const adminDoc = await t.mutation(unionTestApi.unionCreate, { + type: "admin", + name: "Admin User", + permissions: ["read", "write", "delete"], + }); + expect(adminDoc).toMatchObject({ + type: "admin", + name: "Admin User", + permissions: ["read", "write", "delete"], + }); + + await t.mutation(unionTestApi.unionUpdate, { + id: adminDoc._id, + patch: { permissions: ["read", "write"] }, + }); + + const updatedAdmin = await t.query(unionTestApi.unionRead, { id: adminDoc._id }); + expect(updatedAdmin).toMatchObject({ + type: "admin", + name: "Admin User", + permissions: ["read", "write"], + }); +}); + +test("union table - guest type", async () => { + const t = convexTest(schema, modules); + const guestDoc = await t.mutation(unionTestApi.unionCreate, { + type: "guest", + sessionId: "session_123", + }); + expect(guestDoc).toMatchObject({ + type: "guest", + sessionId: "session_123", + }); + + await t.mutation(unionTestApi.unionUpdate, { + id: guestDoc._id, + patch: { sessionId: "session_456" }, + }); + + const updatedGuest = await t.query(unionTestApi.unionRead, { id: guestDoc._id }); + expect(updatedGuest).toMatchObject({ + type: "guest", + sessionId: "session_456", + }); +}); + +test("complex object - full structure", async () => { + const t = convexTest(schema, modules); + const complexDoc = await t.mutation(complexTestApi.complexCreate, { + profile: { + name: "Complex User", + age: 30, + address: { + street: "123 Main St", + city: "Anytown", + country: "USA", + }, + }, + tags: ["developer", "typescript", "react"], + metadata: { + theme: "dark", + language: "en", + timezone: "UTC", + }, + nested: { + level1: { + level2: { + deep: true, + }, + }, + }, + optionalArray: [ + { id: "item1", value: 100 }, + { id: "item2", value: 200 }, + ], + }); + + expect(complexDoc).toMatchObject({ + profile: { + name: "Complex User", + age: 30, + address: { + street: "123 Main St", + city: "Anytown", + country: "USA", + }, + }, + tags: ["developer", "typescript", "react"], + metadata: { + theme: "dark", + language: "en", + timezone: "UTC", + }, + nested: { + level1: { + level2: { + deep: true, + }, + }, + }, + optionalArray: [ + { id: "item1", value: 100 }, + { id: "item2", value: 200 }, + ], + }); + + const readComplex = await t.query(complexTestApi.complexRead, { id: complexDoc._id }); + expect(readComplex).toMatchObject(complexDoc); +}); + +test("complex object - partial updates", async () => { + const t = convexTest(schema, modules); + const complexDoc = await t.mutation(complexTestApi.complexCreate, { + profile: { + name: "User", + address: { + street: "Old Street", + city: "Old City", + country: "Old Country", + }, + }, + tags: ["old-tag"], + metadata: { version: "1.0" }, + nested: { + level1: { + level2: { + deep: false, + }, + }, + }, + }); + + // Update nested address + await t.mutation(complexTestApi.complexUpdate, { + id: complexDoc._id, + patch: { + profile: { + name: "Updated User", + address: { + street: "New Street", + city: "New City", + country: "New Country", + }, + }, + }, + }); + + const updated = await t.query(complexTestApi.complexRead, { id: complexDoc._id }); + expect(updated?.profile.address).toMatchObject({ + street: "New Street", + city: "New City", + country: "New Country", + }); + + // Update array + await t.mutation(complexTestApi.complexUpdate, { + id: complexDoc._id, + patch: { + tags: ["new-tag", "another-tag"], + optionalArray: [{ id: "new-item", value: 999 }], + }, + }); + + const updated2 = await t.query(complexTestApi.complexRead, { id: complexDoc._id }); + expect(updated2?.tags).toEqual(["new-tag", "another-tag"]); + expect(updated2?.optionalArray).toEqual([{ id: "new-item", value: 999 }]); +}); + +test("complex object - optional fields", async () => { + const t = convexTest(schema, modules); + const minimalDoc = await t.mutation(complexTestApi.complexCreate, { + profile: { + name: "Minimal User", + address: { + street: "Street", + city: "City", + country: "Country", + }, + }, + tags: [], + metadata: {}, + nested: { + level1: { + level2: { + deep: false, + }, + }, + }, + }); + + expect(minimalDoc).toMatchObject({ + profile: { + name: "Minimal User", + address: { + street: "Street", + city: "City", + country: "Country", + }, + }, + tags: [], + metadata: {}, + nested: { + level1: { + level2: { + deep: false, + }, + }, + }, + }); + + // optionalArray should be undefined + expect(minimalDoc.optionalArray).toBeUndefined(); + expect(minimalDoc.profile.age).toBeUndefined(); +}); + +test("pagination works", async () => { + const t = convexTest(schema, modules); + + // Create multiple documents + const docs: any[] = []; + for (let i = 0; i < 5; i++) { + const doc = await t.mutation(testApi.create, { + foo: `item-${i}`, + bar: { n: i }, + }); + docs.push(doc); + } + + // Test pagination + const page1 = await t.query(testApi.paginate, { + paginationOpts: { numItems: 3, cursor: null }, + }); + + expect(page1.page).toHaveLength(3); + expect(page1.isDone).toBe(false); + + const page2 = await t.query(testApi.paginate, { + paginationOpts: { numItems: 3, cursor: page1.continueCursor }, + }); + + expect(page2.page).toHaveLength(2); + expect(page2.isDone).toBe(true); +}); + +test("destroy returns the deleted document", async () => { + const t = convexTest(schema, modules); + const doc = await t.mutation(testApi.create, { + foo: "to-delete", + bar: null, + }); + + const deleted = await t.mutation(testApi.destroy, { id: doc._id }); + expect(deleted).not.toBe(null); + expect(deleted).toMatchObject({ foo: "to-delete", bar: null }); + + // Verify it's actually deleted + const read = await t.query(testApi.read, { id: doc._id }); + expect(read).toBe(null); +}); + +test("destroy non-existent document returns null", async () => { + const t = convexTest(schema, modules); + const doc = await t.mutation(testApi.create, { + foo: "temp", + bar: null, + }); + + // Delete it once + await t.mutation(testApi.destroy, { id: doc._id }); + + // Try to delete again + const deleted = await t.mutation(testApi.destroy, { id: doc._id }); + expect(deleted).toBe(null); +}); + /** * Custom function tests */ diff --git a/packages/convex-helpers/server/crud.ts b/packages/convex-helpers/server/crud.ts index 9bff5140..7999d72d 100644 --- a/packages/convex-helpers/server/crud.ts +++ b/packages/convex-helpers/server/crud.ts @@ -17,8 +17,8 @@ import { internalQueryGeneric, internalMutationGeneric, } from "convex/server"; -import type { GenericId, Infer } from "convex/values"; -import { v } from "convex/values"; +import type { GenericId, Infer, Validator } from "convex/values"; +import { asObjectValidator, v } from "convex/values"; import { partial } from "../validators.js"; /** * Create CRUD operations for a table. @@ -67,28 +67,44 @@ export function crud< > = internalMutationGeneric as any, ) { type DataModel = DataModelFromSchemaDefinition>; - const systemFields = { - _id: v.id(table), - _creationTime: v.number(), - }; + const validator = schema.tables[table]?.validator; if (!validator) { throw new Error( `Table ${table} not found in schema. Did you define it in defineSchema?`, ); } - if (validator.kind !== "object") { - throw new Error( - `CRUD only supports simple tables ${table} is a ${validator.type}`, - ); - } + + + + const makeOptional: (validator: Validator) => Validator = + (validator: Validator) => + "fields" in validator + ? v.object(partial(validator.fields)) + : "members" in validator + ? v.union(...validator.members.map((value) => makeOptional(value))) + : validator.kind === "record" ? v.record(validator.key, makeOptional(validator.value)) + : v.optional(validator); + + const makeSystemFieldsOptional: (validator: Validator) => Validator = + (validator: Validator) => + "fields" in validator + ? v.object({ + ...validator.fields, + _id: v.optional(v.id(table)), + _creationTime: v.optional(v.number()), + }) + : "members" in validator + ? v.union(...validator.members.map((value) => makeSystemFieldsOptional(value))) + : validator.kind === "record" ? v.record(validator.key, makeSystemFieldsOptional(validator.value)) + : validator; + + const optionalValidator = makeOptional(validator); + const createValidator = makeSystemFieldsOptional(validator); return { create: mutation({ - args: { - ...validator.fields, - ...partial(systemFields), - }, + args: createValidator, handler: async (ctx, args) => { if ("_id" in args) delete args._id; if ("_creationTime" in args) delete args._creationTime; @@ -132,10 +148,7 @@ export function crud< id: v.id(table), // this could be partial(table.withSystemFields) but keeping // the api less coupled to Table - patch: v.object({ - ...partial(validator.fields), - ...partial(systemFields), - }), + patch: optionalValidator, }, handler: async (ctx, args) => { await ctx.db.patch( From 80f4550ab75e84d7d632bfc99fe52f23626f8130 Mon Sep 17 00:00:00 2001 From: front_depiction Date: Sun, 6 Jul 2025 12:41:34 +0200 Subject: [PATCH 02/10] refactor(crud): make system fields optional in schema validation fix bug where system fields were being added, instead of made optional if present. --- packages/convex-helpers/server/crud.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/convex-helpers/server/crud.ts b/packages/convex-helpers/server/crud.ts index 7999d72d..593da5ce 100644 --- a/packages/convex-helpers/server/crud.ts +++ b/packages/convex-helpers/server/crud.ts @@ -91,8 +91,8 @@ export function crud< "fields" in validator ? v.object({ ...validator.fields, - _id: v.optional(v.id(table)), - _creationTime: v.optional(v.number()), + ...(validator.fields._id ? { _id: v.optional(validator.fields._id) } : {}), + ...(validator.fields._creationTime ? { _creationTime: v.optional(validator.fields._creationTime) } : {}), }) : "members" in validator ? v.union(...validator.members.map((value) => makeSystemFieldsOptional(value))) From e3283b78676b22a61e43a39511264eccd0564950 Mon Sep 17 00:00:00 2001 From: front-depiction <96551451+front-depiction@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:41:58 +0200 Subject: [PATCH 03/10] Update packages/convex-helpers/server/crud.ts "We don't support top-level records, and top-level fields will get replaced, not recursively updated by db.patch" Co-authored-by: Ian Macartney --- packages/convex-helpers/server/crud.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/convex-helpers/server/crud.ts b/packages/convex-helpers/server/crud.ts index 593da5ce..003147a2 100644 --- a/packages/convex-helpers/server/crud.ts +++ b/packages/convex-helpers/server/crud.ts @@ -83,8 +83,7 @@ export function crud< ? v.object(partial(validator.fields)) : "members" in validator ? v.union(...validator.members.map((value) => makeOptional(value))) - : validator.kind === "record" ? v.record(validator.key, makeOptional(validator.value)) - : v.optional(validator); + : v.optional(validator); const makeSystemFieldsOptional: (validator: Validator) => Validator = (validator: Validator) => From 8d0b873128abc12d3833c3e7700ae8085f86bdaa Mon Sep 17 00:00:00 2001 From: front-depiction <96551451+front-depiction@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:42:07 +0200 Subject: [PATCH 04/10] Update packages/convex-helpers/server/crud.ts Co-authored-by: Ian Macartney --- packages/convex-helpers/server/crud.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/convex-helpers/server/crud.ts b/packages/convex-helpers/server/crud.ts index 003147a2..e7b7a3ac 100644 --- a/packages/convex-helpers/server/crud.ts +++ b/packages/convex-helpers/server/crud.ts @@ -79,7 +79,7 @@ export function crud< const makeOptional: (validator: Validator) => Validator = (validator: Validator) => - "fields" in validator + validator.kind === "object" ? v.object(partial(validator.fields)) : "members" in validator ? v.union(...validator.members.map((value) => makeOptional(value))) From b986d37b3ddb848773e7eb1c30fa335e49fed8b6 Mon Sep 17 00:00:00 2001 From: front_depiction Date: Mon, 7 Jul 2025 22:00:51 +0200 Subject: [PATCH 05/10] some fixes to crud, following @ianmacartney suggestions #678 --- packages/convex-helpers/server/crud.test.ts | 36 +++++++++++++++------ packages/convex-helpers/server/crud.ts | 35 ++++++-------------- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/packages/convex-helpers/server/crud.test.ts b/packages/convex-helpers/server/crud.test.ts index 887126c7..9ded0094 100644 --- a/packages/convex-helpers/server/crud.test.ts +++ b/packages/convex-helpers/server/crud.test.ts @@ -60,10 +60,14 @@ const ComplexFields = { }), }), }), - optionalArray: v.optional(v.array(v.object({ - id: v.string(), - value: v.number(), - }))), + optionalArray: v.optional( + v.array( + v.object({ + id: v.string(), + value: v.number(), + }), + ), + ), }; const schema = defineSchema({ @@ -175,7 +179,9 @@ test("union table - user type", async () => { patch: { name: "Jane Doe", email: "jane@example.com" }, }); - const updatedUser = await t.query(unionTestApi.unionRead, { id: userDoc._id }); + const updatedUser = await t.query(unionTestApi.unionRead, { + id: userDoc._id, + }); expect(updatedUser).toMatchObject({ type: "user", name: "Jane Doe", @@ -204,7 +210,9 @@ test("union table - admin type", async () => { patch: { permissions: ["read", "write"] }, }); - const updatedAdmin = await t.query(unionTestApi.unionRead, { id: adminDoc._id }); + const updatedAdmin = await t.query(unionTestApi.unionRead, { + id: adminDoc._id, + }); expect(updatedAdmin).toMatchObject({ type: "admin", name: "Admin User", @@ -228,7 +236,9 @@ test("union table - guest type", async () => { patch: { sessionId: "session_456" }, }); - const updatedGuest = await t.query(unionTestApi.unionRead, { id: guestDoc._id }); + const updatedGuest = await t.query(unionTestApi.unionRead, { + id: guestDoc._id, + }); expect(updatedGuest).toMatchObject({ type: "guest", sessionId: "session_456", @@ -295,7 +305,9 @@ test("complex object - full structure", async () => { ], }); - const readComplex = await t.query(complexTestApi.complexRead, { id: complexDoc._id }); + const readComplex = await t.query(complexTestApi.complexRead, { + id: complexDoc._id, + }); expect(readComplex).toMatchObject(complexDoc); }); @@ -336,7 +348,9 @@ test("complex object - partial updates", async () => { }, }); - const updated = await t.query(complexTestApi.complexRead, { id: complexDoc._id }); + const updated = await t.query(complexTestApi.complexRead, { + id: complexDoc._id, + }); expect(updated?.profile.address).toMatchObject({ street: "New Street", city: "New City", @@ -352,7 +366,9 @@ test("complex object - partial updates", async () => { }, }); - const updated2 = await t.query(complexTestApi.complexRead, { id: complexDoc._id }); + const updated2 = await t.query(complexTestApi.complexRead, { + id: complexDoc._id, + }); expect(updated2?.tags).toEqual(["new-tag", "another-tag"]); expect(updated2?.optionalArray).toEqual([{ id: "new-item", value: 999 }]); }); diff --git a/packages/convex-helpers/server/crud.ts b/packages/convex-helpers/server/crud.ts index e7b7a3ac..3388f31b 100644 --- a/packages/convex-helpers/server/crud.ts +++ b/packages/convex-helpers/server/crud.ts @@ -18,7 +18,7 @@ import { internalMutationGeneric, } from "convex/server"; import type { GenericId, Infer, Validator } from "convex/values"; -import { asObjectValidator, v } from "convex/values"; +import { v } from "convex/values"; import { partial } from "../validators.js"; /** * Create CRUD operations for a table. @@ -75,35 +75,20 @@ export function crud< ); } - - - const makeOptional: (validator: Validator) => Validator = - (validator: Validator) => - validator.kind === "object" - ? v.object(partial(validator.fields)) - : "members" in validator - ? v.union(...validator.members.map((value) => makeOptional(value))) - : v.optional(validator); - - const makeSystemFieldsOptional: (validator: Validator) => Validator = - (validator: Validator) => - "fields" in validator - ? v.object({ - ...validator.fields, - ...(validator.fields._id ? { _id: v.optional(validator.fields._id) } : {}), - ...(validator.fields._creationTime ? { _creationTime: v.optional(validator.fields._creationTime) } : {}), - }) - : "members" in validator - ? v.union(...validator.members.map((value) => makeSystemFieldsOptional(value))) - : validator.kind === "record" ? v.record(validator.key, makeSystemFieldsOptional(validator.value)) - : validator; + const makeOptional: ( + validator: Validator, + ) => Validator = (validator: Validator) => + validator.kind === "object" + ? v.object(partial(validator.fields)) + : "members" in validator + ? v.union(...validator.members.map((value) => makeOptional(value))) + : v.optional(validator); const optionalValidator = makeOptional(validator); - const createValidator = makeSystemFieldsOptional(validator); return { create: mutation({ - args: createValidator, + args: validator, handler: async (ctx, args) => { if ("_id" in args) delete args._id; if ("_creationTime" in args) delete args._creationTime; From def5f257c73bfa91786aec5c8dda8a7a82f155b1 Mon Sep 17 00:00:00 2001 From: front_depiction Date: Mon, 7 Jul 2025 22:11:31 +0200 Subject: [PATCH 06/10] refactor(crud): enhanced typing. Tbh I am by no means familiar enough with validators to validate (no pun intended) whether this is the correct intented typing behaviour --- packages/convex-helpers/server/crud.ts | 32 ++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/convex-helpers/server/crud.ts b/packages/convex-helpers/server/crud.ts index 3388f31b..992e1d72 100644 --- a/packages/convex-helpers/server/crud.ts +++ b/packages/convex-helpers/server/crud.ts @@ -17,7 +17,12 @@ import { internalQueryGeneric, internalMutationGeneric, } from "convex/server"; -import type { GenericId, Infer, Validator } from "convex/values"; +import type { + GenericId, + Infer, + OptionalProperty, + Validator, +} from "convex/values"; import { v } from "convex/values"; import { partial } from "../validators.js"; /** @@ -75,14 +80,23 @@ export function crud< ); } - const makeOptional: ( - validator: Validator, - ) => Validator = (validator: Validator) => - validator.kind === "object" - ? v.object(partial(validator.fields)) - : "members" in validator - ? v.union(...validator.members.map((value) => makeOptional(value))) - : v.optional(validator); + const makeOptional = < + Type, + IsOptional extends OptionalProperty, + FieldPaths extends string, + >( + validator: Validator, + ): Validator => { + if (validator.kind === "object") { + return v.object(partial(validator.fields)) as any; + } else if ("members" in validator) { + return v.union( + ...(validator.members.map((value) => makeOptional(value)) as any), + ) as any; + } else { + return v.optional(validator) as Validator; + } + }; const optionalValidator = makeOptional(validator); From 130406eceb222df6877dafe79ced148658634152 Mon Sep 17 00:00:00 2001 From: front-depiction <96551451+front-depiction@users.noreply.github.com> Date: Tue, 8 Jul 2025 07:42:43 +0200 Subject: [PATCH 07/10] Update crud.ts Co-authored-by: Ian Macartney --- packages/convex-helpers/server/crud.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/convex-helpers/server/crud.ts b/packages/convex-helpers/server/crud.ts index 992e1d72..346d7837 100644 --- a/packages/convex-helpers/server/crud.ts +++ b/packages/convex-helpers/server/crud.ts @@ -89,7 +89,7 @@ export function crud< ): Validator => { if (validator.kind === "object") { return v.object(partial(validator.fields)) as any; - } else if ("members" in validator) { + } else if (validator.kind === "union") { return v.union( ...(validator.members.map((value) => makeOptional(value)) as any), ) as any; From db9e146c9ae2d3e02338078ab80ae9a71384e78e Mon Sep 17 00:00:00 2001 From: front-depiction <96551451+front-depiction@users.noreply.github.com> Date: Tue, 8 Jul 2025 18:32:01 +0200 Subject: [PATCH 08/10] Update packages/convex-helpers/server/crud.ts Co-authored-by: Ian Macartney --- packages/convex-helpers/server/crud.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/convex-helpers/server/crud.ts b/packages/convex-helpers/server/crud.ts index 346d7837..7ada2a82 100644 --- a/packages/convex-helpers/server/crud.ts +++ b/packages/convex-helpers/server/crud.ts @@ -86,7 +86,7 @@ export function crud< FieldPaths extends string, >( validator: Validator, - ): Validator => { + ): Validator => { if (validator.kind === "object") { return v.object(partial(validator.fields)) as any; } else if (validator.kind === "union") { From 8db5768f8e9757ee5674de8ba6ca7c8277369cb7 Mon Sep 17 00:00:00 2001 From: front_depiction Date: Tue, 8 Jul 2025 23:45:46 +0200 Subject: [PATCH 09/10] refactor(crud): enhance schema validation by making system fields optional Added functionality to make system fields optional in the CRUD schema validation process. This change ensures that fields like _id and _creationTime are handled correctly, improving the overall flexibility of the CRUD operations. --- packages/convex-helpers/server/crud.ts | 32 +++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/convex-helpers/server/crud.ts b/packages/convex-helpers/server/crud.ts index 7ada2a82..6fc61cce 100644 --- a/packages/convex-helpers/server/crud.ts +++ b/packages/convex-helpers/server/crud.ts @@ -24,7 +24,7 @@ import type { Validator, } from "convex/values"; import { v } from "convex/values"; -import { partial } from "../validators.js"; +import { partial, systemFields } from "../validators.js"; /** * Create CRUD operations for a table. * You can expose these operations in your API. For example, in convex/users.ts: @@ -91,7 +91,7 @@ export function crud< return v.object(partial(validator.fields)) as any; } else if (validator.kind === "union") { return v.union( - ...(validator.members.map((value) => makeOptional(value)) as any), + ...validator.members.map((value) => makeOptional(value) as any), ) as any; } else { return v.optional(validator) as Validator; @@ -100,9 +100,35 @@ export function crud< const optionalValidator = makeOptional(validator); + const makeSystemFieldsOptional = < + Type, + IsOptional extends OptionalProperty, + FieldPaths extends string, + >( + validator: Validator, + ): Validator => { + if (validator.kind === "object") { + return v.object({ + ...validator.fields, + ...partial({ + _id: v.optional(v.id(table)), + _creationTime: v.optional(v.number()), + }), + }) as any; + } else if (validator.kind === "union") { + return v.union( + ...validator.members.map((value) => makeSystemFieldsOptional(value)), + ) as any; + } else { + return validator; + } + }; + + const validatorWithSystemFields = makeSystemFieldsOptional(validator); + return { create: mutation({ - args: validator, + args: validatorWithSystemFields, handler: async (ctx, args) => { if ("_id" in args) delete args._id; if ("_creationTime" in args) delete args._creationTime; From afc0a87ccca47c11203b0d219e91486798f965d1 Mon Sep 17 00:00:00 2001 From: front_depiction Date: Tue, 15 Jul 2025 21:38:11 +0200 Subject: [PATCH 10/10] Refactor CRUD This adds support for partial unions, but feels like a bit of a hack to be quite frank --- packages/convex-helpers/server/crud.test.ts | 2 + packages/convex-helpers/server/crud.ts | 51 ++++++--------------- 2 files changed, 17 insertions(+), 36 deletions(-) diff --git a/packages/convex-helpers/server/crud.test.ts b/packages/convex-helpers/server/crud.test.ts index 652d05cb..71c421fa 100644 --- a/packages/convex-helpers/server/crud.test.ts +++ b/packages/convex-helpers/server/crud.test.ts @@ -12,6 +12,7 @@ import { v } from "convex/values"; import { internalQueryGeneric, internalMutationGeneric } from "convex/server"; import { modules } from "./setup.test.js"; import { customCtx, customMutation, customQuery } from "./customFunctions.js"; +import { doc } from "../validators.js"; const ExampleFields = { foo: v.string(), @@ -357,6 +358,7 @@ test("complex object - partial updates", async () => { country: "New Country", }); + // Update array await t.mutation(complexTestApi.complexUpdate, { id: complexDoc._id, diff --git a/packages/convex-helpers/server/crud.ts b/packages/convex-helpers/server/crud.ts index 6fc61cce..195f3b65 100644 --- a/packages/convex-helpers/server/crud.ts +++ b/packages/convex-helpers/server/crud.ts @@ -20,11 +20,10 @@ import { import type { GenericId, Infer, - OptionalProperty, Validator, } from "convex/values"; import { v } from "convex/values"; -import { partial, systemFields } from "../validators.js"; +import { doc, partial } from "../validators.js"; /** * Create CRUD operations for a table. * You can expose these operations in your API. For example, in convex/users.ts: @@ -73,62 +72,42 @@ export function crud< ) { type DataModel = DataModelFromSchemaDefinition>; - const validator = schema.tables[table]?.validator; + const validator = schema.tables[table]?.validator if (!validator) { throw new Error( `Table ${table} not found in schema. Did you define it in defineSchema?`, ); } - const makeOptional = < - Type, - IsOptional extends OptionalProperty, - FieldPaths extends string, - >( - validator: Validator, - ): Validator => { - if (validator.kind === "object") { - return v.object(partial(validator.fields)) as any; - } else if (validator.kind === "union") { - return v.union( - ...validator.members.map((value) => makeOptional(value) as any), - ) as any; - } else { - return v.optional(validator) as Validator; - } - }; + const systemFields = v.object({ + _id: v.id(table), + _creationTime: v.number(), + }); + + const partialSystemFields = partial(systemFields).fields; - const optionalValidator = makeOptional(validator); - const makeSystemFieldsOptional = < - Type, - IsOptional extends OptionalProperty, - FieldPaths extends string, - >( - validator: Validator, - ): Validator => { + const makeSystemFieldsOptional = ( + validator: Validator, + ): Validator => { if (validator.kind === "object") { return v.object({ ...validator.fields, - ...partial({ - _id: v.optional(v.id(table)), - _creationTime: v.optional(v.number()), - }), + ...partialSystemFields, }) as any; } else if (validator.kind === "union") { return v.union( - ...validator.members.map((value) => makeSystemFieldsOptional(value)), + ...validator.members.map((value) => makeSystemFieldsOptional(value) as any), ) as any; } else { return validator; } }; - const validatorWithSystemFields = makeSystemFieldsOptional(validator); return { create: mutation({ - args: validatorWithSystemFields, + args: makeSystemFieldsOptional(validator), handler: async (ctx, args) => { if ("_id" in args) delete args._id; if ("_creationTime" in args) delete args._creationTime; @@ -172,7 +151,7 @@ export function crud< id: v.id(table), // this could be partial(table.withSystemFields) but keeping // the api less coupled to Table - patch: optionalValidator, + patch: partial(v.union(doc(schema, table))) }, handler: async (ctx, args) => { await ctx.db.patch(