appwrite-utils-cli 1.8.5 → 1.8.6

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.
@@ -235,6 +235,11 @@ export const createOrUpdateCollectionsViaAdapter = async (adapter, databaseId, c
235
235
  const plusminus = plan.toUpdate.map((u) => u.attribute.key);
236
236
  const minus = plan.toRecreate.map((r) => r.newAttribute.key);
237
237
  const skip = plan.unchanged;
238
+ // Compute deletions (remote extras not present locally)
239
+ const desiredKeysForDelete = new Set((attributes || []).map((a) => a.key));
240
+ const extraRemoteKeys = (existingCols || [])
241
+ .map((c) => c?.key)
242
+ .filter((k) => !!k && !desiredKeysForDelete.has(k));
238
243
  const parts = [];
239
244
  if (plus.length)
240
245
  parts.push(`➕ ${plus.length} (${plus.join(', ')})`);
@@ -244,6 +249,7 @@ export const createOrUpdateCollectionsViaAdapter = async (adapter, databaseId, c
244
249
  parts.push(`♻️ ${minus.length} (${minus.join(', ')})`);
245
250
  if (skip.length)
246
251
  parts.push(`⏭️ ${skip.length}`);
252
+ parts.push(`🗑️ ${extraRemoteKeys.length}${extraRemoteKeys.length ? ` (${extraRemoteKeys.join(', ')})` : ''}`);
247
253
  MessageFormatter.info(`Plan → ${parts.join(' | ') || 'no changes'}`, { prefix: 'Attributes' });
248
254
  // Execute
249
255
  const colResults = await executeColumnOperations(adapter, databaseId, tableId, plan);
@@ -458,6 +464,121 @@ export const createOrUpdateCollectionsViaAdapter = async (adapter, databaseId, c
458
464
  catch (e) {
459
465
  MessageFormatter.error(`Failed to list/create indexes`, e instanceof Error ? e : new Error(String(e)), { prefix: 'Indexes' });
460
466
  }
467
+ // Deletions for indexes: remove remote indexes not declared in YAML/config
468
+ try {
469
+ const desiredIndexKeys = new Set((indexes || []).map((i) => i.key));
470
+ const idxRes = await adapter.listIndexes({ databaseId, tableId });
471
+ const existingIdx = idxRes.data || idxRes.indexes || [];
472
+ const extraIdx = existingIdx
473
+ .filter((i) => i?.key && !desiredIndexKeys.has(i.key))
474
+ .map((i) => i.key);
475
+ if (extraIdx.length > 0) {
476
+ MessageFormatter.info(`Plan → 🗑️ ${extraIdx.length} indexes (${extraIdx.join(', ')})`, { prefix: 'Indexes' });
477
+ const deleted = [];
478
+ const errors = [];
479
+ for (const key of extraIdx) {
480
+ try {
481
+ await adapter.deleteIndex({ databaseId, tableId, key });
482
+ // Optionally wait for index to disappear
483
+ const start = Date.now();
484
+ const maxWait = 30000;
485
+ while (Date.now() - start < maxWait) {
486
+ try {
487
+ const li = await adapter.listIndexes({ databaseId, tableId });
488
+ const list = li.data || li.indexes || [];
489
+ if (!list.find((ix) => ix.key === key))
490
+ break;
491
+ }
492
+ catch { }
493
+ await delay(1000);
494
+ }
495
+ deleted.push(key);
496
+ }
497
+ catch (e) {
498
+ errors.push({ key, error: e?.message || String(e) });
499
+ }
500
+ }
501
+ if (deleted.length) {
502
+ MessageFormatter.success(`Deleted ${deleted.length} indexes: ${deleted.join(', ')}`, { prefix: 'Indexes' });
503
+ }
504
+ if (errors.length) {
505
+ MessageFormatter.error(`${errors.length} index deletions failed`, undefined, { prefix: 'Indexes' });
506
+ errors.forEach(er => MessageFormatter.error(` ${er.key}: ${er.error}`, undefined, { prefix: 'Indexes' }));
507
+ }
508
+ }
509
+ else {
510
+ MessageFormatter.info(`Plan → 🗑️ 0 indexes`, { prefix: 'Indexes' });
511
+ }
512
+ }
513
+ catch (e) {
514
+ MessageFormatter.warning(`Could not evaluate index deletions: ${e?.message || e}`, { prefix: 'Indexes' });
515
+ }
516
+ // Deletions: remove columns/attributes that are present remotely but not in desired config
517
+ try {
518
+ const desiredKeys = new Set((attributes || []).map((a) => a.key));
519
+ const tableInfo3 = await adapter.getTable({ databaseId, tableId });
520
+ const existingCols3 = tableInfo3.data?.columns || tableInfo3.data?.attributes || [];
521
+ const toDelete = existingCols3
522
+ .filter((col) => col?.key && !desiredKeys.has(col.key))
523
+ .map((col) => col.key);
524
+ if (toDelete.length > 0) {
525
+ MessageFormatter.info(`Plan → 🗑️ ${toDelete.length} (${toDelete.join(', ')})`, { prefix: 'Attributes' });
526
+ const deleted = [];
527
+ const errors = [];
528
+ for (const key of toDelete) {
529
+ try {
530
+ // Drop any indexes that reference this attribute to avoid server errors
531
+ try {
532
+ const idxRes = await adapter.listIndexes({ databaseId, tableId });
533
+ const ilist = idxRes.data || idxRes.indexes || [];
534
+ for (const idx of ilist) {
535
+ const attrs = Array.isArray(idx.attributes) ? idx.attributes : [];
536
+ if (attrs.includes(key)) {
537
+ MessageFormatter.info(`🗑️ Deleting index '${idx.key}' referencing '${key}'`, { prefix: 'Indexes' });
538
+ await adapter.deleteIndex({ databaseId, tableId, key: idx.key });
539
+ await delay(500);
540
+ }
541
+ }
542
+ }
543
+ catch { }
544
+ await adapter.deleteAttribute({ databaseId, tableId, key });
545
+ // Wait briefly for deletion to settle
546
+ const start = Date.now();
547
+ const maxWaitMs = 60000;
548
+ while (Date.now() - start < maxWaitMs) {
549
+ try {
550
+ const tinfo = await adapter.getTable({ databaseId, tableId });
551
+ const cols = tinfo.data?.columns || tinfo.data?.attributes || [];
552
+ const found = cols.find((c) => c.key === key);
553
+ if (!found)
554
+ break;
555
+ if (found.status && found.status !== 'deleting')
556
+ break;
557
+ }
558
+ catch { }
559
+ await delay(1000);
560
+ }
561
+ deleted.push(key);
562
+ }
563
+ catch (e) {
564
+ errors.push({ key, error: e?.message || String(e) });
565
+ }
566
+ }
567
+ if (deleted.length) {
568
+ MessageFormatter.success(`Deleted ${deleted.length} attributes: ${deleted.join(', ')}`, { prefix: 'Attributes' });
569
+ }
570
+ if (errors.length) {
571
+ MessageFormatter.error(`${errors.length} deletions failed`, undefined, { prefix: 'Attributes' });
572
+ errors.forEach(er => MessageFormatter.error(` ${er.key}: ${er.error}`, undefined, { prefix: 'Attributes' }));
573
+ }
574
+ }
575
+ else {
576
+ MessageFormatter.info(`Plan → 🗑️ 0`, { prefix: 'Attributes' });
577
+ }
578
+ }
579
+ catch (e) {
580
+ MessageFormatter.warning(`Could not evaluate deletions: ${e?.message || e}`, { prefix: 'Attributes' });
581
+ }
461
582
  // Mark this table as fully processed to prevent re-processing
462
583
  markCollectionProcessed(tableId, collectionData.name);
463
584
  }
@@ -619,6 +619,15 @@ export class UtilsController {
619
619
  MessageFormatter.error("Database not initialized", undefined, { prefix: "Controller" });
620
620
  return;
621
621
  }
622
+ // Always reload config from disk so pushes use current local YAML/Ts definitions
623
+ try {
624
+ await this.reloadConfig();
625
+ MessageFormatter.info("Reloaded config from disk for push", { prefix: "Controller" });
626
+ }
627
+ catch (e) {
628
+ // Non-fatal; continue with existing config
629
+ MessageFormatter.warning("Could not reload config; continuing with current in-memory config", { prefix: "Controller" });
630
+ }
622
631
  MessageFormatter.progress("Starting selective push (local config → Appwrite)...", { prefix: "Controller" });
623
632
  // Convert database selections to Models.Database format
624
633
  const selectedDatabases = [];
@@ -665,7 +674,8 @@ export class UtilsController {
665
674
  // Check if this collection was selected for THIS database
666
675
  if (dbSelection.tableIds.includes(collectionId)) {
667
676
  collectionsForDatabase.push(collection);
668
- MessageFormatter.info(` - Selected collection: ${collection.name || collectionId} for database ${dbSelection.databaseId}`, { prefix: "Controller" });
677
+ const source = collection._isFromTablesDir ? 'tables/' : 'collections/';
678
+ MessageFormatter.info(` - Selected collection: ${collection.name || collectionId} for database ${dbSelection.databaseId} [source: ${source}]`, { prefix: "Controller" });
669
679
  }
670
680
  }
671
681
  databaseCollectionsMap.set(dbSelection.databaseId, collectionsForDatabase);
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.8.5",
4
+ "version": "1.8.6",
5
5
  "main": "src/main.ts",
6
6
  "type": "module",
7
7
  "repository": {
@@ -319,11 +319,18 @@ export const createOrUpdateCollectionsViaAdapter = async (
319
319
  const minus = plan.toRecreate.map((r: any) => (r.newAttribute as any).key);
320
320
  const skip = plan.unchanged;
321
321
 
322
+ // Compute deletions (remote extras not present locally)
323
+ const desiredKeysForDelete = new Set((attributes || []).map((a: any) => a.key));
324
+ const extraRemoteKeys = (existingCols || [])
325
+ .map((c: any) => c?.key)
326
+ .filter((k: any): k is string => !!k && !desiredKeysForDelete.has(k));
327
+
322
328
  const parts: string[] = [];
323
329
  if (plus.length) parts.push(`➕ ${plus.length} (${plus.join(', ')})`);
324
330
  if (plusminus.length) parts.push(`🔧 ${plusminus.length} (${plusminus.join(', ')})`);
325
331
  if (minus.length) parts.push(`♻️ ${minus.length} (${minus.join(', ')})`);
326
332
  if (skip.length) parts.push(`⏭️ ${skip.length}`);
333
+ parts.push(`🗑️ ${extraRemoteKeys.length}${extraRemoteKeys.length ? ` (${extraRemoteKeys.join(', ')})` : ''}`);
327
334
  MessageFormatter.info(`Plan → ${parts.join(' | ') || 'no changes'}`, { prefix: 'Attributes' });
328
335
 
329
336
  // Execute
@@ -510,10 +517,117 @@ export const createOrUpdateCollectionsViaAdapter = async (
510
517
  } catch (e) {
511
518
  MessageFormatter.error(`Failed to list/create indexes`, e instanceof Error ? e : new Error(String(e)), { prefix: 'Indexes' });
512
519
  }
513
-
514
- // Mark this table as fully processed to prevent re-processing
515
- markCollectionProcessed(tableId, collectionData.name);
516
- }
520
+
521
+ // Deletions for indexes: remove remote indexes not declared in YAML/config
522
+ try {
523
+ const desiredIndexKeys = new Set((indexes || []).map((i: any) => i.key));
524
+ const idxRes = await adapter.listIndexes({ databaseId, tableId });
525
+ const existingIdx: any[] = (idxRes as any).data || (idxRes as any).indexes || [];
526
+ const extraIdx = existingIdx
527
+ .filter((i: any) => i?.key && !desiredIndexKeys.has(i.key))
528
+ .map((i: any) => i.key as string);
529
+ if (extraIdx.length > 0) {
530
+ MessageFormatter.info(`Plan → 🗑️ ${extraIdx.length} indexes (${extraIdx.join(', ')})`, { prefix: 'Indexes' });
531
+ const deleted: string[] = [];
532
+ const errors: Array<{ key: string; error: string }> = [];
533
+ for (const key of extraIdx) {
534
+ try {
535
+ await adapter.deleteIndex({ databaseId, tableId, key });
536
+ // Optionally wait for index to disappear
537
+ const start = Date.now();
538
+ const maxWait = 30000;
539
+ while (Date.now() - start < maxWait) {
540
+ try {
541
+ const li = await adapter.listIndexes({ databaseId, tableId });
542
+ const list: any[] = (li as any).data || (li as any).indexes || [];
543
+ if (!list.find((ix: any) => ix.key === key)) break;
544
+ } catch {}
545
+ await delay(1000);
546
+ }
547
+ deleted.push(key);
548
+ } catch (e: any) {
549
+ errors.push({ key, error: e?.message || String(e) });
550
+ }
551
+ }
552
+ if (deleted.length) {
553
+ MessageFormatter.success(`Deleted ${deleted.length} indexes: ${deleted.join(', ')}`, { prefix: 'Indexes' });
554
+ }
555
+ if (errors.length) {
556
+ MessageFormatter.error(`${errors.length} index deletions failed`, undefined, { prefix: 'Indexes' });
557
+ errors.forEach(er => MessageFormatter.error(` ${er.key}: ${er.error}`, undefined, { prefix: 'Indexes' }));
558
+ }
559
+ } else {
560
+ MessageFormatter.info(`Plan → 🗑️ 0 indexes`, { prefix: 'Indexes' });
561
+ }
562
+ } catch (e) {
563
+ MessageFormatter.warning(`Could not evaluate index deletions: ${(e as Error)?.message || e}`, { prefix: 'Indexes' });
564
+ }
565
+
566
+ // Deletions: remove columns/attributes that are present remotely but not in desired config
567
+ try {
568
+ const desiredKeys = new Set((attributes || []).map((a: any) => a.key));
569
+ const tableInfo3 = await adapter.getTable({ databaseId, tableId });
570
+ const existingCols3: any[] = (tableInfo3 as any).data?.columns || (tableInfo3 as any).data?.attributes || [];
571
+ const toDelete = existingCols3
572
+ .filter((col: any) => col?.key && !desiredKeys.has(col.key))
573
+ .map((col: any) => col.key as string);
574
+
575
+ if (toDelete.length > 0) {
576
+ MessageFormatter.info(`Plan → 🗑️ ${toDelete.length} (${toDelete.join(', ')})`, { prefix: 'Attributes' });
577
+ const deleted: string[] = [];
578
+ const errors: Array<{ key: string; error: string }> = [];
579
+ for (const key of toDelete) {
580
+ try {
581
+ // Drop any indexes that reference this attribute to avoid server errors
582
+ try {
583
+ const idxRes = await adapter.listIndexes({ databaseId, tableId });
584
+ const ilist: any[] = (idxRes as any).data || (idxRes as any).indexes || [];
585
+ for (const idx of ilist) {
586
+ const attrs: string[] = Array.isArray(idx.attributes) ? idx.attributes : [];
587
+ if (attrs.includes(key)) {
588
+ MessageFormatter.info(`🗑️ Deleting index '${idx.key}' referencing '${key}'`, { prefix: 'Indexes' });
589
+ await adapter.deleteIndex({ databaseId, tableId, key: idx.key });
590
+ await delay(500);
591
+ }
592
+ }
593
+ } catch {}
594
+
595
+ await adapter.deleteAttribute({ databaseId, tableId, key });
596
+ // Wait briefly for deletion to settle
597
+ const start = Date.now();
598
+ const maxWaitMs = 60000;
599
+ while (Date.now() - start < maxWaitMs) {
600
+ try {
601
+ const tinfo = await adapter.getTable({ databaseId, tableId });
602
+ const cols = (tinfo as any).data?.columns || (tinfo as any).data?.attributes || [];
603
+ const found = cols.find((c: any) => c.key === key);
604
+ if (!found) break;
605
+ if (found.status && found.status !== 'deleting') break;
606
+ } catch {}
607
+ await delay(1000);
608
+ }
609
+ deleted.push(key);
610
+ } catch (e: any) {
611
+ errors.push({ key, error: e?.message || String(e) });
612
+ }
613
+ }
614
+ if (deleted.length) {
615
+ MessageFormatter.success(`Deleted ${deleted.length} attributes: ${deleted.join(', ')}`, { prefix: 'Attributes' });
616
+ }
617
+ if (errors.length) {
618
+ MessageFormatter.error(`${errors.length} deletions failed`, undefined, { prefix: 'Attributes' });
619
+ errors.forEach(er => MessageFormatter.error(` ${er.key}: ${er.error}`, undefined, { prefix: 'Attributes' }));
620
+ }
621
+ } else {
622
+ MessageFormatter.info(`Plan → 🗑️ 0`, { prefix: 'Attributes' });
623
+ }
624
+ } catch (e) {
625
+ MessageFormatter.warning(`Could not evaluate deletions: ${(e as Error)?.message || e}`, { prefix: 'Attributes' });
626
+ }
627
+
628
+ // Mark this table as fully processed to prevent re-processing
629
+ markCollectionProcessed(tableId, collectionData.name);
630
+ }
517
631
 
518
632
  // Process queued relationships once mapping likely populated
519
633
  if (relQueue.length > 0) {
@@ -848,15 +848,24 @@ export class UtilsController {
848
848
  MessageFormatter.success("Selective pull completed successfully! Remote config pulled to local.", { prefix: "Controller" });
849
849
  }
850
850
 
851
- async selectivePush(
852
- databaseSelections: DatabaseSelection[],
853
- bucketSelections: BucketSelection[]
854
- ): Promise<void> {
855
- await this.init();
856
- if (!this.database) {
857
- MessageFormatter.error("Database not initialized", undefined, { prefix: "Controller" });
858
- return;
859
- }
851
+ async selectivePush(
852
+ databaseSelections: DatabaseSelection[],
853
+ bucketSelections: BucketSelection[]
854
+ ): Promise<void> {
855
+ await this.init();
856
+ if (!this.database) {
857
+ MessageFormatter.error("Database not initialized", undefined, { prefix: "Controller" });
858
+ return;
859
+ }
860
+
861
+ // Always reload config from disk so pushes use current local YAML/Ts definitions
862
+ try {
863
+ await this.reloadConfig();
864
+ MessageFormatter.info("Reloaded config from disk for push", { prefix: "Controller" });
865
+ } catch (e) {
866
+ // Non-fatal; continue with existing config
867
+ MessageFormatter.warning("Could not reload config; continuing with current in-memory config", { prefix: "Controller" });
868
+ }
860
869
 
861
870
  MessageFormatter.progress("Starting selective push (local config → Appwrite)...", { prefix: "Controller" });
862
871
 
@@ -900,7 +909,7 @@ export class UtilsController {
900
909
  const databaseCollectionsMap = new Map<string, any[]>();
901
910
 
902
911
  // Get all collections/tables from config (they're at the root level, not nested in databases)
903
- const allCollections = this.config?.collections || this.config?.tables || [];
912
+ const allCollections = this.config?.collections || this.config?.tables || [];
904
913
 
905
914
  // Create database-specific collection mapping to preserve relationships
906
915
  for (const dbSelection of databaseSelections) {
@@ -908,16 +917,17 @@ export class UtilsController {
908
917
 
909
918
  MessageFormatter.info(`Processing collections for database: ${dbSelection.databaseId}`, { prefix: "Controller" });
910
919
 
911
- // Filter collections that were selected for THIS specific database
912
- for (const collection of allCollections) {
913
- const collectionId = collection.$id || (collection as any).id;
914
-
915
- // Check if this collection was selected for THIS database
916
- if (dbSelection.tableIds.includes(collectionId)) {
917
- collectionsForDatabase.push(collection);
918
- MessageFormatter.info(` - Selected collection: ${collection.name || collectionId} for database ${dbSelection.databaseId}`, { prefix: "Controller" });
919
- }
920
- }
920
+ // Filter collections that were selected for THIS specific database
921
+ for (const collection of allCollections) {
922
+ const collectionId = collection.$id || (collection as any).id;
923
+
924
+ // Check if this collection was selected for THIS database
925
+ if (dbSelection.tableIds.includes(collectionId)) {
926
+ collectionsForDatabase.push(collection);
927
+ const source = (collection as any)._isFromTablesDir ? 'tables/' : 'collections/';
928
+ MessageFormatter.info(` - Selected collection: ${collection.name || collectionId} for database ${dbSelection.databaseId} [source: ${source}]`, { prefix: "Controller" });
929
+ }
930
+ }
921
931
 
922
932
  databaseCollectionsMap.set(dbSelection.databaseId, collectionsForDatabase);
923
933
  MessageFormatter.info(`Database ${dbSelection.databaseId}: ${collectionsForDatabase.length} collections selected`, { prefix: "Controller" });