appwrite-utils-cli
Version:
Appwrite Utility Functions to help with database management, data conversion, data import, migrations, and much more. Meant to be used as a CLI tool, I do not recommend installing this in frontend environments.
1,468 lines (1,323 loc) • 76.2 kB
text/typescript
import {
converterFunctions,
tryAwaitWithRetry,
parseAttribute,
objectNeedsUpdate,
} from "appwrite-utils";
import {
Client,
Databases,
Storage,
Users,
Functions,
Teams,
type Models,
Query,
AppwriteException,
} from "node-appwrite";
import { InputFile } from "node-appwrite/file";
import { MessageFormatter } from "../shared/messageFormatter.js";
import { processQueue, queuedOperations } from "../shared/operationQueue.js";
import { ProgressManager } from "../shared/progressManager.js";
import { getClient } from "../utils/getClientFromConfig.js";
import {
transferDatabaseLocalToLocal,
transferDatabaseLocalToRemote,
transferStorageLocalToLocal,
transferStorageLocalToRemote,
transferUsersLocalToRemote,
} from "./transfer.js";
import { deployLocalFunction } from "../functions/deployments.js";
import {
listFunctions,
downloadLatestFunctionDeployment,
} from "../functions/methods.js";
import pLimit from "p-limit";
import chalk from "chalk";
import { join } from "node:path";
import fs from "node:fs";
import type { DatabaseAdapter } from "../adapters/DatabaseAdapter.js";
import { getAdapter } from "../utils/getClientFromConfig.js";
import { mapToCreateAttributeParams } from "../shared/attributeMapper.js";
export interface ComprehensiveTransferOptions {
sourceEndpoint: string;
sourceProject: string;
sourceKey: string;
targetEndpoint: string;
targetProject: string;
targetKey: string;
transferUsers?: boolean;
transferTeams?: boolean;
transferDatabases?: boolean;
transferBuckets?: boolean;
transferFunctions?: boolean;
concurrencyLimit?: number; // 5-100 in steps of 5
dryRun?: boolean;
}
export interface TransferResults {
users: { transferred: number; skipped: number; failed: number };
teams: { transferred: number; skipped: number; failed: number };
databases: { transferred: number; skipped: number; failed: number };
buckets: { transferred: number; skipped: number; failed: number };
functions: { transferred: number; skipped: number; failed: number };
totalTime: number;
}
export class ComprehensiveTransfer {
private sourceClient: Client;
private targetClient: Client;
private sourceUsers: Users;
private targetUsers: Users;
private sourceTeams: Teams;
private targetTeams: Teams;
private sourceDatabases: Databases;
private targetDatabases: Databases;
private sourceStorage: Storage;
private targetStorage: Storage;
private sourceFunctions: Functions;
private targetFunctions: Functions;
private limit: ReturnType<typeof pLimit>;
private userLimit: ReturnType<typeof pLimit>;
private fileLimit: ReturnType<typeof pLimit>;
private results: TransferResults;
private startTime: number;
private tempDir: string;
private cachedMaxFileSize?: number; // Cache successful maximumFileSize for subsequent buckets
private sourceAdapter?: DatabaseAdapter;
private targetAdapter?: DatabaseAdapter;
constructor(private options: ComprehensiveTransferOptions) {
this.sourceClient = getClient(
options.sourceEndpoint,
options.sourceProject,
options.sourceKey
);
this.targetClient = getClient(
options.targetEndpoint,
options.targetProject,
options.targetKey
);
this.sourceUsers = new Users(this.sourceClient);
this.targetUsers = new Users(this.targetClient);
this.sourceTeams = new Teams(this.sourceClient);
this.targetTeams = new Teams(this.targetClient);
this.sourceDatabases = new Databases(this.sourceClient);
this.targetDatabases = new Databases(this.targetClient);
this.sourceStorage = new Storage(this.sourceClient);
this.targetStorage = new Storage(this.targetClient);
this.sourceFunctions = new Functions(this.sourceClient);
this.targetFunctions = new Functions(this.targetClient);
const baseLimit = options.concurrencyLimit || 10;
this.limit = pLimit(baseLimit);
// Different rate limits for different operations to prevent API throttling
// Users: Half speed (more sensitive operations)
// Files: Quarter speed (most bandwidth intensive)
this.userLimit = pLimit(Math.max(1, Math.floor(baseLimit / 2)));
this.fileLimit = pLimit(Math.max(1, Math.floor(baseLimit / 4)));
this.results = {
users: { transferred: 0, skipped: 0, failed: 0 },
teams: { transferred: 0, skipped: 0, failed: 0 },
databases: { transferred: 0, skipped: 0, failed: 0 },
buckets: { transferred: 0, skipped: 0, failed: 0 },
functions: { transferred: 0, skipped: 0, failed: 0 },
totalTime: 0,
};
this.startTime = Date.now();
this.tempDir = join(process.cwd(), ".appwrite-transfer-temp");
}
async execute(): Promise<TransferResults> {
try {
MessageFormatter.info("Starting comprehensive transfer", {
prefix: "Transfer",
});
// Initialize adapters for unified API (TablesDB or legacy via adapter)
const source = await getAdapter(
this.options.sourceEndpoint,
this.options.sourceProject,
this.options.sourceKey,
'auto'
);
const target = await getAdapter(
this.options.targetEndpoint,
this.options.targetProject,
this.options.targetKey,
'auto'
);
this.sourceAdapter = source.adapter;
this.targetAdapter = target.adapter;
if (this.options.dryRun) {
MessageFormatter.info("DRY RUN MODE - No actual changes will be made", {
prefix: "Transfer",
});
}
// Show rate limiting configuration
const baseLimit = this.options.concurrencyLimit || 10;
const userLimit = Math.max(1, Math.floor(baseLimit / 2));
const fileLimit = Math.max(1, Math.floor(baseLimit / 4));
MessageFormatter.info(
`Rate limits: General=${baseLimit}, Users=${userLimit}, Files=${fileLimit}`,
{ prefix: "Transfer" }
);
// Ensure temp directory exists
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
// Execute transfers in the correct order
if (this.options.transferUsers !== false) {
await this.transferAllUsers();
}
if (this.options.transferTeams !== false) {
await this.transferAllTeams();
}
if (this.options.transferDatabases !== false) {
await this.transferAllDatabases();
}
if (this.options.transferBuckets !== false) {
await this.transferAllBuckets();
}
if (this.options.transferFunctions !== false) {
await this.transferAllFunctions();
}
this.results.totalTime = Date.now() - this.startTime;
this.printSummary();
return this.results;
} catch (error) {
MessageFormatter.error(
"Comprehensive transfer failed",
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
throw error;
} finally {
// Clean up temp directory
if (fs.existsSync(this.tempDir)) {
fs.rmSync(this.tempDir, { recursive: true, force: true });
}
}
}
private async transferAllUsers(): Promise<void> {
MessageFormatter.info("Starting user transfer phase", {
prefix: "Transfer",
});
if (this.options.dryRun) {
const usersList = await this.sourceUsers.list([Query.limit(1)]);
MessageFormatter.info(
`DRY RUN: Would transfer ${usersList.total} users`,
{ prefix: "Transfer" }
);
return;
}
try {
// Use the existing user transfer function
// Note: The rate limiting is handled at the API level, not per-user
// since user operations are already sequential in the existing implementation
await transferUsersLocalToRemote(
this.sourceUsers,
this.options.targetEndpoint,
this.options.targetProject,
this.options.targetKey
);
// Get actual count for results
const usersList = await this.sourceUsers.list([Query.limit(1)]);
this.results.users.transferred = usersList.total;
MessageFormatter.success(`User transfer completed`, {
prefix: "Transfer",
});
} catch (error) {
MessageFormatter.error(
"User transfer failed",
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
this.results.users.failed = 1;
}
}
private async transferAllTeams(): Promise<void> {
MessageFormatter.info("Starting team transfer phase", {
prefix: "Transfer",
});
try {
// Fetch all teams from source with pagination
const allSourceTeams = await this.fetchAllTeams(this.sourceTeams);
const allTargetTeams = await this.fetchAllTeams(this.targetTeams);
if (this.options.dryRun) {
let totalMemberships = 0;
for (const team of allSourceTeams) {
const memberships = await this.sourceTeams.listMemberships(team.$id, [
Query.limit(1),
]);
totalMemberships += memberships.total;
}
MessageFormatter.info(
`DRY RUN: Would transfer ${allSourceTeams.length} teams with ${totalMemberships} memberships`,
{ prefix: "Transfer" }
);
return;
}
const transferTasks = allSourceTeams.map((team) =>
this.limit(async () => {
try {
// Check if team exists in target
const existingTeam = allTargetTeams.find(
(tt) => tt.$id === team.$id
);
if (!existingTeam) {
// Fetch all memberships to extract unique roles before creating team
MessageFormatter.info(
`Fetching memberships for team ${team.name} to extract roles`,
{ prefix: "Transfer" }
);
const memberships = await this.fetchAllMemberships(team.$id);
// Extract unique roles from all memberships
const allRoles = new Set<string>();
memberships.forEach((membership) => {
membership.roles.forEach((role) => allRoles.add(role));
});
const uniqueRoles = Array.from(allRoles);
MessageFormatter.info(
`Found ${uniqueRoles.length} unique roles for team ${
team.name
}: ${uniqueRoles.join(", ")}`,
{ prefix: "Transfer" }
);
// Create team in target with the collected roles
await this.targetTeams.create(team.$id, team.name, uniqueRoles);
MessageFormatter.success(
`Created team: ${team.name} with roles: ${uniqueRoles.join(
", "
)}`,
{ prefix: "Transfer" }
);
} else {
MessageFormatter.info(
`Team ${team.name} already exists, updating if needed`,
{ prefix: "Transfer" }
);
// Update team if needed
if (existingTeam.name !== team.name) {
await this.targetTeams.updateName(team.$id, team.name);
MessageFormatter.success(`Updated team name: ${team.name}`, {
prefix: "Transfer",
});
}
}
// Transfer team memberships
await this.transferTeamMemberships(team.$id);
this.results.teams.transferred++;
MessageFormatter.success(
`Team ${team.name} transferred successfully`,
{ prefix: "Transfer" }
);
} catch (error) {
MessageFormatter.error(
`Team ${team.name} transfer failed`,
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
this.results.teams.failed++;
}
})
);
await Promise.all(transferTasks);
MessageFormatter.success("Team transfer phase completed", {
prefix: "Transfer",
});
} catch (error) {
MessageFormatter.error(
"Team transfer phase failed",
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
}
}
private async transferAllDatabases(): Promise<void> {
MessageFormatter.info("Starting database transfer phase", {
prefix: "Transfer",
});
try {
const sourceDatabases = await this.sourceDatabases.list();
const targetDatabases = await this.targetDatabases.list();
if (this.options.dryRun) {
MessageFormatter.info(
`DRY RUN: Would transfer ${sourceDatabases.databases.length} databases`,
{ prefix: "Transfer" }
);
return;
}
// Phase 1: Create all databases and collections (structure only)
MessageFormatter.info(
"Phase 1: Creating database structures (databases, collections, attributes, indexes)",
{ prefix: "Transfer" }
);
const structureCreationTasks = sourceDatabases.databases.map((db) =>
this.limit(async () => {
try {
// Check if database exists in target
const existingDb = targetDatabases.databases.find(
(tdb) => tdb.$id === db.$id
);
if (!existingDb) {
// Create database in target
await this.targetDatabases.create(db.$id, db.name, db.enabled);
MessageFormatter.success(`Created database: ${db.name}`, {
prefix: "Transfer",
});
}
// Create collections, attributes, and indexes WITHOUT transferring documents
await this.createDatabaseStructure(db.$id);
MessageFormatter.success(`Database structure created: ${db.name}`, {
prefix: "Transfer",
});
} catch (error) {
MessageFormatter.error(
`Database structure creation failed for ${db.name}`,
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
this.results.databases.failed++;
}
})
);
await Promise.all(structureCreationTasks);
// Phase 2: Transfer all documents after all structures are created
MessageFormatter.info(
"Phase 2: Transferring documents to all collections",
{ prefix: "Transfer" }
);
const documentTransferTasks = sourceDatabases.databases.map((db) =>
this.limit(async () => {
try {
// Transfer documents for this database
await this.transferDatabaseDocuments(db.$id);
this.results.databases.transferred++;
MessageFormatter.success(
`Database documents transferred: ${db.name}`,
{ prefix: "Transfer" }
);
} catch (error) {
MessageFormatter.error(
`Document transfer failed for ${db.name}`,
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
this.results.databases.failed++;
}
})
);
await Promise.all(documentTransferTasks);
MessageFormatter.success("Database transfer phase completed", {
prefix: "Transfer",
});
} catch (error) {
MessageFormatter.error(
"Database transfer phase failed",
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
}
}
/**
* Phase 1: Create database structure (collections, attributes, indexes) without transferring documents
*/
private async createDatabaseStructure(dbId: string): Promise<void> {
MessageFormatter.info(`Creating database structure for ${dbId}`, {
prefix: "Transfer",
});
try {
// Get all collections from source database
const sourceCollections = await this.fetchAllCollections(
dbId,
this.sourceDatabases
);
MessageFormatter.info(
`Found ${sourceCollections.length} collections in source database ${dbId}`,
{ prefix: "Transfer" }
);
// Process each collection
for (const collection of sourceCollections) {
MessageFormatter.info(
`Processing collection: ${collection.name} (${collection.$id})`,
{ prefix: "Transfer" }
);
try {
// Create or update collection in target
let targetCollection: Models.Collection;
const existingCollection = await tryAwaitWithRetry(async () =>
this.targetDatabases.listCollections(dbId, [
Query.equal("$id", collection.$id),
])
);
if (existingCollection.collections.length > 0) {
targetCollection = existingCollection.collections[0];
MessageFormatter.info(
`Collection ${collection.name} exists in target database`,
{ prefix: "Transfer" }
);
// Update collection if needed
if (
targetCollection.name !== collection.name ||
JSON.stringify(targetCollection.$permissions) !==
JSON.stringify(collection.$permissions) ||
targetCollection.documentSecurity !==
collection.documentSecurity ||
targetCollection.enabled !== collection.enabled
) {
targetCollection = await tryAwaitWithRetry(async () =>
this.targetDatabases.updateCollection(
dbId,
collection.$id,
collection.name,
collection.$permissions,
collection.documentSecurity,
collection.enabled
)
);
MessageFormatter.success(
`Collection ${collection.name} updated`,
{ prefix: "Transfer" }
);
}
} else {
MessageFormatter.info(
`Creating collection ${collection.name} in target database...`,
{ prefix: "Transfer" }
);
targetCollection = await tryAwaitWithRetry(async () =>
this.targetDatabases.createCollection(
dbId,
collection.$id,
collection.name,
collection.$permissions,
collection.documentSecurity,
collection.enabled
)
);
MessageFormatter.success(`Collection ${collection.name} created`, {
prefix: "Transfer",
});
}
// Handle attributes with enhanced status checking
MessageFormatter.info(
`Creating attributes for collection ${collection.name} with enhanced monitoring...`,
{ prefix: "Transfer" }
);
const attributesToCreate = collection.attributes.map((attr) =>
parseAttribute(attr as any)
);
const attributesSuccess =
await this.createCollectionAttributesWithStatusCheck(
this.targetDatabases,
dbId,
targetCollection,
attributesToCreate
);
if (!attributesSuccess) {
MessageFormatter.error(
`Failed to create some attributes for collection ${collection.name}`,
undefined,
{ prefix: "Transfer" }
);
MessageFormatter.error(
`Skipping index creation and document transfer for collection ${collection.name} due to attribute failures`,
undefined,
{ prefix: "Transfer" }
);
// Skip indexes and document transfer if attributes failed
continue;
} else {
MessageFormatter.success(
`All attributes created successfully for collection ${collection.name}`,
{ prefix: "Transfer" }
);
}
// Handle indexes with enhanced status checking
MessageFormatter.info(
`Creating indexes for collection ${collection.name} with enhanced monitoring...`,
{ prefix: "Transfer" }
);
let indexesSuccess = true;
// Check if indexes need to be created ahead of time
if (
collection.indexes.some(
(index) =>
!targetCollection.indexes.some(
(ti) =>
ti.key === index.key ||
ti.attributes.sort().join(",") ===
index.attributes.sort().join(",")
)
) ||
collection.indexes.length !== targetCollection.indexes.length
) {
indexesSuccess = await this.createCollectionIndexesWithStatusCheck(
dbId,
this.targetDatabases,
targetCollection.$id,
targetCollection,
collection.indexes as any
);
}
if (!indexesSuccess) {
MessageFormatter.error(
`Failed to create some indexes for collection ${collection.name}`,
undefined,
{ prefix: "Transfer" }
);
MessageFormatter.warning(
`Proceeding with document transfer despite index failures for collection ${collection.name}`,
{ prefix: "Transfer" }
);
} else {
MessageFormatter.success(
`All indexes created successfully for collection ${collection.name}`,
{ prefix: "Transfer" }
);
}
MessageFormatter.success(
`Structure complete for collection ${collection.name}`,
{ prefix: "Transfer" }
);
} catch (error) {
MessageFormatter.error(
`Error processing collection ${collection.name}`,
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
}
}
// After processing all collections' attributes and indexes, process any queued
// relationship attributes so dependencies are resolved within this phase.
if (queuedOperations.length > 0) {
MessageFormatter.info(
`Processing ${queuedOperations.length} queued relationship operations`,
{ prefix: "Transfer" }
);
await processQueue(this.targetDatabases, dbId);
} else {
MessageFormatter.info("No queued relationship operations to process", {
prefix: "Transfer",
});
}
} catch (error) {
MessageFormatter.error(
`Failed to create database structure for ${dbId}`,
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
throw error;
}
}
/**
* Phase 2: Transfer documents to all collections in the database
*/
private async transferDatabaseDocuments(dbId: string): Promise<void> {
MessageFormatter.info(`Transferring documents for database ${dbId}`, {
prefix: "Transfer",
});
try {
// Get all collections from source database
const sourceCollections = await this.fetchAllCollections(
dbId,
this.sourceDatabases
);
MessageFormatter.info(
`Transferring documents for ${sourceCollections.length} collections in database ${dbId}`,
{ prefix: "Transfer" }
);
// Process each collection
for (const collection of sourceCollections) {
MessageFormatter.info(
`Transferring documents for collection: ${collection.name} (${collection.$id})`,
{ prefix: "Transfer" }
);
try {
// Transfer documents
await this.transferDocumentsBetweenDatabases(
this.sourceDatabases,
this.targetDatabases,
dbId,
dbId,
collection.$id,
collection.$id
);
MessageFormatter.success(
`Documents transferred for collection ${collection.name}`,
{ prefix: "Transfer" }
);
} catch (error) {
MessageFormatter.error(
`Error transferring documents for collection ${collection.name}`,
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
}
}
} catch (error) {
MessageFormatter.error(
`Failed to transfer documents for database ${dbId}`,
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
throw error;
}
}
private async transferAllBuckets(): Promise<void> {
MessageFormatter.info("Starting bucket transfer phase", {
prefix: "Transfer",
});
try {
// Get all buckets from source with pagination
const allSourceBuckets = await this.fetchAllBuckets(this.sourceStorage);
const allTargetBuckets = await this.fetchAllBuckets(this.targetStorage);
if (this.options.dryRun) {
let totalFiles = 0;
for (const bucket of allSourceBuckets) {
const files = await this.sourceStorage.listFiles(bucket.$id, [
Query.limit(1),
]);
totalFiles += files.total;
}
MessageFormatter.info(
`DRY RUN: Would transfer ${allSourceBuckets.length} buckets with ${totalFiles} files`,
{ prefix: "Transfer" }
);
return;
}
const transferTasks = allSourceBuckets.map((bucket) =>
this.limit(async () => {
try {
// Check if bucket exists in target
const existingBucket = allTargetBuckets.find(
(tb) => tb.$id === bucket.$id
);
if (!existingBucket) {
// Create bucket with fallback strategy for maximumFileSize
await this.createBucketWithFallback(bucket);
MessageFormatter.success(`Created bucket: ${bucket.name}`, {
prefix: "Transfer",
});
} else {
// Compare bucket permissions and update if needed
const sourcePermissions = JSON.stringify(
bucket.$permissions?.sort() || []
);
const targetPermissions = JSON.stringify(
existingBucket.$permissions?.sort() || []
);
if (
sourcePermissions !== targetPermissions ||
existingBucket.name !== bucket.name ||
existingBucket.fileSecurity !== bucket.fileSecurity ||
existingBucket.enabled !== bucket.enabled
) {
MessageFormatter.warning(
`Bucket ${bucket.name} exists but has different settings. Updating to match source.`,
{ prefix: "Transfer" }
);
try {
await this.targetStorage.updateBucket(
bucket.$id,
bucket.name,
bucket.$permissions,
bucket.fileSecurity,
bucket.enabled,
bucket.maximumFileSize,
bucket.allowedFileExtensions,
bucket.compression as any,
bucket.encryption,
bucket.antivirus
);
MessageFormatter.success(
`Updated bucket ${bucket.name} to match source`,
{ prefix: "Transfer" }
);
} catch (updateError) {
MessageFormatter.error(
`Failed to update bucket ${bucket.name}`,
updateError instanceof Error
? updateError
: new Error(String(updateError)),
{ prefix: "Transfer" }
);
}
} else {
MessageFormatter.info(
`Bucket ${bucket.name} already exists with matching settings`,
{ prefix: "Transfer" }
);
}
}
// Transfer bucket files with enhanced validation
await this.transferBucketFiles(bucket.$id, bucket.$id);
this.results.buckets.transferred++;
MessageFormatter.success(
`Bucket ${bucket.name} transferred successfully`,
{ prefix: "Transfer" }
);
} catch (error) {
MessageFormatter.error(
`Bucket ${bucket.name} transfer failed`,
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
this.results.buckets.failed++;
}
})
);
await Promise.all(transferTasks);
MessageFormatter.success("Bucket transfer phase completed", {
prefix: "Transfer",
});
} catch (error) {
MessageFormatter.error(
"Bucket transfer phase failed",
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
}
}
private async createBucketWithFallback(bucket: Models.Bucket): Promise<void> {
// Determine the optimal size to try first
let sizeToTry: number;
if (this.cachedMaxFileSize) {
// Use cached size if it's smaller than or equal to the bucket's original size
if (bucket.maximumFileSize >= this.cachedMaxFileSize) {
sizeToTry = this.cachedMaxFileSize;
MessageFormatter.info(
`Bucket ${bucket.name}: Using cached maximumFileSize ${sizeToTry} (${(
sizeToTry / 1_000_000_000
).toFixed(1)}GB)`,
{ prefix: "Transfer" }
);
} else {
// Original size is smaller than cached size, try original first
sizeToTry = bucket.maximumFileSize;
}
} else {
// No cached size yet, try original size first
sizeToTry = bucket.maximumFileSize;
}
// Try the optimal size first
try {
await this.targetStorage.createBucket(
bucket.$id,
bucket.name,
bucket.$permissions,
bucket.fileSecurity,
bucket.enabled,
sizeToTry,
bucket.allowedFileExtensions,
bucket.compression as any,
bucket.encryption,
bucket.antivirus
);
// Success - cache this size if it's not already cached or is smaller than cached
if (!this.cachedMaxFileSize || sizeToTry < this.cachedMaxFileSize) {
this.cachedMaxFileSize = sizeToTry;
MessageFormatter.info(
`Bucket ${
bucket.name
}: Cached successful maximumFileSize ${sizeToTry} (${(
sizeToTry / 1_000_000_000
).toFixed(1)}GB)`,
{ prefix: "Transfer" }
);
}
// Log if we used a different size than original
if (sizeToTry !== bucket.maximumFileSize) {
MessageFormatter.warning(
`Bucket ${
bucket.name
}: maximumFileSize used ${sizeToTry} instead of original ${
bucket.maximumFileSize
} (${(sizeToTry / 1_000_000_000).toFixed(1)}GB)`,
{ prefix: "Transfer" }
);
}
return; // Success, exit the function
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
// Check if the error is related to maximumFileSize validation
if (
err.message.includes("maximumFileSize") ||
err.message.includes("valid range")
) {
MessageFormatter.warning(
`Bucket ${bucket.name}: Failed with maximumFileSize ${sizeToTry}, falling back to smaller sizes...`,
{ prefix: "Transfer" }
);
// Continue to fallback logic below
} else {
// Different error, don't retry
throw err;
}
}
// Fallback to progressively smaller sizes
const fallbackSizes = [
5_000_000_000, // 5GB
2_500_000_000, // 2.5GB
2_000_000_000, // 2GB
1_000_000_000, // 1GB
500_000_000, // 500MB
100_000_000, // 100MB
];
// Remove sizes that are larger than or equal to the already-tried size
const validSizes = fallbackSizes
.filter((size) => size < sizeToTry)
.sort((a, b) => b - a); // Sort descending
let lastError: Error | null = null;
for (const fileSize of validSizes) {
try {
await this.targetStorage.createBucket(
bucket.$id,
bucket.name,
bucket.$permissions,
bucket.fileSecurity,
bucket.enabled,
fileSize,
bucket.allowedFileExtensions,
bucket.compression as any,
bucket.encryption,
bucket.antivirus
);
// Success - cache this size if it's not already cached or is smaller than cached
if (!this.cachedMaxFileSize || fileSize < this.cachedMaxFileSize) {
this.cachedMaxFileSize = fileSize;
MessageFormatter.info(
`Bucket ${
bucket.name
}: Cached successful maximumFileSize ${fileSize} (${(
fileSize / 1_000_000_000
).toFixed(1)}GB)`,
{ prefix: "Transfer" }
);
}
// Log if we had to reduce the file size
if (fileSize !== bucket.maximumFileSize) {
MessageFormatter.warning(
`Bucket ${bucket.name}: maximumFileSize reduced from ${
bucket.maximumFileSize
} to ${fileSize} (${(fileSize / 1_000_000_000).toFixed(1)}GB)`,
{ prefix: "Transfer" }
);
}
return; // Success, exit the function
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Check if the error is related to maximumFileSize validation
if (
lastError.message.includes("maximumFileSize") ||
lastError.message.includes("valid range")
) {
MessageFormatter.warning(
`Bucket ${bucket.name}: Failed with maximumFileSize ${fileSize}, trying smaller size...`,
{ prefix: "Transfer" }
);
continue; // Try next smaller size
} else {
// Different error, don't retry
throw lastError;
}
}
}
// If we get here, all fallback sizes failed
MessageFormatter.error(
`Bucket ${bucket.name}: All fallback file sizes failed. Last error: ${lastError?.message}`,
lastError || undefined,
{ prefix: "Transfer" }
);
throw lastError || new Error("All fallback file sizes failed");
}
private async transferBucketFiles(
sourceBucketId: string,
targetBucketId: string
): Promise<void> {
let lastFileId: string | undefined;
let transferredFiles = 0;
while (true) {
const queries = [Query.limit(50)]; // Smaller batch size for better rate limiting
if (lastFileId) {
queries.push(Query.cursorAfter(lastFileId));
}
const files = await this.sourceStorage.listFiles(sourceBucketId, queries);
if (files.files.length === 0) break;
// Process files with rate limiting
const fileTasks = files.files.map((file) =>
this.fileLimit(async () => {
try {
// Check if file already exists and compare permissions
let existingFile: Models.File | null = null;
try {
existingFile = await this.targetStorage.getFile(
targetBucketId,
file.$id
);
// Compare permissions between source and target file
const sourcePermissions = JSON.stringify(
file.$permissions?.sort() || []
);
const targetPermissions = JSON.stringify(
existingFile.$permissions?.sort() || []
);
if (sourcePermissions !== targetPermissions) {
MessageFormatter.warning(
`File ${file.name} (${file.$id}) exists but has different permissions. Source: ${sourcePermissions}, Target: ${targetPermissions}`,
{ prefix: "Transfer" }
);
// Update file permissions to match source
try {
await this.targetStorage.updateFile(
targetBucketId,
file.$id,
file.name,
file.$permissions
);
MessageFormatter.success(
`Updated file ${file.name} permissions to match source`,
{ prefix: "Transfer" }
);
} catch (updateError) {
MessageFormatter.error(
`Failed to update permissions for file ${file.name}`,
updateError instanceof Error
? updateError
: new Error(String(updateError)),
{ prefix: "Transfer" }
);
}
} else {
MessageFormatter.info(
`File ${file.name} already exists with matching permissions, skipping`,
{ prefix: "Transfer" }
);
}
return;
} catch (error) {
// File doesn't exist, proceed with transfer
}
// Download file with validation
const fileData = await this.validateAndDownloadFile(
sourceBucketId,
file.$id
);
if (!fileData) {
MessageFormatter.warning(
`File ${file.name} failed validation, skipping`,
{ prefix: "Transfer" }
);
return;
}
// Upload file to target
const fileToCreate = InputFile.fromBuffer(
new Uint8Array(fileData),
file.name
);
await this.targetStorage.createFile(
targetBucketId,
file.$id,
fileToCreate,
file.$permissions
);
transferredFiles++;
MessageFormatter.success(`Transferred file: ${file.name}`, {
prefix: "Transfer",
});
} catch (error) {
MessageFormatter.error(
`Failed to transfer file ${file.name}`,
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
}
})
);
await Promise.all(fileTasks);
if (files.files.length < 50) break;
lastFileId = files.files[files.files.length - 1].$id;
}
MessageFormatter.info(
`Transferred ${transferredFiles} files from bucket ${sourceBucketId}`,
{ prefix: "Transfer" }
);
}
private async validateAndDownloadFile(
bucketId: string,
fileId: string
): Promise<ArrayBuffer | null> {
let attempts = 3;
while (attempts > 0) {
try {
const fileData = await this.sourceStorage.getFileDownload(
bucketId,
fileId
);
// Basic validation - ensure file is not empty and not too large
if (fileData.byteLength === 0) {
MessageFormatter.warning(`File ${fileId} is empty`, {
prefix: "Transfer",
});
return null;
}
if (fileData.byteLength > 50 * 1024 * 1024) {
// 50MB limit
MessageFormatter.warning(
`File ${fileId} is too large (${fileData.byteLength} bytes)`,
{ prefix: "Transfer" }
);
return null;
}
return fileData;
} catch (error) {
attempts--;
MessageFormatter.warning(
`Error downloading file ${fileId}, attempts left: ${attempts}`,
{ prefix: "Transfer" }
);
if (attempts === 0) {
MessageFormatter.error(
`Failed to download file ${fileId} after all attempts`,
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
return null;
}
// Wait before retry
await new Promise((resolve) =>
setTimeout(resolve, 1000 * (4 - attempts))
);
}
}
return null;
}
private async transferAllFunctions(): Promise<void> {
MessageFormatter.info("Starting function transfer phase", {
prefix: "Transfer",
});
try {
const sourceFunctions = await listFunctions(this.sourceClient, [
Query.limit(1000),
]);
const targetFunctions = await listFunctions(this.targetClient, [
Query.limit(1000),
]);
if (this.options.dryRun) {
MessageFormatter.info(
`DRY RUN: Would transfer ${sourceFunctions.functions.length} functions`,
{ prefix: "Transfer" }
);
return;
}
const transferTasks = sourceFunctions.functions.map((func) =>
this.limit(async () => {
try {
// Check if function exists in target
const existingFunc = targetFunctions.functions.find(
(tf) => tf.$id === func.$id
);
if (existingFunc) {
MessageFormatter.info(
`Function ${func.name} already exists, skipping creation`,
{ prefix: "Transfer" }
);
this.results.functions.skipped++;
return;
}
// Download function from source
const functionPath = await this.downloadFunction(func);
if (!functionPath) {
MessageFormatter.error(
`Failed to download function ${func.name}`,
undefined,
{ prefix: "Transfer" }
);
this.results.functions.failed++;
return;
}
// Deploy function to target
const functionConfig = {
$id: func.$id,
name: func.name,
runtime: func.runtime as any,
execute: func.execute,
events: func.events,
enabled: func.enabled,
logging: func.logging,
entrypoint: func.entrypoint,
commands: func.commands,
scopes: func.scopes as any,
timeout: func.timeout,
schedule: func.schedule,
installationId: func.installationId,
providerRepositoryId: func.providerRepositoryId,
providerBranch: func.providerBranch,
providerSilentMode: func.providerSilentMode,
providerRootDirectory: func.providerRootDirectory,
specification: func.specification as any,
dirPath: functionPath,
};
await deployLocalFunction(
this.targetClient,
func.name,
functionConfig
);
this.results.functions.transferred++;
MessageFormatter.success(
`Function ${func.name} transferred successfully`,
{ prefix: "Transfer" }
);
} catch (error) {
MessageFormatter.error(
`Function ${func.name} transfer failed`,
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
this.results.functions.failed++;
}
})
);
await Promise.all(transferTasks);
MessageFormatter.success("Function transfer phase completed", {
prefix: "Transfer",
});
} catch (error) {
MessageFormatter.error(
"Function transfer phase failed",
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
}
}
private async downloadFunction(
func: Models.Function
): Promise<string | null> {
try {
const { path } = await downloadLatestFunctionDeployment(
this.sourceClient,
func.$id,
this.tempDir
);
return path;
} catch (error) {
MessageFormatter.error(
`Failed to download function ${func.name}`,
error instanceof Error ? error : new Error(String(error)),
{ prefix: "Transfer" }
);
return null;
}
}
/**
* Helper method to fetch all collections from a database
*/
private async fetchAllCollections(
dbId: string,
databases: Databases
): Promise<Models.Collection[]> {
const collections: Models.Collection[] = [];
let lastId: string | undefined;
while (true) {
const queries = [Query.limit(100)];
if (lastId) {
queries.push(Query.cursorAfter(lastId));
}
const result = await tryAwaitWithRetry(async () =>
databases.listCollections(dbId, queries)
);
if (result.collections.length === 0) {
break;
}
collections.push(...result.collections);
if (result.collections.length < 100) {
break;
}
lastId = result.collections[result.collections.length - 1].$id;
}
return collections;
}
/**
* Helper method to fetch all buckets with pagination
*/
private async fetchAllBuckets(storage: Storage): Promise<Models.Bucket[]> {
const buckets: Models.Bucket[] = [];
let lastId: string | undefined;
while (true) {
const queries = [Query.limit(100)];
if (lastId) {
queries.push(Query.cursorAfter(lastId));
}
const result = await tryAwaitWithRetry(async () =>
storage.listBuckets(queries)
);
if (result.buckets.length === 0) {
break;
}
buckets.push(...result.buckets);
if (result.buckets.length < 100) {
break;
}
lastId = result.buckets[result.buckets.length - 1].$id;
}
return buckets;
}
/**
* Helper method to parse attribute objects (simplified version of parseAttribute)
*/
private parseAttribute(attr: any): any {
// This is a simplified version - in production you'd use the actual parseAttribute from appwrite-utils
return {
key: attr.key,
type: attr.type,
size: attr.size,
required: attr.required,
array: attr.array,
default: attr.default,
format: attr.format,
elements: attr.elements,
min: attr.min,
max: attr.max,
relatedCollection: attr.relatedCollection,
relationType: attr.relationType,
twoWay: attr.twoWay,
twoWayKey: attr.twoWayKey,
onDelete: attr.onDelete,
side: attr.side,
};
}
/**
* Helper method to create collection attributes with status checking
*/
private async createCollectionAttributesWithStatusCheck(
databases: Databases,
dbId: string,
collection: Models.Collection,
attributes: any[]
): Promise<boolean> {
if (!this.targetAdapter) {
throw new Error('Target adapter not initialized');
}
try {
// Create non-relationship attributes first
const nonRel = (attributes || []).filter((a: any) => a.type !== 'relationship');
for (const attr of nonRel) {
const params = mapToCreateAttributeParams(attr as any, { databaseId: dbId, tableId: collection.$id });
await this.targetAdapter.createAttribute(params);
// Small delay between creations
await new Promise((r) => setTimeout(r, 150));
}
// Wait for attributes to become available
for (const attr of nonRel) {
const maxWait = 60000; // 60s
const start = Date.now();
let lastStatus = '';
while (Date.now() - start < maxWait) {
try {
const tableRes = await this.targetAdapter.getTable({ databaseId: dbId, tableId: collection.$id });
const cols = (tableRes as any).attributes || (tableRes as any).columns || [];
const col = cols.find((c: any) => c.key === attr.key);
if (col) {
if (col.status === 'available') break;
if (col.status === 'failed' || col.status === 'stuck') {
throw new Error(col.error || `Attribute ${attr.key} failed`);
}
lastStatus = col.status;
}
await new Promise((r) => setTimeout(r, 2000));
} catch {