UNPKG

convex-helpers

Version:

A collection of useful code to complement the official convex package.

627 lines (626 loc) 23.1 kB
import { asObjectValidator, v } from "convex/values"; import { assert } from "./index.js"; /** * Helper for defining a union of literals more concisely. * * e.g. `literals("a", 1, false)` is equivalent to * `v.union(v.literal("a"), v.literal(1), v.literal(false))` * To use with an array: * ```ts * const myLiterals = ["a", 1, false] as const; * const literalValidator = literals(...myLiterals) * ``` * A similar result can be achieved with `v.union(...myLiterals.map(v.literal))` * however the type of each union member will be the union of literal types, * rather than each member being a specific literal type. * * @param args Values you want to use in a union of literals. * @returns A validator for the union of the literals. */ export const literals = (...args) => { return v.union(...args.map(v.literal)); }; /** * nullable define a validator that can be the value or null more consisely. * * @param x The validator to make nullable. As in, it can be the value or null. * @returns A new validator that can be the value or null. */ export const nullable = (x) => v.union(v.null(), x); export function partial(fieldsOrObjOrUnion) { if (fieldsOrObjOrUnion.isConvexValidator) { if (fieldsOrObjOrUnion.kind === "object") { return partialVObject(fieldsOrObjOrUnion); } if (fieldsOrObjOrUnion.kind === "union") { return partialUnion(fieldsOrObjOrUnion); } throw new Error("partial only works with union or object Validators, or a Record<string, Validator> currently"); } return partialFields(fieldsOrObjOrUnion); } /** * partialFields helps you define an object of optional validators more concisely. * * e.g. `partialFields({a: v.string(), b: v.number()})` is equivalent to * `{a: v.optional(v.string()), b: v.optional(v.number())}` * * @param obj The object of validators to make optional. e.g. {a: v.string()} * @returns A new object of validators that can be the value or undefined. */ function partialFields(obj) { return Object.fromEntries(Object.entries(obj).map(([k, vv]) => [ k, vv.isOptional === "optional" ? vv : v.optional(vv), ])); } /** * partialObject helps you define an object of optional validators more concisely. * * e.g. `partialObject({a: v.string(), b: v.number()})` is equivalent to * `{a: v.optional(v.string()), b: v.optional(v.number())}` * * @param obj The object of validators to make optional. e.g. {a: v.string()} * @returns A new object of validators that can be the value or undefined. */ function partialVObject(obj) { const o = v.object(partialFields(obj.fields)); if (obj.isOptional === "optional") { return v.optional(o); } return o; } function partialUnion(union) { const u = v.union(...union.members.map((m) => { assert(m.isOptional === "required", "Union members cannot be optional"); if (m.kind === "object") { return partialVObject(m); } if (m.kind === "union") { return partialUnion(m); } throw new Error(`Invalid union member type: ${m.kind}`); })); if (union.isOptional === "optional") { return v.optional(u); } return u; } // Shorthand for defining validators that look like types. /** @deprecated Use `v.string()` instead. Any string value. */ export const string = v.string(); /** @deprecated Use `v.float64()` instead. JavaScript number, represented as a float64 in the database. */ export const number = v.float64(); /** @deprecated Use `v.float64()` instead. JavaScript number, represented as a float64 in the database. */ export const float64 = v.float64(); /** @deprecated Use `v.boolean()` instead. boolean value. For typing it only as true, use `l(true)` */ export const boolean = v.boolean(); /** @deprecated Use `v.int64()` instead. bigint, though stored as an int64 in the database. */ export const biging = v.int64(); /** @deprecated Use `v.int64()` instead. bigint, though stored as an int64 in the database. */ export const int64 = v.int64(); /** @deprecated Use `v.any()` instead. Any Convex value */ export const any = v.any(); /** @deprecated Use `v.null()` instead. Null value. Underscore is so it doesn't shadow the null builtin */ export const null_ = v.null(); /** @deprecated Use `v.*()` instead. */ export const { id, object, array, bytes, literal, optional, union } = v; /** @deprecated Use `v.bytes()` instead. ArrayBuffer validator. */ export const arrayBuffer = v.bytes(); /** * Utility to get the validators for fields associated with a table. * e.g. for systemFields("users") it would return: * { _id: v.id("users"), _creationTime: v.number() } * * @param tableName The table name in the schema. * @returns Validators for the system fields: _id and _creationTime */ export const systemFields = (tableName) => ({ _id: v.id(tableName), _creationTime: v.number(), }); /** * Utility to add system fields to an object with fields mapping to validators. * e.g. withSystemFields("users", { name: v.string() }) would return: * { name: v.string(), _id: v.id("users"), _creationTime: v.number() } * * @param tableName Table name in the schema. * @param fields The fields of the table mapped to their validators. * @returns The fields plus system fields _id and _creationTime. */ export const withSystemFields = (tableName, fields) => { const system = systemFields(tableName); return { ...fields, ...system, }; }; export function addFieldsToValidator(validatorOrFields, fields) { const validator = asObjectValidator(validatorOrFields); if (Object.keys(fields).length === 0) { return validator; } switch (validator.kind) { case "object": return v.object(intersectValidators(validator.fields, fields)); case "union": return v.union(...validator.members.map((m) => addFieldsToValidator(m, fields))); default: throw new Error("Cannot add arguments to a validator that is not an object or union."); } } function intersectValidators(fields, fields2) { const specificFields = { ...fields }; for (const [k, v] of Object.entries(fields2)) { const existing = specificFields[k]; if (existing) { if (existing.kind !== v.kind) { // TODO: handle unions & literals & other sub-types (incl. optionals) throw new Error(`Cannot intersect validators with different kinds: ${existing.kind} and ${v.kind}`); } if (existing.isOptional !== v.isOptional) { if (existing.isOptional === "optional") { // prefer the required validator specificFields[k] = v; } } } else { specificFields[k] = v; } } return specificFields; } export const doc = (schema, tableName) => { function addSystemFields(validator) { 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) { 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) => 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) => doc(schema, tableName), }; } /** * A string validator that is a branded string type. * * Read more at https://stack.convex.dev/using-branded-types-in-validators * * @param _brand - A unique string literal to brand the string with */ export const brandedString = (_brand) => v.string(); /** Mark fields as deprecated with this permissive validator typed as null */ export const deprecated = v.optional(v.any()); /** A maximally permissive validator that type checks as a given validator. * * If you want to have types that match some validator but you have invalid data * and you want to temporarily not validate schema for this field, * you can use this function to cast the permissive validator. * * Example in a schema: * ```ts * export default defineSchema({ * myTable: defineTable({ * myString: pretend(v.array(v.string())), * }), * }); * //...in some mutation * ctx.db.insert("myTable", { myString: 123 as any }); // no runtime error * ``` * Example in function argument validation: * ```ts * const myQuery = defineQuery({ * args: { myNumber: pretend(v.number()) }, * handler: async (ctx, args) => { * // args.myNumber is typed as number, but it's not validated. * const num = typeof args.myNumber === "number" ? * args.myNumber : Number(args.myNumber); * }, * }); */ export const pretend = (_typeToImmitate) => v.optional(v.any()); /** A validator that validates as optional but type checks as required. * * If you want to assume a field is set for type checking, but your data may not * actually have it set for all documents (e.g. when adding a new field), * you can use this function to allow the field to be unset at runtime. * This is unsafe, but can be convenient in these situations: * * 1. You are developing locally and want to add a required field and write * code assuming it is set. Once you push the code & schema, you can update * the data to match before running your code. * 2. You are going to run a migration right after pushing code, and are ok with * and you don't want to edit your code to handle the field being unset, * your app being in an inconsistent state until the migration completes. * * This differs from {@link pretend} in that it type checks the inner validator, * if the value is provided. * * Example in a schema: * ```ts * export default defineSchema({ * myTable: defineTable({ * myString: pretendRequired(v.array(v.string())), * }), * }); * //...in some mutation * ctx.db.insert("myTable", { myString: undefined }); // no runtime error * ``` * Example in function argument validation: * ```ts * const myQuery = defineQuery({ * args: { myNumber: pretendRequired(v.number()) }, * handler: async (ctx, args) => { * // args.myNumber is typed as number, but it might be undefined * const num = args.myNumber || 0; * }, * }); */ export const pretendRequired = (optionalType) => v.optional(optionalType); export class ValidationError extends Error { expected; got; path; constructor(expected, got, path) { const message = `Validator error${path ? ` for ${path}` : ""}: Expected \`${expected}\`, got \`${got}\``; super(message); this.expected = expected; this.got = got; this.path = path; this.name = "ValidationError"; } } /** * Validate a value against a validator. * * WARNING: This does not validate that v.id is an ID for the given table. * It only validates that the ID is a string. Function `args`, `returns` and * schema definitions will validate that the ID is an ID for the given table. * * @param validator The validator to validate against. * @param value The value to validate. * @returns Whether the value is valid against the validator. */ export function validate(validator, value, opts) { let valid = true; let expected = validator.kind; let got; if (value === undefined) { if (validator.isOptional !== "optional") { valid = false; } } else { switch (validator.kind) { case "null": { if (value !== null) { valid = false; } break; } case "float64": { if (typeof value !== "number") { expected = "number"; valid = false; } break; } case "int64": { if (typeof value !== "bigint") { expected = "bigint"; valid = false; } break; } case "boolean": { if (typeof value !== "boolean") { valid = false; } break; } case "string": { if (typeof value !== "string") { valid = false; } break; } case "bytes": { if (!(value instanceof ArrayBuffer)) { valid = false; } break; } case "any": { break; } case "literal": { if (value !== validator.value) { valid = false; expected = validator.value; if (["string", "number", "boolean", "bigint"].includes(typeof value)) { got = `"${value}"`; } } break; } case "id": { if (typeof value !== "string") { valid = false; } else if (opts?.db) { expected = `Id<${validator.tableName}>`; const id = opts.db.normalizeId(validator.tableName, value); if (!id) { valid = false; } } break; } case "array": { if (!Array.isArray(value)) { valid = false; break; } for (const [index, v] of value.entries()) { const path = `${opts?._pathPrefix ?? ""}[${index}]`; valid = validate(validator.element, v, { ...opts, _pathPrefix: path, }); if (!valid) { expected = validator.element.kind; break; } } break; } case "object": { if (typeof value !== "object" || value === null) { valid = false; break; } const prototype = Object.getPrototypeOf(value); const isSimple = prototype === null || prototype === Object.prototype || // Objects generated from other contexts (e.g. across Node.js `vm` modules) will not satisfy the previous // conditions but are still simple objects. prototype?.constructor?.name === "Object"; if (!isSimple) { expected = (prototype?.constructor?.name ?? typeof prototype) || "object"; valid = false; break; } for (const [k, fieldValidator] of Object.entries(validator.fields)) { valid = validate(fieldValidator, value[k], { ...opts, _pathPrefix: appendPath(opts, k), }); if (!valid) { break; } } if (!opts?.allowUnknownFields) { for (const k of Object.keys(value)) { if (validator.fields[k] === undefined) { if (opts?.throw) { throw new ValidationError("nothing", typeof value[k], appendPath(opts, k)); } valid = false; break; } } } break; } case "union": { valid = false; let error; for (const member of validator.members) { try { if (validate(member, value, opts)) { valid = true; break; } } catch (e) { error = e; } } if (!valid && error) { throw error; } break; } case "record": { if (typeof value !== "object" || value === null) { valid = false; break; } for (const [k, fieldValue] of Object.entries(value)) { valid = validate(validator.key, k, { ...opts, _pathPrefix: appendPath(opts, k), }); if (!valid) { expected = validator.key.kind; break; } valid = validate(validator.value, fieldValue, { ...opts, _pathPrefix: appendPath(opts, k), }); if (!valid) { expected = validator.value.kind; break; } } break; } } } if (!valid && opts?.throw) { throw new ValidationError(expected, got ?? (value === null ? "null" : typeof value), opts?._pathPrefix); } return valid; } /** * Parse a value, using a Convex validator. This differs from `validate` in that * it strips unknown fields instead of throwing an error on them. * * @param validator - The Convex validator to parse the value against. * @param value - The value to parse. * @returns The parsed value, without fields not specified in the validator. */ export function parse(validator, value) { validate(validator, value, { allowUnknownFields: true, throw: true }); return stripUnknownFields(validator, value); } function stripUnknownFields(validator, value) { if (validator.isOptional === "optional" && value === undefined) { return value; } assert(value !== undefined); switch (validator.kind) { case "object": { const result = {}; for (const [k, v] of Object.entries(value)) { if (validator.fields[k] !== undefined && v !== undefined) { result[k] = stripUnknownFields(validator.fields[k], v); } } return result; } case "record": { const result = {}; for (const [k, v] of Object.entries(value)) { result[k] = stripUnknownFields(validator.value, v); } return result; } case "array": { return value.map((e) => stripUnknownFields(validator.element, e)); } case "union": { // First try a strict match for (const member of validator.members) { if (validate(member, value, { allowUnknownFields: false })) { return stripUnknownFields(member, value); } } // Then try a permissive match for (const member of validator.members) { if (validate(member, value, { allowUnknownFields: true })) { return stripUnknownFields(member, value); } } throw new Error("No matching member in union"); } default: { return value; } } } function appendPath(opts, path) { return opts?._pathPrefix ? `${opts._pathPrefix}.${path}` : path; } /** * Converts an optional validator to a required validator. * * This is the inverse of `v.optional()`. It takes a validator that may be optional * and returns the equivalent required validator. * * ```ts * const optionalString = v.optional(v.string()); * const requiredString = vRequired(optionalString); // v.string() * * // Already required validators are returned as-is * const alreadyRequired = v.string(); * const stillRequired = vRequired(alreadyRequired); // v.string() * ``` * * @param validator The validator to make required. * @returns A required version of the validator. */ export function vRequired(validator) { const { kind, isOptional } = validator; if (isOptional === "required") { // TypeScript can't prove T is already VRequired<T>, so we go via unknown. return validator; } switch (kind) { case "id": return v.id(validator.tableName); case "string": return v.string(); case "float64": return v.float64(); case "int64": return v.int64(); case "boolean": return v.boolean(); case "null": return v.null(); case "any": return v.any(); case "literal": return v.literal(validator.value); case "bytes": return v.bytes(); case "object": return v.object(validator.fields); case "array": return v.array(validator.element); case "record": return v.record(validator.key, validator.value); case "union": return v.union(...validator.members); default: kind; throw new Error("Unknown Convex validator type: " + kind); } }