UNPKG

appwrite-utils

Version:
465 lines (464 loc) 18.3 kB
import { z } from "zod"; import { Decimal } from "decimal.js"; const MIN_MAX_THRESHOLD = 1_000_000_000_000; const EXTREME_MIN_INTEGER = -9223372036854776000; const EXTREME_MAX_INTEGER = 9223372036854776000; const allowedStringFormats = new Set(["email", "ip", "url", "datetime"]); const normalizeNumericBoundary = (value, extreme) => { if (value === undefined || value === null) { return undefined; } // Use Decimal.js for precise comparison with extreme values try { const valueDecimal = new Decimal(value.toString()); const extremeDecimal = new Decimal(extreme.toString()); const thresholdDecimal = new Decimal(MIN_MAX_THRESHOLD.toString()); // Check if value equals extreme value or exceeds threshold if (valueDecimal.equals(extremeDecimal) || valueDecimal.abs().greaterThanOrEqualTo(thresholdDecimal)) { return undefined; } } catch (error) { // Fallback to original logic if Decimal.js fails console.warn(`Failed to compare numeric boundary with Decimal.js, falling back to Number comparison:`, error); if (Math.abs(value) >= MIN_MAX_THRESHOLD || value === extreme) { return undefined; } } return value; }; /** * Shared schema across attribute types. * Includes keys that Appwrite returns on all attributes along with * optional metadata (attributes/orders) used when normalizing indexes. */ export const baseAttributeSchema = z.object({ key: z.string().describe("The key of the attribute"), status: z .string() .optional() .describe("The current lifecycle status returned by Appwrite"), attributes: z .array(z.string()) .optional() .describe("Optional list of dependent attribute keys (used by index metadata)"), orders: z .array(z.string()) .optional() .describe("Optional list of sort directions aligned with the attributes array"), error: z .string() .optional() .describe("The error message if the attribute is invalid"), required: z .boolean() .optional() .default(false) .describe("Whether the attribute is required or not"), array: z .boolean() .optional() .describe("Whether the attribute is an array or not"), $createdAt: z .string() .optional() .describe("ISO timestamp when the attribute or column was created"), $updatedAt: z .string() .optional() .describe("ISO timestamp when the attribute or column was last updated"), format: z .string() .optional() .describe("Original format hint supplied by Appwrite for string types"), }); /** * Helper to build schemas based on the base attribute definition. * The base definition already includes common fields, so individual schemas * only need to add type-specific properties. */ const extendBase = (shape) => baseAttributeSchema.omit({ error: true }).extend({ // Keep `error` optional without a default so it doesn't // become required in the discriminated union's output type. error: z .string() .optional() .describe("The error message if the attribute is invalid"), ...shape, }); export const stringAttributeSchema = extendBase({ type: z.literal("string").describe("The type of the attribute"), size: z .number() .optional() .default(50) .describe("The max length or size of the attribute"), xdefault: z.string().nullish().describe("The default value of the attribute"), encrypt: z .boolean() .optional() .describe("Whether the attribute is encrypted or not"), }); export const integerAttributeSchema = extendBase({ type: z.literal("integer").describe("The type of the attribute"), min: z .number() .optional() .describe("The minimum value of the attribute"), max: z .number() .optional() .describe("The maximum value of the attribute"), xdefault: z .number() .nullish() .describe("The default value of the attribute"), }).overwrite((attribute) => { const { min, max, ...rest } = attribute; const normalizedMin = normalizeNumericBoundary(min, EXTREME_MIN_INTEGER); const normalizedMax = normalizeNumericBoundary(max, EXTREME_MAX_INTEGER); const normalized = { ...rest }; if (normalizedMin !== undefined) { normalized.min = Math.trunc(normalizedMin); } else { delete normalized.min; } if (normalizedMax !== undefined) { normalized.max = Math.trunc(normalizedMax); } else { delete normalized.max; } return normalized; }); export const doubleAttributeSchema = extendBase({ type: z.literal("double").describe("The type of the attribute"), min: z.number().optional().describe("The minimum value of the attribute"), max: z.number().optional().describe("The maximum value of the attribute"), xdefault: z.number().nullish().describe("The default value of the attribute"), }); export const floatAttributeSchema = extendBase({ type: z .literal("float") .describe("The type of the attribute (backwards compatibility)"), min: z.number().optional().describe("The minimum value of the attribute"), max: z.number().optional().describe("The maximum value of the attribute"), xdefault: z.number().nullish().describe("The default value of the attribute"), }); export const booleanAttributeSchema = extendBase({ type: z.literal("boolean").describe("The type of the attribute"), xdefault: z .boolean() .nullish() .describe("The default value of the attribute"), }); export const datetimeAttributeSchema = extendBase({ type: z.literal("datetime").describe("The type of the attribute"), xdefault: z.string().nullish().describe("The default value of the attribute"), }); export const emailAttributeSchema = extendBase({ type: z.literal("email").describe("The type of the attribute"), xdefault: z.string().nullish().describe("The default value of the attribute"), }); export const ipAttributeSchema = extendBase({ type: z.literal("ip").describe("The type of the attribute"), xdefault: z.string().nullish().describe("The default value of the attribute"), }); export const urlAttributeSchema = extendBase({ type: z.literal("url").describe("The type of the attribute"), xdefault: z.string().nullish().describe("The default value of the attribute"), }); export const enumAttributeSchema = extendBase({ type: z.literal("enum").describe("The type of the attribute"), elements: z .array(z.string()) .default([]) .describe("The elements of the enum attribute"), xdefault: z.string().nullish().describe("The default value of the attribute"), }); export const relationshipAttributeSchema = extendBase({ type: z.literal("relationship").describe("The type of the attribute"), // Appwrite < 1.8 emits `relatedCollection`; >= 1.8 emits `relatedTable`. // Accept either, require at least one via superRefine, and mirror them // in the transform so downstream code can keep reading `relatedCollection`. relatedCollection: z .string() .optional() .describe("The collection/table ID of the related collection. Appwrite < 1.8 emits this; provide either this or `relatedTable`."), relatedTable: z .string() .optional() .describe("The table ID of the related table. Appwrite >= 1.8 emits this; provide either this or `relatedCollection`."), relationType: z .enum(["oneToMany", "manyToOne", "oneToOne", "manyToMany"]) .describe("The relation type of the relationship attribute"), twoWay: z.boolean().describe("Whether the relationship is two way or not"), twoWayKey: z .string() .optional() .describe("The ID of the foreign key in the other collection (required when twoWay is true)"), onDelete: z .enum(["setNull", "cascade", "restrict"]) .default("setNull") .describe("The action to take when the related document is deleted"), side: z .enum(["parent", "child"]) .optional() .describe("The side of the relationship (required when twoWay is true)"), importMapping: z .object({ originalIdField: z .string() .describe("The field in the import data representing the original ID to match"), targetField: z .string() .optional() .describe("The field in the target collection that matches the original ID. Optional, defaults to the same as originalIdField if not provided"), }) .optional() .describe("Configuration for mapping and resolving relationships during data import"), }).superRefine((val, ctx) => { if (!val.relatedCollection && !val.relatedTable) { ctx.addIssue({ code: "custom", path: ["relatedCollection"], message: "relationship attribute requires either `relatedCollection` (Appwrite < 1.8) or `relatedTable` (Appwrite >= 1.8)", }); } if (val.twoWay) { if (!val.twoWayKey) { ctx.addIssue({ code: "custom", path: ["twoWayKey"], message: "twoWayKey is required when twoWay is true", }); } if (!val.side) { ctx.addIssue({ code: "custom", path: ["side"], message: "side is required when twoWay is true", }); } } }); // Text variant attribute schemas (node-appwrite v22+) export const varcharAttributeSchema = extendBase({ type: z.literal("varchar").describe("The type of the attribute"), size: z.number().optional().describe("The max length of the attribute"), xdefault: z.string().nullish().describe("The default value of the attribute"), encrypt: z.boolean().optional().describe("Whether the attribute is encrypted or not"), }); export const textAttributeSchema = extendBase({ type: z.literal("text").describe("The type of the attribute"), xdefault: z.string().nullish().describe("The default value of the attribute"), encrypt: z.boolean().optional().describe("Whether the attribute is encrypted or not"), }); export const mediumtextAttributeSchema = extendBase({ type: z.literal("mediumtext").describe("The type of the attribute"), xdefault: z.string().nullish().describe("The default value of the attribute"), encrypt: z.boolean().optional().describe("Whether the attribute is encrypted or not"), }); export const longtextAttributeSchema = extendBase({ type: z.literal("longtext").describe("The type of the attribute"), xdefault: z.string().nullish().describe("The default value of the attribute"), encrypt: z.boolean().optional().describe("Whether the attribute is encrypted or not"), }); // Geospatial attribute schemas (node-appwrite v22+) export const pointAttributeSchema = extendBase({ type: z.literal("point").describe("The type of the attribute"), xdefault: z.array(z.any()).nullish().describe("The default value of the attribute"), }); export const lineAttributeSchema = extendBase({ type: z.literal("line").describe("The type of the attribute"), xdefault: z.array(z.any()).nullish().describe("The default value of the attribute"), }); export const polygonAttributeSchema = extendBase({ type: z.literal("polygon").describe("The type of the attribute"), xdefault: z.array(z.any()).nullish().describe("The default value of the attribute"), }); const attributeVariants = z.discriminatedUnion("type", [ stringAttributeSchema, integerAttributeSchema, doubleAttributeSchema, floatAttributeSchema, booleanAttributeSchema, datetimeAttributeSchema, emailAttributeSchema, ipAttributeSchema, urlAttributeSchema, enumAttributeSchema, relationshipAttributeSchema, varcharAttributeSchema, textAttributeSchema, mediumtextAttributeSchema, longtextAttributeSchema, pointAttributeSchema, lineAttributeSchema, polygonAttributeSchema, ]); const attributeDefaultValueSchema = z.union([ z.string(), z.number(), z.boolean(), z.array(z.any()), z.null(), ]); const attributeNormalizerSchema = z .object({ key: z.string(), type: z.string(), attributes: z.array(z.string()).optional(), orders: z.array(z.string()).optional(), error: z.string().optional(), required: z.boolean().optional(), array: z.boolean().optional(), default: attributeDefaultValueSchema.optional(), xdefault: attributeDefaultValueSchema.optional(), format: z.string().optional(), size: z.union([z.number(), z.string()]).optional(), min: z.union([z.number(), z.string(), z.bigint()]).optional(), max: z.union([z.number(), z.string(), z.bigint()]).optional(), elements: z.array(z.string()).optional(), encrypt: z.boolean().optional(), relatedCollection: z.string().optional(), relationType: z.string().optional(), twoWay: z.boolean().optional(), twoWayKey: z.string().optional(), onDelete: z.string().optional(), side: z.string().optional(), importMapping: z .object({ originalIdField: z.string(), targetField: z.string().optional(), }) .optional(), }) .loose() .overwrite((raw) => { const { default: defaultValue, format, attributes: rawAttributes, orders: rawOrders, size: rawSize, min: rawMin, max: rawMax, ...rest } = raw; const normalizedAttributes = Array.isArray(rawAttributes) ? rawAttributes.filter((value) => typeof value === "string") : undefined; const normalizedOrders = Array.isArray(rawOrders) ? rawOrders.filter((value) => typeof value === "string") : undefined; const normalizedTypeBase = raw.type?.toString().toLowerCase() ?? "string"; let normalizedType = normalizedTypeBase; if (normalizedTypeBase === "string" && format) { const candidate = format .toString() .toLowerCase() .replace(/[^a-z]/g, ""); if (allowedStringFormats.has(candidate)) { normalizedType = candidate; } } const required = raw.required ?? false; const array = raw.array ?? false; const defaultCandidate = defaultValue !== undefined ? defaultValue : raw.xdefault; const xdefault = !required && defaultCandidate !== undefined && defaultCandidate !== null ? defaultCandidate : undefined; const toNumber = (value) => { if (value === undefined || value === null) { return undefined; } // Handle bigint values from node-appwrite v23+ if (typeof value === 'bigint') { const num = Number(value); if (Math.abs(num) >= MIN_MAX_THRESHOLD) { return undefined; } return num; } // Handle string values that might be too large for Number() if (typeof value === 'string') { try { const decimal = new Decimal(value); // Check if the decimal is within safe JavaScript number range if (decimal.abs().greaterThan(Number.MAX_SAFE_INTEGER)) { // For values larger than MAX_SAFE_INTEGER, keep as string to preserve precision // But return undefined if it's an extreme database value const numValue = decimal.toNumber(); if (Math.abs(numValue) >= MIN_MAX_THRESHOLD) { return undefined; } // For other large values, we still need to return a number for the schema // but log a warning about potential precision loss console.warn(`Large number value '${value}' may lose precision when converted to JavaScript number`); return numValue; } return decimal.toNumber(); } catch (error) { // If Decimal parsing fails, fall back to Number() console.warn(`Failed to parse large number value '${value}' with Decimal.js:`, error); } } const numeric = Number(value); return Number.isNaN(numeric) ? undefined : numeric; }; const size = toNumber(rawSize); const min = toNumber(rawMin); const max = toNumber(rawMax); const normalized = { ...rest, }; normalized.type = normalizedType; normalized.required = required; normalized.array = array; if (normalizedAttributes) { normalized.attributes = normalizedAttributes; } else { delete normalized.attributes; } if (normalizedOrders) { normalized.orders = normalizedOrders; } else { delete normalized.orders; } if (size !== undefined) { normalized.size = size; } else { delete normalized.size; } if (min !== undefined) { normalized.min = min; } else { delete normalized.min; } if (max !== undefined) { normalized.max = max; } else { delete normalized.max; } if (xdefault !== undefined) { normalized.xdefault = xdefault; } else { delete normalized.xdefault; } if ("twoWay" in normalized) { normalized.twoWay = Boolean(normalized.twoWay); } delete normalized.default; delete normalized.format; if (typeof normalized.status !== "string" || normalized.status.length === 0) { normalized.status = "available"; } // Don't force set error here - let individual schemas handle defaults // This prevents TypeScript conflicts where error appears required if (normalized.array === undefined || normalized.array === null) { normalized.array = false; } return normalized; }); export const attributeSchema = attributeNormalizerSchema.pipe(attributeVariants); export const attributesSchema = z.array(attributeSchema);