appwrite-utils-cli 1.2.5 → 1.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- import { converterFunctions, tryAwaitWithRetry, parseAttribute } from "appwrite-utils";
2
- import { Client, Databases, Storage, Users, Functions, Query, } from "node-appwrite";
1
+ import { converterFunctions, tryAwaitWithRetry, parseAttribute, objectNeedsUpdate } from "appwrite-utils";
2
+ import { Client, Databases, Storage, Users, Functions, Teams, Query, } from "node-appwrite";
3
3
  import { InputFile } from "node-appwrite/file";
4
4
  import { MessageFormatter } from "../shared/messageFormatter.js";
5
5
  import { ProgressManager } from "../shared/progressManager.js";
@@ -17,6 +17,8 @@ export class ComprehensiveTransfer {
17
17
  targetClient;
18
18
  sourceUsers;
19
19
  targetUsers;
20
+ sourceTeams;
21
+ targetTeams;
20
22
  sourceDatabases;
21
23
  targetDatabases;
22
24
  sourceStorage;
@@ -36,6 +38,8 @@ export class ComprehensiveTransfer {
36
38
  this.targetClient = getClient(options.targetEndpoint, options.targetProject, options.targetKey);
37
39
  this.sourceUsers = new Users(this.sourceClient);
38
40
  this.targetUsers = new Users(this.targetClient);
41
+ this.sourceTeams = new Teams(this.sourceClient);
42
+ this.targetTeams = new Teams(this.targetClient);
39
43
  this.sourceDatabases = new Databases(this.sourceClient);
40
44
  this.targetDatabases = new Databases(this.targetClient);
41
45
  this.sourceStorage = new Storage(this.sourceClient);
@@ -51,6 +55,7 @@ export class ComprehensiveTransfer {
51
55
  this.fileLimit = pLimit(Math.max(1, Math.floor(baseLimit / 4)));
52
56
  this.results = {
53
57
  users: { transferred: 0, skipped: 0, failed: 0 },
58
+ teams: { transferred: 0, skipped: 0, failed: 0 },
54
59
  databases: { transferred: 0, skipped: 0, failed: 0 },
55
60
  buckets: { transferred: 0, skipped: 0, failed: 0 },
56
61
  functions: { transferred: 0, skipped: 0, failed: 0 },
@@ -78,6 +83,9 @@ export class ComprehensiveTransfer {
78
83
  if (this.options.transferUsers !== false) {
79
84
  await this.transferAllUsers();
80
85
  }
86
+ if (this.options.transferTeams !== false) {
87
+ await this.transferAllTeams();
88
+ }
81
89
  if (this.options.transferDatabases !== false) {
82
90
  await this.transferAllDatabases();
83
91
  }
@@ -124,6 +132,65 @@ export class ComprehensiveTransfer {
124
132
  this.results.users.failed = 1;
125
133
  }
126
134
  }
135
+ async transferAllTeams() {
136
+ MessageFormatter.info("Starting team transfer phase", { prefix: "Transfer" });
137
+ try {
138
+ // Fetch all teams from source with pagination
139
+ const allSourceTeams = await this.fetchAllTeams(this.sourceTeams);
140
+ const allTargetTeams = await this.fetchAllTeams(this.targetTeams);
141
+ if (this.options.dryRun) {
142
+ let totalMemberships = 0;
143
+ for (const team of allSourceTeams) {
144
+ const memberships = await this.sourceTeams.listMemberships(team.$id, [Query.limit(1)]);
145
+ totalMemberships += memberships.total;
146
+ }
147
+ MessageFormatter.info(`DRY RUN: Would transfer ${allSourceTeams.length} teams with ${totalMemberships} memberships`, { prefix: "Transfer" });
148
+ return;
149
+ }
150
+ const transferTasks = allSourceTeams.map(team => this.limit(async () => {
151
+ try {
152
+ // Check if team exists in target
153
+ const existingTeam = allTargetTeams.find(tt => tt.$id === team.$id);
154
+ if (!existingTeam) {
155
+ // Fetch all memberships to extract unique roles before creating team
156
+ MessageFormatter.info(`Fetching memberships for team ${team.name} to extract roles`, { prefix: "Transfer" });
157
+ const memberships = await this.fetchAllMemberships(team.$id);
158
+ // Extract unique roles from all memberships
159
+ const allRoles = new Set();
160
+ memberships.forEach(membership => {
161
+ membership.roles.forEach(role => allRoles.add(role));
162
+ });
163
+ const uniqueRoles = Array.from(allRoles);
164
+ MessageFormatter.info(`Found ${uniqueRoles.length} unique roles for team ${team.name}: ${uniqueRoles.join(', ')}`, { prefix: "Transfer" });
165
+ // Create team in target with the collected roles
166
+ await this.targetTeams.create(team.$id, team.name, uniqueRoles);
167
+ MessageFormatter.success(`Created team: ${team.name} with roles: ${uniqueRoles.join(', ')}`, { prefix: "Transfer" });
168
+ }
169
+ else {
170
+ MessageFormatter.info(`Team ${team.name} already exists, updating if needed`, { prefix: "Transfer" });
171
+ // Update team if needed
172
+ if (existingTeam.name !== team.name) {
173
+ await this.targetTeams.updateName(team.$id, team.name);
174
+ MessageFormatter.success(`Updated team name: ${team.name}`, { prefix: "Transfer" });
175
+ }
176
+ }
177
+ // Transfer team memberships
178
+ await this.transferTeamMemberships(team.$id);
179
+ this.results.teams.transferred++;
180
+ MessageFormatter.success(`Team ${team.name} transferred successfully`, { prefix: "Transfer" });
181
+ }
182
+ catch (error) {
183
+ MessageFormatter.error(`Team ${team.name} transfer failed`, error instanceof Error ? error : new Error(String(error)), { prefix: "Transfer" });
184
+ this.results.teams.failed++;
185
+ }
186
+ }));
187
+ await Promise.all(transferTasks);
188
+ MessageFormatter.success("Team transfer phase completed", { prefix: "Transfer" });
189
+ }
190
+ catch (error) {
191
+ MessageFormatter.error("Team transfer phase failed", error instanceof Error ? error : new Error(String(error)), { prefix: "Transfer" });
192
+ }
193
+ }
127
194
  async transferAllDatabases() {
128
195
  MessageFormatter.info("Starting database transfer phase", { prefix: "Transfer" });
129
196
  try {
@@ -294,6 +361,27 @@ export class ComprehensiveTransfer {
294
361
  await this.createBucketWithFallback(bucket);
295
362
  MessageFormatter.success(`Created bucket: ${bucket.name}`, { prefix: "Transfer" });
296
363
  }
364
+ else {
365
+ // Compare bucket permissions and update if needed
366
+ const sourcePermissions = JSON.stringify(bucket.$permissions?.sort() || []);
367
+ const targetPermissions = JSON.stringify(existingBucket.$permissions?.sort() || []);
368
+ if (sourcePermissions !== targetPermissions ||
369
+ existingBucket.name !== bucket.name ||
370
+ existingBucket.fileSecurity !== bucket.fileSecurity ||
371
+ existingBucket.enabled !== bucket.enabled) {
372
+ MessageFormatter.warning(`Bucket ${bucket.name} exists but has different settings. Updating to match source.`, { prefix: "Transfer" });
373
+ try {
374
+ await this.targetStorage.updateBucket(bucket.$id, bucket.name, bucket.$permissions, bucket.fileSecurity, bucket.enabled, bucket.maximumFileSize, bucket.allowedFileExtensions, bucket.compression, bucket.encryption, bucket.antivirus);
375
+ MessageFormatter.success(`Updated bucket ${bucket.name} to match source`, { prefix: "Transfer" });
376
+ }
377
+ catch (updateError) {
378
+ MessageFormatter.error(`Failed to update bucket ${bucket.name}`, updateError instanceof Error ? updateError : new Error(String(updateError)), { prefix: "Transfer" });
379
+ }
380
+ }
381
+ else {
382
+ MessageFormatter.info(`Bucket ${bucket.name} already exists with matching settings`, { prefix: "Transfer" });
383
+ }
384
+ }
297
385
  // Transfer bucket files with enhanced validation
298
386
  await this.transferBucketFiles(bucket.$id, bucket.$id);
299
387
  this.results.buckets.transferred++;
@@ -414,10 +502,27 @@ export class ComprehensiveTransfer {
414
502
  // Process files with rate limiting
415
503
  const fileTasks = files.files.map(file => this.fileLimit(async () => {
416
504
  try {
417
- // Check if file already exists
505
+ // Check if file already exists and compare permissions
506
+ let existingFile = null;
418
507
  try {
419
- await this.targetStorage.getFile(targetBucketId, file.$id);
420
- MessageFormatter.info(`File ${file.name} already exists, skipping`, { prefix: "Transfer" });
508
+ existingFile = await this.targetStorage.getFile(targetBucketId, file.$id);
509
+ // Compare permissions between source and target file
510
+ const sourcePermissions = JSON.stringify(file.$permissions?.sort() || []);
511
+ const targetPermissions = JSON.stringify(existingFile.$permissions?.sort() || []);
512
+ if (sourcePermissions !== targetPermissions) {
513
+ MessageFormatter.warning(`File ${file.name} (${file.$id}) exists but has different permissions. Source: ${sourcePermissions}, Target: ${targetPermissions}`, { prefix: "Transfer" });
514
+ // Update file permissions to match source
515
+ try {
516
+ await this.targetStorage.updateFile(targetBucketId, file.$id, file.name, file.$permissions);
517
+ MessageFormatter.success(`Updated file ${file.name} permissions to match source`, { prefix: "Transfer" });
518
+ }
519
+ catch (updateError) {
520
+ MessageFormatter.error(`Failed to update permissions for file ${file.name}`, updateError instanceof Error ? updateError : new Error(String(updateError)), { prefix: "Transfer" });
521
+ }
522
+ }
523
+ else {
524
+ MessageFormatter.info(`File ${file.name} already exists with matching permissions, skipping`, { prefix: "Transfer" });
525
+ }
421
526
  return;
422
527
  }
423
528
  catch (error) {
@@ -635,61 +740,389 @@ export class ComprehensiveTransfer {
635
740
  return await createOrUpdateIndexesWithStatusCheck(dbId, databases, collectionId, collection, indexes);
636
741
  }
637
742
  /**
638
- * Helper method to transfer documents between databases
743
+ * Helper method to transfer documents between databases using bulk operations with content and permission-based filtering
639
744
  */
640
745
  async transferDocumentsBetweenDatabases(sourceDb, targetDb, sourceDbId, targetDbId, sourceCollectionId, targetCollectionId) {
641
- MessageFormatter.info(`Transferring documents from ${sourceCollectionId} to ${targetCollectionId}`, { prefix: "Transfer" });
746
+ MessageFormatter.info(`Transferring documents from ${sourceCollectionId} to ${targetCollectionId} with bulk operations, content comparison, and permission filtering`, { prefix: "Transfer" });
642
747
  let lastId;
643
748
  let totalTransferred = 0;
749
+ let totalSkipped = 0;
750
+ let totalUpdated = 0;
751
+ // Check if bulk operations are supported
752
+ const supportsBulk = this.options.sourceEndpoint.includes('cloud.appwrite.io') ||
753
+ this.options.targetEndpoint.includes('cloud.appwrite.io');
754
+ if (supportsBulk) {
755
+ MessageFormatter.info(`Using bulk operations for enhanced performance`, { prefix: "Transfer" });
756
+ }
757
+ while (true) {
758
+ // Fetch source documents in larger batches (1000 instead of 50)
759
+ const queries = [Query.limit(1000)];
760
+ if (lastId) {
761
+ queries.push(Query.cursorAfter(lastId));
762
+ }
763
+ const sourceDocuments = await tryAwaitWithRetry(async () => sourceDb.listDocuments(sourceDbId, sourceCollectionId, queries));
764
+ if (sourceDocuments.documents.length === 0) {
765
+ break;
766
+ }
767
+ MessageFormatter.info(`Processing batch of ${sourceDocuments.documents.length} source documents`, { prefix: "Transfer" });
768
+ // Extract document IDs from the current batch
769
+ const sourceDocIds = sourceDocuments.documents.map(doc => doc.$id);
770
+ // Fetch existing documents from target in a single query
771
+ const existingTargetDocs = await this.fetchTargetDocumentsBatch(targetDb, targetDbId, targetCollectionId, sourceDocIds);
772
+ // Create a map for quick lookup of existing documents
773
+ const existingDocsMap = new Map();
774
+ existingTargetDocs.forEach(doc => {
775
+ existingDocsMap.set(doc.$id, doc);
776
+ });
777
+ // Filter documents based on existence, content comparison, and permission comparison
778
+ const documentsToTransfer = [];
779
+ const documentsToUpdate = [];
780
+ for (const sourceDoc of sourceDocuments.documents) {
781
+ const existingTargetDoc = existingDocsMap.get(sourceDoc.$id);
782
+ if (!existingTargetDoc) {
783
+ // Document doesn't exist in target, needs to be transferred
784
+ documentsToTransfer.push(sourceDoc);
785
+ }
786
+ else {
787
+ // Document exists, compare both content and permissions
788
+ const sourcePermissions = JSON.stringify((sourceDoc.$permissions || []).sort());
789
+ const targetPermissions = JSON.stringify((existingTargetDoc.$permissions || []).sort());
790
+ const permissionsDiffer = sourcePermissions !== targetPermissions;
791
+ // Use objectNeedsUpdate to compare document content (excluding system fields)
792
+ const contentDiffers = objectNeedsUpdate(existingTargetDoc, sourceDoc);
793
+ if (contentDiffers && permissionsDiffer) {
794
+ // Both content and permissions differ
795
+ documentsToUpdate.push({
796
+ doc: sourceDoc,
797
+ targetDoc: existingTargetDoc,
798
+ reason: "content and permissions differ"
799
+ });
800
+ MessageFormatter.info(`Document ${sourceDoc.$id} exists but content and permissions differ - will update`, { prefix: "Transfer" });
801
+ }
802
+ else if (contentDiffers) {
803
+ // Only content differs
804
+ documentsToUpdate.push({
805
+ doc: sourceDoc,
806
+ targetDoc: existingTargetDoc,
807
+ reason: "content differs"
808
+ });
809
+ MessageFormatter.info(`Document ${sourceDoc.$id} exists but content differs - will update`, { prefix: "Transfer" });
810
+ }
811
+ else if (permissionsDiffer) {
812
+ // Only permissions differ
813
+ documentsToUpdate.push({
814
+ doc: sourceDoc,
815
+ targetDoc: existingTargetDoc,
816
+ reason: "permissions differ"
817
+ });
818
+ MessageFormatter.info(`Document ${sourceDoc.$id} exists but permissions differ - will update`, { prefix: "Transfer" });
819
+ }
820
+ else {
821
+ // Document exists with identical content AND permissions, skip
822
+ totalSkipped++;
823
+ MessageFormatter.info(`Document ${sourceDoc.$id} exists with matching content and permissions - skipping`, { prefix: "Transfer" });
824
+ }
825
+ }
826
+ }
827
+ MessageFormatter.info(`Batch analysis: ${documentsToTransfer.length} to create, ${documentsToUpdate.length} to update, ${totalSkipped} skipped so far`, { prefix: "Transfer" });
828
+ // Process new documents with bulk operations if supported and available
829
+ if (documentsToTransfer.length > 0) {
830
+ if (supportsBulk && documentsToTransfer.length >= 10) {
831
+ // Use bulk operations for large batches
832
+ await this.transferDocumentsBulk(targetDb, targetDbId, targetCollectionId, documentsToTransfer);
833
+ totalTransferred += documentsToTransfer.length;
834
+ MessageFormatter.success(`Bulk transferred ${documentsToTransfer.length} new documents`, { prefix: "Transfer" });
835
+ }
836
+ else {
837
+ // Use individual transfers for smaller batches or non-bulk endpoints
838
+ const transferCount = await this.transferDocumentsIndividual(targetDb, targetDbId, targetCollectionId, documentsToTransfer);
839
+ totalTransferred += transferCount;
840
+ }
841
+ }
842
+ // Process document updates (always individual since bulk update with permissions needs special handling)
843
+ if (documentsToUpdate.length > 0) {
844
+ const updateCount = await this.updateDocumentsIndividual(targetDb, targetDbId, targetCollectionId, documentsToUpdate);
845
+ totalUpdated += updateCount;
846
+ }
847
+ if (sourceDocuments.documents.length < 1000) {
848
+ break;
849
+ }
850
+ lastId = sourceDocuments.documents[sourceDocuments.documents.length - 1].$id;
851
+ }
852
+ MessageFormatter.info(`Transfer complete: ${totalTransferred} new, ${totalUpdated} updated, ${totalSkipped} skipped from ${sourceCollectionId} to ${targetCollectionId}`, { prefix: "Transfer" });
853
+ }
854
+ /**
855
+ * Fetch target documents by IDs in batches to check existence and permissions
856
+ */
857
+ async fetchTargetDocumentsBatch(targetDb, targetDbId, targetCollectionId, docIds) {
858
+ const documents = [];
859
+ // Split IDs into chunks of 100 for Query.equal limitations
860
+ const idChunks = this.chunkArray(docIds, 100);
861
+ for (const chunk of idChunks) {
862
+ try {
863
+ const result = await tryAwaitWithRetry(async () => targetDb.listDocuments(targetDbId, targetCollectionId, [
864
+ Query.equal('$id', chunk),
865
+ Query.limit(100)
866
+ ]));
867
+ documents.push(...result.documents);
868
+ }
869
+ catch (error) {
870
+ // If query fails, fall back to individual gets (less efficient but more reliable)
871
+ MessageFormatter.warning(`Batch query failed for ${chunk.length} documents, falling back to individual checks`, { prefix: "Transfer" });
872
+ for (const docId of chunk) {
873
+ try {
874
+ const doc = await targetDb.getDocument(targetDbId, targetCollectionId, docId);
875
+ documents.push(doc);
876
+ }
877
+ catch (getError) {
878
+ // Document doesn't exist, which is fine
879
+ }
880
+ }
881
+ }
882
+ }
883
+ return documents;
884
+ }
885
+ /**
886
+ * Transfer documents using bulk operations with proper batch size handling
887
+ */
888
+ async transferDocumentsBulk(targetDb, targetDbId, targetCollectionId, documents) {
889
+ // Prepare documents for bulk upsert
890
+ const preparedDocs = documents.map(doc => {
891
+ const { $id, $createdAt, $updatedAt, $permissions, $databaseId, $collectionId, ...docData } = doc;
892
+ return {
893
+ $id,
894
+ $permissions,
895
+ ...docData
896
+ };
897
+ });
898
+ // Process in smaller chunks for bulk operations (1000 for Pro, 100 for Free tier)
899
+ const batchSizes = [1000, 100]; // Start with Pro plan, fallback to Free
900
+ let processed = false;
901
+ for (const maxBatchSize of batchSizes) {
902
+ const documentBatches = this.chunkArray(preparedDocs, maxBatchSize);
903
+ try {
904
+ for (const batch of documentBatches) {
905
+ MessageFormatter.info(`Bulk upserting ${batch.length} documents...`, { prefix: "Transfer" });
906
+ await this.bulkUpsertDocuments(this.targetClient, targetDbId, targetCollectionId, batch);
907
+ MessageFormatter.success(`✅ Bulk upserted ${batch.length} documents`, { prefix: "Transfer" });
908
+ // Add delay between batches to respect rate limits
909
+ if (documentBatches.indexOf(batch) < documentBatches.length - 1) {
910
+ await new Promise(resolve => setTimeout(resolve, 200));
911
+ }
912
+ }
913
+ processed = true;
914
+ break; // Success, exit batch size loop
915
+ }
916
+ catch (error) {
917
+ MessageFormatter.warning(`Bulk upsert with batch size ${maxBatchSize} failed, trying smaller size...`, { prefix: "Transfer" });
918
+ continue; // Try next smaller batch size
919
+ }
920
+ }
921
+ if (!processed) {
922
+ MessageFormatter.warning(`All bulk operations failed, falling back to individual transfers`, { prefix: "Transfer" });
923
+ // Fall back to individual transfers
924
+ await this.transferDocumentsIndividual(targetDb, targetDbId, targetCollectionId, documents);
925
+ }
926
+ }
927
+ /**
928
+ * Direct HTTP implementation of bulk upsert API
929
+ */
930
+ async bulkUpsertDocuments(client, dbId, collectionId, documents) {
931
+ const apiPath = `/databases/${dbId}/collections/${collectionId}/documents`;
932
+ const url = new URL(client.config.endpoint + apiPath);
933
+ const headers = {
934
+ 'Content-Type': 'application/json',
935
+ 'X-Appwrite-Project': client.config.project,
936
+ 'X-Appwrite-Key': client.config.key
937
+ };
938
+ const response = await fetch(url.toString(), {
939
+ method: 'PUT',
940
+ headers,
941
+ body: JSON.stringify({ documents })
942
+ });
943
+ if (!response.ok) {
944
+ const errorData = await response.json().catch(() => ({ message: 'Unknown error' }));
945
+ throw new Error(`Bulk upsert failed: ${response.status} - ${errorData.message || 'Unknown error'}`);
946
+ }
947
+ return await response.json();
948
+ }
949
+ /**
950
+ * Transfer documents individually with rate limiting
951
+ */
952
+ async transferDocumentsIndividual(targetDb, targetDbId, targetCollectionId, documents) {
953
+ let successCount = 0;
954
+ const transferTasks = documents.map(doc => this.limit(async () => {
955
+ try {
956
+ const { $id, $createdAt, $updatedAt, $permissions, $databaseId, $collectionId, ...docData } = doc;
957
+ await tryAwaitWithRetry(async () => targetDb.createDocument(targetDbId, targetCollectionId, doc.$id, docData, doc.$permissions));
958
+ successCount++;
959
+ MessageFormatter.success(`Transferred document ${doc.$id}`, { prefix: "Transfer" });
960
+ }
961
+ catch (error) {
962
+ MessageFormatter.error(`Failed to transfer document ${doc.$id}`, error instanceof Error ? error : new Error(String(error)), { prefix: "Transfer" });
963
+ }
964
+ }));
965
+ await Promise.all(transferTasks);
966
+ return successCount;
967
+ }
968
+ /**
969
+ * Update documents individually with content and/or permission changes
970
+ */
971
+ async updateDocumentsIndividual(targetDb, targetDbId, targetCollectionId, documentPairs) {
972
+ let successCount = 0;
973
+ const updateTasks = documentPairs.map(({ doc, targetDoc, reason }) => this.limit(async () => {
974
+ try {
975
+ const { $id, $createdAt, $updatedAt, $permissions, $databaseId, $collectionId, ...docData } = doc;
976
+ await tryAwaitWithRetry(async () => targetDb.updateDocument(targetDbId, targetCollectionId, doc.$id, docData, doc.$permissions));
977
+ successCount++;
978
+ MessageFormatter.success(`Updated document ${doc.$id} (${reason}) - permissions: [${targetDoc.$permissions?.join(', ')}] → [${doc.$permissions?.join(', ')}]`, { prefix: "Transfer" });
979
+ }
980
+ catch (error) {
981
+ MessageFormatter.error(`Failed to update document ${doc.$id} (${reason})`, error instanceof Error ? error : new Error(String(error)), { prefix: "Transfer" });
982
+ }
983
+ }));
984
+ await Promise.all(updateTasks);
985
+ return successCount;
986
+ }
987
+ /**
988
+ * Utility method to chunk arrays
989
+ */
990
+ chunkArray(array, size) {
991
+ const chunks = [];
992
+ for (let i = 0; i < array.length; i += size) {
993
+ chunks.push(array.slice(i, i + size));
994
+ }
995
+ return chunks;
996
+ }
997
+ /**
998
+ * Helper method to fetch all teams with pagination
999
+ */
1000
+ async fetchAllTeams(teams) {
1001
+ const teamsList = [];
1002
+ let lastId;
644
1003
  while (true) {
645
- const queries = [Query.limit(50)]; // Smaller batch size for better performance
1004
+ const queries = [Query.limit(100)];
646
1005
  if (lastId) {
647
1006
  queries.push(Query.cursorAfter(lastId));
648
1007
  }
649
- const documents = await tryAwaitWithRetry(async () => sourceDb.listDocuments(sourceDbId, sourceCollectionId, queries));
650
- if (documents.documents.length === 0) {
1008
+ const result = await tryAwaitWithRetry(async () => teams.list(queries));
1009
+ if (result.teams.length === 0) {
651
1010
  break;
652
1011
  }
653
- // Transfer documents with rate limiting
654
- const transferTasks = documents.documents.map(doc => this.limit(async () => {
1012
+ teamsList.push(...result.teams);
1013
+ if (result.teams.length < 100) {
1014
+ break;
1015
+ }
1016
+ lastId = result.teams[result.teams.length - 1].$id;
1017
+ }
1018
+ return teamsList;
1019
+ }
1020
+ /**
1021
+ * Helper method to fetch all memberships for a team with pagination
1022
+ */
1023
+ async fetchAllMemberships(teamId) {
1024
+ const membershipsList = [];
1025
+ let lastId;
1026
+ while (true) {
1027
+ const queries = [Query.limit(100)];
1028
+ if (lastId) {
1029
+ queries.push(Query.cursorAfter(lastId));
1030
+ }
1031
+ const result = await tryAwaitWithRetry(async () => this.sourceTeams.listMemberships(teamId, queries));
1032
+ if (result.memberships.length === 0) {
1033
+ break;
1034
+ }
1035
+ membershipsList.push(...result.memberships);
1036
+ if (result.memberships.length < 100) {
1037
+ break;
1038
+ }
1039
+ lastId = result.memberships[result.memberships.length - 1].$id;
1040
+ }
1041
+ return membershipsList;
1042
+ }
1043
+ /**
1044
+ * Helper method to transfer team memberships
1045
+ */
1046
+ async transferTeamMemberships(teamId) {
1047
+ MessageFormatter.info(`Transferring memberships for team ${teamId}`, { prefix: "Transfer" });
1048
+ try {
1049
+ // Fetch all memberships for this team
1050
+ const memberships = await this.fetchAllMemberships(teamId);
1051
+ if (memberships.length === 0) {
1052
+ MessageFormatter.info(`No memberships found for team ${teamId}`, { prefix: "Transfer" });
1053
+ return;
1054
+ }
1055
+ MessageFormatter.info(`Found ${memberships.length} memberships for team ${teamId}`, { prefix: "Transfer" });
1056
+ let totalTransferred = 0;
1057
+ // Transfer memberships with rate limiting
1058
+ const transferTasks = memberships.map(membership => this.userLimit(async () => {
655
1059
  try {
656
- // Check if document already exists
1060
+ // Check if membership already exists and compare roles
1061
+ let existingMembership = null;
657
1062
  try {
658
- await targetDb.getDocument(targetDbId, targetCollectionId, doc.$id);
659
- MessageFormatter.info(`Document ${doc.$id} already exists, skipping`, { prefix: "Transfer" });
1063
+ existingMembership = await this.targetTeams.getMembership(teamId, membership.$id);
1064
+ // Compare roles between source and target membership
1065
+ const sourceRoles = JSON.stringify(membership.roles?.sort() || []);
1066
+ const targetRoles = JSON.stringify(existingMembership.roles?.sort() || []);
1067
+ if (sourceRoles !== targetRoles) {
1068
+ MessageFormatter.warning(`Membership ${membership.$id} exists but has different roles. Source: ${sourceRoles}, Target: ${targetRoles}`, { prefix: "Transfer" });
1069
+ // Update membership roles to match source
1070
+ try {
1071
+ await this.targetTeams.updateMembership(teamId, membership.$id, membership.roles);
1072
+ MessageFormatter.success(`Updated membership ${membership.$id} roles to match source`, { prefix: "Transfer" });
1073
+ }
1074
+ catch (updateError) {
1075
+ MessageFormatter.error(`Failed to update roles for membership ${membership.$id}`, updateError instanceof Error ? updateError : new Error(String(updateError)), { prefix: "Transfer" });
1076
+ }
1077
+ }
1078
+ else {
1079
+ MessageFormatter.info(`Membership ${membership.$id} already exists with matching roles, skipping`, { prefix: "Transfer" });
1080
+ }
660
1081
  return;
661
1082
  }
662
1083
  catch (error) {
663
- // Document doesn't exist, proceed with creation
1084
+ // Membership doesn't exist, proceed with creation
664
1085
  }
665
- // Create document in target
666
- const { $id, $createdAt, $updatedAt, $permissions, $databaseId, $collectionId, ...docData } = doc;
667
- await tryAwaitWithRetry(async () => targetDb.createDocument(targetDbId, targetCollectionId, doc.$id, docData, doc.$permissions));
1086
+ // Get user data from target (users should already be transferred)
1087
+ let userData = null;
1088
+ try {
1089
+ userData = await this.targetUsers.get(membership.userId);
1090
+ }
1091
+ catch (error) {
1092
+ MessageFormatter.warning(`User ${membership.userId} not found in target, membership ${membership.$id} may fail`, { prefix: "Transfer" });
1093
+ }
1094
+ // Create membership using the comprehensive user data
1095
+ await tryAwaitWithRetry(async () => this.targetTeams.createMembership(teamId, membership.roles, userData?.email || membership.userEmail, // Use target user email if available, fallback to membership email
1096
+ membership.userId, // User ID
1097
+ userData?.phone || undefined, // Use target user phone if available
1098
+ undefined, // Invitation URL placeholder
1099
+ userData?.name || membership.userName // Use target user name if available, fallback to membership name
1100
+ ));
668
1101
  totalTransferred++;
669
- MessageFormatter.success(`Transferred document ${doc.$id}`, { prefix: "Transfer" });
1102
+ MessageFormatter.success(`Transferred membership ${membership.$id} for user ${userData?.name || membership.userName}`, { prefix: "Transfer" });
670
1103
  }
671
1104
  catch (error) {
672
- MessageFormatter.error(`Failed to transfer document ${doc.$id}`, error instanceof Error ? error : new Error(String(error)), { prefix: "Transfer" });
1105
+ MessageFormatter.error(`Failed to transfer membership ${membership.$id}`, error instanceof Error ? error : new Error(String(error)), { prefix: "Transfer" });
673
1106
  }
674
1107
  }));
675
1108
  await Promise.all(transferTasks);
676
- if (documents.documents.length < 50) {
677
- break;
678
- }
679
- lastId = documents.documents[documents.documents.length - 1].$id;
1109
+ MessageFormatter.info(`Transferred ${totalTransferred} memberships for team ${teamId}`, { prefix: "Transfer" });
1110
+ }
1111
+ catch (error) {
1112
+ MessageFormatter.error(`Failed to transfer memberships for team ${teamId}`, error instanceof Error ? error : new Error(String(error)), { prefix: "Transfer" });
680
1113
  }
681
- MessageFormatter.info(`Transferred ${totalTransferred} documents from ${sourceCollectionId} to ${targetCollectionId}`, { prefix: "Transfer" });
682
1114
  }
683
1115
  printSummary() {
684
1116
  const duration = Math.round((Date.now() - this.startTime) / 1000);
685
1117
  MessageFormatter.info("=== COMPREHENSIVE TRANSFER SUMMARY ===", { prefix: "Transfer" });
686
1118
  MessageFormatter.info(`Total Time: ${duration}s`, { prefix: "Transfer" });
687
1119
  MessageFormatter.info(`Users: ${this.results.users.transferred} transferred, ${this.results.users.skipped} skipped, ${this.results.users.failed} failed`, { prefix: "Transfer" });
1120
+ MessageFormatter.info(`Teams: ${this.results.teams.transferred} transferred, ${this.results.teams.skipped} skipped, ${this.results.teams.failed} failed`, { prefix: "Transfer" });
688
1121
  MessageFormatter.info(`Databases: ${this.results.databases.transferred} transferred, ${this.results.databases.skipped} skipped, ${this.results.databases.failed} failed`, { prefix: "Transfer" });
689
1122
  MessageFormatter.info(`Buckets: ${this.results.buckets.transferred} transferred, ${this.results.buckets.skipped} skipped, ${this.results.buckets.failed} failed`, { prefix: "Transfer" });
690
1123
  MessageFormatter.info(`Functions: ${this.results.functions.transferred} transferred, ${this.results.functions.skipped} skipped, ${this.results.functions.failed} failed`, { prefix: "Transfer" });
691
- const totalTransferred = this.results.users.transferred + this.results.databases.transferred + this.results.buckets.transferred + this.results.functions.transferred;
692
- const totalFailed = this.results.users.failed + this.results.databases.failed + this.results.buckets.failed + this.results.functions.failed;
1124
+ const totalTransferred = this.results.users.transferred + this.results.teams.transferred + this.results.databases.transferred + this.results.buckets.transferred + this.results.functions.transferred;
1125
+ const totalFailed = this.results.users.failed + this.results.teams.failed + this.results.databases.failed + this.results.buckets.failed + this.results.functions.failed;
693
1126
  if (totalFailed === 0) {
694
1127
  MessageFormatter.success(`All ${totalTransferred} items transferred successfully!`, { prefix: "Transfer" });
695
1128
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "appwrite-utils-cli",
3
3
  "description": "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.",
4
- "version": "1.2.5",
4
+ "version": "1.2.7",
5
5
  "main": "src/main.ts",
6
6
  "type": "module",
7
7
  "repository": {
@@ -2202,6 +2202,7 @@ export class InteractiveCLI {
2202
2202
  message: "Select what to transfer:",
2203
2203
  choices: [
2204
2204
  { name: "👥 Users", value: "users", checked: true },
2205
+ { name: "👥 Teams", value: "teams", checked: true },
2205
2206
  { name: "🗄️ Databases", value: "databases", checked: true },
2206
2207
  { name: "📦 Storage Buckets", value: "buckets", checked: true },
2207
2208
  { name: "⚡ Functions", value: "functions", checked: true },
@@ -2294,6 +2295,7 @@ export class InteractiveCLI {
2294
2295
  targetProject: targetConfig.targetProject,
2295
2296
  targetKey: targetConfig.targetKey,
2296
2297
  transferUsers: transferOptions.transferTypes.includes("users"),
2298
+ transferTeams: transferOptions.transferTypes.includes("teams"),
2297
2299
  transferDatabases: transferOptions.transferTypes.includes("databases"),
2298
2300
  transferBuckets: transferOptions.transferTypes.includes("buckets"),
2299
2301
  transferFunctions: transferOptions.transferTypes.includes("functions"),
@@ -2313,6 +2315,9 @@ export class InteractiveCLI {
2313
2315
  MessageFormatter.info("Users with preserved password hashes can log in with their original passwords", { prefix: "Transfer" });
2314
2316
  MessageFormatter.info("Users with temporary passwords will need to reset their passwords", { prefix: "Transfer" });
2315
2317
  }
2318
+ if (transferOptions.transferTypes.includes("teams") && results.teams.transferred > 0) {
2319
+ MessageFormatter.info("Team memberships have been transferred and may require user acceptance of invitations", { prefix: "Transfer" });
2320
+ }
2316
2321
  }
2317
2322
 
2318
2323
  } catch (error) {