From adab5ed262a17db377a87d1c02bb503748e72f23 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 16 Jan 2026 17:02:58 +0530 Subject: [PATCH 01/24] feat: appwrite generate --- templates/cli/cli.ts.twig | 2 + templates/cli/lib/commands/db.ts | 392 +++++++++++++++++++++---------- 2 files changed, 271 insertions(+), 123 deletions(-) diff --git a/templates/cli/cli.ts.twig b/templates/cli/cli.ts.twig index 204224fea1..f14289ddc9 100644 --- a/templates/cli/cli.ts.twig +++ b/templates/cli/cli.ts.twig @@ -23,6 +23,7 @@ import { pull } from './lib/commands/pull.js'; import { run } from './lib/commands/run.js'; import { push, deploy } from './lib/commands/push.js'; import { update } from './lib/commands/update.js'; +import { generate } from './lib/commands/db.js'; {% else %} import { migrate } from './lib/commands/generic.js'; {% endif %} @@ -122,6 +123,7 @@ if (process.argv.includes('-v') || process.argv.includes('--version')) { .addCommand(deploy) .addCommand(run) .addCommand(update) + .addCommand(generate) .addCommand(logout) {% endif %} {% for service in spec.services %} diff --git a/templates/cli/lib/commands/db.ts b/templates/cli/lib/commands/db.ts index 1149bee400..dc325540f5 100644 --- a/templates/cli/lib/commands/db.ts +++ b/templates/cli/lib/commands/db.ts @@ -2,10 +2,9 @@ import { ConfigType, AttributeSchema } from "./config.js"; import * as fs from "fs"; import * as path from "path"; import { z } from "zod"; - -export interface GenerateOptions { - strict?: boolean; -} +import { Command } from "commander"; +import { localConfig } from "../config.js"; +import { success, error, log, actionRunner } from "../parser.js"; export interface GenerateResult { dbContent: string; @@ -77,12 +76,6 @@ export class Db { .replace(/^(.)/, (char) => char.toUpperCase()); } - private toCamelCase(str: string): string { - return str - .replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : "")) - .replace(/^(.)/, (char) => char.toLowerCase()); - } - private toUpperSnakeCase(str: string): string { return str .replace(/([a-z])([A-Z])/g, "$1_$2") @@ -90,21 +83,23 @@ export class Db { .toUpperCase(); } - private generateCollectionType( - collection: NonNullable[number], - collections: NonNullable, - options: GenerateOptions = {}, + private generateTableType( + entity: NonNullable[number] | NonNullable[number], + entities: NonNullable | NonNullable, ): string { - if (!collection.attributes) { + // Handle both tables (columns) and collections (attributes) + const fields = "columns" in entity + ? (entity as NonNullable[number]).columns + : (entity as NonNullable[number]).attributes; + + if (!fields) { return ""; } - const { strict = false } = options; - const typeName = this.toPascalCase(collection.name); - const attributes = collection.attributes + const typeName = this.toPascalCase(entity.name); + const attributes = fields .map((attr: z.infer) => { - const key = strict ? this.toCamelCase(attr.key) : attr.key; - return ` ${key}: ${this.getType(attr, collections)};`; + return ` ${attr.key}: ${this.getType(attr, entities as any)};`; }) .join("\n"); @@ -112,20 +107,24 @@ export class Db { } private generateEnums( - collections: NonNullable, + entities: NonNullable | NonNullable, ): string { const enumTypes: string[] = []; - for (const collection of collections) { - if (!collection.attributes) continue; - - for (const attribute of collection.attributes) { - if (attribute.format === "enum" && attribute.elements) { - const enumName = this.toPascalCase(attribute.key); - const enumValues = attribute.elements - .map((element, index) => { + for (const entity of entities) { + // Handle both tables (columns) and collections (attributes) + const fields = "columns" in entity + ? (entity as NonNullable[number]).columns + : (entity as NonNullable[number]).attributes; + if (!fields) continue; + + for (const field of fields) { + if (field.format === "enum" && field.elements) { + const enumName = this.toPascalCase(field.key); + const enumValues = field.elements + .map((element: string, index: number) => { const key = this.toUpperSnakeCase(element); - const isLast = index === attribute.elements!.length - 1; + const isLast = index === field.elements!.length - 1; return ` ${key} = "${element}"${isLast ? "" : ","}`; }) .join("\n"); @@ -138,23 +137,54 @@ export class Db { return enumTypes.join("\n\n"); } - private generateTypesFile( - config: ConfigType, - options: GenerateOptions = {}, - ): string { - if (!config.collections || config.collections.length === 0) { - return "// No collections found in configuration\n"; + private generateTypesFile(config: ConfigType): string { + // Use tables if available, fall back to collections + const entities = config.tables?.length ? config.tables : config.collections; + + if (!entities || entities.length === 0) { + return "// No tables or collections found in configuration\n"; } const appwriteDep = this.getAppwriteDependency(); - const enums = this.generateEnums(config.collections); - const types = config.collections - .map((collection) => - this.generateCollectionType(collection, config.collections!, options), - ) + const enums = this.generateEnums(entities); + const types = entities + .map((entity) => this.generateTableType(entity, entities)) .join("\n\n"); - const parts = [`import { type Models } from '${appwriteDep}';`, ""]; + // Group entities by databaseId for DatabaseTables type + const entitiesByDb = new Map>(); + for (const entity of entities) { + const dbId = entity.databaseId; + if (!entitiesByDb.has(dbId)) { + entitiesByDb.set(dbId, []); + } + entitiesByDb.get(dbId)!.push(entity); + } + + // Generate DatabaseId type + const dbIds = Array.from(entitiesByDb.keys()); + const dbIdType = dbIds.map((id) => `'${id}'`).join(" | "); + + // Generate DatabaseTables type + const dbReturnTypes = Array.from(entitiesByDb.entries()) + .map(([dbId, dbEntities]) => { + const tableTypes = dbEntities + .map((entity) => { + const typeName = this.toPascalCase(entity.name); + return ` ${entity.name}: { + create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: Permission[] }) => Promise<${typeName}>; + get: (id: string) => Promise<${typeName}>; + update: (id: string, data: Partial>, options?: { permissions?: Permission[] }) => Promise<${typeName}>; + delete: (id: string) => Promise; + list: (queries?: string[]) => Promise<{ total: number; rows: ${typeName}[] }>; + }`; + }) + .join(";\n"); + return ` '${dbId}': {\n${tableTypes}\n }`; + }) + .join(";\n"); + + const parts = [`import { type Models, Permission } from '${appwriteDep}';`, ""]; if (enums) { parts.push(enums); @@ -164,6 +194,12 @@ export class Db { parts.push(types); parts.push(""); + // Add database types + parts.push(`export type DatabaseId = ${dbIdType};`); + parts.push(""); + parts.push(`export type DatabaseTables = {\n${dbReturnTypes}\n};`); + parts.push(""); + return parts.join("\n"); } @@ -192,76 +228,84 @@ export class Db { return "appwrite"; } - private generateDbFile( - config: ConfigType, - options: GenerateOptions = {}, - ): string { - const { strict = false } = options; - const typesFileName = "appwrite.types.ts"; + private generateDbFile(config: ConfigType): string { + // Use tables if available, fall back to collections + const entities = config.tables?.length ? config.tables : config.collections; + + if (!entities || entities.length === 0) { + return "// No tables or collections found in configuration\n"; + } - if (!config.collections || config.collections.length === 0) { - return "// No collections found in configuration\n"; + // Group entities by databaseId + const entitiesByDb = new Map>(); + for (const entity of entities) { + const dbId = entity.databaseId; + if (!entitiesByDb.has(dbId)) { + entitiesByDb.set(dbId, []); + } + entitiesByDb.get(dbId)!.push(entity); } - const typeNames = config.collections.map((c) => this.toPascalCase(c.name)); - const importPath = typesFileName - .replace(/\.d\.ts$/, "") - .replace(/\.ts$/, ""); + const typeNames = entities.map((e) => this.toPascalCase(e.name)); const appwriteDep = this.getAppwriteDependency(); - const collectionsCode = config.collections - .map((collection) => { - const collectionName = strict - ? this.toCamelCase(collection.name) - : collection.name; - const typeName = this.toPascalCase(collection.name); - - return ` ${collectionName}: { - create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: string[] }) => - tablesDB.createRow<${typeName}>({ - databaseId: process.env.APPWRITE_DB_ID!, - tableId: '${collection.$id}', - rowId: options?.rowId ?? ID.unique(), - data, - permissions: [ - Permission.write(Role.user(data.createdBy)), - Permission.read(Role.user(data.createdBy)), - Permission.update(Role.user(data.createdBy)), - Permission.delete(Role.user(data.createdBy)) - ] - }), - get: (id: string) => - tablesDB.getRow<${typeName}>({ - databaseId: process.env.APPWRITE_DB_ID!, - tableId: '${collection.$id}', - rowId: id, - }), - update: (id: string, data: Partial>, options?: { permissions?: string[] }) => - tablesDB.updateRow<${typeName}>({ - databaseId: process.env.APPWRITE_DB_ID!, - tableId: '${collection.$id}', - rowId: id, - data, - ...(options?.permissions ? { permissions: options.permissions } : {}), - }), - delete: (id: string) => - tablesDB.deleteRow({ - databaseId: process.env.APPWRITE_DB_ID!, - tableId: '${collection.$id}', - rowId: id, - }), - list: (queries?: string[]) => - tablesDB.listRows<${typeName}>({ - databaseId: process.env.APPWRITE_DB_ID!, - tableId: '${collection.$id}', - queries, - }), - }`; + // Generate table helpers for each database + const generateTableHelpers = (dbId: string, dbEntities: Array<(typeof entities)[number]>) => { + return dbEntities + .map((entity) => { + const entityName = entity.name; + const typeName = this.toPascalCase(entity.name); + + return ` ${entityName}: { + create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: Permission[] }) => + tablesDB.createRow<${typeName}>({ + databaseId: '${dbId}', + tableId: '${entity.$id}', + rowId: options?.rowId ?? ID.unique(), + data, + permissions: options?.permissions?.map((p) => p.toString()), + }), + get: (id: string) => + tablesDB.getRow<${typeName}>({ + databaseId: '${dbId}', + tableId: '${entity.$id}', + rowId: id, + }), + update: (id: string, data: Partial>, options?: { permissions?: Permission[] }) => + tablesDB.updateRow<${typeName}>({ + databaseId: '${dbId}', + tableId: '${entity.$id}', + rowId: id, + data, + ...(options?.permissions ? { permissions: options.permissions.map((p) => p.toString()) } : {}), + }), + delete: async (id: string) => { + await tablesDB.deleteRow({ + databaseId: '${dbId}', + tableId: '${entity.$id}', + rowId: id, + }); + }, + list: (queries?: string[]) => + tablesDB.listRows<${typeName}>({ + databaseId: '${dbId}', + tableId: '${entity.$id}', + queries, + }), + }`; + }) + .join(",\n"); + }; + + // Generate the database map + const databasesMap = Array.from(entitiesByDb.entries()) + .map(([dbId, dbEntities]) => { + return ` '${dbId}': {\n${generateTableHelpers(dbId, dbEntities)}\n }`; }) .join(",\n"); - return `import { Client, TablesDB, ID, type Models, Permission, Role } from '${appwriteDep}'; -import type { ${typeNames.join(", ")} } from './${importPath}'; + return `import { Client, TablesDB, ID, type Models, Permission } from '${appwriteDep}'; +import type { ${typeNames.join(", ")}, DatabaseId, DatabaseTables } from './types.js'; const client = new Client() .setEndpoint(process.env.APPWRITE_ENDPOINT!) @@ -270,9 +314,12 @@ const client = new Client() const tablesDB = new TablesDB(client); +const _databases: { [K in DatabaseId]: DatabaseTables[K] } = { +${databasesMap} +}; -export const db = { -${collectionsCode} +export const databases = { + from: (databaseId: T): DatabaseTables[T] => _databases[databaseId], }; `; } @@ -282,43 +329,142 @@ ${collectionsCode} * * This method returns generated content as strings: * 1. A types string containing TypeScript interfaces for each collection. - * 2. A database client string with helper methods for CRUD operations on each collection. + * 2. A database client string with helper methods for CRUD operations on each table/collection. * - * @param config - The Appwrite project configuration, including collections and project details. - * @param options - Optional settings for code generation: - * - strict: Whether to use strict naming conventions for collections (default: false). + * @param config - The Appwrite project configuration, including tables/collections and project details. * @returns A Promise that resolves with an object containing dbContent and typesContent strings. - * @throws If the configuration is missing a projectId or contains no collections. + * @throws If the configuration is missing a projectId or contains no tables/collections. */ - public async generate( - config: ConfigType, - options: GenerateOptions = {}, - ): Promise { - const { strict = false } = options; - + public async generate(config: ConfigType): Promise { if (!config.projectId) { throw new Error("Project ID is required in configuration"); } - if (!config.collections || config.collections.length === 0) { + // Use tables if available, fall back to collections + const hasEntities = + (config.tables && config.tables.length > 0) || + (config.collections && config.collections.length > 0); + + if (!hasEntities) { console.log( - "No collections found in configuration. Skipping database generation.", + "No tables or collections found in configuration. Skipping database generation.", ); return { - dbContent: "// No collections found in configuration\n", - typesContent: "// No collections found in configuration\n", + dbContent: "// No tables or collections found in configuration\n", + typesContent: "// No tables or collections found in configuration\n", }; } // Generate types content - const typesContent = this.generateTypesFile(config, { strict }); + const typesContent = this.generateTypesFile(config); // Generate database client content - const dbContent = this.generateDbFile(config, { strict }); + const dbContent = this.generateDbFile(config); return { dbContent, typesContent, }; } + + /** + * Writes generated files to the specified output directory. + * + * @param outputDir - The directory to write the generated files to. + * @param result - The generated content from the generate method. + */ + public async writeFiles( + outputDir: string, + result: GenerateResult, + ): Promise { + // Create appwrite subdirectory + const appwriteDir = path.join(outputDir, "appwrite"); + if (!fs.existsSync(appwriteDir)) { + fs.mkdirSync(appwriteDir, { recursive: true }); + } + + // Write db.ts + const dbFilePath = path.join(appwriteDir, "db.ts"); + fs.writeFileSync(dbFilePath, result.dbContent, "utf-8"); + + // Write types.ts + const typesFilePath = path.join(appwriteDir, "types.ts"); + fs.writeFileSync(typesFilePath, result.typesContent, "utf-8"); + + // Write index.ts (main entry point) + const mainContent = this.generateMainEntryFile(); + const mainFilePath = path.join(appwriteDir, "index.ts"); + fs.writeFileSync(mainFilePath, mainContent, "utf-8"); + } + + /** + * Generates the main entry file that exports the db module. + */ + private generateMainEntryFile(): string { + return `/** + * Appwrite Generated SDK + * + * This file is auto-generated. Do not edit manually. + * Re-run \`appwrite generate\` to regenerate. + */ + +export { databases } from "./db.js"; +export * from "./types.js"; +`; + } } + +export interface GenerateCommandOptions { + output: string; +} + +const generateAction = async (options: GenerateCommandOptions): Promise => { + const db = new Db(); + const project = localConfig.getProject(); + + if (!project.projectId) { + error("No project found. Please run 'appwrite init project' first."); + process.exit(1); + } + + const config: ConfigType = { + projectId: project.projectId, + projectName: project.projectName, + tablesDB: localConfig.getTablesDBs(), + tables: localConfig.getTables(), + databases: localConfig.getDatabases(), + collections: localConfig.getCollections(), + }; + + const outputDir = options.output; + const absoluteOutputDir = path.isAbsolute(outputDir) + ? outputDir + : path.join(process.cwd(), outputDir); + + log(`Generating type-safe SDK to ${absoluteOutputDir}...`); + + try { + const result = await db.generate(config); + await db.writeFiles(absoluteOutputDir, result); + + success(`Generated files:`); + console.log(` - ${path.join(outputDir, "appwrite.ts")}`); + console.log(` - ${path.join(outputDir, "appwrite.db.ts")}`); + console.log(` - ${path.join(outputDir, "appwrite.types.ts")}`); + console.log(""); + log(`Import the generated SDK in your project:`); + console.log(` import { databases } from "./${outputDir}/appwrite.js";`); + console.log(""); + log(`Usage:`); + console.log(` const db = databases.from('your-database-id');`); + console.log(` await db.tableName.create({ ... });`); + } catch (err: any) { + error(`Failed to generate SDK: ${err.message}`); + process.exit(1); + } +}; + +export const generate = new Command("generate") + .description("Generate a type-safe SDK from your Appwrite project configuration") + .option("-o, --output ", "Output directory for generated files (default: generated)", "generated") + .action(actionRunner(generateAction)); From bd46e40bca5c4899fe440454a54c5f0b0d086e7b Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 17 Jan 2026 12:58:27 +0530 Subject: [PATCH 02/24] stuff --- templates/cli/lib/commands/db.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/templates/cli/lib/commands/db.ts b/templates/cli/lib/commands/db.ts index dc325540f5..b7828b80ad 100644 --- a/templates/cli/lib/commands/db.ts +++ b/templates/cli/lib/commands/db.ts @@ -307,19 +307,16 @@ export class Db { return `import { Client, TablesDB, ID, type Models, Permission } from '${appwriteDep}'; import type { ${typeNames.join(", ")}, DatabaseId, DatabaseTables } from './types.js'; -const client = new Client() - .setEndpoint(process.env.APPWRITE_ENDPOINT!) - .setProject(process.env.APPWRITE_PROJECT_ID!) - .setKey(process.env.APPWRITE_API_KEY!); +export const createDatabases = (client: Client) => { + const tablesDB = new TablesDB(client); -const tablesDB = new TablesDB(client); - -const _databases: { [K in DatabaseId]: DatabaseTables[K] } = { + const _databases: { [K in DatabaseId]: DatabaseTables[K] } = { ${databasesMap} -}; + }; -export const databases = { - from: (databaseId: T): DatabaseTables[T] => _databases[databaseId], + return { + from: (databaseId: T): DatabaseTables[T] => _databases[databaseId], + }; }; `; } @@ -408,7 +405,7 @@ export const databases = { * Re-run \`appwrite generate\` to regenerate. */ -export { databases } from "./db.js"; +export { createDatabases } from "./db.js"; export * from "./types.js"; `; } @@ -448,14 +445,17 @@ const generateAction = async (options: GenerateCommandOptions): Promise => await db.writeFiles(absoluteOutputDir, result); success(`Generated files:`); - console.log(` - ${path.join(outputDir, "appwrite.ts")}`); - console.log(` - ${path.join(outputDir, "appwrite.db.ts")}`); - console.log(` - ${path.join(outputDir, "appwrite.types.ts")}`); + console.log(` - ${path.join(outputDir, "appwrite/index.ts")}`); + console.log(` - ${path.join(outputDir, "appwrite/db.ts")}`); + console.log(` - ${path.join(outputDir, "appwrite/types.ts")}`); console.log(""); log(`Import the generated SDK in your project:`); - console.log(` import { databases } from "./${outputDir}/appwrite.js";`); + console.log(` import { createDatabases } from "./${outputDir}/appwrite/index.js";`); console.log(""); log(`Usage:`); + console.log(` import { Client } from 'node-appwrite';`); + console.log(` const client = new Client().setEndpoint('...').setProject('...').setKey('...');`); + console.log(` const databases = createDatabases(client);`); console.log(` const db = databases.from('your-database-id');`); console.log(` await db.tableName.create({ ... });`); } catch (err: any) { From c6311860dd860db2462153d3cdaf3d1e4e0cdadc Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 17 Jan 2026 13:09:44 +0530 Subject: [PATCH 03/24] stuff --- templates/cli/lib/commands/db.ts | 62 +++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/templates/cli/lib/commands/db.ts b/templates/cli/lib/commands/db.ts index b7828b80ad..505e6af97f 100644 --- a/templates/cli/lib/commands/db.ts +++ b/templates/cli/lib/commands/db.ts @@ -176,7 +176,7 @@ export class Db { get: (id: string) => Promise<${typeName}>; update: (id: string, data: Partial>, options?: { permissions?: Permission[] }) => Promise<${typeName}>; delete: (id: string) => Promise; - list: (queries?: string[]) => Promise<{ total: number; rows: ${typeName}[] }>; + list: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[] }) => Promise<{ total: number; rows: ${typeName}[] }>; }`; }) .join(";\n"); @@ -194,6 +194,33 @@ export class Db { parts.push(types); parts.push(""); + // Add QueryBuilder type + parts.push(`export type QueryBuilder = { + equal: (field: K, value: T[K]) => string; + notEqual: (field: K, value: T[K]) => string; + lessThan: (field: K, value: T[K]) => string; + lessThanEqual: (field: K, value: T[K]) => string; + greaterThan: (field: K, value: T[K]) => string; + greaterThanEqual: (field: K, value: T[K]) => string; + contains: (field: K, value: T[K] extends (infer U)[] ? U : T[K]) => string; + search: (field: K, value: string) => string; + isNull: (field: K) => string; + isNotNull: (field: K) => string; + startsWith: (field: K, value: string) => string; + endsWith: (field: K, value: string) => string; + between: (field: K, start: T[K], end: T[K]) => string; + select: (fields: K[]) => string; + orderAsc: (field: K) => string; + orderDesc: (field: K) => string; + limit: (value: number) => string; + offset: (value: number) => string; + cursorAfter: (documentId: string) => string; + cursorBefore: (documentId: string) => string; + or: (...queries: string[]) => string; + and: (...queries: string[]) => string; +};`); + parts.push(""); + // Add database types parts.push(`export type DatabaseId = ${dbIdType};`); parts.push(""); @@ -286,11 +313,11 @@ export class Db { rowId: id, }); }, - list: (queries?: string[]) => + list: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[] }) => tablesDB.listRows<${typeName}>({ databaseId: '${dbId}', tableId: '${entity.$id}', - queries, + queries: options?.queries?.(createQueryBuilder<${typeName}>()), }), }`; }) @@ -304,8 +331,33 @@ export class Db { }) .join(",\n"); - return `import { Client, TablesDB, ID, type Models, Permission } from '${appwriteDep}'; -import type { ${typeNames.join(", ")}, DatabaseId, DatabaseTables } from './types.js'; + return `import { Client, TablesDB, ID, Query, type Models, Permission } from '${appwriteDep}'; +import type { ${typeNames.join(", ")}, DatabaseId, DatabaseTables, QueryBuilder } from './types.js'; + +const createQueryBuilder = (): QueryBuilder => ({ + equal: (field, value) => Query.equal(String(field), value as any), + notEqual: (field, value) => Query.notEqual(String(field), value as any), + lessThan: (field, value) => Query.lessThan(String(field), value as any), + lessThanEqual: (field, value) => Query.lessThanEqual(String(field), value as any), + greaterThan: (field, value) => Query.greaterThan(String(field), value as any), + greaterThanEqual: (field, value) => Query.greaterThanEqual(String(field), value as any), + contains: (field, value) => Query.contains(String(field), value as any), + search: (field, value) => Query.search(String(field), value), + isNull: (field) => Query.isNull(String(field)), + isNotNull: (field) => Query.isNotNull(String(field)), + startsWith: (field, value) => Query.startsWith(String(field), value), + endsWith: (field, value) => Query.endsWith(String(field), value), + between: (field, start, end) => Query.between(String(field), start as any, end as any), + select: (fields) => Query.select(fields.map(String)), + orderAsc: (field) => Query.orderAsc(String(field)), + orderDesc: (field) => Query.orderDesc(String(field)), + limit: (value) => Query.limit(value), + offset: (value) => Query.offset(value), + cursorAfter: (documentId) => Query.cursorAfter(documentId), + cursorBefore: (documentId) => Query.cursorBefore(documentId), + or: (...queries) => Query.or(queries), + and: (...queries) => Query.and(queries), +}); export const createDatabases = (client: Client) => { const tablesDB = new TablesDB(client); From 7b3ab91ff55ff32aa2df90b987010c321a6c6e83 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 17 Jan 2026 13:17:01 +0530 Subject: [PATCH 04/24] rename and refactor, add transactionId support --- src/SDK/Language/CLI.php | 9 +- templates/cli/cli.ts.twig | 2 +- templates/cli/lib/commands/generate.ts | 64 +++ .../{db.ts => generators/databases.ts} | 465 +++++++----------- templates/cli/lib/commands/schema.ts | 6 +- templates/cli/lib/utils.ts | 13 + 6 files changed, 260 insertions(+), 299 deletions(-) create mode 100644 templates/cli/lib/commands/generate.ts rename templates/cli/lib/commands/{db.ts => generators/databases.ts} (56%) diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index b5fff8f004..5d387416aa 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -343,8 +343,13 @@ public function getFiles(): array ], [ 'scope' => 'copy', - 'destination' => 'lib/commands/db.ts', - 'template' => 'cli/lib/commands/db.ts', + 'destination' => 'lib/commands/generate.ts', + 'template' => 'cli/lib/commands/generate.ts', + ], + [ + 'scope' => 'copy', + 'destination' => 'lib/commands/generators/databases.ts', + 'template' => 'cli/lib/commands/generators/databases.ts', ], [ 'scope' => 'copy', diff --git a/templates/cli/cli.ts.twig b/templates/cli/cli.ts.twig index f14289ddc9..4592d41917 100644 --- a/templates/cli/cli.ts.twig +++ b/templates/cli/cli.ts.twig @@ -23,7 +23,7 @@ import { pull } from './lib/commands/pull.js'; import { run } from './lib/commands/run.js'; import { push, deploy } from './lib/commands/push.js'; import { update } from './lib/commands/update.js'; -import { generate } from './lib/commands/db.js'; +import { generate } from './lib/commands/generate.js'; {% else %} import { migrate } from './lib/commands/generic.js'; {% endif %} diff --git a/templates/cli/lib/commands/generate.ts b/templates/cli/lib/commands/generate.ts new file mode 100644 index 0000000000..3dc2fd8ef6 --- /dev/null +++ b/templates/cli/lib/commands/generate.ts @@ -0,0 +1,64 @@ +import * as path from "path"; +import { Command } from "commander"; +import { ConfigType } from "./config.js"; +import { localConfig } from "../config.js"; +import { success, error, log, actionRunner } from "../parser.js"; +import { DatabasesGenerator } from "./generators/databases.js"; + +export interface GenerateCommandOptions { + output: string; +} + +const generateAction = async (options: GenerateCommandOptions): Promise => { + const generator = new DatabasesGenerator(); + const project = localConfig.getProject(); + + if (!project.projectId) { + error("No project found. Please run 'appwrite init project' first."); + process.exit(1); + } + + const config: ConfigType = { + projectId: project.projectId, + projectName: project.projectName, + tablesDB: localConfig.getTablesDBs(), + tables: localConfig.getTables(), + databases: localConfig.getDatabases(), + collections: localConfig.getCollections(), + }; + + const outputDir = options.output; + const absoluteOutputDir = path.isAbsolute(outputDir) + ? outputDir + : path.join(process.cwd(), outputDir); + + log(`Generating type-safe SDK to ${absoluteOutputDir}...`); + + try { + const result = await generator.generate(config); + await generator.writeFiles(absoluteOutputDir, result); + + success(`Generated files:`); + console.log(` - ${path.join(outputDir, "appwrite/index.ts")}`); + console.log(` - ${path.join(outputDir, "appwrite/databases.ts")}`); + console.log(` - ${path.join(outputDir, "appwrite/types.ts")}`); + console.log(""); + log(`Import the generated SDK in your project:`); + console.log(` import { createDatabases } from "./${outputDir}/appwrite/index.js";`); + console.log(""); + log(`Usage:`); + console.log(` import { Client } from 'node-appwrite';`); + console.log(` const client = new Client().setEndpoint('...').setProject('...').setKey('...');`); + console.log(` const databases = createDatabases(client);`); + console.log(` const db = databases.from('your-database-id');`); + console.log(` await db.tableName.create({ ... });`); + } catch (err: any) { + error(`Failed to generate SDK: ${err.message}`); + process.exit(1); + } +}; + +export const generate = new Command("generate") + .description("Generate a type-safe SDK from your Appwrite project configuration") + .option("-o, --output ", "Output directory for generated files (default: generated)", "generated") + .action(actionRunner(generateAction)); diff --git a/templates/cli/lib/commands/db.ts b/templates/cli/lib/commands/generators/databases.ts similarity index 56% rename from templates/cli/lib/commands/db.ts rename to templates/cli/lib/commands/generators/databases.ts index 505e6af97f..465f7f3512 100644 --- a/templates/cli/lib/commands/db.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -1,17 +1,19 @@ -import { ConfigType, AttributeSchema } from "./config.js"; import * as fs from "fs"; import * as path from "path"; import { z } from "zod"; -import { Command } from "commander"; -import { localConfig } from "../config.js"; -import { success, error, log, actionRunner } from "../parser.js"; +import { ConfigType, AttributeSchema } from "../config.js"; +import { toPascalCase, toUpperSnakeCase } from "../../utils.js"; export interface GenerateResult { - dbContent: string; + databasesContent: string; typesContent: string; + indexContent: string; } -export class Db { +type Entity = NonNullable[number] | NonNullable[number]; +type Entities = NonNullable | NonNullable; + +export class DatabasesGenerator { private getType( attribute: z.infer, collections: NonNullable, @@ -23,12 +25,10 @@ export class Db { case "datetime": type = "string"; if (attribute.format === "enum") { - type = this.toPascalCase(attribute.key); + type = toPascalCase(attribute.key); } break; case "integer": - type = "number"; - break; case "double": type = "number"; break; @@ -44,7 +44,7 @@ export class Db { `Related collection with ID '${attribute.relatedCollection}' not found.`, ); } - type = this.toPascalCase(relatedCollection.name); + type = toPascalCase(relatedCollection.name); if ( (attribute.relationType === "oneToMany" && attribute.side === "parent") || @@ -70,60 +70,37 @@ export class Db { return type; } - private toPascalCase(str: string): string { - return str - .replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : "")) - .replace(/^(.)/, (char) => char.toUpperCase()); - } - - private toUpperSnakeCase(str: string): string { - return str - .replace(/([a-z])([A-Z])/g, "$1_$2") - .replace(/[-\s]+/g, "_") - .toUpperCase(); - } - - private generateTableType( - entity: NonNullable[number] | NonNullable[number], - entities: NonNullable | NonNullable, - ): string { - // Handle both tables (columns) and collections (attributes) - const fields = "columns" in entity + private getFields(entity: Entity): z.infer[] | undefined { + return "columns" in entity ? (entity as NonNullable[number]).columns : (entity as NonNullable[number]).attributes; + } - if (!fields) { - return ""; - } + private generateTableType(entity: Entity, entities: Entities): string { + const fields = this.getFields(entity); + if (!fields) return ""; - const typeName = this.toPascalCase(entity.name); + const typeName = toPascalCase(entity.name); const attributes = fields - .map((attr: z.infer) => { - return ` ${attr.key}: ${this.getType(attr, entities as any)};`; - }) + .map((attr) => ` ${attr.key}: ${this.getType(attr, entities as any)};`) .join("\n"); return `export type ${typeName} = Models.Row & {\n${attributes}\n}`; } - private generateEnums( - entities: NonNullable | NonNullable, - ): string { + private generateEnums(entities: Entities): string { const enumTypes: string[] = []; for (const entity of entities) { - // Handle both tables (columns) and collections (attributes) - const fields = "columns" in entity - ? (entity as NonNullable[number]).columns - : (entity as NonNullable[number]).attributes; + const fields = this.getFields(entity); if (!fields) continue; for (const field of fields) { if (field.format === "enum" && field.elements) { - const enumName = this.toPascalCase(field.key); + const enumName = toPascalCase(field.key); const enumValues = field.elements .map((element: string, index: number) => { - const key = this.toUpperSnakeCase(element); + const key = toUpperSnakeCase(element); const isLast = index === field.elements!.length - 1; return ` ${key} = "${element}"${isLast ? "" : ","}`; }) @@ -137,22 +114,8 @@ export class Db { return enumTypes.join("\n\n"); } - private generateTypesFile(config: ConfigType): string { - // Use tables if available, fall back to collections - const entities = config.tables?.length ? config.tables : config.collections; - - if (!entities || entities.length === 0) { - return "// No tables or collections found in configuration\n"; - } - - const appwriteDep = this.getAppwriteDependency(); - const enums = this.generateEnums(entities); - const types = entities - .map((entity) => this.generateTableType(entity, entities)) - .join("\n\n"); - - // Group entities by databaseId for DatabaseTables type - const entitiesByDb = new Map>(); + private groupEntitiesByDb(entities: Entities): Map { + const entitiesByDb = new Map(); for (const entity of entities) { const dbId = entity.databaseId; if (!entitiesByDb.has(dbId)) { @@ -160,42 +123,36 @@ export class Db { } entitiesByDb.get(dbId)!.push(entity); } + return entitiesByDb; + } - // Generate DatabaseId type - const dbIds = Array.from(entitiesByDb.keys()); - const dbIdType = dbIds.map((id) => `'${id}'`).join(" | "); - - // Generate DatabaseTables type - const dbReturnTypes = Array.from(entitiesByDb.entries()) - .map(([dbId, dbEntities]) => { - const tableTypes = dbEntities - .map((entity) => { - const typeName = this.toPascalCase(entity.name); - return ` ${entity.name}: { - create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: Permission[] }) => Promise<${typeName}>; - get: (id: string) => Promise<${typeName}>; - update: (id: string, data: Partial>, options?: { permissions?: Permission[] }) => Promise<${typeName}>; - delete: (id: string) => Promise; - list: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[] }) => Promise<{ total: number; rows: ${typeName}[] }>; - }`; - }) - .join(";\n"); - return ` '${dbId}': {\n${tableTypes}\n }`; - }) - .join(";\n"); + private getAppwriteDependency(): string { + const cwd = process.cwd(); - const parts = [`import { type Models, Permission } from '${appwriteDep}';`, ""]; + if (fs.existsSync(path.resolve(cwd, "package.json"))) { + try { + const packageJsonRaw = fs.readFileSync( + path.resolve(cwd, "package.json"), + "utf-8", + ); + const packageJson = JSON.parse(packageJsonRaw); + return packageJson.dependencies?.["appwrite"] + ? "appwrite" + : "node-appwrite"; + } catch { + // Fallback if package.json is invalid + } + } - if (enums) { - parts.push(enums); - parts.push(""); + if (fs.existsSync(path.resolve(cwd, "deno.json"))) { + return "https://deno.land/x/appwrite/mod.ts"; } - parts.push(types); - parts.push(""); + return "appwrite"; + } - // Add QueryBuilder type - parts.push(`export type QueryBuilder = { + private generateQueryBuilderType(): string { + return `export type QueryBuilder = { equal: (field: K, value: T[K]) => string; notEqual: (field: K, value: T[K]) => string; lessThan: (field: K, value: T[K]) => string; @@ -218,79 +175,108 @@ export class Db { cursorBefore: (documentId: string) => string; or: (...queries: string[]) => string; and: (...queries: string[]) => string; -};`); - parts.push(""); - - // Add database types - parts.push(`export type DatabaseId = ${dbIdType};`); - parts.push(""); - parts.push(`export type DatabaseTables = {\n${dbReturnTypes}\n};`); - parts.push(""); - - return parts.join("\n"); +}`; } - private getAppwriteDependency(): string { - const cwd = process.cwd(); - - if (fs.existsSync(path.resolve(cwd, "package.json"))) { - try { - const packageJsonRaw = fs.readFileSync( - path.resolve(cwd, "package.json"), - "utf-8", - ); - const packageJson = JSON.parse(packageJsonRaw); - return packageJson.dependencies?.["appwrite"] - ? "appwrite" - : "node-appwrite"; - } catch { - // Fallback if package.json is invalid - } - } - - if (fs.existsSync(path.resolve(cwd, "deno.json"))) { - return "https://deno.land/x/appwrite/mod.ts"; - } + private generateDatabaseTablesType(entitiesByDb: Map): string { + const dbReturnTypes = Array.from(entitiesByDb.entries()) + .map(([dbId, dbEntities]) => { + const tableTypes = dbEntities + .map((entity) => { + const typeName = toPascalCase(entity.name); + return ` ${entity.name}: { + create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: Permission[]; transactionId?: string }) => Promise<${typeName}>; + get: (id: string) => Promise<${typeName}>; + update: (id: string, data: Partial>, options?: { permissions?: Permission[]; transactionId?: string }) => Promise<${typeName}>; + delete: (id: string, options?: { transactionId?: string }) => Promise; + list: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[] }) => Promise<{ total: number; rows: ${typeName}[] }>; + }`; + }) + .join(";\n"); + return ` '${dbId}': {\n${tableTypes}\n }`; + }) + .join(";\n"); - return "appwrite"; + return `export type DatabaseTables = {\n${dbReturnTypes}\n}`; } - private generateDbFile(config: ConfigType): string { - // Use tables if available, fall back to collections + generateTypesFile(config: ConfigType): string { const entities = config.tables?.length ? config.tables : config.collections; if (!entities || entities.length === 0) { return "// No tables or collections found in configuration\n"; } - // Group entities by databaseId - const entitiesByDb = new Map>(); - for (const entity of entities) { - const dbId = entity.databaseId; - if (!entitiesByDb.has(dbId)) { - entitiesByDb.set(dbId, []); - } - entitiesByDb.get(dbId)!.push(entity); + const appwriteDep = this.getAppwriteDependency(); + const enums = this.generateEnums(entities); + const types = entities + .map((entity) => this.generateTableType(entity, entities)) + .join("\n\n"); + const entitiesByDb = this.groupEntitiesByDb(entities); + const dbIds = Array.from(entitiesByDb.keys()); + const dbIdType = dbIds.map((id) => `'${id}'`).join(" | "); + + const parts = [`import { type Models, Permission } from '${appwriteDep}';`, ""]; + + if (enums) { + parts.push(enums); + parts.push(""); } - const typeNames = entities.map((e) => this.toPascalCase(e.name)); - const appwriteDep = this.getAppwriteDependency(); + parts.push(types); + parts.push(""); + parts.push(this.generateQueryBuilderType()); + parts.push(""); + parts.push(`export type DatabaseId = ${dbIdType};`); + parts.push(""); + parts.push(this.generateDatabaseTablesType(entitiesByDb)); + parts.push(""); + + return parts.join("\n"); + } + + private generateQueryBuilder(): string { + return `const createQueryBuilder = (): QueryBuilder => ({ + equal: (field, value) => Query.equal(String(field), value as any), + notEqual: (field, value) => Query.notEqual(String(field), value as any), + lessThan: (field, value) => Query.lessThan(String(field), value as any), + lessThanEqual: (field, value) => Query.lessThanEqual(String(field), value as any), + greaterThan: (field, value) => Query.greaterThan(String(field), value as any), + greaterThanEqual: (field, value) => Query.greaterThanEqual(String(field), value as any), + contains: (field, value) => Query.contains(String(field), value as any), + search: (field, value) => Query.search(String(field), value), + isNull: (field) => Query.isNull(String(field)), + isNotNull: (field) => Query.isNotNull(String(field)), + startsWith: (field, value) => Query.startsWith(String(field), value), + endsWith: (field, value) => Query.endsWith(String(field), value), + between: (field, start, end) => Query.between(String(field), start as any, end as any), + select: (fields) => Query.select(fields.map(String)), + orderAsc: (field) => Query.orderAsc(String(field)), + orderDesc: (field) => Query.orderDesc(String(field)), + limit: (value) => Query.limit(value), + offset: (value) => Query.offset(value), + cursorAfter: (documentId) => Query.cursorAfter(documentId), + cursorBefore: (documentId) => Query.cursorBefore(documentId), + or: (...queries) => Query.or(queries), + and: (...queries) => Query.and(queries), +})`; + } - // Generate table helpers for each database - const generateTableHelpers = (dbId: string, dbEntities: Array<(typeof entities)[number]>) => { - return dbEntities - .map((entity) => { - const entityName = entity.name; - const typeName = this.toPascalCase(entity.name); + private generateTableHelpers(dbId: string, dbEntities: Entity[]): string { + return dbEntities + .map((entity) => { + const entityName = entity.name; + const typeName = toPascalCase(entity.name); - return ` ${entityName}: { - create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: Permission[] }) => + return ` ${entityName}: { + create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: Permission[]; transactionId?: string }) => tablesDB.createRow<${typeName}>({ databaseId: '${dbId}', tableId: '${entity.$id}', rowId: options?.rowId ?? ID.unique(), data, permissions: options?.permissions?.map((p) => p.toString()), + transactionId: options?.transactionId, }), get: (id: string) => tablesDB.getRow<${typeName}>({ @@ -298,19 +284,21 @@ export class Db { tableId: '${entity.$id}', rowId: id, }), - update: (id: string, data: Partial>, options?: { permissions?: Permission[] }) => + update: (id: string, data: Partial>, options?: { permissions?: Permission[]; transactionId?: string }) => tablesDB.updateRow<${typeName}>({ databaseId: '${dbId}', tableId: '${entity.$id}', rowId: id, data, ...(options?.permissions ? { permissions: options.permissions.map((p) => p.toString()) } : {}), + transactionId: options?.transactionId, }), - delete: async (id: string) => { + delete: async (id: string, options?: { transactionId?: string }) => { await tablesDB.deleteRow({ databaseId: '${dbId}', tableId: '${entity.$id}', rowId: id, + transactionId: options?.transactionId, }); }, list: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[] }) => @@ -320,44 +308,31 @@ export class Db { queries: options?.queries?.(createQueryBuilder<${typeName}>()), }), }`; - }) - .join(",\n"); - }; + }) + .join(",\n"); + } + + generateDatabasesFile(config: ConfigType): string { + const entities = config.tables?.length ? config.tables : config.collections; + + if (!entities || entities.length === 0) { + return "// No tables or collections found in configuration\n"; + } + + const entitiesByDb = this.groupEntitiesByDb(entities); + const typeNames = entities.map((e) => toPascalCase(e.name)); + const appwriteDep = this.getAppwriteDependency(); - // Generate the database map const databasesMap = Array.from(entitiesByDb.entries()) .map(([dbId, dbEntities]) => { - return ` '${dbId}': {\n${generateTableHelpers(dbId, dbEntities)}\n }`; + return ` '${dbId}': {\n${this.generateTableHelpers(dbId, dbEntities)}\n }`; }) .join(",\n"); return `import { Client, TablesDB, ID, Query, type Models, Permission } from '${appwriteDep}'; import type { ${typeNames.join(", ")}, DatabaseId, DatabaseTables, QueryBuilder } from './types.js'; -const createQueryBuilder = (): QueryBuilder => ({ - equal: (field, value) => Query.equal(String(field), value as any), - notEqual: (field, value) => Query.notEqual(String(field), value as any), - lessThan: (field, value) => Query.lessThan(String(field), value as any), - lessThanEqual: (field, value) => Query.lessThanEqual(String(field), value as any), - greaterThan: (field, value) => Query.greaterThan(String(field), value as any), - greaterThanEqual: (field, value) => Query.greaterThanEqual(String(field), value as any), - contains: (field, value) => Query.contains(String(field), value as any), - search: (field, value) => Query.search(String(field), value), - isNull: (field) => Query.isNull(String(field)), - isNotNull: (field) => Query.isNotNull(String(field)), - startsWith: (field, value) => Query.startsWith(String(field), value), - endsWith: (field, value) => Query.endsWith(String(field), value), - between: (field, start, end) => Query.between(String(field), start as any, end as any), - select: (fields) => Query.select(fields.map(String)), - orderAsc: (field) => Query.orderAsc(String(field)), - orderDesc: (field) => Query.orderDesc(String(field)), - limit: (value) => Query.limit(value), - offset: (value) => Query.offset(value), - cursorAfter: (documentId) => Query.cursorAfter(documentId), - cursorBefore: (documentId) => Query.cursorBefore(documentId), - or: (...queries) => Query.or(queries), - and: (...queries) => Query.and(queries), -}); +${this.generateQueryBuilder()}; export const createDatabases = (client: Client) => { const tablesDB = new TablesDB(client); @@ -373,23 +348,24 @@ ${databasesMap} `; } - /** - * Generates TypeScript code for Appwrite database collections and types based on the provided configuration. - * - * This method returns generated content as strings: - * 1. A types string containing TypeScript interfaces for each collection. - * 2. A database client string with helper methods for CRUD operations on each table/collection. - * - * @param config - The Appwrite project configuration, including tables/collections and project details. - * @returns A Promise that resolves with an object containing dbContent and typesContent strings. - * @throws If the configuration is missing a projectId or contains no tables/collections. - */ - public async generate(config: ConfigType): Promise { + generateIndexFile(): string { + return `/** + * Appwrite Generated SDK + * + * This file is auto-generated. Do not edit manually. + * Re-run \`appwrite generate\` to regenerate. + */ + +export { createDatabases } from "./databases.js"; +export * from "./types.js"; +`; + } + + async generate(config: ConfigType): Promise { if (!config.projectId) { throw new Error("Project ID is required in configuration"); } - // Use tables if available, fall back to collections const hasEntities = (config.tables && config.tables.length > 0) || (config.collections && config.collections.length > 0); @@ -399,124 +375,27 @@ ${databasesMap} "No tables or collections found in configuration. Skipping database generation.", ); return { - dbContent: "// No tables or collections found in configuration\n", + databasesContent: "// No tables or collections found in configuration\n", typesContent: "// No tables or collections found in configuration\n", + indexContent: this.generateIndexFile(), }; } - // Generate types content - const typesContent = this.generateTypesFile(config); - - // Generate database client content - const dbContent = this.generateDbFile(config); - return { - dbContent, - typesContent, + typesContent: this.generateTypesFile(config), + databasesContent: this.generateDatabasesFile(config), + indexContent: this.generateIndexFile(), }; } - /** - * Writes generated files to the specified output directory. - * - * @param outputDir - The directory to write the generated files to. - * @param result - The generated content from the generate method. - */ - public async writeFiles( - outputDir: string, - result: GenerateResult, - ): Promise { - // Create appwrite subdirectory + async writeFiles(outputDir: string, result: GenerateResult): Promise { const appwriteDir = path.join(outputDir, "appwrite"); if (!fs.existsSync(appwriteDir)) { fs.mkdirSync(appwriteDir, { recursive: true }); } - // Write db.ts - const dbFilePath = path.join(appwriteDir, "db.ts"); - fs.writeFileSync(dbFilePath, result.dbContent, "utf-8"); - - // Write types.ts - const typesFilePath = path.join(appwriteDir, "types.ts"); - fs.writeFileSync(typesFilePath, result.typesContent, "utf-8"); - - // Write index.ts (main entry point) - const mainContent = this.generateMainEntryFile(); - const mainFilePath = path.join(appwriteDir, "index.ts"); - fs.writeFileSync(mainFilePath, mainContent, "utf-8"); - } - - /** - * Generates the main entry file that exports the db module. - */ - private generateMainEntryFile(): string { - return `/** - * Appwrite Generated SDK - * - * This file is auto-generated. Do not edit manually. - * Re-run \`appwrite generate\` to regenerate. - */ - -export { createDatabases } from "./db.js"; -export * from "./types.js"; -`; + fs.writeFileSync(path.join(appwriteDir, "databases.ts"), result.databasesContent, "utf-8"); + fs.writeFileSync(path.join(appwriteDir, "types.ts"), result.typesContent, "utf-8"); + fs.writeFileSync(path.join(appwriteDir, "index.ts"), result.indexContent, "utf-8"); } } - -export interface GenerateCommandOptions { - output: string; -} - -const generateAction = async (options: GenerateCommandOptions): Promise => { - const db = new Db(); - const project = localConfig.getProject(); - - if (!project.projectId) { - error("No project found. Please run 'appwrite init project' first."); - process.exit(1); - } - - const config: ConfigType = { - projectId: project.projectId, - projectName: project.projectName, - tablesDB: localConfig.getTablesDBs(), - tables: localConfig.getTables(), - databases: localConfig.getDatabases(), - collections: localConfig.getCollections(), - }; - - const outputDir = options.output; - const absoluteOutputDir = path.isAbsolute(outputDir) - ? outputDir - : path.join(process.cwd(), outputDir); - - log(`Generating type-safe SDK to ${absoluteOutputDir}...`); - - try { - const result = await db.generate(config); - await db.writeFiles(absoluteOutputDir, result); - - success(`Generated files:`); - console.log(` - ${path.join(outputDir, "appwrite/index.ts")}`); - console.log(` - ${path.join(outputDir, "appwrite/db.ts")}`); - console.log(` - ${path.join(outputDir, "appwrite/types.ts")}`); - console.log(""); - log(`Import the generated SDK in your project:`); - console.log(` import { createDatabases } from "./${outputDir}/appwrite/index.js";`); - console.log(""); - log(`Usage:`); - console.log(` import { Client } from 'node-appwrite';`); - console.log(` const client = new Client().setEndpoint('...').setProject('...').setKey('...');`); - console.log(` const databases = createDatabases(client);`); - console.log(` const db = databases.from('your-database-id');`); - console.log(` await db.tableName.create({ ... });`); - } catch (err: any) { - error(`Failed to generate SDK: ${err.message}`); - process.exit(1); - } -}; - -export const generate = new Command("generate") - .description("Generate a type-safe SDK from your Appwrite project configuration") - .option("-o, --output ", "Output directory for generated files (default: generated)", "generated") - .action(actionRunner(generateAction)); diff --git a/templates/cli/lib/commands/schema.ts b/templates/cli/lib/commands/schema.ts index a76436df4d..09b8ab59eb 100644 --- a/templates/cli/lib/commands/schema.ts +++ b/templates/cli/lib/commands/schema.ts @@ -7,7 +7,7 @@ import { parseWithBetterErrors } from "./utils/error-formatter.js"; import JSONbig from "json-bigint"; import * as fs from "fs"; import * as path from "path"; -import { Db } from "./db.js"; +import { DatabasesGenerator } from "./generators/databases.js"; const JSONBig = JSONbig({ useNativeBigInt: true }); @@ -17,7 +17,7 @@ export class Schema { private pullCommandSilent: Pull; - public db: Db; + public databasesGenerator: DatabasesGenerator; constructor({ projectClient, @@ -31,7 +31,7 @@ export class Schema { this.pullCommandSilent = new Pull(projectClient, consoleClient, true); - this.db = new Db(); + this.databasesGenerator = new DatabasesGenerator(); } /** diff --git a/templates/cli/lib/utils.ts b/templates/cli/lib/utils.ts index 0b08d67312..41abba9e09 100644 --- a/templates/cli/lib/utils.ts +++ b/templates/cli/lib/utils.ts @@ -391,3 +391,16 @@ export function isCloud(): boolean { const hostname = new URL(endpoint).hostname; return hostname.endsWith("appwrite.io"); } + +export function toPascalCase(str: string): string { + return str + .replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : "")) + .replace(/^(.)/, (char) => char.toUpperCase()); +} + +export function toUpperSnakeCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/[-\s]+/g, "_") + .toUpperCase(); +} From 236f92bf917be7d6418ab2099553de38772474bd Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 17 Jan 2026 13:26:20 +0530 Subject: [PATCH 05/24] fix relationships --- templates/cli/lib/commands/config.ts | 1 + templates/cli/lib/commands/generators/databases.ts | 12 +++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/templates/cli/lib/commands/config.ts b/templates/cli/lib/commands/config.ts index 3671e2c1ec..aebee9e23e 100644 --- a/templates/cli/lib/commands/config.ts +++ b/templates/cli/lib/commands/config.ts @@ -234,6 +234,7 @@ const AttributeSchemaBase = z .optional(), elements: z.array(z.string()).optional(), relatedCollection: z.string().optional(), + relatedTable: z.string().optional(), relationType: z.string().optional(), twoWay: z.boolean().optional(), twoWayKey: z.string().optional(), diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index 465f7f3512..2d0a862c57 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -36,15 +36,17 @@ export class DatabasesGenerator { type = "boolean"; break; case "relationship": - const relatedCollection = collections.find( - (c) => c.$id === attribute.relatedCollection, + // Handle both collections (relatedCollection) and tables (relatedTable) + const relatedId = attribute.relatedCollection ?? attribute.relatedTable; + const relatedEntity = collections.find( + (c) => c.$id === relatedId || c.name === relatedId, ); - if (!relatedCollection) { + if (!relatedEntity) { throw new Error( - `Related collection with ID '${attribute.relatedCollection}' not found.`, + `Related entity with ID '${relatedId}' not found.`, ); } - type = toPascalCase(relatedCollection.name); + type = toPascalCase(relatedEntity.name); if ( (attribute.relationType === "oneToMany" && attribute.side === "parent") || From 5cb47394e0e028547550f528602018cb141b6d22 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 16:43:48 +0530 Subject: [PATCH 06/24] bulk methods support --- .../cli/lib/commands/generators/databases.ts | 62 +++++++++++++++---- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index 2d0a862c57..8ea89d2180 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -180,19 +180,25 @@ export class DatabasesGenerator { }`; } - private generateDatabaseTablesType(entitiesByDb: Map): string { + private generateDatabaseTablesType(entitiesByDb: Map, appwriteDep: string): string { + const isNodeAppwrite = appwriteDep === 'node-appwrite'; const dbReturnTypes = Array.from(entitiesByDb.entries()) .map(([dbId, dbEntities]) => { const tableTypes = dbEntities .map((entity) => { const typeName = toPascalCase(entity.name); - return ` ${entity.name}: { - create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: Permission[]; transactionId?: string }) => Promise<${typeName}>; + const baseMethods = ` create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: Permission[]; transactionId?: string }) => Promise<${typeName}>; get: (id: string) => Promise<${typeName}>; update: (id: string, data: Partial>, options?: { permissions?: Permission[]; transactionId?: string }) => Promise<${typeName}>; delete: (id: string, options?: { transactionId?: string }) => Promise; - list: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[] }) => Promise<{ total: number; rows: ${typeName}[] }>; - }`; + list: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[] }) => Promise<{ total: number; rows: ${typeName}[] }>;`; + + const bulkMethods = isNodeAppwrite ? ` + createMany: (rows: Array<{ data: Omit<${typeName}, keyof Models.Row>; rowId?: string; permissions?: Permission[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; + updateMany: (rows: Array<{ rowId: string; data: Partial>; permissions?: Permission[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; + deleteMany: (rowIds: string[], options?: { transactionId?: string }) => Promise;` : ''; + + return ` ${entity.name}: {\n${baseMethods}${bulkMethods}\n }`; }) .join(";\n"); return ` '${dbId}': {\n${tableTypes}\n }`; @@ -231,7 +237,7 @@ export class DatabasesGenerator { parts.push(""); parts.push(`export type DatabaseId = ${dbIdType};`); parts.push(""); - parts.push(this.generateDatabaseTablesType(entitiesByDb)); + parts.push(this.generateDatabaseTablesType(entitiesByDb, appwriteDep)); parts.push(""); return parts.join("\n"); @@ -264,14 +270,15 @@ export class DatabasesGenerator { })`; } - private generateTableHelpers(dbId: string, dbEntities: Entity[]): string { + private generateTableHelpers(dbId: string, dbEntities: Entity[], appwriteDep: string): string { + const isNodeAppwrite = appwriteDep === 'node-appwrite'; + return dbEntities .map((entity) => { const entityName = entity.name; const typeName = toPascalCase(entity.name); - return ` ${entityName}: { - create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: Permission[]; transactionId?: string }) => + const baseMethods = ` create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: Permission[]; transactionId?: string }) => tablesDB.createRow<${typeName}>({ databaseId: '${dbId}', tableId: '${entity.$id}', @@ -308,8 +315,41 @@ export class DatabasesGenerator { databaseId: '${dbId}', tableId: '${entity.$id}', queries: options?.queries?.(createQueryBuilder<${typeName}>()), + }),`; + + const bulkMethods = isNodeAppwrite ? ` + createMany: (rows: Array<{ data: Omit<${typeName}, keyof Models.Row>; rowId?: string; permissions?: Permission[] }>, options?: { transactionId?: string }) => + tablesDB.createRows<${typeName}>({ + databaseId: '${dbId}', + tableId: '${entity.$id}', + rows: rows.map((row) => ({ + rowId: row.rowId ?? ID.unique(), + data: row.data, + permissions: row.permissions?.map((p) => p.toString()), + })), + transactionId: options?.transactionId, + }), + updateMany: (rows: Array<{ rowId: string; data: Partial>; permissions?: Permission[] }>, options?: { transactionId?: string }) => + tablesDB.updateRows<${typeName}>({ + databaseId: '${dbId}', + tableId: '${entity.$id}', + rows: rows.map((row) => ({ + rowId: row.rowId, + data: row.data, + permissions: row.permissions?.map((p) => p.toString()), + })), + transactionId: options?.transactionId, }), - }`; + deleteMany: async (rowIds: string[], options?: { transactionId?: string }) => { + await tablesDB.deleteRows({ + databaseId: '${dbId}', + tableId: '${entity.$id}', + rows: rowIds.map((rowId) => ({ rowId })), + transactionId: options?.transactionId, + }); + },` : ''; + + return ` ${entityName}: {\n${baseMethods}${bulkMethods}\n }`; }) .join(",\n"); } @@ -327,7 +367,7 @@ export class DatabasesGenerator { const databasesMap = Array.from(entitiesByDb.entries()) .map(([dbId, dbEntities]) => { - return ` '${dbId}': {\n${this.generateTableHelpers(dbId, dbEntities)}\n }`; + return ` '${dbId}': {\n${this.generateTableHelpers(dbId, dbEntities, appwriteDep)}\n }`; }) .join(",\n"); From 553234c40e3ce25f02bedac96352cf2369bf6a47 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 16:45:48 +0530 Subject: [PATCH 07/24] make generation typesafe --- .../cli/lib/commands/generators/databases.ts | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index 8ea89d2180..e44ad9a001 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -154,20 +154,30 @@ export class DatabasesGenerator { } private generateQueryBuilderType(): string { - return `export type QueryBuilder = { - equal: (field: K, value: T[K]) => string; - notEqual: (field: K, value: T[K]) => string; - lessThan: (field: K, value: T[K]) => string; - lessThanEqual: (field: K, value: T[K]) => string; - greaterThan: (field: K, value: T[K]) => string; - greaterThanEqual: (field: K, value: T[K]) => string; - contains: (field: K, value: T[K] extends (infer U)[] ? U : T[K]) => string; - search: (field: K, value: string) => string; - isNull: (field: K) => string; - isNotNull: (field: K) => string; - startsWith: (field: K, value: string) => string; - endsWith: (field: K, value: string) => string; - between: (field: K, start: T[K], end: T[K]) => string; + return `export type QueryValue = string | number | boolean; + +export type ExtractQueryValue = T extends (infer U)[] + ? U extends QueryValue ? U : never + : T extends QueryValue | null ? NonNullable : never; + +export type QueryableKeys = { + [K in keyof T]: ExtractQueryValue extends never ? never : K; +}[keyof T]; + +export type QueryBuilder = { + equal: >(field: K, value: ExtractQueryValue) => string; + notEqual: >(field: K, value: ExtractQueryValue) => string; + lessThan: >(field: K, value: ExtractQueryValue) => string; + lessThanEqual: >(field: K, value: ExtractQueryValue) => string; + greaterThan: >(field: K, value: ExtractQueryValue) => string; + greaterThanEqual: >(field: K, value: ExtractQueryValue) => string; + contains: >(field: K, value: ExtractQueryValue) => string; + search: >(field: K, value: string) => string; + isNull: >(field: K) => string; + isNotNull: >(field: K) => string; + startsWith: >(field: K, value: string) => string; + endsWith: >(field: K, value: string) => string; + between: >(field: K, start: ExtractQueryValue, end: ExtractQueryValue) => string; select: (fields: K[]) => string; orderAsc: (field: K) => string; orderDesc: (field: K) => string; @@ -245,19 +255,19 @@ export class DatabasesGenerator { private generateQueryBuilder(): string { return `const createQueryBuilder = (): QueryBuilder => ({ - equal: (field, value) => Query.equal(String(field), value as any), - notEqual: (field, value) => Query.notEqual(String(field), value as any), - lessThan: (field, value) => Query.lessThan(String(field), value as any), - lessThanEqual: (field, value) => Query.lessThanEqual(String(field), value as any), - greaterThan: (field, value) => Query.greaterThan(String(field), value as any), - greaterThanEqual: (field, value) => Query.greaterThanEqual(String(field), value as any), - contains: (field, value) => Query.contains(String(field), value as any), + equal: (field, value) => Query.equal(String(field), value), + notEqual: (field, value) => Query.notEqual(String(field), value), + lessThan: (field, value) => Query.lessThan(String(field), value), + lessThanEqual: (field, value) => Query.lessThanEqual(String(field), value), + greaterThan: (field, value) => Query.greaterThan(String(field), value), + greaterThanEqual: (field, value) => Query.greaterThanEqual(String(field), value), + contains: (field, value) => Query.contains(String(field), value), search: (field, value) => Query.search(String(field), value), isNull: (field) => Query.isNull(String(field)), isNotNull: (field) => Query.isNotNull(String(field)), startsWith: (field, value) => Query.startsWith(String(field), value), endsWith: (field, value) => Query.endsWith(String(field), value), - between: (field, start, end) => Query.between(String(field), start as any, end as any), + between: (field, start, end) => Query.between(String(field), start, end), select: (fields) => Query.select(fields.map(String)), orderAsc: (field) => Query.orderAsc(String(field)), orderDesc: (field) => Query.orderDesc(String(field)), From b4f4649166545c72ab6cb9fcb97ccd67b5b1ca59 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 16:51:49 +0530 Subject: [PATCH 08/24] rn and console sdk support --- .../cli/lib/commands/generators/databases.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index e44ad9a001..82262baed0 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -138,9 +138,20 @@ export class DatabasesGenerator { "utf-8", ); const packageJson = JSON.parse(packageJsonRaw); - return packageJson.dependencies?.["appwrite"] - ? "appwrite" - : "node-appwrite"; + const deps = packageJson.dependencies ?? {}; + + if (deps["@appwrite.io/console"]) { + return "@appwrite.io/console"; + } + if (deps["react-native-appwrite"]) { + return "react-native-appwrite"; + } + if (deps["appwrite"]) { + return "appwrite"; + } + if (deps["node-appwrite"]) { + return "node-appwrite"; + } } catch { // Fallback if package.json is invalid } @@ -153,6 +164,10 @@ export class DatabasesGenerator { return "appwrite"; } + private supportsBulkMethods(appwriteDep: string): boolean { + return appwriteDep === "node-appwrite" || appwriteDep === "@appwrite.io/console"; + } + private generateQueryBuilderType(): string { return `export type QueryValue = string | number | boolean; @@ -191,7 +206,7 @@ export type QueryBuilder = { } private generateDatabaseTablesType(entitiesByDb: Map, appwriteDep: string): string { - const isNodeAppwrite = appwriteDep === 'node-appwrite'; + const hasBulkMethods = this.supportsBulkMethods(appwriteDep); const dbReturnTypes = Array.from(entitiesByDb.entries()) .map(([dbId, dbEntities]) => { const tableTypes = dbEntities @@ -203,7 +218,7 @@ export type QueryBuilder = { delete: (id: string, options?: { transactionId?: string }) => Promise; list: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[] }) => Promise<{ total: number; rows: ${typeName}[] }>;`; - const bulkMethods = isNodeAppwrite ? ` + const bulkMethods = hasBulkMethods ? ` createMany: (rows: Array<{ data: Omit<${typeName}, keyof Models.Row>; rowId?: string; permissions?: Permission[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; updateMany: (rows: Array<{ rowId: string; data: Partial>; permissions?: Permission[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; deleteMany: (rowIds: string[], options?: { transactionId?: string }) => Promise;` : ''; @@ -281,7 +296,7 @@ export type QueryBuilder = { } private generateTableHelpers(dbId: string, dbEntities: Entity[], appwriteDep: string): string { - const isNodeAppwrite = appwriteDep === 'node-appwrite'; + const hasBulkMethods = this.supportsBulkMethods(appwriteDep); return dbEntities .map((entity) => { @@ -327,7 +342,7 @@ export type QueryBuilder = { queries: options?.queries?.(createQueryBuilder<${typeName}>()), }),`; - const bulkMethods = isNodeAppwrite ? ` + const bulkMethods = hasBulkMethods ? ` createMany: (rows: Array<{ data: Omit<${typeName}, keyof Models.Row>; rowId?: string; permissions?: Permission[] }>, options?: { transactionId?: string }) => tablesDB.createRows<${typeName}>({ databaseId: '${dbId}', From 3c585f9d4a62d513c9fbf31387a65db668367b5f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 16:55:05 +0530 Subject: [PATCH 09/24] enum handling and optional handling --- templates/cli/lib/commands/generators/databases.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index 82262baed0..b2ba3ed565 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -17,6 +17,7 @@ export class DatabasesGenerator { private getType( attribute: z.infer, collections: NonNullable, + entityName: string, ): string { let type = ""; @@ -25,7 +26,7 @@ export class DatabasesGenerator { case "datetime": type = "string"; if (attribute.format === "enum") { - type = toPascalCase(attribute.key); + type = toPascalCase(entityName) + toPascalCase(attribute.key); } break; case "integer": @@ -84,7 +85,7 @@ export class DatabasesGenerator { const typeName = toPascalCase(entity.name); const attributes = fields - .map((attr) => ` ${attr.key}: ${this.getType(attr, entities as any)};`) + .map((attr) => ` ${attr.key}${attr.required ? '' : '?'}: ${this.getType(attr, entities as any, entity.name)};`) .join("\n"); return `export type ${typeName} = Models.Row & {\n${attributes}\n}`; @@ -99,7 +100,7 @@ export class DatabasesGenerator { for (const field of fields) { if (field.format === "enum" && field.elements) { - const enumName = toPascalCase(field.key); + const enumName = toPascalCase(entity.name) + toPascalCase(field.key); const enumValues = field.elements .map((element: string, index: number) => { const key = toUpperSnakeCase(element); From c9b474a2660cdf60b5f8a0ee5971398d4f2a5802 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 16:56:25 +0530 Subject: [PATCH 10/24] suggestions --- templates/cli/lib/commands/generators/databases.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index b2ba3ed565..58c5712744 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -36,7 +36,7 @@ export class DatabasesGenerator { case "boolean": type = "boolean"; break; - case "relationship": + case "relationship": { // Handle both collections (relatedCollection) and tables (relatedTable) const relatedId = attribute.relatedCollection ?? attribute.relatedTable; const relatedEntity = collections.find( @@ -58,6 +58,7 @@ export class DatabasesGenerator { type = `${type}[]`; } break; + } default: throw new Error(`Unknown attribute type: ${attribute.type}`); } @@ -224,7 +225,7 @@ export type QueryBuilder = { updateMany: (rows: Array<{ rowId: string; data: Partial>; permissions?: Permission[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; deleteMany: (rowIds: string[], options?: { transactionId?: string }) => Promise;` : ''; - return ` ${entity.name}: {\n${baseMethods}${bulkMethods}\n }`; + return ` '${entity.name}': {\n${baseMethods}${bulkMethods}\n }`; }) .join(";\n"); return ` '${dbId}': {\n${tableTypes}\n }`; @@ -375,7 +376,7 @@ export type QueryBuilder = { }); },` : ''; - return ` ${entityName}: {\n${baseMethods}${bulkMethods}\n }`; + return ` '${entityName}': {\n${baseMethods}${bulkMethods}\n }`; }) .join(",\n"); } From 2488d8019b9d094719f58f61742aaf25234c27cb Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 16:59:09 +0530 Subject: [PATCH 11/24] rename back to db --- templates/cli/lib/commands/schema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/cli/lib/commands/schema.ts b/templates/cli/lib/commands/schema.ts index 09b8ab59eb..4fd3ad16ea 100644 --- a/templates/cli/lib/commands/schema.ts +++ b/templates/cli/lib/commands/schema.ts @@ -17,7 +17,7 @@ export class Schema { private pullCommandSilent: Pull; - public databasesGenerator: DatabasesGenerator; + public db: DatabasesGenerator; constructor({ projectClient, @@ -31,7 +31,7 @@ export class Schema { this.pullCommandSilent = new Pull(projectClient, consoleClient, true); - this.databasesGenerator = new DatabasesGenerator(); + this.db = new DatabasesGenerator(); } /** From 08fdc17b815e081b65d88e88328f75c655bf36c4 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 17:00:34 +0530 Subject: [PATCH 12/24] use npm for deno --- templates/cli/lib/commands/generators/databases.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index 58c5712744..704350f7e4 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -160,14 +160,14 @@ export class DatabasesGenerator { } if (fs.existsSync(path.resolve(cwd, "deno.json"))) { - return "https://deno.land/x/appwrite/mod.ts"; + return "npm:node-appwrite"; } return "appwrite"; } private supportsBulkMethods(appwriteDep: string): boolean { - return appwriteDep === "node-appwrite" || appwriteDep === "@appwrite.io/console"; + return appwriteDep === "node-appwrite" || appwriteDep === "npm:node-appwrite" || appwriteDep === "@appwrite.io/console"; } private generateQueryBuilderType(): string { From e38aee36b0a0e8d12e5c06687844805638b2b744 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 17:02:47 +0530 Subject: [PATCH 13/24] sanitize invalid enum keys --- templates/cli/lib/commands/generate.ts | 22 +++-- .../cli/lib/commands/generators/databases.ts | 80 ++++++++++++++----- templates/cli/lib/utils.ts | 19 +++++ 3 files changed, 95 insertions(+), 26 deletions(-) diff --git a/templates/cli/lib/commands/generate.ts b/templates/cli/lib/commands/generate.ts index 3dc2fd8ef6..76a997a3bc 100644 --- a/templates/cli/lib/commands/generate.ts +++ b/templates/cli/lib/commands/generate.ts @@ -9,7 +9,9 @@ export interface GenerateCommandOptions { output: string; } -const generateAction = async (options: GenerateCommandOptions): Promise => { +const generateAction = async ( + options: GenerateCommandOptions, +): Promise => { const generator = new DatabasesGenerator(); const project = localConfig.getProject(); @@ -44,11 +46,15 @@ const generateAction = async (options: GenerateCommandOptions): Promise => console.log(` - ${path.join(outputDir, "appwrite/types.ts")}`); console.log(""); log(`Import the generated SDK in your project:`); - console.log(` import { createDatabases } from "./${outputDir}/appwrite/index.js";`); + console.log( + ` import { createDatabases } from "./${outputDir}/appwrite/index.js";`, + ); console.log(""); log(`Usage:`); console.log(` import { Client } from 'node-appwrite';`); - console.log(` const client = new Client().setEndpoint('...').setProject('...').setKey('...');`); + console.log( + ` const client = new Client().setEndpoint('...').setProject('...').setKey('...');`, + ); console.log(` const databases = createDatabases(client);`); console.log(` const db = databases.from('your-database-id');`); console.log(` await db.tableName.create({ ... });`); @@ -59,6 +65,12 @@ const generateAction = async (options: GenerateCommandOptions): Promise => }; export const generate = new Command("generate") - .description("Generate a type-safe SDK from your Appwrite project configuration") - .option("-o, --output ", "Output directory for generated files (default: generated)", "generated") + .description( + "Generate a type-safe SDK from your Appwrite project configuration", + ) + .option( + "-o, --output ", + "Output directory for generated files (default: generated)", + "generated", + ) .action(actionRunner(generateAction)); diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index 704350f7e4..1aafe4a4aa 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -2,7 +2,7 @@ import * as fs from "fs"; import * as path from "path"; import { z } from "zod"; import { ConfigType, AttributeSchema } from "../config.js"; -import { toPascalCase, toUpperSnakeCase } from "../../utils.js"; +import { toPascalCase, sanitizeEnumKey } from "../../utils.js"; export interface GenerateResult { databasesContent: string; @@ -10,8 +10,12 @@ export interface GenerateResult { indexContent: string; } -type Entity = NonNullable[number] | NonNullable[number]; -type Entities = NonNullable | NonNullable; +type Entity = + | NonNullable[number] + | NonNullable[number]; +type Entities = + | NonNullable + | NonNullable; export class DatabasesGenerator { private getType( @@ -43,9 +47,7 @@ export class DatabasesGenerator { (c) => c.$id === relatedId || c.name === relatedId, ); if (!relatedEntity) { - throw new Error( - `Related entity with ID '${relatedId}' not found.`, - ); + throw new Error(`Related entity with ID '${relatedId}' not found.`); } type = toPascalCase(relatedEntity.name); if ( @@ -74,7 +76,9 @@ export class DatabasesGenerator { return type; } - private getFields(entity: Entity): z.infer[] | undefined { + private getFields( + entity: Entity, + ): z.infer[] | undefined { return "columns" in entity ? (entity as NonNullable[number]).columns : (entity as NonNullable[number]).attributes; @@ -86,7 +90,10 @@ export class DatabasesGenerator { const typeName = toPascalCase(entity.name); const attributes = fields - .map((attr) => ` ${attr.key}${attr.required ? '' : '?'}: ${this.getType(attr, entities as any, entity.name)};`) + .map( + (attr) => + ` ${attr.key}${attr.required ? "" : "?"}: ${this.getType(attr, entities as any, entity.name)};`, + ) .join("\n"); return `export type ${typeName} = Models.Row & {\n${attributes}\n}`; @@ -104,7 +111,7 @@ export class DatabasesGenerator { const enumName = toPascalCase(entity.name) + toPascalCase(field.key); const enumValues = field.elements .map((element: string, index: number) => { - const key = toUpperSnakeCase(element); + const key = sanitizeEnumKey(element); const isLast = index === field.elements!.length - 1; return ` ${key} = "${element}"${isLast ? "" : ","}`; }) @@ -167,7 +174,11 @@ export class DatabasesGenerator { } private supportsBulkMethods(appwriteDep: string): boolean { - return appwriteDep === "node-appwrite" || appwriteDep === "npm:node-appwrite" || appwriteDep === "@appwrite.io/console"; + return ( + appwriteDep === "node-appwrite" || + appwriteDep === "npm:node-appwrite" || + appwriteDep === "@appwrite.io/console" + ); } private generateQueryBuilderType(): string { @@ -207,7 +218,10 @@ export type QueryBuilder = { }`; } - private generateDatabaseTablesType(entitiesByDb: Map, appwriteDep: string): string { + private generateDatabaseTablesType( + entitiesByDb: Map, + appwriteDep: string, + ): string { const hasBulkMethods = this.supportsBulkMethods(appwriteDep); const dbReturnTypes = Array.from(entitiesByDb.entries()) .map(([dbId, dbEntities]) => { @@ -220,10 +234,12 @@ export type QueryBuilder = { delete: (id: string, options?: { transactionId?: string }) => Promise; list: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[] }) => Promise<{ total: number; rows: ${typeName}[] }>;`; - const bulkMethods = hasBulkMethods ? ` + const bulkMethods = hasBulkMethods + ? ` createMany: (rows: Array<{ data: Omit<${typeName}, keyof Models.Row>; rowId?: string; permissions?: Permission[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; updateMany: (rows: Array<{ rowId: string; data: Partial>; permissions?: Permission[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; - deleteMany: (rowIds: string[], options?: { transactionId?: string }) => Promise;` : ''; + deleteMany: (rowIds: string[], options?: { transactionId?: string }) => Promise;` + : ""; return ` '${entity.name}': {\n${baseMethods}${bulkMethods}\n }`; }) @@ -251,7 +267,10 @@ export type QueryBuilder = { const dbIds = Array.from(entitiesByDb.keys()); const dbIdType = dbIds.map((id) => `'${id}'`).join(" | "); - const parts = [`import { type Models, Permission } from '${appwriteDep}';`, ""]; + const parts = [ + `import { type Models, Permission } from '${appwriteDep}';`, + "", + ]; if (enums) { parts.push(enums); @@ -297,7 +316,11 @@ export type QueryBuilder = { })`; } - private generateTableHelpers(dbId: string, dbEntities: Entity[], appwriteDep: string): string { + private generateTableHelpers( + dbId: string, + dbEntities: Entity[], + appwriteDep: string, + ): string { const hasBulkMethods = this.supportsBulkMethods(appwriteDep); return dbEntities @@ -344,7 +367,8 @@ export type QueryBuilder = { queries: options?.queries?.(createQueryBuilder<${typeName}>()), }),`; - const bulkMethods = hasBulkMethods ? ` + const bulkMethods = hasBulkMethods + ? ` createMany: (rows: Array<{ data: Omit<${typeName}, keyof Models.Row>; rowId?: string; permissions?: Permission[] }>, options?: { transactionId?: string }) => tablesDB.createRows<${typeName}>({ databaseId: '${dbId}', @@ -374,7 +398,8 @@ export type QueryBuilder = { rows: rowIds.map((rowId) => ({ rowId })), transactionId: options?.transactionId, }); - },` : ''; + },` + : ""; return ` '${entityName}': {\n${baseMethods}${bulkMethods}\n }`; }) @@ -444,7 +469,8 @@ export * from "./types.js"; "No tables or collections found in configuration. Skipping database generation.", ); return { - databasesContent: "// No tables or collections found in configuration\n", + databasesContent: + "// No tables or collections found in configuration\n", typesContent: "// No tables or collections found in configuration\n", indexContent: this.generateIndexFile(), }; @@ -463,8 +489,20 @@ export * from "./types.js"; fs.mkdirSync(appwriteDir, { recursive: true }); } - fs.writeFileSync(path.join(appwriteDir, "databases.ts"), result.databasesContent, "utf-8"); - fs.writeFileSync(path.join(appwriteDir, "types.ts"), result.typesContent, "utf-8"); - fs.writeFileSync(path.join(appwriteDir, "index.ts"), result.indexContent, "utf-8"); + fs.writeFileSync( + path.join(appwriteDir, "databases.ts"), + result.databasesContent, + "utf-8", + ); + fs.writeFileSync( + path.join(appwriteDir, "types.ts"), + result.typesContent, + "utf-8", + ); + fs.writeFileSync( + path.join(appwriteDir, "index.ts"), + result.indexContent, + "utf-8", + ); } } diff --git a/templates/cli/lib/utils.ts b/templates/cli/lib/utils.ts index 41abba9e09..249d3b1176 100644 --- a/templates/cli/lib/utils.ts +++ b/templates/cli/lib/utils.ts @@ -404,3 +404,22 @@ export function toUpperSnakeCase(str: string): string { .replace(/[-\s]+/g, "_") .toUpperCase(); } + +export function sanitizeEnumKey(key: string): string { + let sanitized = toUpperSnakeCase(key) + .replace(/[^A-Z0-9_]/gi, "_") // Replace non-alphanumeric with underscores + .replace(/_+/g, "_") // Collapse consecutive underscores + .replace(/^_+|_+$/g, ""); // Trim leading/trailing underscores + + // Prefix with underscore if starts with a digit + if (/^[0-9]/.test(sanitized)) { + sanitized = "_" + sanitized; + } + + // Fallback if empty after sanitization + if (!sanitized) { + sanitized = "_VALUE"; + } + + return sanitized; +} From 80274310202af75356ac444ad5b39f9ffa503814 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 17:40:56 +0530 Subject: [PATCH 14/24] dont generate bulk methods for tables with relationships --- .../cli/lib/commands/generators/databases.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index 1aafe4a4aa..0548de88af 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -84,6 +84,20 @@ export class DatabasesGenerator { : (entity as NonNullable[number]).attributes; } + /** + * Checks if an entity has relationship columns. + * Used to disable bulk methods for tables with relationships. + * + * TODO: Remove this restriction when bulk operations support relationships. + * To enable bulk methods for all tables, simply return false here: + * return false; + */ + private hasRelationshipColumns(entity: Entity): boolean { + const fields = this.getFields(entity); + if (!fields) return false; + return fields.some((field) => field.type === "relationship"); + } + private generateTableType(entity: Entity, entities: Entities): string { const fields = this.getFields(entity); if (!fields) return ""; @@ -222,7 +236,7 @@ export type QueryBuilder = { entitiesByDb: Map, appwriteDep: string, ): string { - const hasBulkMethods = this.supportsBulkMethods(appwriteDep); + const supportsBulk = this.supportsBulkMethods(appwriteDep); const dbReturnTypes = Array.from(entitiesByDb.entries()) .map(([dbId, dbEntities]) => { const tableTypes = dbEntities @@ -234,7 +248,9 @@ export type QueryBuilder = { delete: (id: string, options?: { transactionId?: string }) => Promise; list: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[] }) => Promise<{ total: number; rows: ${typeName}[] }>;`; - const bulkMethods = hasBulkMethods + // Bulk methods not supported for tables with relationship columns (see hasRelationshipColumns) + const canUseBulkMethods = supportsBulk && !this.hasRelationshipColumns(entity); + const bulkMethods = canUseBulkMethods ? ` createMany: (rows: Array<{ data: Omit<${typeName}, keyof Models.Row>; rowId?: string; permissions?: Permission[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; updateMany: (rows: Array<{ rowId: string; data: Partial>; permissions?: Permission[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; @@ -321,12 +337,14 @@ export type QueryBuilder = { dbEntities: Entity[], appwriteDep: string, ): string { - const hasBulkMethods = this.supportsBulkMethods(appwriteDep); + const supportsBulk = this.supportsBulkMethods(appwriteDep); return dbEntities .map((entity) => { const entityName = entity.name; const typeName = toPascalCase(entity.name); + // Bulk methods not supported for tables with relationship columns (see hasRelationshipColumns) + const canUseBulkMethods = supportsBulk && !this.hasRelationshipColumns(entity); const baseMethods = ` create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: Permission[]; transactionId?: string }) => tablesDB.createRow<${typeName}>({ @@ -367,7 +385,7 @@ export type QueryBuilder = { queries: options?.queries?.(createQueryBuilder<${typeName}>()), }),`; - const bulkMethods = hasBulkMethods + const bulkMethods = canUseBulkMethods ? ` createMany: (rows: Array<{ data: Omit<${typeName}, keyof Models.Row>; rowId?: string; permissions?: Permission[] }>, options?: { transactionId?: string }) => tablesDB.createRows<${typeName}>({ From 37177fadcf746ec9197d324dfa10c0e842807602 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 17:58:34 +0530 Subject: [PATCH 15/24] fix bulk methods --- .../cli/lib/commands/generators/databases.ts | 48 ++++++++----------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index 0548de88af..ced3229e47 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -252,9 +252,9 @@ export type QueryBuilder = { const canUseBulkMethods = supportsBulk && !this.hasRelationshipColumns(entity); const bulkMethods = canUseBulkMethods ? ` - createMany: (rows: Array<{ data: Omit<${typeName}, keyof Models.Row>; rowId?: string; permissions?: Permission[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; - updateMany: (rows: Array<{ rowId: string; data: Partial>; permissions?: Permission[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; - deleteMany: (rowIds: string[], options?: { transactionId?: string }) => Promise;` + createMany: (rows: Array & { $id?: string; $permissions?: string[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; + updateMany: (data: Partial>, options?: { queries?: (q: QueryBuilder<${typeName}>) => string[]; transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; + deleteMany: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[]; transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>;` : ""; return ` '${entity.name}': {\n${baseMethods}${bulkMethods}\n }`; @@ -307,19 +307,19 @@ export type QueryBuilder = { private generateQueryBuilder(): string { return `const createQueryBuilder = (): QueryBuilder => ({ - equal: (field, value) => Query.equal(String(field), value), - notEqual: (field, value) => Query.notEqual(String(field), value), - lessThan: (field, value) => Query.lessThan(String(field), value), - lessThanEqual: (field, value) => Query.lessThanEqual(String(field), value), - greaterThan: (field, value) => Query.greaterThan(String(field), value), - greaterThanEqual: (field, value) => Query.greaterThanEqual(String(field), value), - contains: (field, value) => Query.contains(String(field), value), + equal: (field, value) => Query.equal(String(field), value as any), + notEqual: (field, value) => Query.notEqual(String(field), value as any), + lessThan: (field, value) => Query.lessThan(String(field), value as any), + lessThanEqual: (field, value) => Query.lessThanEqual(String(field), value as any), + greaterThan: (field, value) => Query.greaterThan(String(field), value as any), + greaterThanEqual: (field, value) => Query.greaterThanEqual(String(field), value as any), + contains: (field, value) => Query.contains(String(field), value as any), search: (field, value) => Query.search(String(field), value), isNull: (field) => Query.isNull(String(field)), isNotNull: (field) => Query.isNotNull(String(field)), startsWith: (field, value) => Query.startsWith(String(field), value), endsWith: (field, value) => Query.endsWith(String(field), value), - between: (field, start, end) => Query.between(String(field), start, end), + between: (field, start, end) => Query.between(String(field), start as any, end as any), select: (fields) => Query.select(fields.map(String)), orderAsc: (field) => Query.orderAsc(String(field)), orderDesc: (field) => Query.orderDesc(String(field)), @@ -387,36 +387,28 @@ export type QueryBuilder = { const bulkMethods = canUseBulkMethods ? ` - createMany: (rows: Array<{ data: Omit<${typeName}, keyof Models.Row>; rowId?: string; permissions?: Permission[] }>, options?: { transactionId?: string }) => + createMany: (rows: Array & { $id?: string; $permissions?: string[] }>, options?: { transactionId?: string }) => tablesDB.createRows<${typeName}>({ databaseId: '${dbId}', tableId: '${entity.$id}', - rows: rows.map((row) => ({ - rowId: row.rowId ?? ID.unique(), - data: row.data, - permissions: row.permissions?.map((p) => p.toString()), - })), + rows, transactionId: options?.transactionId, }), - updateMany: (rows: Array<{ rowId: string; data: Partial>; permissions?: Permission[] }>, options?: { transactionId?: string }) => + updateMany: (data: Partial>, options?: { queries?: (q: QueryBuilder<${typeName}>) => string[]; transactionId?: string }) => tablesDB.updateRows<${typeName}>({ databaseId: '${dbId}', tableId: '${entity.$id}', - rows: rows.map((row) => ({ - rowId: row.rowId, - data: row.data, - permissions: row.permissions?.map((p) => p.toString()), - })), + data, + queries: options?.queries?.(createQueryBuilder<${typeName}>()), transactionId: options?.transactionId, }), - deleteMany: async (rowIds: string[], options?: { transactionId?: string }) => { - await tablesDB.deleteRows({ + deleteMany: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[]; transactionId?: string }) => + tablesDB.deleteRows<${typeName}>({ databaseId: '${dbId}', tableId: '${entity.$id}', - rows: rowIds.map((rowId) => ({ rowId })), + queries: options?.queries?.(createQueryBuilder<${typeName}>()), transactionId: options?.transactionId, - }); - },` + }),` : ""; return ` '${entityName}': {\n${baseMethods}${bulkMethods}\n }`; From 0173a1e0d1efc9471acb45472affd2a86e457df8 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 21:27:02 +0530 Subject: [PATCH 16/24] use proxy --- .../cli/lib/commands/generators/databases.ts | 242 +++++++++++------- 1 file changed, 150 insertions(+), 92 deletions(-) diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index ced3229e47..ebebb8e432 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -332,88 +332,35 @@ export type QueryBuilder = { })`; } - private generateTableHelpers( - dbId: string, - dbEntities: Entity[], - appwriteDep: string, - ): string { - const supportsBulk = this.supportsBulkMethods(appwriteDep); - - return dbEntities - .map((entity) => { - const entityName = entity.name; - const typeName = toPascalCase(entity.name); - // Bulk methods not supported for tables with relationship columns (see hasRelationshipColumns) - const canUseBulkMethods = supportsBulk && !this.hasRelationshipColumns(entity); - - const baseMethods = ` create: (data: Omit<${typeName}, keyof Models.Row>, options?: { rowId?: string; permissions?: Permission[]; transactionId?: string }) => - tablesDB.createRow<${typeName}>({ - databaseId: '${dbId}', - tableId: '${entity.$id}', - rowId: options?.rowId ?? ID.unique(), - data, - permissions: options?.permissions?.map((p) => p.toString()), - transactionId: options?.transactionId, - }), - get: (id: string) => - tablesDB.getRow<${typeName}>({ - databaseId: '${dbId}', - tableId: '${entity.$id}', - rowId: id, - }), - update: (id: string, data: Partial>, options?: { permissions?: Permission[]; transactionId?: string }) => - tablesDB.updateRow<${typeName}>({ - databaseId: '${dbId}', - tableId: '${entity.$id}', - rowId: id, - data, - ...(options?.permissions ? { permissions: options.permissions.map((p) => p.toString()) } : {}), - transactionId: options?.transactionId, - }), - delete: async (id: string, options?: { transactionId?: string }) => { - await tablesDB.deleteRow({ - databaseId: '${dbId}', - tableId: '${entity.$id}', - rowId: id, - transactionId: options?.transactionId, - }); - }, - list: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[] }) => - tablesDB.listRows<${typeName}>({ - databaseId: '${dbId}', - tableId: '${entity.$id}', - queries: options?.queries?.(createQueryBuilder<${typeName}>()), - }),`; - - const bulkMethods = canUseBulkMethods - ? ` - createMany: (rows: Array & { $id?: string; $permissions?: string[] }>, options?: { transactionId?: string }) => - tablesDB.createRows<${typeName}>({ - databaseId: '${dbId}', - tableId: '${entity.$id}', - rows, - transactionId: options?.transactionId, - }), - updateMany: (data: Partial>, options?: { queries?: (q: QueryBuilder<${typeName}>) => string[]; transactionId?: string }) => - tablesDB.updateRows<${typeName}>({ - databaseId: '${dbId}', - tableId: '${entity.$id}', - data, - queries: options?.queries?.(createQueryBuilder<${typeName}>()), - transactionId: options?.transactionId, - }), - deleteMany: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[]; transactionId?: string }) => - tablesDB.deleteRows<${typeName}>({ - databaseId: '${dbId}', - tableId: '${entity.$id}', - queries: options?.queries?.(createQueryBuilder<${typeName}>()), - transactionId: options?.transactionId, - }),` - : ""; - - return ` '${entityName}': {\n${baseMethods}${bulkMethods}\n }`; + private generateTableIdMap(entitiesByDb: Map): string { + const dbMappings = Array.from(entitiesByDb.entries()) + .map(([dbId, dbEntities]) => { + const tableMappings = dbEntities + .map((entity) => ` '${entity.name}': '${entity.$id}'`) + .join(",\n"); + return ` '${dbId}': {\n${tableMappings}\n }`; }) .join(",\n"); + + return `const tableIdMap: Record> = {\n${dbMappings}\n}`; + } + + private generateTablesWithRelationships(entitiesByDb: Map): string { + const tablesWithRelationships: string[] = []; + + for (const [dbId, dbEntities] of entitiesByDb.entries()) { + for (const entity of dbEntities) { + if (this.hasRelationshipColumns(entity)) { + tablesWithRelationships.push(`'${dbId}:${entity.name}'`); + } + } + } + + if (tablesWithRelationships.length === 0) { + return `const tablesWithRelationships = new Set()`; + } + + return `const tablesWithRelationships = new Set([${tablesWithRelationships.join(", ")}])`; } generateDatabasesFile(config: ConfigType): string { @@ -424,29 +371,140 @@ export type QueryBuilder = { } const entitiesByDb = this.groupEntitiesByDb(entities); - const typeNames = entities.map((e) => toPascalCase(e.name)); const appwriteDep = this.getAppwriteDependency(); + const supportsBulk = this.supportsBulkMethods(appwriteDep); - const databasesMap = Array.from(entitiesByDb.entries()) - .map(([dbId, dbEntities]) => { - return ` '${dbId}': {\n${this.generateTableHelpers(dbId, dbEntities, appwriteDep)}\n }`; - }) - .join(",\n"); + const bulkMethodsCode = supportsBulk + ? ` + createMany: (rows: any[], options?: { transactionId?: string }) => + tablesDB.createRows({ + databaseId, + tableId, + rows, + transactionId: options?.transactionId, + }), + updateMany: (data: any, options?: { queries?: (q: any) => string[]; transactionId?: string }) => + tablesDB.updateRows({ + databaseId, + tableId, + data, + queries: options?.queries?.(createQueryBuilder()), + transactionId: options?.transactionId, + }), + deleteMany: (options?: { queries?: (q: any) => string[]; transactionId?: string }) => + tablesDB.deleteRows({ + databaseId, + tableId, + queries: options?.queries?.(createQueryBuilder()), + transactionId: options?.transactionId, + }),` + : ""; + + const hasBulkCheck = supportsBulk + ? `const hasBulkMethods = (dbId: string, tableName: string) => !tablesWithRelationships.has(\`\${dbId}:\${tableName}\`);` + : ""; return `import { Client, TablesDB, ID, Query, type Models, Permission } from '${appwriteDep}'; -import type { ${typeNames.join(", ")}, DatabaseId, DatabaseTables, QueryBuilder } from './types.js'; +import type { DatabaseId, DatabaseTables, QueryBuilder } from './types.js'; ${this.generateQueryBuilder()}; -export const createDatabases = (client: Client) => { - const tablesDB = new TablesDB(client); +${this.generateTableIdMap(entitiesByDb)}; + +${this.generateTablesWithRelationships(entitiesByDb)}; - const _databases: { [K in DatabaseId]: DatabaseTables[K] } = { -${databasesMap} +function createTableApi( + tablesDB: TablesDB, + databaseId: string, + tableId: string, +) { + return { + create: (data: any, options?: { rowId?: string; permissions?: Permission[]; transactionId?: string }) => + tablesDB.createRow({ + databaseId, + tableId, + rowId: options?.rowId ?? ID.unique(), + data, + permissions: options?.permissions?.map((p) => p.toString()), + transactionId: options?.transactionId, + }), + get: (id: string) => + tablesDB.getRow({ + databaseId, + tableId, + rowId: id, + }), + update: (id: string, data: any, options?: { permissions?: Permission[]; transactionId?: string }) => + tablesDB.updateRow({ + databaseId, + tableId, + rowId: id, + data, + ...(options?.permissions ? { permissions: options.permissions.map((p) => p.toString()) } : {}), + transactionId: options?.transactionId, + }), + delete: async (id: string, options?: { transactionId?: string }) => { + await tablesDB.deleteRow({ + databaseId, + tableId, + rowId: id, + transactionId: options?.transactionId, + }); + }, + list: (options?: { queries?: (q: any) => string[] }) => + tablesDB.listRows({ + databaseId, + tableId, + queries: options?.queries?.(createQueryBuilder()), + }),${bulkMethodsCode} }; +} + +${hasBulkCheck} + +function createDatabaseProxy( + tablesDB: TablesDB, + databaseId: D, +): DatabaseTables[D] { + const tableApiCache = new Map>(); + + return new Proxy({} as DatabaseTables[D], { + get(_target, tableName: string) { + if (typeof tableName === 'symbol') return undefined; + + if (!tableApiCache.has(tableName)) { + const tableId = tableIdMap[databaseId]?.[tableName]; + if (!tableId) return undefined; + + const api = createTableApi(tablesDB, databaseId, tableId); + ${supportsBulk ? ` + // Remove bulk methods for tables with relationships + if (!hasBulkMethods(databaseId, tableName)) { + delete (api as any).createMany; + delete (api as any).updateMany; + delete (api as any).deleteMany; + }` : ""} + tableApiCache.set(tableName, api); + } + return tableApiCache.get(tableName); + }, + has(_target, tableName: string) { + return typeof tableName === 'string' && tableName in (tableIdMap[databaseId] ?? {}); + }, + }); +} + +export const createDatabases = (client: Client) => { + const tablesDB = new TablesDB(client); + const dbCache = new Map(); return { - from: (databaseId: T): DatabaseTables[T] => _databases[databaseId], + from: (databaseId: T): DatabaseTables[T] => { + if (!dbCache.has(databaseId)) { + dbCache.set(databaseId, createDatabaseProxy(tablesDB, databaseId)); + } + return dbCache.get(databaseId) as DatabaseTables[T]; + }, }; }; `; From 68fd34a943201152479beedc235e9ff7f47d6953 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 21:28:19 +0530 Subject: [PATCH 17/24] disambiguate duplicate keys --- templates/cli/lib/commands/generators/databases.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index ebebb8e432..2f02164acc 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -123,9 +123,18 @@ export class DatabasesGenerator { for (const field of fields) { if (field.format === "enum" && field.elements) { const enumName = toPascalCase(entity.name) + toPascalCase(field.key); + const usedKeys = new Set(); const enumValues = field.elements .map((element: string, index: number) => { - const key = sanitizeEnumKey(element); + let key = sanitizeEnumKey(element); + if (usedKeys.has(key)) { + let disambiguator = 1; + while (usedKeys.has(`${key}_${disambiguator}`)) { + disambiguator++; + } + key = `${key}_${disambiguator}`; + } + usedKeys.add(key); const isLast = index === field.elements!.length - 1; return ` ${key} = "${element}"${isLast ? "" : ","}`; }) From 18bf87873a8864c3d608e1d89d3508f4f73ea3c7 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 21:28:40 +0530 Subject: [PATCH 18/24] formatting --- .../cli/lib/commands/generators/databases.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index 2f02164acc..ae0707d9e8 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -258,7 +258,8 @@ export type QueryBuilder = { list: (options?: { queries?: (q: QueryBuilder<${typeName}>) => string[] }) => Promise<{ total: number; rows: ${typeName}[] }>;`; // Bulk methods not supported for tables with relationship columns (see hasRelationshipColumns) - const canUseBulkMethods = supportsBulk && !this.hasRelationshipColumns(entity); + const canUseBulkMethods = + supportsBulk && !this.hasRelationshipColumns(entity); const bulkMethods = canUseBulkMethods ? ` createMany: (rows: Array & { $id?: string; $permissions?: string[] }>, options?: { transactionId?: string }) => Promise<{ total: number; rows: ${typeName}[] }>; @@ -354,7 +355,9 @@ export type QueryBuilder = { return `const tableIdMap: Record> = {\n${dbMappings}\n}`; } - private generateTablesWithRelationships(entitiesByDb: Map): string { + private generateTablesWithRelationships( + entitiesByDb: Map, + ): string { const tablesWithRelationships: string[] = []; for (const [dbId, dbEntities] of entitiesByDb.entries()) { @@ -486,13 +489,17 @@ function createDatabaseProxy( if (!tableId) return undefined; const api = createTableApi(tablesDB, databaseId, tableId); - ${supportsBulk ? ` + ${ + supportsBulk + ? ` // Remove bulk methods for tables with relationships if (!hasBulkMethods(databaseId, tableName)) { delete (api as any).createMany; delete (api as any).updateMany; delete (api as any).deleteMany; - }` : ""} + }` + : "" + } tableApiCache.set(tableName, api); } return tableApiCache.get(tableName); From d3505c7d1c0ac24eacc0cfbea15a19b97d7275a5 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 22:05:00 +0530 Subject: [PATCH 19/24] use constants --- templates/cli/lib/commands/generate.ts | 15 ++++++++------- .../cli/lib/commands/generators/databases.ts | 17 +++++++++-------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/templates/cli/lib/commands/generate.ts b/templates/cli/lib/commands/generate.ts index 76a997a3bc..21c6fb287c 100644 --- a/templates/cli/lib/commands/generate.ts +++ b/templates/cli/lib/commands/generate.ts @@ -4,6 +4,7 @@ import { ConfigType } from "./config.js"; import { localConfig } from "../config.js"; import { success, error, log, actionRunner } from "../parser.js"; import { DatabasesGenerator } from "./generators/databases.js"; +import { SDK_TITLE, SDK_TITLE_LOWER, EXECUTABLE_NAME, NPM_PACKAGE_NAME } from "../constants.js"; export interface GenerateCommandOptions { output: string; @@ -16,7 +17,7 @@ const generateAction = async ( const project = localConfig.getProject(); if (!project.projectId) { - error("No project found. Please run 'appwrite init project' first."); + error(`No project found. Please run '${EXECUTABLE_NAME} init project' first.`); process.exit(1); } @@ -41,17 +42,17 @@ const generateAction = async ( await generator.writeFiles(absoluteOutputDir, result); success(`Generated files:`); - console.log(` - ${path.join(outputDir, "appwrite/index.ts")}`); - console.log(` - ${path.join(outputDir, "appwrite/databases.ts")}`); - console.log(` - ${path.join(outputDir, "appwrite/types.ts")}`); + console.log(` - ${path.join(outputDir, `${SDK_TITLE_LOWER}/index.ts`)}`); + console.log(` - ${path.join(outputDir, `${SDK_TITLE_LOWER}/databases.ts`)}`); + console.log(` - ${path.join(outputDir, `${SDK_TITLE_LOWER}/types.ts`)}`); console.log(""); log(`Import the generated SDK in your project:`); console.log( - ` import { createDatabases } from "./${outputDir}/appwrite/index.js";`, + ` import { createDatabases } from "./${outputDir}/${SDK_TITLE_LOWER}/index.js";`, ); console.log(""); log(`Usage:`); - console.log(` import { Client } from 'node-appwrite';`); + console.log(` import { Client } from '${NPM_PACKAGE_NAME}';`); console.log( ` const client = new Client().setEndpoint('...').setProject('...').setKey('...');`, ); @@ -66,7 +67,7 @@ const generateAction = async ( export const generate = new Command("generate") .description( - "Generate a type-safe SDK from your Appwrite project configuration", + `Generate a type-safe SDK from your ${SDK_TITLE} project configuration`, ) .option( "-o, --output ", diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index ae0707d9e8..4ef27d0570 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -3,6 +3,7 @@ import * as path from "path"; import { z } from "zod"; import { ConfigType, AttributeSchema } from "../config.js"; import { toPascalCase, sanitizeEnumKey } from "../../utils.js"; +import { SDK_TITLE, SDK_TITLE_LOWER, EXECUTABLE_NAME } from "../../constants.js"; export interface GenerateResult { databasesContent: string; @@ -528,10 +529,10 @@ export const createDatabases = (client: Client) => { generateIndexFile(): string { return `/** - * Appwrite Generated SDK + * ${SDK_TITLE} Generated SDK * * This file is auto-generated. Do not edit manually. - * Re-run \`appwrite generate\` to regenerate. + * Re-run \`${EXECUTABLE_NAME} generate\` to regenerate. */ export { createDatabases } from "./databases.js"; @@ -568,23 +569,23 @@ export * from "./types.js"; } async writeFiles(outputDir: string, result: GenerateResult): Promise { - const appwriteDir = path.join(outputDir, "appwrite"); - if (!fs.existsSync(appwriteDir)) { - fs.mkdirSync(appwriteDir, { recursive: true }); + const sdkDir = path.join(outputDir, SDK_TITLE_LOWER); + if (!fs.existsSync(sdkDir)) { + fs.mkdirSync(sdkDir, { recursive: true }); } fs.writeFileSync( - path.join(appwriteDir, "databases.ts"), + path.join(sdkDir, "databases.ts"), result.databasesContent, "utf-8", ); fs.writeFileSync( - path.join(appwriteDir, "types.ts"), + path.join(sdkDir, "types.ts"), result.typesContent, "utf-8", ); fs.writeFileSync( - path.join(appwriteDir, "index.ts"), + path.join(sdkDir, "index.ts"), result.indexContent, "utf-8", ); From 774b86de8b7c13df452189b51a10a1ea4f6ec849 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 22:09:58 +0530 Subject: [PATCH 20/24] review comments --- .../cli/lib/commands/generators/databases.ts | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index 4ef27d0570..dd73167564 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -107,7 +107,7 @@ export class DatabasesGenerator { const attributes = fields .map( (attr) => - ` ${attr.key}${attr.required ? "" : "?"}: ${this.getType(attr, entities as any, entity.name)};`, + ` ${JSON.stringify(attr.key)}${attr.required ? "" : "?"}: ${this.getType(attr, entities as any, entity.name)};`, ) .join("\n"); @@ -137,7 +137,7 @@ export class DatabasesGenerator { } usedKeys.add(key); const isLast = index === field.elements!.length - 1; - return ` ${key} = "${element}"${isLast ? "" : ","}`; + return ` ${key} = ${JSON.stringify(element)}${isLast ? "" : ","}`; }) .join("\n"); @@ -344,16 +344,20 @@ export type QueryBuilder = { } private generateTableIdMap(entitiesByDb: Map): string { - const dbMappings = Array.from(entitiesByDb.entries()) - .map(([dbId, dbEntities]) => { - const tableMappings = dbEntities - .map((entity) => ` '${entity.name}': '${entity.$id}'`) - .join(",\n"); - return ` '${dbId}': {\n${tableMappings}\n }`; - }) - .join(",\n"); + const lines: string[] = [ + "const tableIdMap: Record> = Object.create(null);", + ]; + + for (const [dbId, dbEntities] of entitiesByDb.entries()) { + lines.push(`tableIdMap[${JSON.stringify(dbId)}] = Object.create(null);`); + for (const entity of dbEntities) { + lines.push( + `tableIdMap[${JSON.stringify(dbId)}][${JSON.stringify(entity.name)}] = ${JSON.stringify(entity.$id)};`, + ); + } + } - return `const tableIdMap: Record> = {\n${dbMappings}\n}`; + return lines.join("\n"); } private generateTablesWithRelationships( @@ -475,19 +479,23 @@ function createTableApi( ${hasBulkCheck} +const hasOwn = (obj: unknown, key: string): boolean => + obj != null && Object.prototype.hasOwnProperty.call(obj, key); + function createDatabaseProxy( tablesDB: TablesDB, databaseId: D, ): DatabaseTables[D] { const tableApiCache = new Map>(); + const dbMap = tableIdMap[databaseId]; return new Proxy({} as DatabaseTables[D], { get(_target, tableName: string) { if (typeof tableName === 'symbol') return undefined; if (!tableApiCache.has(tableName)) { - const tableId = tableIdMap[databaseId]?.[tableName]; - if (!tableId) return undefined; + if (!hasOwn(dbMap, tableName)) return undefined; + const tableId = dbMap[tableName]; const api = createTableApi(tablesDB, databaseId, tableId); ${ @@ -506,7 +514,7 @@ function createDatabaseProxy( return tableApiCache.get(tableName); }, has(_target, tableName: string) { - return typeof tableName === 'string' && tableName in (tableIdMap[databaseId] ?? {}); + return typeof tableName === 'string' && hasOwn(dbMap, tableName); }, }); } From 419f20d31aa84068893c6dc12899682ef182ba02 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 18 Jan 2026 22:10:17 +0530 Subject: [PATCH 21/24] format --- templates/cli/lib/commands/generate.ts | 15 ++++++++++++--- .../cli/lib/commands/generators/databases.ts | 6 +++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/templates/cli/lib/commands/generate.ts b/templates/cli/lib/commands/generate.ts index 21c6fb287c..ede2689376 100644 --- a/templates/cli/lib/commands/generate.ts +++ b/templates/cli/lib/commands/generate.ts @@ -4,7 +4,12 @@ import { ConfigType } from "./config.js"; import { localConfig } from "../config.js"; import { success, error, log, actionRunner } from "../parser.js"; import { DatabasesGenerator } from "./generators/databases.js"; -import { SDK_TITLE, SDK_TITLE_LOWER, EXECUTABLE_NAME, NPM_PACKAGE_NAME } from "../constants.js"; +import { + SDK_TITLE, + SDK_TITLE_LOWER, + EXECUTABLE_NAME, + NPM_PACKAGE_NAME, +} from "../constants.js"; export interface GenerateCommandOptions { output: string; @@ -17,7 +22,9 @@ const generateAction = async ( const project = localConfig.getProject(); if (!project.projectId) { - error(`No project found. Please run '${EXECUTABLE_NAME} init project' first.`); + error( + `No project found. Please run '${EXECUTABLE_NAME} init project' first.`, + ); process.exit(1); } @@ -43,7 +50,9 @@ const generateAction = async ( success(`Generated files:`); console.log(` - ${path.join(outputDir, `${SDK_TITLE_LOWER}/index.ts`)}`); - console.log(` - ${path.join(outputDir, `${SDK_TITLE_LOWER}/databases.ts`)}`); + console.log( + ` - ${path.join(outputDir, `${SDK_TITLE_LOWER}/databases.ts`)}`, + ); console.log(` - ${path.join(outputDir, `${SDK_TITLE_LOWER}/types.ts`)}`); console.log(""); log(`Import the generated SDK in your project:`); diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/databases.ts index dd73167564..d90dc3531c 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/databases.ts @@ -3,7 +3,11 @@ import * as path from "path"; import { z } from "zod"; import { ConfigType, AttributeSchema } from "../config.js"; import { toPascalCase, sanitizeEnumKey } from "../../utils.js"; -import { SDK_TITLE, SDK_TITLE_LOWER, EXECUTABLE_NAME } from "../../constants.js"; +import { + SDK_TITLE, + SDK_TITLE_LOWER, + EXECUTABLE_NAME, +} from "../../constants.js"; export interface GenerateResult { databasesContent: string; From 670ffbfa3f9c9942f67ba6927c5c7bb96c106414 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 20 Jan 2026 18:02:58 +0530 Subject: [PATCH 22/24] make code modular --- src/SDK/Language/CLI.php | 19 +- templates/cli/lib/commands/generate.ts | 102 ++++++++--- templates/cli/lib/commands/generators/base.ts | 91 ++++++++++ .../cli/lib/commands/generators/index.ts | 87 ++++++++++ .../commands/generators/language-detector.ts | 163 ++++++++++++++++++ .../generators/{ => typescript}/databases.ts | 85 ++++----- templates/cli/lib/commands/schema.ts | 6 +- 7 files changed, 475 insertions(+), 78 deletions(-) create mode 100644 templates/cli/lib/commands/generators/base.ts create mode 100644 templates/cli/lib/commands/generators/index.ts create mode 100644 templates/cli/lib/commands/generators/language-detector.ts rename templates/cli/lib/commands/generators/{ => typescript}/databases.ts (92%) diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index b0147219c5..7b78e8c799 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -353,8 +353,23 @@ public function getFiles(): array ], [ 'scope' => 'copy', - 'destination' => 'lib/commands/generators/databases.ts', - 'template' => 'cli/lib/commands/generators/databases.ts', + 'destination' => 'lib/commands/generators/base.ts', + 'template' => 'cli/lib/commands/generators/base.ts', + ], + [ + 'scope' => 'copy', + 'destination' => 'lib/commands/generators/index.ts', + 'template' => 'cli/lib/commands/generators/index.ts', + ], + [ + 'scope' => 'copy', + 'destination' => 'lib/commands/generators/language-detector.ts', + 'template' => 'cli/lib/commands/generators/language-detector.ts', + ], + [ + 'scope' => 'copy', + 'destination' => 'lib/commands/generators/typescript/databases.ts', + 'template' => 'cli/lib/commands/generators/typescript/databases.ts', ], [ 'scope' => 'copy', diff --git a/templates/cli/lib/commands/generate.ts b/templates/cli/lib/commands/generate.ts index ede2689376..dd66e92e12 100644 --- a/templates/cli/lib/commands/generate.ts +++ b/templates/cli/lib/commands/generate.ts @@ -2,8 +2,14 @@ import * as path from "path"; import { Command } from "commander"; import { ConfigType } from "./config.js"; import { localConfig } from "../config.js"; -import { success, error, log, actionRunner } from "../parser.js"; -import { DatabasesGenerator } from "./generators/databases.js"; +import { success, error, log, warn, actionRunner } from "../parser.js"; +import { + createGeneratorFromDetection, + createGenerator, + getSupportedLanguages, + LanguageDetector, + SupportedLanguage, +} from "./generators/index.js"; import { SDK_TITLE, SDK_TITLE_LOWER, @@ -13,12 +19,12 @@ import { export interface GenerateCommandOptions { output: string; + language?: string; } const generateAction = async ( options: GenerateCommandOptions, ): Promise => { - const generator = new DatabasesGenerator(); const project = localConfig.getProject(); if (!project.projectId) { @@ -28,6 +34,47 @@ const generateAction = async ( process.exit(1); } + // Determine the generator to use + let generator; + let detectedLanguage: string; + + if (options.language) { + // User explicitly specified a language + if (!LanguageDetector.isSupported(options.language)) { + const supported = getSupportedLanguages().join(", "); + error( + `Unsupported language: ${options.language}. Supported languages: ${supported}`, + ); + process.exit(1); + } + generator = createGenerator(options.language as SupportedLanguage); + detectedLanguage = options.language; + log(`Using specified language: ${detectedLanguage}`); + } else { + // Auto-detect language + try { + const { generator: detectedGenerator, detection } = + createGeneratorFromDetection(); + generator = detectedGenerator; + detectedLanguage = detection.language; + + if (detection.confidence === "low") { + warn( + `Detected language '${detectedLanguage}' with low confidence (${detection.reason}). ` + + `Use --language to specify explicitly.`, + ); + } else { + log(`Detected language: ${detectedLanguage} (${detection.reason})`); + } + } catch (err: any) { + const supported = getSupportedLanguages().join(", "); + error( + `${err.message}\nUse --language to specify the target language. Supported: ${supported}`, + ); + process.exit(1); + } + } + const config: ConfigType = { projectId: project.projectId, projectName: project.projectName, @@ -42,32 +89,37 @@ const generateAction = async ( ? outputDir : path.join(process.cwd(), outputDir); - log(`Generating type-safe SDK to ${absoluteOutputDir}...`); + log( + `Generating type-safe ${detectedLanguage} SDK to ${absoluteOutputDir}...`, + ); try { const result = await generator.generate(config); await generator.writeFiles(absoluteOutputDir, result); + const generatedFiles = generator.getGeneratedFilePaths(result); success(`Generated files:`); - console.log(` - ${path.join(outputDir, `${SDK_TITLE_LOWER}/index.ts`)}`); - console.log( - ` - ${path.join(outputDir, `${SDK_TITLE_LOWER}/databases.ts`)}`, - ); - console.log(` - ${path.join(outputDir, `${SDK_TITLE_LOWER}/types.ts`)}`); - console.log(""); - log(`Import the generated SDK in your project:`); - console.log( - ` import { createDatabases } from "./${outputDir}/${SDK_TITLE_LOWER}/index.js";`, - ); - console.log(""); - log(`Usage:`); - console.log(` import { Client } from '${NPM_PACKAGE_NAME}';`); - console.log( - ` const client = new Client().setEndpoint('...').setProject('...').setKey('...');`, - ); - console.log(` const databases = createDatabases(client);`); - console.log(` const db = databases.from('your-database-id');`); - console.log(` await db.tableName.create({ ... });`); + for (const file of generatedFiles) { + console.log(` - ${path.join(outputDir, file)}`); + } + + // Show language-specific usage instructions + if (detectedLanguage === "typescript") { + console.log(""); + log(`Import the generated SDK in your project:`); + console.log( + ` import { createDatabases } from "./${outputDir}/${SDK_TITLE_LOWER}/index.js";`, + ); + console.log(""); + log(`Usage:`); + console.log(` import { Client } from '${NPM_PACKAGE_NAME}';`); + console.log( + ` const client = new Client().setEndpoint('...').setProject('...').setKey('...');`, + ); + console.log(` const databases = createDatabases(client);`); + console.log(` const db = databases.from('your-database-id');`); + console.log(` await db.tableName.create({ ... });`); + } } catch (err: any) { error(`Failed to generate SDK: ${err.message}`); process.exit(1); @@ -83,4 +135,8 @@ export const generate = new Command("generate") "Output directory for generated files (default: generated)", "generated", ) + .option( + "-l, --language ", + `Target language for SDK generation (supported: ${getSupportedLanguages().join(", ")})`, + ) .action(actionRunner(generateAction)); diff --git a/templates/cli/lib/commands/generators/base.ts b/templates/cli/lib/commands/generators/base.ts new file mode 100644 index 0000000000..2d51c32828 --- /dev/null +++ b/templates/cli/lib/commands/generators/base.ts @@ -0,0 +1,91 @@ +import * as fs from "fs"; +import * as path from "path"; +import { ConfigType } from "../config.js"; +import { SDK_TITLE_LOWER } from "../../constants.js"; + +/** + * Supported programming languages for SDK generation. + * Add new languages here as they are implemented. + */ +export type SupportedLanguage = "typescript"; + +/** + * Result of the generation process. + * Each language generator should return files as a map of filename to content. + */ +export interface GenerateResult { + /** Map of relative file paths to their content */ + files: Map; +} + +/** + * Base interface for all language-specific database generators. + * Implement this interface to add support for new languages. + */ +export interface IDatabasesGenerator { + /** + * The language this generator produces code for. + */ + readonly language: SupportedLanguage; + + /** + * File extension for the generated files (e.g., "ts", "py", "go"). + */ + readonly fileExtension: string; + + /** + * Generate the SDK files from the configuration. + * @param config - The project configuration containing tables/collections + * @returns Promise resolving to the generated files + */ + generate(config: ConfigType): Promise; + + /** + * Write the generated files to disk. + * @param outputDir - The base output directory + * @param result - The generation result containing files to write + */ + writeFiles(outputDir: string, result: GenerateResult): Promise; + + /** + * Get the list of generated file paths (relative to output directory). + * Used for displaying success messages. + * @param result - The generation result + */ + getGeneratedFilePaths(result: GenerateResult): string[]; +} + +/** + * Abstract base class providing common functionality for database generators. + * Extend this class to implement language-specific generators. + */ +export abstract class BaseDatabasesGenerator implements IDatabasesGenerator { + abstract readonly language: SupportedLanguage; + abstract readonly fileExtension: string; + + abstract generate(config: ConfigType): Promise; + + async writeFiles(outputDir: string, result: GenerateResult): Promise { + const sdkDir = path.join(outputDir, SDK_TITLE_LOWER); + if (!fs.existsSync(sdkDir)) { + fs.mkdirSync(sdkDir, { recursive: true }); + } + + for (const [relativePath, content] of result.files) { + const filePath = path.join(sdkDir, relativePath); + const fileDir = path.dirname(filePath); + + if (!fs.existsSync(fileDir)) { + fs.mkdirSync(fileDir, { recursive: true }); + } + + fs.writeFileSync(filePath, content, "utf-8"); + } + } + + getGeneratedFilePaths(result: GenerateResult): string[] { + return Array.from(result.files.keys()).map((relativePath) => + path.join(SDK_TITLE_LOWER, relativePath), + ); + } +} diff --git a/templates/cli/lib/commands/generators/index.ts b/templates/cli/lib/commands/generators/index.ts new file mode 100644 index 0000000000..8415b5db8a --- /dev/null +++ b/templates/cli/lib/commands/generators/index.ts @@ -0,0 +1,87 @@ +import { IDatabasesGenerator, SupportedLanguage } from "./base.js"; +import { + LanguageDetector, + LanguageDetectionResult, +} from "./language-detector.js"; +import { TypeScriptDatabasesGenerator } from "./typescript/databases.js"; + +export { + IDatabasesGenerator, + SupportedLanguage, + GenerateResult, +} from "./base.js"; +export { + LanguageDetector, + LanguageDetectionResult, +} from "./language-detector.js"; + +/** + * Factory function type for creating database generators. + */ +type GeneratorFactory = () => IDatabasesGenerator; + +/** + * Registry of generator factories by language. + * Add new language generators here as they are implemented. + */ +const generatorRegistry: Record = { + typescript: () => new TypeScriptDatabasesGenerator(), + // Future languages: + // python: () => new PythonDatabasesGenerator(), + // go: () => new GoDatabasesGenerator(), + // dart: () => new DartDatabasesGenerator(), +}; + +/** + * Create a database generator for the specified language. + * @param language - The target language + * @returns The appropriate generator instance + * @throws Error if the language is not supported + */ +export function createGenerator( + language: SupportedLanguage, +): IDatabasesGenerator { + const factory = generatorRegistry[language]; + if (!factory) { + throw new Error( + `No generator available for language: ${language}. ` + + `Supported languages: ${Object.keys(generatorRegistry).join(", ")}`, + ); + } + return factory(); +} + +/** + * Create a database generator by auto-detecting the project language. + * @param cwd - The working directory to detect language from (defaults to process.cwd()) + * @returns Object containing the generator and detection result + * @throws Error if no supported language is detected + */ +export function createGeneratorFromDetection(cwd?: string): { + generator: IDatabasesGenerator; + detection: LanguageDetectionResult; +} { + const detector = new LanguageDetector(cwd); + const detection = detector.detect(); + + if (!detection) { + const supported = LanguageDetector.getSupportedLanguages().join(", "); + throw new Error( + `Could not detect project language. ` + + `Supported languages: ${supported}. ` + + `Please ensure your project has the appropriate configuration files (e.g., package.json for TypeScript).`, + ); + } + + return { + generator: createGenerator(detection.language), + detection, + }; +} + +/** + * Get all supported languages. + */ +export function getSupportedLanguages(): SupportedLanguage[] { + return Object.keys(generatorRegistry) as SupportedLanguage[]; +} diff --git a/templates/cli/lib/commands/generators/language-detector.ts b/templates/cli/lib/commands/generators/language-detector.ts new file mode 100644 index 0000000000..d2562ca4c6 --- /dev/null +++ b/templates/cli/lib/commands/generators/language-detector.ts @@ -0,0 +1,163 @@ +import * as fs from "fs"; +import * as path from "path"; +import { SupportedLanguage } from "./base.js"; + +/** + * Language detection result with confidence level. + */ +export interface LanguageDetectionResult { + language: SupportedLanguage; + confidence: "high" | "medium" | "low"; + reason: string; +} + +/** + * Configuration for detecting a specific language. + */ +interface LanguageConfig { + language: SupportedLanguage; + /** Files that indicate this language with high confidence */ + primaryIndicators: string[]; + /** Files that indicate this language with medium confidence */ + secondaryIndicators: string[]; + /** File extensions to scan for */ + fileExtensions: string[]; +} + +/** + * Language detection configurations. + * Add new languages here as they are supported. + */ +const languageConfigs: LanguageConfig[] = [ + { + language: "typescript", + primaryIndicators: ["tsconfig.json", "package.json", "deno.json"], + secondaryIndicators: [ + ".nvmrc", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "bun.lockb", + ], + fileExtensions: [".ts", ".tsx", ".js", ".jsx"], + }, + // Future languages can be added here: + // { + // language: "python", + // primaryIndicators: ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"], + // secondaryIndicators: [".python-version", "poetry.lock"], + // fileExtensions: [".py"], + // }, + // { + // language: "go", + // primaryIndicators: ["go.mod"], + // secondaryIndicators: ["go.sum"], + // fileExtensions: [".go"], + // }, + // { + // language: "dart", + // primaryIndicators: ["pubspec.yaml"], + // secondaryIndicators: ["pubspec.lock"], + // fileExtensions: [".dart"], + // }, +]; + +/** + * Detects the programming language of a project based on its directory contents. + */ +export class LanguageDetector { + private cwd: string; + + constructor(cwd: string = process.cwd()) { + this.cwd = cwd; + } + + /** + * Detect the primary language of the project. + * @returns The detected language and confidence level, or null if no supported language detected + */ + detect(): LanguageDetectionResult | null { + for (const config of languageConfigs) { + const result = this.checkLanguage(config); + if (result) { + return result; + } + } + + return null; + } + + /** + * Check if a specific language is detected in the project. + */ + private checkLanguage( + config: LanguageConfig, + ): LanguageDetectionResult | null { + // Check primary indicators first + for (const indicator of config.primaryIndicators) { + if (this.fileExists(indicator)) { + return { + language: config.language, + confidence: "high", + reason: `Found ${indicator}`, + }; + } + } + + // Check secondary indicators + for (const indicator of config.secondaryIndicators) { + if (this.fileExists(indicator)) { + return { + language: config.language, + confidence: "medium", + reason: `Found ${indicator}`, + }; + } + } + + // Check for file extensions in the current directory + const hasMatchingFiles = this.hasFilesWithExtensions(config.fileExtensions); + if (hasMatchingFiles) { + return { + language: config.language, + confidence: "low", + reason: `Found files with extensions: ${config.fileExtensions.join(", ")}`, + }; + } + + return null; + } + + /** + * Check if a file exists in the project directory. + */ + private fileExists(filename: string): boolean { + return fs.existsSync(path.resolve(this.cwd, filename)); + } + + /** + * Check if files with specific extensions exist in the current directory. + */ + private hasFilesWithExtensions(extensions: string[]): boolean { + try { + const files = fs.readdirSync(this.cwd); + return files.some((file) => extensions.some((ext) => file.endsWith(ext))); + } catch { + return false; + } + } + + /** + * Get all supported languages. + */ + static getSupportedLanguages(): SupportedLanguage[] { + return languageConfigs.map((c) => c.language); + } + + /** + * Check if a language is supported. + */ + static isSupported(language: string): language is SupportedLanguage { + return languageConfigs.some((c) => c.language === language); + } +} diff --git a/templates/cli/lib/commands/generators/databases.ts b/templates/cli/lib/commands/generators/typescript/databases.ts similarity index 92% rename from templates/cli/lib/commands/generators/databases.ts rename to templates/cli/lib/commands/generators/typescript/databases.ts index d90dc3531c..408524800f 100644 --- a/templates/cli/lib/commands/generators/databases.ts +++ b/templates/cli/lib/commands/generators/typescript/databases.ts @@ -1,19 +1,14 @@ import * as fs from "fs"; import * as path from "path"; import { z } from "zod"; -import { ConfigType, AttributeSchema } from "../config.js"; -import { toPascalCase, sanitizeEnumKey } from "../../utils.js"; +import { ConfigType, AttributeSchema } from "../../config.js"; +import { toPascalCase, sanitizeEnumKey } from "../../../utils.js"; +import { SDK_TITLE, EXECUTABLE_NAME } from "../../../constants.js"; import { - SDK_TITLE, - SDK_TITLE_LOWER, - EXECUTABLE_NAME, -} from "../../constants.js"; - -export interface GenerateResult { - databasesContent: string; - typesContent: string; - indexContent: string; -} + BaseDatabasesGenerator, + GenerateResult, + SupportedLanguage, +} from "../base.js"; type Entity = | NonNullable[number] @@ -22,7 +17,14 @@ type Entities = | NonNullable | NonNullable; -export class DatabasesGenerator { +/** + * TypeScript-specific database generator. + * Generates type-safe SDK files for TypeScript/JavaScript projects. + */ +export class TypeScriptDatabasesGenerator extends BaseDatabasesGenerator { + readonly language: SupportedLanguage = "typescript"; + readonly fileExtension = "ts"; + private getType( attribute: z.infer, collections: NonNullable, @@ -282,7 +284,7 @@ export type QueryBuilder = { return `export type DatabaseTables = {\n${dbReturnTypes}\n}`; } - generateTypesFile(config: ConfigType): string { + private generateTypesFile(config: ConfigType): string { const entities = config.tables?.length ? config.tables : config.collections; if (!entities || entities.length === 0) { @@ -292,7 +294,7 @@ export type QueryBuilder = { const appwriteDep = this.getAppwriteDependency(); const enums = this.generateEnums(entities); const types = entities - .map((entity) => this.generateTableType(entity, entities)) + .map((entity: Entity) => this.generateTableType(entity, entities)) .join("\n\n"); const entitiesByDb = this.groupEntitiesByDb(entities); const dbIds = Array.from(entitiesByDb.keys()); @@ -384,7 +386,7 @@ export type QueryBuilder = { return `const tablesWithRelationships = new Set([${tablesWithRelationships.join(", ")}])`; } - generateDatabasesFile(config: ConfigType): string { + private generateDatabasesFile(config: ConfigType): string { const entities = config.tables?.length ? config.tables : config.collections; if (!entities || entities.length === 0) { @@ -539,7 +541,7 @@ export const createDatabases = (client: Client) => { `; } - generateIndexFile(): string { + private generateIndexFile(): string { return `/** * ${SDK_TITLE} Generated SDK * @@ -557,6 +559,8 @@ export * from "./types.js"; throw new Error("Project ID is required in configuration"); } + const files = new Map(); + const hasEntities = (config.tables && config.tables.length > 0) || (config.collections && config.collections.length > 0); @@ -565,41 +569,22 @@ export * from "./types.js"; console.log( "No tables or collections found in configuration. Skipping database generation.", ); - return { - databasesContent: - "// No tables or collections found in configuration\n", - typesContent: "// No tables or collections found in configuration\n", - indexContent: this.generateIndexFile(), - }; + files.set( + "databases.ts", + "// No tables or collections found in configuration\n", + ); + files.set( + "types.ts", + "// No tables or collections found in configuration\n", + ); + files.set("index.ts", this.generateIndexFile()); + return { files }; } - return { - typesContent: this.generateTypesFile(config), - databasesContent: this.generateDatabasesFile(config), - indexContent: this.generateIndexFile(), - }; - } - - async writeFiles(outputDir: string, result: GenerateResult): Promise { - const sdkDir = path.join(outputDir, SDK_TITLE_LOWER); - if (!fs.existsSync(sdkDir)) { - fs.mkdirSync(sdkDir, { recursive: true }); - } + files.set("types.ts", this.generateTypesFile(config)); + files.set("databases.ts", this.generateDatabasesFile(config)); + files.set("index.ts", this.generateIndexFile()); - fs.writeFileSync( - path.join(sdkDir, "databases.ts"), - result.databasesContent, - "utf-8", - ); - fs.writeFileSync( - path.join(sdkDir, "types.ts"), - result.typesContent, - "utf-8", - ); - fs.writeFileSync( - path.join(sdkDir, "index.ts"), - result.indexContent, - "utf-8", - ); + return { files }; } } diff --git a/templates/cli/lib/commands/schema.ts b/templates/cli/lib/commands/schema.ts index 4fd3ad16ea..10d4d7029e 100644 --- a/templates/cli/lib/commands/schema.ts +++ b/templates/cli/lib/commands/schema.ts @@ -7,7 +7,7 @@ import { parseWithBetterErrors } from "./utils/error-formatter.js"; import JSONbig from "json-bigint"; import * as fs from "fs"; import * as path from "path"; -import { DatabasesGenerator } from "./generators/databases.js"; +import { TypeScriptDatabasesGenerator } from "./generators/typescript/databases.js"; const JSONBig = JSONbig({ useNativeBigInt: true }); @@ -17,7 +17,7 @@ export class Schema { private pullCommandSilent: Pull; - public db: DatabasesGenerator; + public db: TypeScriptDatabasesGenerator; constructor({ projectClient, @@ -31,7 +31,7 @@ export class Schema { this.pullCommandSilent = new Pull(projectClient, consoleClient, true); - this.db = new DatabasesGenerator(); + this.db = new TypeScriptDatabasesGenerator(); } /** From 2cf10eb902cd968d8e4f990b08fe7f7512e21cc9 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 20 Jan 2026 19:16:41 +0530 Subject: [PATCH 23/24] improve validation and seperate concerns --- src/SDK/Language/CLI.php | 5 + .../cli/lib/commands/config-validations.ts | 199 ++++++++++++++++++ templates/cli/lib/commands/config.ts | 197 ++++------------- templates/cli/lib/commands/pull.ts | 8 +- .../cli/lib/commands/utils/attributes.ts | 42 +++- templates/cli/lib/config.ts | 8 +- 6 files changed, 289 insertions(+), 170 deletions(-) create mode 100644 templates/cli/lib/commands/config-validations.ts diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index 7b78e8c799..b88f7e0bcd 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -346,6 +346,11 @@ public function getFiles(): array 'destination' => 'lib/commands/config.ts', 'template' => 'cli/lib/commands/config.ts', ], + [ + 'scope' => 'copy', + 'destination' => 'lib/commands/config-validations.ts', + 'template' => 'cli/lib/commands/config-validations.ts', + ], [ 'scope' => 'copy', 'destination' => 'lib/commands/generate.ts', diff --git a/templates/cli/lib/commands/config-validations.ts b/templates/cli/lib/commands/config-validations.ts new file mode 100644 index 0000000000..98fc06c4aa --- /dev/null +++ b/templates/cli/lib/commands/config-validations.ts @@ -0,0 +1,199 @@ +import { z } from "zod"; + +// ============================================================================ +// Attribute Validations +// ============================================================================ + +/** + * Validates that when 'required' is true, 'default' must be null + */ +export const validateRequiredDefault = (data: { + required?: boolean; + default?: any; +}) => { + if (data.required === true && data.default !== null) { + return false; + } + return true; +}; + +/** + * Validates that string type attributes must have a size defined + */ +export const validateStringSize = (data: { + type: string; + size?: number | null; +}) => { + if ( + data.type === "string" && + (data.size === undefined || data.size === null) + ) { + return false; + } + return true; +}; + +// ============================================================================ +// Collection & Table Validations +// ============================================================================ + +interface AttributeOrColumn { + key: string; +} + +interface Index { + key: string; +} + +interface CollectionOrTableData { + attributes?: AttributeOrColumn[]; + columns?: AttributeOrColumn[]; + indexes?: Index[]; +} + +/** + * Validates duplicate keys in attributes/columns and indexes + */ +export const validateContainerDuplicates = ( + data: CollectionOrTableData, + ctx: z.RefinementCtx, +) => { + const items = data.attributes || data.columns || []; + const itemType = data.attributes ? "Attribute" : "Column"; + const itemPath = data.attributes ? "attributes" : "columns"; + + // Validate duplicate item keys + if (items.length > 0) { + const seenKeys = new Set(); + + items.forEach((item, index) => { + if (seenKeys.has(item.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${itemType} with the key '${item.key}' already exists. ${itemType} keys must be unique, try again with a different key.`, + path: [itemPath, index, "key"], + }); + } else { + seenKeys.add(item.key); + } + }); + } + + // Validate duplicate index keys + if (data.indexes && data.indexes.length > 0) { + const seenKeys = new Set(); + + data.indexes.forEach((index, indexPos) => { + if (seenKeys.has(index.key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Index with the key '${index.key}' already exists. Index keys must be unique, try again with a different key.`, + path: ["indexes", indexPos, "key"], + }); + } else { + seenKeys.add(index.key); + } + }); + } +}; + +// ============================================================================ +// Config Validations +// ============================================================================ + +interface RelationshipItem { + key: string; + type: string; + relatedTable?: string; + relatedCollection?: string; +} + +interface ContainerWithDatabase { + $id: string; + name: string; + databaseId: string; + columns?: RelationshipItem[]; + attributes?: RelationshipItem[]; +} + +interface ConfigData { + tables?: ContainerWithDatabase[]; + collections?: ContainerWithDatabase[]; +} + +/** + * Validates cross-database relationships + */ +export const validateCrossDatabase = ( + data: ConfigData, + ctx: z.RefinementCtx, +) => { + // Validate cross-database relationships for tables + if (data.tables && data.tables.length > 0) { + // Build a map of table IDs to their database IDs + const tableDatabaseMap = new Map(); + for (const table of data.tables) { + tableDatabaseMap.set(table.$id, table.databaseId); + } + + // Check each table's relationship columns + data.tables.forEach((table, tableIndex) => { + const columns = table.columns || []; + columns.forEach((column, columnIndex) => { + if (column.type === "relationship" && column.relatedTable) { + const relatedDatabaseId = tableDatabaseMap.get(column.relatedTable); + if (relatedDatabaseId && relatedDatabaseId !== table.databaseId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid cross-database relationship: Table '${table.name}' (database: '${table.databaseId}') has relationship '${column.key}' pointing to table '${column.relatedTable}' which is in database '${relatedDatabaseId}'. Appwrite only supports relationships within the same database.`, + path: [ + "tables", + tableIndex, + "columns", + columnIndex, + "relatedTable", + ], + }); + } + } + }); + }); + } + + // Validate cross-database relationships for collections (legacy) + if (data.collections && data.collections.length > 0) { + // Build a map of collection IDs to their database IDs + const collectionDatabaseMap = new Map(); + for (const collection of data.collections) { + collectionDatabaseMap.set(collection.$id, collection.databaseId); + } + + // Check each collection's relationship attributes + data.collections.forEach((collection, collectionIndex) => { + const attributes = collection.attributes || []; + attributes.forEach((attribute, attributeIndex) => { + if (attribute.type === "relationship" && attribute.relatedCollection) { + const relatedDatabaseId = collectionDatabaseMap.get( + attribute.relatedCollection, + ); + if ( + relatedDatabaseId && + relatedDatabaseId !== collection.databaseId + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid cross-database relationship: Collection '${collection.name}' (database: '${collection.databaseId}') has relationship '${attribute.key}' pointing to collection '${attribute.relatedCollection}' which is in database '${relatedDatabaseId}'. Appwrite only supports relationships within the same database.`, + path: [ + "collections", + collectionIndex, + "attributes", + attributeIndex, + "relatedCollection", + ], + }); + } + } + }); + }); + } +}; diff --git a/templates/cli/lib/commands/config.ts b/templates/cli/lib/commands/config.ts index f251e6742d..5947ac9cc9 100644 --- a/templates/cli/lib/commands/config.ts +++ b/templates/cli/lib/commands/config.ts @@ -1,7 +1,13 @@ import { z } from "zod"; +import { + validateRequiredDefault, + validateStringSize, + validateContainerDuplicates, + validateCrossDatabase, +} from "./config-validations.js"; // ============================================================================ -// Internal Helpers (not exported) +// Internal Helpers // ============================================================================ const INT64_MIN = BigInt("-9223372036854775808"); @@ -65,29 +71,6 @@ const MockNumberSchema = z }) .strict(); -// ============================================================================ -// Config Schema -// ============================================================================ - -const ConfigSchema = z - .object({ - projectId: z.string(), - projectName: z.string().optional(), - endpoint: z.string().optional(), - settings: z.lazy(() => SettingsSchema).optional(), - functions: z.array(z.lazy(() => FunctionSchema)).optional(), - sites: z.array(z.lazy(() => SiteSchema)).optional(), - databases: z.array(z.lazy(() => DatabaseSchema)).optional(), - collections: z.array(z.lazy(() => CollectionSchema)).optional(), - tablesDB: z.array(z.lazy(() => DatabaseSchema)).optional(), - tables: z.array(z.lazy(() => TablesDBSchema)).optional(), - topics: z.array(z.lazy(() => TopicSchema)).optional(), - teams: z.array(z.lazy(() => TeamSchema)).optional(), - buckets: z.array(z.lazy(() => BucketSchema)).optional(), - messages: z.array(z.lazy(() => MessageSchema)).optional(), - }) - .strict(); - // ============================================================================ // Project Settings // ============================================================================ @@ -206,7 +189,7 @@ const DatabaseSchema = z // Collections (legacy) // ============================================================================ -const AttributeSchemaBase = z +const AttributeSchema = z .object({ key: z.string(), type: z.enum([ @@ -244,34 +227,15 @@ const AttributeSchemaBase = z orders: z.array(z.string()).optional(), encrypt: z.boolean().optional(), }) - .strict(); - -const AttributeSchema = AttributeSchemaBase.refine( - (data) => { - if (data.required === true && data.default !== null) { - return false; - } - return true; - }, - { + .strict() + .refine(validateRequiredDefault, { message: "When 'required' is true, 'default' must be null", path: ["default"], - }, -).refine( - (data) => { - if ( - data.type === "string" && - (data.size === undefined || data.size === null) - ) { - return false; - } - return true; - }, - { + }) + .refine(validateStringSize, { message: "When 'type' is 'string', 'size' must be defined", path: ["size"], - }, -); + }); const IndexSchema = z .object({ @@ -295,45 +259,13 @@ const CollectionSchema = z indexes: z.array(IndexSchema).optional(), }) .strict() - .superRefine((data, ctx) => { - if (data.attributes && data.attributes.length > 0) { - const seenKeys = new Set(); - - data.attributes.forEach((attr, index) => { - if (seenKeys.has(attr.key)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Attribute with the key '${attr.key}' already exists. Attribute keys must be unique, try again with a different key.`, - path: ["attributes", index, "key"], - }); - } else { - seenKeys.add(attr.key); - } - }); - } - - if (data.indexes && data.indexes.length > 0) { - const seenKeys = new Set(); - - data.indexes.forEach((index, indexPos) => { - if (seenKeys.has(index.key)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Index with the key '${index.key}' already exists. Index keys must be unique, try again with a different key.`, - path: ["indexes", indexPos, "key"], - }); - } else { - seenKeys.add(index.key); - } - }); - } - }); + .superRefine(validateContainerDuplicates); // ============================================================================ // Tables // ============================================================================ -const ColumnSchemaBase = z +const ColumnSchema = z .object({ key: z.string(), type: z.enum([ @@ -374,33 +306,6 @@ const ColumnSchemaBase = z }) .strict(); -const ColumnSchema = ColumnSchemaBase.refine( - (data) => { - if (data.required === true && data.default !== null) { - return false; - } - return true; - }, - { - message: "When 'required' is true, 'default' must be null", - path: ["default"], - }, -).refine( - (data) => { - if ( - data.type === "string" && - (data.size === undefined || data.size === null) - ) { - return false; - } - return true; - }, - { - message: "When 'type' is 'string', 'size' must be defined", - path: ["size"], - }, -); - const IndexTableSchema = z .object({ key: z.string(), @@ -411,7 +316,7 @@ const IndexTableSchema = z }) .strict(); -const TablesDBSchemaBase = z +const TableSchema = z .object({ $id: z.string(), $permissions: z.array(z.string()).optional(), @@ -422,41 +327,8 @@ const TablesDBSchemaBase = z columns: z.array(ColumnSchema).optional(), indexes: z.array(IndexTableSchema).optional(), }) - .strict(); - -const TablesDBSchema = TablesDBSchemaBase.superRefine((data, ctx) => { - if (data.columns && data.columns.length > 0) { - const seenKeys = new Set(); - - data.columns.forEach((col, index) => { - if (seenKeys.has(col.key)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Column with the key '${col.key}' already exists. Column keys must be unique, try again with a different key.`, - path: ["columns", index, "key"], - }); - } else { - seenKeys.add(col.key); - } - }); - } - - if (data.indexes && data.indexes.length > 0) { - const seenKeys = new Set(); - - data.indexes.forEach((index, indexPos) => { - if (seenKeys.has(index.key)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Index with the key '${index.key}' already exists. Index keys must be unique, try again with a different key.`, - path: ["indexes", indexPos, "key"], - }); - } else { - seenKeys.add(index.key); - } - }); - } -}); + .strict() + .superRefine(validateContainerDuplicates); // ============================================================================ // Topics @@ -516,7 +388,31 @@ const BucketSchema = z .strict(); // ============================================================================ -// Type Exports (inferred from Zod schemas - single source of truth) +// Config Schema +// ============================================================================ + +const ConfigSchema = z + .object({ + projectId: z.string(), + projectName: z.string().optional(), + endpoint: z.string().optional(), + settings: z.lazy(() => SettingsSchema).optional(), + functions: z.array(z.lazy(() => FunctionSchema)).optional(), + sites: z.array(z.lazy(() => SiteSchema)).optional(), + databases: z.array(z.lazy(() => DatabaseSchema)).optional(), + collections: z.array(z.lazy(() => CollectionSchema)).optional(), + tablesDB: z.array(z.lazy(() => DatabaseSchema)).optional(), + tables: z.array(z.lazy(() => TableSchema)).optional(), + topics: z.array(z.lazy(() => TopicSchema)).optional(), + teams: z.array(z.lazy(() => TeamSchema)).optional(), + buckets: z.array(z.lazy(() => BucketSchema)).optional(), + messages: z.array(z.lazy(() => MessageSchema)).optional(), + }) + .strict() + .superRefine(validateCrossDatabase); + +// ============================================================================ +// Type Exports // ============================================================================ export type ConfigType = z.infer; @@ -527,7 +423,7 @@ export type DatabaseType = z.infer; export type CollectionType = z.infer; export type AttributeType = z.infer; export type IndexType = z.infer; -export type TableType = z.infer; +export type TableType = z.infer; export type ColumnType = z.infer; export type TableIndexType = z.infer; export type TopicType = z.infer; @@ -540,6 +436,7 @@ export type BucketType = z.infer; // ============================================================================ export { + /** Config */ ConfigSchema, /** Project Settings */ @@ -558,10 +455,8 @@ export { IndexSchema, /** Tables */ - TablesDBSchema, - TablesDBSchemaBase, + TableSchema, ColumnSchema, - ColumnSchemaBase, IndexTableSchema, /** Topics */ diff --git a/templates/cli/lib/commands/pull.ts b/templates/cli/lib/commands/pull.ts index 55128694bb..4240f25b54 100644 --- a/templates/cli/lib/commands/pull.ts +++ b/templates/cli/lib/commands/pull.ts @@ -39,8 +39,8 @@ import { import type { ConfigType } from "./config.js"; import { DatabaseSchema, - TablesDBSchemaBase, - ColumnSchemaBase, + TableSchema, + ColumnSchema, BucketSchema, TopicSchema, } from "./config.js"; @@ -542,11 +542,11 @@ export class Pull { for (const table of tables) { // Filter columns to only include schema-defined fields const filteredColumns = table.columns?.map((col: any) => - filterBySchema(col, ColumnSchemaBase), + filterBySchema(col, ColumnSchema), ); allTables.push({ - ...filterBySchema(table, TablesDBSchemaBase), + ...filterBySchema(table, TableSchema), columns: filteredColumns || [], }); } diff --git a/templates/cli/lib/commands/utils/attributes.ts b/templates/cli/lib/commands/utils/attributes.ts index f83fb4bf52..91d2f1df26 100644 --- a/templates/cli/lib/commands/utils/attributes.ts +++ b/templates/cli/lib/commands/utils/attributes.ts @@ -480,6 +480,13 @@ export class Attributes { ); }; + /** + * Check if attribute is a child-side relationship + * Child-side relationships are auto-generated by Appwrite and should be skipped + */ + private isChildSideRelationship = (attribute: any): boolean => + attribute.type === "relationship" && attribute.side === "child"; + /** * Filter deleted and recreated attributes, * return list of attributes to create and whether any changes were made @@ -490,30 +497,42 @@ export class Attributes { collection: Collection, isIndex: boolean = false, ): Promise<{ attributes: any[]; hasChanges: boolean }> => { - const deleting = remoteAttributes + // Filter out child-side relationships from both local and remote attributes for comparison + // Child-side relationships are auto-generated by Appwrite when creating two-way relationships + // from the parent side, so we should not compare or try to create them directly + const filteredLocalAttributes = localAttributes.filter( + (attr) => !this.isChildSideRelationship(attr), + ); + let filteredRemoteAttributes = remoteAttributes.filter( + (attr) => !this.isChildSideRelationship(attr), + ); + + const deleting = filteredRemoteAttributes .filter( - (attribute) => !this.attributesContains(attribute, localAttributes), + (attribute) => + !this.attributesContains(attribute, filteredLocalAttributes), ) .map((attr) => this.generateChangesObject(attr, collection, false)); - const adding = localAttributes + const adding = filteredLocalAttributes .filter( - (attribute) => !this.attributesContains(attribute, remoteAttributes), + (attribute) => + !this.attributesContains(attribute, filteredRemoteAttributes), ) .map((attr) => this.generateChangesObject(attr, collection, true)); - const conflicts = remoteAttributes + const conflicts = filteredRemoteAttributes .map((attribute) => this.checkAttributeChanges( attribute, - this.attributesContains(attribute, localAttributes), + this.attributesContains(attribute, filteredLocalAttributes), collection, ), ) .filter((attribute) => attribute !== undefined) as AttributeChange[]; - const changes = remoteAttributes + const changes = filteredRemoteAttributes .map((attribute) => this.checkAttributeChanges( attribute, - this.attributesContains(attribute, localAttributes), + this.attributesContains(attribute, filteredLocalAttributes), collection, false, ), @@ -584,7 +603,7 @@ export class Attributes { this.deleteAttribute(collection, changed, isIndex), ), ); - remoteAttributes = remoteAttributes.filter( + filteredRemoteAttributes = filteredRemoteAttributes.filter( (attribute) => !this.attributesContains(attribute, changedAttributes), ); } @@ -631,8 +650,9 @@ export class Attributes { } } - const newAttributes = localAttributes.filter( - (attribute) => !this.attributesContains(attribute, remoteAttributes), + const newAttributes = filteredLocalAttributes.filter( + (attribute) => + !this.attributesContains(attribute, filteredRemoteAttributes), ); return { attributes: newAttributes, hasChanges: true }; }; diff --git a/templates/cli/lib/config.ts b/templates/cli/lib/config.ts index 9d303ff900..ae273a85d8 100644 --- a/templates/cli/lib/config.ts +++ b/templates/cli/lib/config.ts @@ -22,8 +22,8 @@ import { CollectionSchema, AttributeSchema, IndexSchema, - TablesDBSchemaBase, - ColumnSchemaBase, + TableSchema, + ColumnSchema, IndexTableSchema, TopicSchema, TeamSchema, @@ -60,12 +60,12 @@ const KeysSite = getSchemaKeys(SiteSchema); const KeysFunction = getSchemaKeys(FunctionSchema); const KeysDatabase = getSchemaKeys(DatabaseSchema); const KeysCollection = getSchemaKeys(CollectionSchema); -const KeysTable = getSchemaKeys(TablesDBSchemaBase); +const KeysTable = getSchemaKeys(TableSchema); const KeysStorage = getSchemaKeys(BucketSchema); const KeysTopics = getSchemaKeys(TopicSchema); const KeysTeams = getSchemaKeys(TeamSchema); const KeysAttributes = getSchemaKeys(AttributeSchema); -const KeysColumns = getSchemaKeys(ColumnSchemaBase); +const KeysColumns = getSchemaKeys(ColumnSchema); const KeyIndexes = getSchemaKeys(IndexSchema); const KeyIndexesColumns = getSchemaKeys(IndexTableSchema); From ff6acdf81905d9b95d07c94d1eb2017cc2a617e3 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 20 Jan 2026 19:32:07 +0530 Subject: [PATCH 24/24] add validation --- templates/cli/lib/commands/config.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/templates/cli/lib/commands/config.ts b/templates/cli/lib/commands/config.ts index 5947ac9cc9..33da6446f4 100644 --- a/templates/cli/lib/commands/config.ts +++ b/templates/cli/lib/commands/config.ts @@ -304,7 +304,15 @@ const ColumnSchema = z orders: z.array(z.string()).optional(), encrypt: z.boolean().optional(), }) - .strict(); + .strict() + .refine(validateRequiredDefault, { + message: "When 'required' is true, 'default' must be null", + path: ["default"], + }) + .refine(validateStringSize, { + message: "When 'type' is 'string', 'size' must be defined", + path: ["size"], + }); const IndexTableSchema = z .object({