appwrite-utils-cli
Version:
Appwrite Utility Functions to help with database management, data conversion, data import, migrations, and much more. Meant to be used as a CLI tool, I do not recommend installing this in frontend environments.
645 lines (589 loc) • 25.4 kB
text/typescript
import { toCamelCase, toPascalCase } from "../utils/index.js";
import type {
AppwriteConfig,
Attribute,
RelationshipAttribute,
} from "appwrite-utils";
import { getVersionAwareDirectory, resolveDirectoryForApiMode, getDualDirectoryPaths } from "appwrite-utils";
import { z } from "zod";
import fs from "fs";
import path from "path";
import { dump } from "js-yaml";
import { getDatabaseFromConfig } from "../migrations/afterImportActions.js";
import { ulid } from "ulidx";
import { JsonSchemaGenerator } from "./jsonSchemaGenerator.js";
import { collectionToYaml, getCollectionYamlFilename } from "../utils/yamlConverter.js";
import {
extractTwoWayRelationships,
resolveCollectionName,
type RelationshipDetail
} from "./relationshipExtractor.js";
import { resolveSchemaDir } from "../utils/pathResolvers.js";
import { MessageFormatter } from "./messageFormatter.js";
export class SchemaGenerator {
private relationshipMap = new Map<string, RelationshipDetail[]>();
private config: AppwriteConfig;
private appwriteFolderPath: string;
constructor(config: AppwriteConfig, appwriteFolderPath: string) {
this.config = config;
this.appwriteFolderPath = appwriteFolderPath;
this.extractRelationships();
}
private resolveCollectionName = (idOrName: string): string => {
return resolveCollectionName(this.config, idOrName);
};
public updateYamlCollections(): void {
const collections = this.config.collections;
delete this.config.collections;
// Determine output directory based on API mode/version detection
const outputDir = this.getVersionAwareCollectionsDirectory();
const collectionsDir = path.join(this.appwriteFolderPath, outputDir);
if (!fs.existsSync(collectionsDir)) {
fs.mkdirSync(collectionsDir, { recursive: true });
}
collections?.forEach((collection) => {
// Determine schema path based on config and output directory
const schemaDir = this.config.schemaConfig?.yamlSchemaDirectory || ".yaml_schemas";
const isTablesMode = outputDir === "tables";
const schemaPath = isTablesMode
? `../${schemaDir}/table.schema.json`
: `../${schemaDir}/collection.schema.json`;
const yamlConfig = {
useTableTerminology: isTablesMode,
entityType: isTablesMode ? 'table' as const : 'collection' as const,
schemaPath
};
const yamlContent = collectionToYaml(collection, yamlConfig);
const filename = getCollectionYamlFilename(collection);
const filePath = path.join(collectionsDir, filename);
fs.writeFileSync(filePath, yamlContent, { encoding: "utf-8" });
MessageFormatter.success(`${outputDir === "tables" ? "Table" : "Collection"} YAML written to ${filePath}`, { prefix: "Schema" });
});
}
public updateTsSchemas(): void {
const collections = this.config.collections;
const functions = this.config.functions || [];
delete this.config.collections;
delete this.config.functions;
const configPath = path.join(this.appwriteFolderPath, "appwriteConfig.ts");
const configContent = `import { type AppwriteConfig } from "appwrite-utils";
const appwriteConfig: AppwriteConfig = {
appwriteEndpoint: "${this.config.appwriteEndpoint}",
appwriteProject: "${this.config.appwriteProject}",
appwriteKey: "${this.config.appwriteKey}",
enableBackups: ${this.config.enableBackups},
backupInterval: ${this.config.backupInterval},
backupRetention: ${this.config.backupRetention},
enableBackupCleanup: ${this.config.enableBackupCleanup},
enableMockData: ${this.config.enableMockData},
documentBucketId: "${this.config.documentBucketId}",
usersCollectionName: "${this.config.usersCollectionName}",
databases: ${JSON.stringify(this.config.databases, null, 4)},
buckets: ${JSON.stringify(this.config.buckets, null, 4)},
functions: ${JSON.stringify(
functions.map((func) => ({
functionId: func.$id || ulid(),
name: func.name,
runtime: func.runtime,
path: func.dirPath || `functions/${func.name}`,
entrypoint: func.entrypoint || "src/index.ts",
execute: func.execute,
events: func.events || [],
schedule: func.schedule || "",
timeout: func.timeout || 15,
enabled: func.enabled !== false,
logging: func.logging !== false,
commands: func.commands || "npm install",
scopes: func.scopes || [],
installationId: func.installationId,
providerRepositoryId: func.providerRepositoryId,
providerBranch: func.providerBranch,
providerSilentMode: func.providerSilentMode,
providerRootDirectory: func.providerRootDirectory,
specification: func.specification,
...(func.predeployCommands
? { predeployCommands: func.predeployCommands }
: {}),
...(func.deployDir ? { deployDir: func.deployDir } : {}),
})),
null,
4
)}
};
export default appwriteConfig;
`;
fs.writeFileSync(configPath, configContent, { encoding: "utf-8" });
// Determine output directory based on API mode/version detection
const outputDir = this.getVersionAwareCollectionsDirectory();
const collectionsFolderPath = path.join(
this.appwriteFolderPath,
outputDir
);
if (!fs.existsSync(collectionsFolderPath)) {
fs.mkdirSync(collectionsFolderPath, { recursive: true });
}
collections?.forEach((collection) => {
const { databaseId, ...collectionWithoutDbId } = collection; // Destructure to exclude databaseId
const collectionFilePath = path.join(
collectionsFolderPath,
`${collection.name}.ts`
);
// Determine if we're in tables mode for terminology
const isTablesMode = outputDir === "tables";
const securityField = isTablesMode ? "rowSecurity" : "documentSecurity";
const collectionContent = `import { type CollectionCreate } from "appwrite-utils";
const ${collection.name}Config: Partial<CollectionCreate> = {
name: "${collection.name}",
$id: "${collection.$id}",
enabled: ${collection.enabled},
${securityField}: ${collection.documentSecurity},
$permissions: [
${collection.$permissions
.map(
(permission) =>
`{ permission: "${permission.permission}", target: "${permission.target}" }`
)
.join(",\n ")}
],
attributes: [
${collection.attributes
.map((attr) => {
return `{ ${Object.entries(attr)
.map(([key, value]) => {
// Handle table vs collection terminology for related fields
let outputKey = key;
let outputValue = value;
if (isTablesMode) {
// Convert collection terminology to table terminology
if (key === "relatedCollection") {
outputKey = "relatedTable";
}
}
// Check the type of the value and format it accordingly
if (typeof outputValue === "string") {
// If the value is a string, wrap it in quotes
return `${outputKey}: "${outputValue.replace(/"/g, '\\"')}"`; // Escape existing quotes in the string
} else if (Array.isArray(outputValue)) {
// If the value is an array, join it with commas
if (outputValue.length > 0) {
return `${outputKey}: [${outputValue
.map((item) => `"${item}"`)
.join(", ")}]`;
} else {
return `${outputKey}: []`;
}
} else {
// If the value is not a string (e.g., boolean or number), output it directly
return `${outputKey}: ${outputValue}`;
}
})
.join(", ")} }`;
})
.join(",\n ")}
],
indexes: [
${(
collection.indexes?.map((index) => {
// Use appropriate terminology for index attributes/columns
const indexField = isTablesMode ? "columns" : "attributes";
const formattedAttributes =
index.attributes.map((attr) => `"${attr}"`).join(", ") ?? "";
return `{ key: "${index.key}", type: "${
index.type
}", ${indexField}: [${formattedAttributes}], orders: [${
index.orders
?.filter((order) => order !== null)
.map((order) => `"${order}"`)
.join(", ") ?? ""
}] }`;
}) ?? []
).join(",\n ")}
]
};
export default ${collection.name}Config;
`;
fs.writeFileSync(collectionFilePath, collectionContent, {
encoding: "utf-8",
});
MessageFormatter.success(`${outputDir === "tables" ? "Table" : "Collection"} schema written to ${collectionFilePath}`, { prefix: "Schema" });
});
}
/**
* Determines the appropriate directory for collections/tables based on API mode
* Uses version detection or config hints to choose between 'collections' and 'tables'
*/
private getVersionAwareCollectionsDirectory(): string {
return getVersionAwareDirectory(this.config, this.appwriteFolderPath);
}
/**
* Get directory for a specific API mode (legacy or tablesdb)
* @param apiMode - The API mode to get directory for
* @returns The directory name for the specified API mode
*/
public getDirectoryForApiMode(apiMode: 'legacy' | 'tablesdb'): string {
return resolveDirectoryForApiMode(this.config, apiMode, this.appwriteFolderPath);
}
/**
* Get both directory paths for dual API support
* @returns Object with both collectionsDirectory and tablesDirectory paths
*/
public getDualDirectoryConfiguration(): { collectionsDirectory: string; tablesDirectory: string } {
return getDualDirectoryPaths(this.config);
}
public async updateConfig(config: AppwriteConfig, isYamlConfig: boolean = false): Promise<void> {
if (isYamlConfig) {
// User has YAML config - find the config file and update it + generate individual collection files
const { findYamlConfig } = await import("../config/yamlConfig.js");
const yamlConfigPath = findYamlConfig(this.appwriteFolderPath);
if (yamlConfigPath) {
await this.updateYamlConfig(config, yamlConfigPath);
} else {
MessageFormatter.warning("YAML config expected but not found, falling back to TypeScript", { prefix: "Schema" });
this.updateTypeScriptConfig(config);
}
} else {
// User has TypeScript config - update the TS file
this.updateTypeScriptConfig(config);
}
}
private async updateYamlConfig(config: AppwriteConfig, yamlConfigPath: string): Promise<void> {
try {
const { writeYamlConfig } = await import("../config/yamlConfig.js");
// Write the main YAML config (without collections)
await writeYamlConfig(yamlConfigPath, config);
// Generate individual collection YAML files
this.updateYamlCollections();
MessageFormatter.success("Updated YAML configuration and collection files", { prefix: "Schema" });
} catch (error) {
MessageFormatter.error("Error updating YAML config", error as Error, { prefix: "Schema" });
throw error;
}
}
private updateTypeScriptConfig(config: AppwriteConfig): void {
const configPath = path.join(this.appwriteFolderPath, "appwriteConfig.ts");
const configContent = `import { type AppwriteConfig } from "appwrite-utils";
const appwriteConfig: AppwriteConfig = {
appwriteEndpoint: "${config.appwriteEndpoint}",
appwriteProject: "${config.appwriteProject}",
appwriteKey: "${config.appwriteKey}",
enableBackups: ${config.enableBackups},
backupInterval: ${config.backupInterval},
backupRetention: ${config.backupRetention},
enableBackupCleanup: ${config.enableBackupCleanup},
enableMockData: ${config.enableMockData},
documentBucketId: "${config.documentBucketId}",
usersCollectionName: "${config.usersCollectionName}",
databases: ${JSON.stringify(config.databases, null, 4)},
buckets: ${JSON.stringify(config.buckets, null, 4)},
functions: ${JSON.stringify(
config.functions?.map((func) => ({
$id: func.$id || ulid(),
name: func.name,
runtime: func.runtime,
dirPath: func.dirPath || "functions/" + func.name,
entrypoint: func.entrypoint || "src/index.ts",
execute: func.execute || [],
events: func.events || [],
schedule: func.schedule || "",
timeout: func.timeout || 15,
enabled: func.enabled !== false,
logging: func.logging !== false,
commands: func.commands || "npm install",
scopes: func.scopes || [],
installationId: func.installationId,
providerRepositoryId: func.providerRepositoryId,
providerBranch: func.providerBranch,
providerSilentMode: func.providerSilentMode,
providerRootDirectory: func.providerRootDirectory,
specification: func.specification,
})),
null,
4
)},
collections: ${JSON.stringify(config.collections, null, 4)}
};
export default appwriteConfig;
`;
fs.writeFileSync(configPath, configContent, { encoding: "utf-8" });
MessageFormatter.success("Updated TypeScript configuration file", { prefix: "Schema" });
}
private extractRelationships(): void {
this.relationshipMap = extractTwoWayRelationships(this.config);
}
public async generateSchemas(options: {
format?: "zod" | "json" | "pydantic" | "both" | "all";
verbose?: boolean;
outputDir?: string;
} = {}): Promise<void> {
const { format = "both", verbose = false, outputDir } = options;
if (!this.config.collections) {
return;
}
// Create schemas directory using config setting
const configuredDir = outputDir || this.config.schemaConfig?.outputDirectory || "schemas";
let schemasPath: string;
if (path.isAbsolute(configuredDir)) {
schemasPath = configuredDir;
} else if (configuredDir === "schemas") {
schemasPath = resolveSchemaDir(this.appwriteFolderPath);
} else {
schemasPath = path.join(this.appwriteFolderPath, configuredDir);
}
if (!fs.existsSync(schemasPath)) {
fs.mkdirSync(schemasPath, { recursive: true });
}
// Generate Zod schemas (TypeScript)
if (format === "zod" || format === "both" || format === "all") {
this.config.collections.forEach((collection) => {
const schemaString = this.createSchemaStringV4(
collection.name,
collection.attributes || []
);
const camelCaseName = toCamelCase(collection.name);
const schemaPath = path.join(schemasPath, `${camelCaseName}.ts`);
fs.writeFileSync(schemaPath, schemaString, { encoding: "utf-8" });
if (verbose) {
MessageFormatter.success(`Zod schema written to ${schemaPath}`, { prefix: "Schema" });
}
});
}
// Generate JSON schemas (all at once)
if (format === "json" || format === "both" || format === "all") {
const jsonSchemaGenerator = new JsonSchemaGenerator(this.config, this.appwriteFolderPath);
jsonSchemaGenerator.generateJsonSchemas({
outputFormat: format === "json" ? "json" : "both",
outputDirectory: configuredDir,
verbose: verbose
});
}
// Generate Python Pydantic models
if (format === "pydantic" || format === "all") {
const mod = await import("./pydanticModelGenerator.js");
const pgen = new mod.PydanticModelGenerator(this.config, this.appwriteFolderPath);
pgen.generatePydanticModels({ baseOutputDirectory: schemasPath, verbose });
}
if (verbose) {
MessageFormatter.success(`Schema generation completed (format: ${format})`, { prefix: "Schema" });
}
}
// Zod v4 recursive getter-based schemas
createSchemaStringV4 = (name: string, attributes: Attribute[]): string => {
const pascalName = toPascalCase(name);
let imports = `import { z } from "zod";\n`;
// Use the relationshipMap to find related collections
const relationshipDetails = this.relationshipMap.get(name) || [];
let relatedCollections = relationshipDetails
.filter((detail, index, self) => {
const uniqueKey = `${detail.parentCollection}-${detail.childCollection}-${detail.parentKey}-${detail.childKey}`;
return (
index ===
self.findIndex(
(obj) =>
`${obj.parentCollection}-${obj.childCollection}-${obj.parentKey}-${obj.childKey}` ===
uniqueKey
)
);
})
.map((detail) => {
const relatedCollectionName = detail.isChild
? detail.parentCollection
: detail.childCollection;
const key = detail.isChild ? detail.childKey : detail.parentKey;
const isArray = detail.isArray ? "array" : "";
return [relatedCollectionName, key, isArray];
});
// Include one-way relationship attributes directly (no twoWayKey)
const oneWayRels: Array<[string, string, string]> = [];
for (const attr of attributes) {
if (attr.type === "relationship" && attr.relatedCollection) {
const relatedName = this.resolveCollectionName(attr.relatedCollection);
const isArray =
attr.relationType === "oneToMany" || attr.relationType === "manyToMany"
? "array"
: "";
oneWayRels.push([relatedName, attr.key, isArray]);
}
}
// Merge and dedupe (by relatedName+key)
relatedCollections = [...relatedCollections, ...oneWayRels].filter(
(item, idx, self) =>
idx === self.findIndex((o) => `${o[0]}::${o[1]}` === `${item[0]}::${item[1]}`)
);
const hasRelationships = relatedCollections.length > 0;
// Build imports for related collections
if (hasRelationships) {
const importLines = relatedCollections.map((rel) => {
const relatedPascalName = toPascalCase(rel[0]);
const relatedCamelName = toCamelCase(rel[0]);
return `import { ${relatedPascalName}Schema } from "./${relatedCamelName}";`;
});
const unique = Array.from(new Set(importLines));
imports += unique.join("\n") + (unique.length ? "\n" : "");
}
let schemaString = `${imports}\n`;
// Single object schema with recursive getters (Zod v4)
schemaString += `export const ${pascalName}Schema = z.object({\n`;
schemaString += ` $id: z.string(),\n`;
schemaString += ` $createdAt: z.string(),\n`;
schemaString += ` $updatedAt: z.string(),\n`;
schemaString += ` $permissions: z.array(z.string()),\n`;
for (const attribute of attributes) {
if (attribute.type === "relationship") continue;
schemaString += ` ${attribute.key}: ${this.typeToZod(attribute)},\n`;
}
// Add recursive getters for relationships (respect required flag)
relatedCollections.forEach((rel) => {
const relatedPascalName = toPascalCase(rel[0]);
const isArray = rel[2] === "array";
const key = String(rel[1]);
const attrMeta = attributes.find(a => a.key === key && a.type === "relationship");
const isRequired = !!attrMeta?.required;
let getterBody = "";
if (isArray) {
getterBody = isRequired
? `${relatedPascalName}Schema.array()`
: `${relatedPascalName}Schema.array().nullish()`;
} else {
getterBody = isRequired
? `${relatedPascalName}Schema`
: `${relatedPascalName}Schema.nullish()`;
}
schemaString += ` get ${key}(){\n return ${getterBody}\n },\n`;
});
schemaString += `});\n\n`;
schemaString += `export type ${pascalName} = z.infer<typeof ${pascalName}Schema>;\n\n`;
return schemaString;
};
typeToZod = (attribute: Attribute) => {
let baseSchemaCode = "";
const finalAttribute: Attribute = (
attribute.type === "string" &&
attribute.format &&
attribute.format === "enum" &&
attribute.type === "string"
? { ...attribute, type: attribute.format }
: attribute
) as Attribute;
switch (finalAttribute.type) {
case "string":
baseSchemaCode = "z.string()";
if (finalAttribute.size) {
baseSchemaCode += `.max(${finalAttribute.size}, "Maximum length of ${finalAttribute.size} characters exceeded")`;
}
if (finalAttribute.xdefault !== undefined) {
baseSchemaCode += `.default("${finalAttribute.xdefault}")`;
}
if (!attribute.required && !attribute.array) {
baseSchemaCode += ".nullish()";
}
break;
case "integer":
baseSchemaCode = "z.number().int()";
if (finalAttribute.min !== undefined) {
if (BigInt(finalAttribute.min) === BigInt(-9223372036854776000)) {
finalAttribute.min = undefined;
} else {
baseSchemaCode += `.min(${finalAttribute.min}, "Minimum value of ${finalAttribute.min} not met")`;
}
}
if (finalAttribute.max !== undefined) {
if (BigInt(finalAttribute.max) === BigInt(9223372036854776000)) {
finalAttribute.max = undefined;
} else {
baseSchemaCode += `.max(${finalAttribute.max}, "Maximum value of ${finalAttribute.max} exceeded")`;
}
}
if (finalAttribute.xdefault !== undefined) {
baseSchemaCode += `.default(${finalAttribute.xdefault})`;
}
if (!finalAttribute.required && !finalAttribute.array) {
baseSchemaCode += ".nullish()";
}
break;
case "double":
case "float": // Backward compatibility
baseSchemaCode = "z.number()";
if (finalAttribute.min !== undefined) {
baseSchemaCode += `.min(${finalAttribute.min}, "Minimum value of ${finalAttribute.min} not met")`;
}
if (finalAttribute.max !== undefined) {
baseSchemaCode += `.max(${finalAttribute.max}, "Maximum value of ${finalAttribute.max} exceeded")`;
}
if (finalAttribute.xdefault !== undefined) {
baseSchemaCode += `.default(${finalAttribute.xdefault})`;
}
if (!finalAttribute.required && !finalAttribute.array) {
baseSchemaCode += ".nullish()";
}
break;
case "boolean":
baseSchemaCode = "z.boolean()";
if (finalAttribute.xdefault !== undefined) {
baseSchemaCode += `.default(${finalAttribute.xdefault})`;
}
if (!finalAttribute.required && !finalAttribute.array) {
baseSchemaCode += ".nullish()";
}
break;
case "datetime":
baseSchemaCode = "z.date()";
if (finalAttribute.xdefault !== undefined) {
baseSchemaCode += `.default(new Date("${finalAttribute.xdefault}"))`;
}
if (!finalAttribute.required && !finalAttribute.array) {
baseSchemaCode += ".nullish()";
}
break;
case "email":
baseSchemaCode = "z.string().email()";
if (finalAttribute.xdefault !== undefined) {
baseSchemaCode += `.default("${finalAttribute.xdefault}")`;
}
if (!finalAttribute.required && !finalAttribute.array) {
baseSchemaCode += ".nullish()";
}
break;
case "ip":
baseSchemaCode = "z.string()"; // Add custom validation as needed
if (finalAttribute.xdefault !== undefined) {
baseSchemaCode += `.default("${finalAttribute.xdefault}")`;
}
if (!finalAttribute.required && !finalAttribute.array) {
baseSchemaCode += ".nullish()";
}
break;
case "url":
baseSchemaCode = "z.string().url()";
if (finalAttribute.xdefault !== undefined) {
baseSchemaCode += `.default("${finalAttribute.xdefault}")`;
}
if (!finalAttribute.required && !finalAttribute.array) {
baseSchemaCode += ".nullish()";
}
break;
case "enum":
baseSchemaCode = `z.enum([${finalAttribute.elements
.map((element) => `"${element}"`)
.join(", ")}])`;
if (finalAttribute.xdefault !== undefined) {
baseSchemaCode += `.default("${finalAttribute.xdefault}")`;
}
if (!attribute.required && !attribute.array) {
baseSchemaCode += ".nullish()";
}
break;
case "relationship":
break;
default:
baseSchemaCode = "z.any()";
}
// Handle arrays
if (attribute.array) {
baseSchemaCode = `z.array(${baseSchemaCode})`;
}
if (attribute.array && !attribute.required) {
baseSchemaCode += ".nullish()";
}
return baseSchemaCode;
};
}