UNPKG

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.

335 lines (302 loc) 10.8 kB
import { Databases, Query, type Models } from "node-appwrite"; import { fetchAllCollections } from "../collections/methods.js"; import type { AppwriteConfig, Attribute, RelationshipAttribute, } from "appwrite-utils"; import { logger } from "../shared/logging.js"; import { MessageFormatter } from "../shared/messageFormatter.js"; /** * Finds collections that have defined relationship attributes. */ export const findCollectionsWithRelationships = (config: AppwriteConfig) => { const toReturn = new Map<string, RelationshipAttribute[]>(); if (!config.collections) { return toReturn; } for (const collection of config.collections) { if (collection.attributes) { for (const attribute of collection.attributes) { if ( attribute.type === "relationship" && attribute.twoWay && attribute.side === "parent" ) { toReturn.set(collection.name, toReturn.get(collection.name) || []); toReturn .get(collection.name) ?.push(attribute as RelationshipAttribute); } } } } return toReturn; }; export async function resolveAndUpdateRelationships( dbId: string, database: Databases, config: AppwriteConfig ) { const collections = await fetchAllCollections(dbId, database); const collectionsWithRelationships = findCollectionsWithRelationships(config); // Process each collection sequentially for (const collection of collections) { MessageFormatter.processing( `Processing collection: ${collection.name} (${collection.$id})`, { prefix: "Migration" } ); const relAttributeMap = collectionsWithRelationships.get( collection.name ) as RelationshipAttribute[]; // Get the relationship attributes for the collections if (!relAttributeMap) { MessageFormatter.info( `No mapping found for collection: ${collection.name}, skipping...`, { prefix: "Migration" } ); continue; } await processCollection(dbId, database, collection, relAttributeMap); } MessageFormatter.success( `Completed relationship resolution and update for database ID: ${dbId}`, { prefix: "Migration" } ); } async function processCollection( dbId: string, database: Databases, collection: Models.Collection, relAttributeMap: RelationshipAttribute[] ) { let after; // For pagination let hasMore = true; while (hasMore) { const response: Models.DocumentList<Models.Document> = await database.listDocuments(dbId, collection.$id, [ Query.limit(100), // Fetch documents in batches of 100 ...(after ? [Query.cursorAfter(after)] : []), ]); const documents = response.documents; MessageFormatter.info( `Fetched ${documents.length} documents from collection: ${collection.name}`, { prefix: "Migration" } ); if (documents.length > 0) { const updates = await prepareDocumentUpdates( database, dbId, collection.name, documents, relAttributeMap ); // Execute updates for the current batch await executeUpdatesInBatches(dbId, database, updates); } if (documents.length === 100) { after = documents[documents.length - 1].$id; // Prepare for the next page } else { hasMore = false; // No more documents to fetch } } } async function findDocumentsByOriginalId( database: Databases, dbId: string, targetCollection: Models.Collection, targetKey: string, originalId: string | string[] ): Promise<Models.Document[] | undefined> { const relatedCollectionId = targetCollection.$id; const collection = await database.listCollections(dbId, [ Query.equal("$id", relatedCollectionId), ]); if (collection.total === 0) { MessageFormatter.warning( `Collection ${relatedCollectionId} doesn't exist, skipping...`, { prefix: "Migration" } ); return undefined; } const targetAttr = collection.collections[0].attributes.find( // @ts-ignore (attr) => attr.key === targetKey ) as any; if (!targetAttr) { MessageFormatter.warning( `Attribute ${targetKey} not found in collection ${relatedCollectionId}, skipping...`, { prefix: "Migration" } ); return undefined; } let queries: string[] = []; if (targetAttr.array) { // @ts-ignore queries.push(Query.contains(targetKey, originalId)); } else { queries.push(Query.equal(targetKey, originalId)); } const response = await database.listDocuments(dbId, relatedCollectionId, [ ...queries, Query.limit(500), // Adjust the limit based on your needs or implement pagination ]); if (response.documents.length < 0) { return undefined; } else if (response.documents.length > 0) { return response.documents; } else { return undefined; } } async function prepareDocumentUpdates( database: Databases, dbId: string, collectionName: string, documents: Models.Document[], relationships: RelationshipAttribute[] ): Promise<{ collectionId: string; documentId: string; updatePayload: any }[]> { MessageFormatter.processing(`Preparing updates for collection: ${collectionName}`, { prefix: "Migration" }); const updates: { collectionId: string; documentId: string; updatePayload: any; }[] = []; const thisCollection = ( await database.listCollections(dbId, [Query.equal("name", collectionName)]) ).collections[0]; const thisCollectionId = thisCollection?.$id; if (!thisCollectionId) { MessageFormatter.warning(`No collection found with name: ${collectionName}`, { prefix: "Migration" }); return []; } for (const doc of documents) { let updatePayload: { [key: string]: any } = {}; for (const rel of relationships) { // Skip if not dealing with the parent side of a two-way relationship if (rel.twoWay && rel.side !== "parent") { MessageFormatter.info("Skipping non-parent side of two-way relationship...", { prefix: "Migration" }); continue; } const isSingleReference = rel.relationType === "oneToOne" || rel.relationType === "manyToOne"; const originalIdField = rel.importMapping?.originalIdField; const targetField = rel.importMapping?.targetField || originalIdField; // Use originalIdField if targetField is not specified if (!originalIdField) { MessageFormatter.warning("Missing originalIdField in importMapping, skipping...", { prefix: "Migration" }); continue; } const originalId = doc[originalIdField as keyof typeof doc]; if (!originalId) { continue; } const relatedCollection = ( await database.listCollections(dbId, [ Query.equal("name", rel.relatedCollection), ]) ).collections[0]; if (!relatedCollection) { MessageFormatter.warning( `Related collection ${rel.relatedCollection} not found, skipping...`, { prefix: "Migration" } ); continue; } const foundDocuments = await findDocumentsByOriginalId( database, dbId, relatedCollection, targetField!, String(originalId) ); if (foundDocuments && foundDocuments.length > 0) { const relationshipKey = rel.key; const existingRefs = doc[relationshipKey as keyof typeof doc] || []; let existingRefIds: string[] = []; if (Array.isArray(existingRefs)) { // @ts-ignore existingRefIds = existingRefs.map((ref) => ref.$id); } else if (existingRefs) { // @ts-ignore existingRefIds = [existingRefs.$id]; } const newRefs = foundDocuments.map((fd) => fd.$id); const allRefs = [...new Set([...existingRefIds, ...newRefs])]; // Combine and remove duplicates // Update logic based on the relationship cardinality updatePayload[relationshipKey] = isSingleReference ? newRefs[0] || existingRefIds[0] : allRefs; MessageFormatter.info(`Updating ${relationshipKey} with ${allRefs.length} refs`, { prefix: "Migration" }); } } if (Object.keys(updatePayload).length > 0) { updates.push({ collectionId: thisCollectionId, documentId: doc.$id, updatePayload: updatePayload, }); } } return updates; } async function processInBatches<T>( items: T[], batchSize: number, processFunction: (batch: T[]) => Promise<void> ) { const maxParallelBatches = 25; // Adjust this value to control the number of parallel batches let currentIndex = 0; let activeBatchPromises: Promise<void>[] = []; while (currentIndex < items.length) { // While there's still data to process and we haven't reached our parallel limit while ( currentIndex < items.length && activeBatchPromises.length < maxParallelBatches ) { const batch = items.slice(currentIndex, currentIndex + batchSize); currentIndex += batchSize; // Add new batch processing promise to the array activeBatchPromises.push(processFunction(batch)); } // Wait for one of the batch processes to complete await Promise.race(activeBatchPromises).then(() => { // Remove the resolved promise from the activeBatchPromises array activeBatchPromises = activeBatchPromises.filter( (p) => p !== Promise.race(activeBatchPromises) ); }); } // After processing all batches, ensure all active promises are resolved await Promise.all(activeBatchPromises); } async function executeUpdatesInBatches( dbId: string, database: Databases, updates: { collectionId: string; documentId: string; updatePayload: any }[] ) { const batchSize = 25; // Adjust based on your rate limit and performance testing for (let i = 0; i < updates.length; i += batchSize) { const batch = updates.slice(i, i + batchSize); await Promise.all( batch.map((update) => database .updateDocument( dbId, update.collectionId, update.documentId, update.updatePayload ) .catch((error) => { logger.error( `Error updating doc ${ update.documentId } in ${dbId}, update payload: ${JSON.stringify( update.updatePayload, undefined, 4 )}, error: ${error}` ); }) ) ); } }