zod-to-appwrite
Version:
Generate Appwrite database collections from Zod schemas
299 lines (298 loc) • 12.6 kB
JavaScript
const ZOD_TYPES = {
STRING: "ZodString",
NUMBER: "ZodNumber",
BOOLEAN: "ZodBoolean",
DATE: "ZodDate",
ENUM: "ZodEnum",
NATIVE_ENUM: "ZodNativeEnum",
LITERAL: "ZodLiteral",
ARRAY: "ZodArray",
OBJECT: "ZodObject",
OPTIONAL: "ZodOptional",
NULLABLE: "ZodNullable",
DEFAULT: "ZodDefault",
};
export async function zodToAppwrite(schema, options) {
const { databases, databaseId, collectionId, collectionName, permissions = [{ permission: "read", role: "any" }], logLevel = "verbose", enumSizeLimit = 100, stringDefaultSize = 255, skipExisting = true, } = options;
// Logging utility
const log = (message, level = "info") => {
if (logLevel === "silent")
return;
if (logLevel === "verbose" || level !== "info") {
console[level](`[zodToAppwrite] ${message}`);
}
};
log(`Starting collection creation for '${collectionName}' with ${Object.keys(schema.shape).length} fields`, "info");
// Create collection
try {
await databases.createCollection(databaseId, collectionId, collectionName, permissions?.map(p => typeof p === 'string' ? p : `${p.permission}("${p.role}")`));
log(`Collection '${collectionName}' created successfully.`);
}
catch (err) {
if (err.message?.includes("already exists")) {
log(`Collection '${collectionName}' already exists, proceeding with attributes.`);
}
else {
throw new Error(`Failed to create collection: ${err.message}`);
}
}
const shape = schema.shape;
const results = {
created: [],
skipped: [],
failed: [],
};
// Process each field in the schema
for (const [key, field] of Object.entries(shape)) {
try {
log(`Processing field '${key}'...`, "info");
const attributeInfo = analyzeZodField(field);
const { type: baseType, isOptional, isNullable, metadata, } = attributeInfo;
const required = !(isOptional || isNullable);
// Throw for unsupported types
if (baseType === "ZodAny" || baseType === "ZodUnknown") {
throw new Error(`Unsupported type '${baseType}' for field '${key}' cannot be stored in Appwrite`);
}
// Skip existing attributes if enabled
if (skipExisting) {
try {
await databases.getAttribute(databaseId, collectionId, key);
log(`Attribute '${key}' already exists, skipping.`);
results.skipped.push(key);
continue;
}
catch {
// Attribute doesn't exist, proceed with creation
}
}
// Create the attribute
await createAppwriteAttribute(databases, databaseId, collectionId, key, baseType, required, metadata, { enumSizeLimit, stringDefaultSize, log });
log(`Created attribute '${key}' (${baseType})`);
results.created.push(key);
}
catch (err) {
const errorMsg = err.message || "Unknown error";
log(`Failed to create attribute '${key}': ${errorMsg}`, "error");
results.failed.push({ key, error: errorMsg });
}
}
// Summary log
const summary = [
`Created: ${results.created.length}`,
`Skipped: ${results.skipped.length}`,
`Failed: ${results.failed.length}`,
].join(", ");
log(summary);
return results;
}
function analyzeZodField(field) {
let currentType = field;
let isOptional = false;
let isNullable = false;
// Unwrap optional, nullable, and default layers
while ([ZOD_TYPES.OPTIONAL, ZOD_TYPES.NULLABLE, ZOD_TYPES.DEFAULT].includes(currentType._def.typeName)) {
if (currentType._def.typeName === ZOD_TYPES.OPTIONAL)
isOptional = true;
if (currentType._def.typeName === ZOD_TYPES.NULLABLE)
isNullable = true;
if (currentType._def.typeName === ZOD_TYPES.DEFAULT)
isOptional = true;
currentType = currentType._def.innerType;
}
return {
type: currentType._def.typeName,
isOptional,
isNullable,
metadata: currentType._def,
};
}
async function createAppwriteAttribute(databases, databaseId, collectionId, key, baseType, required, metadata, options) {
const { enumSizeLimit, stringDefaultSize, log } = options;
switch (baseType) {
case ZOD_TYPES.STRING: {
const checks = metadata.checks || [];
const isEmail = checks.some((c) => c.kind === "email");
const isUrl = checks.some((c) => c.kind === "url");
const isIp = checks.some((c) => c.kind === "ip");
if (isEmail) {
await databases.createEmailAttribute(databaseId, collectionId, key, required);
}
else if (isUrl) {
await databases.createUrlAttribute(databaseId, collectionId, key, required);
}
else if (isIp) {
await databases.createIpAttribute(databaseId, collectionId, key, required);
}
else {
const maxCheck = checks.find((c) => c.kind === "max");
const minCheck = checks.find((c) => c.kind === "min");
const size = maxCheck?.value || stringDefaultSize;
if (minCheck && minCheck.value > size) {
log(`Warning: min length (${minCheck.value}) > max length (${size}) for '${key}'`, "warn");
}
await databases.createStringAttribute(databaseId, collectionId, key, size, required);
}
break;
}
case ZOD_TYPES.NUMBER: {
const checks = metadata.checks || [];
const isInt = checks.some((c) => c.kind === "int");
const minCheck = checks.find((c) => c.kind === "min");
const maxCheck = checks.find((c) => c.kind === "max");
if (isInt) {
const min = minCheck?.value;
const max = maxCheck?.value;
// Check 32-bit integer bounds
if ((min !== undefined && min < -2147483648) ||
(max !== undefined && max > 2147483647)) {
log(`Integer bounds for '${key}' exceed 32-bit limits, using float`, "warn");
await databases.createFloatAttribute(databaseId, collectionId, key, required);
}
else {
await databases.createIntegerAttribute(databaseId, collectionId, key, required);
}
}
else {
await databases.createFloatAttribute(databaseId, collectionId, key, required);
}
break;
}
case ZOD_TYPES.BOOLEAN: {
await databases.createBooleanAttribute(databaseId, collectionId, key, required);
break;
}
case ZOD_TYPES.DATE: {
await databases.createDatetimeAttribute(databaseId, collectionId, key, required);
break;
}
case ZOD_TYPES.ENUM: {
const values = metadata.values;
if (values.length > enumSizeLimit) {
log(`Enum '${key}' has ${values.length} values (>${enumSizeLimit}), using string`, "warn");
await databases.createStringAttribute(databaseId, collectionId, key, stringDefaultSize, required);
}
else {
await databases.createEnumAttribute(databaseId, collectionId, key, values, required);
}
break;
}
case ZOD_TYPES.NATIVE_ENUM: {
const enumObj = metadata.values;
const values = Object.values(enumObj).filter((v) => typeof v === "string");
if (values.length === 0) {
log(`Native enum '${key}' has no string values, using string`, "warn");
await databases.createStringAttribute(databaseId, collectionId, key, stringDefaultSize, required);
}
else if (values.length > enumSizeLimit) {
log(`Native enum '${key}' has ${values.length} values (>${enumSizeLimit}), using string`, "warn");
await databases.createStringAttribute(databaseId, collectionId, key, stringDefaultSize, required);
}
else {
await databases.createEnumAttribute(databaseId, collectionId, key, values, required);
}
break;
}
case ZOD_TYPES.LITERAL: {
const value = metadata.value;
switch (typeof value) {
case "string":
await databases.createEnumAttribute(databaseId, collectionId, key, [value], required);
break;
case "number":
if (Number.isInteger(value)) {
await databases.createIntegerAttribute(databaseId, collectionId, key, required);
}
else {
await databases.createFloatAttribute(databaseId, collectionId, key, required);
}
break;
case "boolean":
await databases.createBooleanAttribute(databaseId, collectionId, key, required);
break;
default:
log(`Literal '${key}' has unsupported type ${typeof value}, using string`, "warn");
await databases.createStringAttribute(databaseId, collectionId, key, stringDefaultSize, required);
}
break;
}
case ZOD_TYPES.ARRAY: {
const itemType = metadata.type;
const itemTypeName = itemType._def.typeName;
if (itemTypeName === ZOD_TYPES.STRING) {
const checks = itemType._def.checks || [];
const maxCheck = checks.find((c) => c.kind === "max");
const size = maxCheck?.value || stringDefaultSize;
await databases.createStringAttribute(databaseId, collectionId, key, size, required, undefined, // default value (optional)
true);
}
else {
// For non-string arrays, store as JSON string (not as array)
log(`Array '${key}' contains non-string items, storing as JSON string`, "warn");
await databases.createStringAttribute(databaseId, collectionId, key, stringDefaultSize, required);
}
break;
}
// Complex types → Store as JSON
case "ZodObject":
case "ZodRecord":
case "ZodUnion":
case "ZodIntersection":
case "ZodTuple":
case "ZodMap":
case "ZodSet":
case "ZodLazy":
case "ZodFunction":
case "ZodPromise":
case "ZodAny":
case "ZodUnknown": {
await databases.createStringAttribute(databaseId, collectionId, key, stringDefaultSize, required);
break;
}
// Skip unsupported types
case "ZodNull":
case "ZodUndefined":
case "ZodVoid":
case "ZodNever": {
throw new Error(`Unsupported type '${baseType}' cannot be stored in Appwrite`);
}
// Fallback to JSON for unknown types
default: {
log(`Unknown type '${baseType}' for field '${key}', using string`, "warn");
await databases.createStringAttribute(databaseId, collectionId, key, stringDefaultSize, required);
}
}
}
// Collection definition utility
export function defineCollection(schema, options) {
return {
schema,
create: (databases) => zodToAppwrite(schema, { databases, ...options }),
infer: {},
};
}
// Data validation helper
export function validateForAppwrite(schema, data) {
return schema.parse(data);
}
// Batch collection creator
export async function createCollections(databases, collections) {
const results = [];
for (const { schema, options } of collections) {
try {
const result = await zodToAppwrite(schema, { databases, ...options });
results.push({
success: true,
collectionId: options.collectionId,
result,
});
}
catch (error) {
results.push({
success: false,
collectionId: options.collectionId,
error: error.message || "Unknown error",
});
}
}
return results;
}