@njdamstra/appwrite-utils-cli 1.11.3 → 1.11.5

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.
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import inquirer from "inquirer";
6
6
  import chalk from "chalk";
7
+ import pLimit from "p-limit";
7
8
  import { MessageFormatter, tryAwaitWithRetry, } from "@njdamstra/appwrite-utils-helpers";
8
9
  import { ProgressManager } from "../shared/progressManager.js";
9
10
  import { MigrationPlanSchema, MigrationCheckpointSchema, suggestTargetType, generateBackupKey, } from "./migrateStringsTypes.js";
@@ -319,7 +320,7 @@ async function migrateOneAttribute(adapter, entry, cpEntry, checkpoint, checkpoi
319
320
  MessageFormatter.info(` Creating backup attribute ${backupKey}...`, {
320
321
  prefix: "Migrate",
321
322
  });
322
- await tryAwaitWithRetry(() => adapter.createAttribute({
323
+ await createAttributeIfNotExists(adapter, {
323
324
  databaseId,
324
325
  tableId: collectionId,
325
326
  key: backupKey,
@@ -327,7 +328,7 @@ async function migrateOneAttribute(adapter, entry, cpEntry, checkpoint, checkpoi
327
328
  size: entry.currentSize,
328
329
  required: false, // always optional for backup
329
330
  array: entry.isArray,
330
- }));
331
+ });
331
332
  const available = await waitForAttribute(adapter, databaseId, collectionId, backupKey);
332
333
  if (!available)
333
334
  throw new Error(`Backup attribute ${backupKey} stuck`);
@@ -384,7 +385,7 @@ async function migrateOneAttribute(adapter, entry, cpEntry, checkpoint, checkpoi
384
385
  if (entry.hasDefault && entry.defaultValue !== undefined) {
385
386
  createParams.default = entry.defaultValue;
386
387
  }
387
- await tryAwaitWithRetry(() => adapter.createAttribute(createParams));
388
+ await createAttributeIfNotExists(adapter, createParams);
388
389
  const available = await waitForAttribute(adapter, databaseId, collectionId, attributeKey);
389
390
  if (!available)
390
391
  throw new Error(`New attribute ${attributeKey} stuck after creation`);
@@ -461,6 +462,7 @@ async function copyAttributeData(adapter, databaseId, collectionId, sourceKey, t
461
462
  title: ` Copy ${sourceKey} → ${targetKey}`,
462
463
  })
463
464
  : undefined;
465
+ const limit = pLimit(5);
464
466
  while (true) {
465
467
  const queries = [Query.limit(batchSize)];
466
468
  if (lastId)
@@ -469,34 +471,18 @@ async function copyAttributeData(adapter, databaseId, collectionId, sourceKey, t
469
471
  const docs = res?.documents || res?.rows || [];
470
472
  if (docs.length === 0)
471
473
  break;
472
- // Batch update: copy sourceKey → targetKey
473
- if (adapter.supportsBulkOperations() && adapter.bulkUpsertRows) {
474
- const rows = docs
475
- .filter((d) => d[sourceKey] !== undefined)
476
- .map((d) => ({
477
- id: d.$id,
478
- data: { [targetKey]: d[sourceKey] },
479
- }));
480
- if (rows.length > 0) {
481
- await tryAwaitWithRetry(() => adapter.bulkUpsertRows({
482
- databaseId,
483
- tableId: collectionId,
484
- rows,
485
- }));
486
- }
487
- }
488
- else {
489
- for (const doc of docs) {
490
- if (doc[sourceKey] === undefined)
491
- continue;
492
- await tryAwaitWithRetry(() => adapter.updateRow({
493
- databaseId,
494
- tableId: collectionId,
495
- id: doc.$id,
496
- data: { [targetKey]: doc[sourceKey] },
497
- }));
498
- }
499
- }
474
+ // Partial update: copy sourceKey → targetKey using updateRow (not bulkUpsert
475
+ // which requires complete document structure and fails on partial payloads)
476
+ const updatePromises = docs
477
+ .filter((d) => d[sourceKey] !== undefined)
478
+ .map((d) => limit(() => tryAwaitWithRetry(() => adapter.updateRow({
479
+ databaseId,
480
+ tableId: collectionId,
481
+ id: d.$id,
482
+ data: { [targetKey]: d[sourceKey] },
483
+ }), 0, true // throwError — surface 400s immediately
484
+ )));
485
+ await Promise.all(updatePromises);
500
486
  totalCopied += docs.length;
501
487
  lastId = docs[docs.length - 1].$id;
502
488
  progress?.update(totalCopied);
@@ -519,9 +505,9 @@ async function verifyDataCopy(adapter, databaseId, collectionId, sourceKey, targ
519
505
  }));
520
506
  const docs = res?.documents || res?.rows || [];
521
507
  for (const doc of docs) {
522
- if (doc[sourceKey] === undefined)
508
+ if (!(sourceKey in doc))
523
509
  continue;
524
- if (doc[sourceKey] !== doc[targetKey]) {
510
+ if (JSON.stringify(doc[sourceKey]) !== JSON.stringify(doc[targetKey])) {
525
511
  throw new Error(`Verification failed: doc ${doc.$id} has ${sourceKey}=${JSON.stringify(doc[sourceKey])} but ${targetKey}=${JSON.stringify(doc[targetKey])}`);
526
512
  }
527
513
  }
@@ -529,7 +515,7 @@ async function verifyDataCopy(adapter, databaseId, collectionId, sourceKey, targ
529
515
  // ────────────────────────────────────────────────────────
530
516
  // Helper: wait for attribute to become available
531
517
  // ────────────────────────────────────────────────────────
532
- async function waitForAttribute(adapter, databaseId, collectionId, key, maxWaitMs = 60_000) {
518
+ async function waitForAttribute(adapter, databaseId, collectionId, key, maxWaitMs = 120_000) {
533
519
  const start = Date.now();
534
520
  const checkInterval = 2000;
535
521
  while (Date.now() - start < maxWaitMs) {
@@ -740,6 +726,22 @@ function printDryRunSummary(plan) {
740
726
  // ────────────────────────────────────────────────────────
741
727
  // Utility
742
728
  // ────────────────────────────────────────────────────────
729
+ async function createAttributeIfNotExists(adapter, params) {
730
+ try {
731
+ await tryAwaitWithRetry(() => adapter.createAttribute(params), 0, true);
732
+ }
733
+ catch (err) {
734
+ const code = err?.code || err?.originalError?.code;
735
+ const type = err?.originalError?.type || "";
736
+ if (code === 409 || type === "column_already_exists") {
737
+ MessageFormatter.info(` (backup attribute already exists, reusing)`, {
738
+ prefix: "Migrate",
739
+ });
740
+ return;
741
+ }
742
+ throw err;
743
+ }
744
+ }
743
745
  function delay(ms) {
744
746
  return new Promise((resolve) => setTimeout(resolve, ms));
745
747
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@njdamstra/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.11.3",
4
+ "version": "1.11.5",
5
5
  "main": "dist/main.js",
6
6
  "type": "module",
7
7
  "repository": {
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import inquirer from "inquirer";
6
6
  import chalk from "chalk";
7
+ import pLimit from "p-limit";
7
8
  import {
8
9
  type DatabaseAdapter,
9
10
  MessageFormatter,
@@ -457,17 +458,15 @@ async function migrateOneAttribute(
457
458
  MessageFormatter.info(` Creating backup attribute ${backupKey}...`, {
458
459
  prefix: "Migrate",
459
460
  });
460
- await tryAwaitWithRetry(() =>
461
- adapter.createAttribute({
462
- databaseId,
463
- tableId: collectionId,
464
- key: backupKey,
465
- type: "string", // backup keeps original type
466
- size: entry.currentSize,
467
- required: false, // always optional for backup
468
- array: entry.isArray,
469
- })
470
- );
461
+ await createAttributeIfNotExists(adapter, {
462
+ databaseId,
463
+ tableId: collectionId,
464
+ key: backupKey,
465
+ type: "string", // backup keeps original type
466
+ size: entry.currentSize,
467
+ required: false, // always optional for backup
468
+ array: entry.isArray,
469
+ });
471
470
  const available = await waitForAttribute(
472
471
  adapter,
473
472
  databaseId,
@@ -559,7 +558,7 @@ async function migrateOneAttribute(
559
558
  createParams.default = entry.defaultValue;
560
559
  }
561
560
 
562
- await tryAwaitWithRetry(() => adapter.createAttribute(createParams as any));
561
+ await createAttributeIfNotExists(adapter, createParams as any);
563
562
  const available = await waitForAttribute(
564
563
  adapter,
565
564
  databaseId,
@@ -688,6 +687,8 @@ async function copyAttributeData(
688
687
  })
689
688
  : undefined;
690
689
 
690
+ const limit = pLimit(5);
691
+
691
692
  while (true) {
692
693
  const queries: string[] = [Query.limit(batchSize)];
693
694
  if (lastId) queries.push(Query.cursorAfter(lastId));
@@ -699,37 +700,26 @@ async function copyAttributeData(
699
700
  const docs = res?.documents || res?.rows || [];
700
701
  if (docs.length === 0) break;
701
702
 
702
- // Batch update: copy sourceKey → targetKey
703
- if (adapter.supportsBulkOperations() && adapter.bulkUpsertRows) {
704
- const rows = docs
705
- .filter((d: any) => d[sourceKey] !== undefined)
706
- .map((d: any) => ({
707
- id: d.$id,
708
- data: { [targetKey]: d[sourceKey] },
709
- }));
710
-
711
- if (rows.length > 0) {
712
- await tryAwaitWithRetry(() =>
713
- adapter.bulkUpsertRows!({
714
- databaseId,
715
- tableId: collectionId,
716
- rows,
717
- })
718
- );
719
- }
720
- } else {
721
- for (const doc of docs) {
722
- if (doc[sourceKey] === undefined) continue;
723
- await tryAwaitWithRetry(() =>
724
- adapter.updateRow({
725
- databaseId,
726
- tableId: collectionId,
727
- id: doc.$id,
728
- data: { [targetKey]: doc[sourceKey] },
729
- })
730
- );
731
- }
732
- }
703
+ // Partial update: copy sourceKey → targetKey using updateRow (not bulkUpsert
704
+ // which requires complete document structure and fails on partial payloads)
705
+ const updatePromises = docs
706
+ .filter((d: any) => d[sourceKey] !== undefined)
707
+ .map((d: any) =>
708
+ limit(() =>
709
+ tryAwaitWithRetry(
710
+ () =>
711
+ adapter.updateRow({
712
+ databaseId,
713
+ tableId: collectionId,
714
+ id: d.$id,
715
+ data: { [targetKey]: d[sourceKey] },
716
+ }),
717
+ 0,
718
+ true // throwError — surface 400s immediately
719
+ )
720
+ )
721
+ );
722
+ await Promise.all(updatePromises);
733
723
 
734
724
  totalCopied += docs.length;
735
725
  lastId = docs[docs.length - 1].$id;
@@ -763,8 +753,8 @@ async function verifyDataCopy(
763
753
  );
764
754
  const docs = res?.documents || res?.rows || [];
765
755
  for (const doc of docs) {
766
- if (doc[sourceKey] === undefined) continue;
767
- if (doc[sourceKey] !== doc[targetKey]) {
756
+ if (!(sourceKey in doc)) continue;
757
+ if (JSON.stringify(doc[sourceKey]) !== JSON.stringify(doc[targetKey])) {
768
758
  throw new Error(
769
759
  `Verification failed: doc ${doc.$id} has ${sourceKey}=${JSON.stringify(doc[sourceKey])} but ${targetKey}=${JSON.stringify(doc[targetKey])}`
770
760
  );
@@ -781,7 +771,7 @@ async function waitForAttribute(
781
771
  databaseId: string,
782
772
  collectionId: string,
783
773
  key: string,
784
- maxWaitMs: number = 60_000
774
+ maxWaitMs: number = 120_000
785
775
  ): Promise<boolean> {
786
776
  const start = Date.now();
787
777
  const checkInterval = 2000;
@@ -1089,6 +1079,25 @@ function printDryRunSummary(plan: MigrationPlan): void {
1089
1079
  // Utility
1090
1080
  // ────────────────────────────────────────────────────────
1091
1081
 
1082
+ async function createAttributeIfNotExists(
1083
+ adapter: DatabaseAdapter,
1084
+ params: Parameters<DatabaseAdapter["createAttribute"]>[0]
1085
+ ): Promise<void> {
1086
+ try {
1087
+ await tryAwaitWithRetry(() => adapter.createAttribute(params), 0, true);
1088
+ } catch (err: any) {
1089
+ const code = err?.code || err?.originalError?.code;
1090
+ const type = err?.originalError?.type || "";
1091
+ if (code === 409 || type === "column_already_exists") {
1092
+ MessageFormatter.info(` (backup attribute already exists, reusing)`, {
1093
+ prefix: "Migrate",
1094
+ });
1095
+ return;
1096
+ }
1097
+ throw err;
1098
+ }
1099
+ }
1100
+
1092
1101
  function delay(ms: number): Promise<void> {
1093
1102
  return new Promise((resolve) => setTimeout(resolve, ms));
1094
1103
  }