diff --git a/package-lock.json b/package-lock.json index ad69551e..6ba18d54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9527,14 +9527,14 @@ }, "packages/convex-helpers/dist": { "name": "convex-helpers", - "version": "0.1.68-alpha.0", + "version": "0.1.68-alpha.1", "license": "Apache-2.0", "bin": { "convex-helpers": "bin.cjs" }, "devDependencies": { "chalk": "5.4.1", - "commander": "13.0.0" + "commander": "13.1.0" }, "peerDependencies": { "convex": "^1.13.0", @@ -9555,9 +9555,9 @@ } }, "packages/convex-helpers/dist/node_modules/commander": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", - "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true, "engines": { "node": ">=18" diff --git a/packages/convex-helpers/package.json b/packages/convex-helpers/package.json index 732da694..fbf25de1 100644 --- a/packages/convex-helpers/package.json +++ b/packages/convex-helpers/package.json @@ -1,6 +1,6 @@ { "name": "convex-helpers", - "version": "0.1.68-alpha.0", + "version": "0.1.68-alpha.1", "description": "A collection of useful code to complement the official convex package.", "type": "module", "bin": { diff --git a/packages/convex-helpers/server/validators.test.ts b/packages/convex-helpers/server/validators.test.ts index b75df1e7..ca4f4133 100644 --- a/packages/convex-helpers/server/validators.test.ts +++ b/packages/convex-helpers/server/validators.test.ts @@ -1,4 +1,4 @@ -import { assert, Equals } from ".."; +import { assert, Equals } from "../index.js"; import { any, array, @@ -6,6 +6,7 @@ import { boolean, brandedString, deprecated, + doc, id, literal as is, literals, @@ -18,18 +19,24 @@ import { pretend, pretendRequired, string, -} from "../validators"; + typedV, +} from "../validators.js"; import { convexTest } from "convex-test"; import { anyApi, ApiFromModules, + DataModelFromSchemaDefinition, defineSchema, defineTable, + internalMutationGeneric, internalQueryGeneric, + MutationBuilder, + QueryBuilder, } from "convex/server"; import { Infer, ObjectType } from "convex/values"; import { expect, test } from "vitest"; import { modules } from "./setup.test.js"; +import { getOrThrow } from "convex-helpers/server/relationships"; export const testLiterals = internalQueryGeneric({ args: { @@ -110,14 +117,104 @@ const schema = defineSchema({ tokenIdentifier: string, }), kitchenSink: defineTable(ExampleFields), + unionTable: defineTable(or(object({ foo: string }), object({ bar: number }))), +}); + +const internalMutation = internalMutationGeneric as MutationBuilder< + DataModelFromSchemaDefinition, + "internal" +>; +const internalQuery = internalQueryGeneric as QueryBuilder< + DataModelFromSchemaDefinition, + "internal" +>; + +export const toDoc = internalMutation({ + args: {}, + handler: async (ctx, args) => { + const kid = await ctx.db.insert("kitchenSink", valid); + const uid = await ctx.db.insert("unionTable", { foo: "" }); + + return { + sink: await getOrThrow(ctx, kid), + union: await getOrThrow(ctx, uid), + }; + }, + returns: object({ + sink: doc(schema, "kitchenSink"), + union: doc(schema, "unionTable"), + }), +}); + +const vv = typedV(schema); + +export const getSink = internalQuery({ + args: { docId: vv.id("kitchenSink") }, + returns: nullable(vv.doc("kitchenSink")), + handler: (ctx, args) => ctx.db.get(args.docId), +}); + +export const getUnion = internalQuery({ + args: { docId: vv.id("unionTable") }, + returns: nullable(vv.doc("unionTable")), + handler: (ctx, args) => ctx.db.get(args.docId), }); const testApi: ApiFromModules<{ fns: { echo: typeof echo; + toDoc: typeof toDoc; + getSink: typeof getSink; + getUnion: typeof getUnion; }; }>["fns"] = anyApi["validators.test"] as any; +test("vv generates the right types for objects", async () => { + const t = convexTest(schema, modules); + const docId = await t.run((ctx) => ctx.db.insert("kitchenSink", valid)); + const doc = await t.query(testApi.getSink, { docId }); + expect(doc).toBeDefined(); + expect(doc!._creationTime).toBeTypeOf("number"); +}); + +test("vv generates the right types for unions", async () => { + const t = convexTest(schema, modules); + const docId = await t.run((ctx) => + ctx.db.insert("unionTable", { foo: "foo" }), + ); + const doc = await t.query(testApi.getUnion, { docId }); + expect(doc).toBeDefined(); + expect(doc!._creationTime).toBeTypeOf("number"); + expect(doc!["foo"]).toBeDefined(); +}); + +test("doc validator adds fields", async () => { + const t = convexTest(schema, modules); + await t.mutation(testApi.toDoc, {}); + const userDoc = doc(schema, "users"); + expect(userDoc.fields.tokenIdentifier).toBeDefined(); + expect(userDoc.fields._id).toBeDefined(); + expect(userDoc.fields._creationTime).toBeDefined(); + const unionDoc = doc(schema, "unionTable"); + expect(unionDoc.kind).toBe("union"); + if (unionDoc.kind !== "union") { + throw new Error("Expected union"); + } + expect(unionDoc.members[0]!.kind).toBe("object"); + if (unionDoc.members[0]!.kind !== "object") { + throw new Error("Expected object"); + } + expect(unionDoc.members[0]!.fields.foo).toBeDefined(); + expect(unionDoc.members[0]!.fields._id).toBeDefined(); + expect(unionDoc.members[0]!.fields._creationTime).toBeDefined(); + if (unionDoc.members[1]!.kind !== "object") { + throw new Error("Expected object"); + } + expect(unionDoc.members[1]!.fields.bar).toBeDefined(); + expect(unionDoc.members[1]!.fields._id).toBeDefined(); + expect(unionDoc.members[1]!.fields._creationTime).toBeDefined(); +}); + test("validators preserve things when they're set", async () => { const t = convexTest(schema, modules); const id = await t.run((ctx) => { diff --git a/packages/convex-helpers/validators.ts b/packages/convex-helpers/validators.ts index 707ab2cd..f5ff7595 100644 --- a/packages/convex-helpers/validators.ts +++ b/packages/convex-helpers/validators.ts @@ -1,6 +1,8 @@ import { GenericValidator, + ObjectType, PropertyValidators, + VObject, VOptional, VString, VUnion, @@ -8,6 +10,11 @@ import { v, } from "convex/values"; import { Expand } from "./index.js"; +import { + DataModelFromSchemaDefinition, + SchemaDefinition, + TableNamesInDataModel, +} from "convex/server"; /** * Helper for defining a union of literals more concisely. @@ -106,6 +113,10 @@ export const systemFields = ( _creationTime: v.number(), }); +export type SystemFields = ReturnType< + typeof systemFields +>; + /** * Utility to add system fields to an object with fields mapping to validators. * e.g. withSystemFields("users", { name: v.string() }) would return: @@ -129,6 +140,112 @@ export const withSystemFields = < } as Expand; }; +export type AddFieldsToValidator< + V extends Validator, + Fields extends PropertyValidators, +> = + V extends VObject + ? VObject>, Expand, O> + : Validator< + Expand>, + V["isOptional"], + V["fieldPaths"] & + { + [Property in keyof Fields & string]: + | `${Property}.${Fields[Property]["fieldPaths"]}` + | Property; + }[keyof Fields & string] & + string + >; + +export const doc = < + Schema extends SchemaDefinition, + TableName extends TableNamesInDataModel< + DataModelFromSchemaDefinition + >, +>( + schema: Schema, + tableName: TableName, +): AddFieldsToValidator< + (typeof schema)["tables"][TableName]["validator"], + SystemFields +> => { + function addSystemFields>( + validator: V, + ): any { + if (validator.kind === "object") { + return v.object({ + ...validator.fields, + ...systemFields(tableName), + }); + } + if (validator.kind !== "union") { + throw new Error( + "Only object and union validators are supported for documents", + ); + } + return v.union(...validator.members.map(addSystemFields)); + } + return addSystemFields(schema.tables[tableName].validator); +}; + +/** + * Creates a validator with a type-safe `.id(table)` and a new `.doc(table)`. + * Can be used instead of `v` for function arugments & return validators. + * However, it cannot be used as part of defining a schema, since it would be + * circular. + * ```ts + * import schema from "./schema"; + * export const vv = typedV(schema); + * + * export const myQuery = query({ + * args: { docId: vv.id("mytable") }, + * returns: vv.doc("mytable"), + * handler: (ctx, args) => ctx.db.get(args.docId), + * }) + * + * @param schema Typically from `import schema from "./schema"`. + * @returns A validator like `v` with type-safe `v.id` and a new `v.doc` + */ +export function typedV>( + schema: Schema, +) { + return { + ...v, + /** + * Similar to v.id but is type-safe on the table name. + * @param tableName A table named in your schema. + * @returns A validator for an ID to the named table. + */ + id: < + TableName extends TableNamesInDataModel< + DataModelFromSchemaDefinition + >, + >( + tableName: TableName, + ) => v.id(tableName), + /** + * Generates a validator for a document, including system fields. + * To be used in validators when passing a full document in or out of a + * function. + * @param tableName A table named in your schema. + * @returns A validator that matches the schema validator, adding _id and + * _creationTime. If the validator was a union, it will update all documents + * recursively, but will currently lose the VUnion-specific type. + */ + doc: < + TableName extends TableNamesInDataModel< + DataModelFromSchemaDefinition + >, + >( + tableName: TableName, + ): AddFieldsToValidator< + (typeof schema)["tables"][TableName]["validator"], + SystemFields + > => doc(schema, tableName), + }; +} + /** * A string validator that is a branded string type. *