@njdamstra/appwrite-utils-cli 1.11.4 → 1.11.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.
@@ -290,7 +290,7 @@ export async function executeMigrationPlan(adapter, options) {
290
290
  },
291
291
  ]);
292
292
  if (updateYaml) {
293
- MessageFormatter.info("Local YAML update: use your editor to change 'type: string' to the new types in your collection YAML files.", { prefix: "Execute" });
293
+ await updateCollectionYaml(first.collectionName, entries, checkpoint);
294
294
  }
295
295
  }
296
296
  }
@@ -320,7 +320,7 @@ async function migrateOneAttribute(adapter, entry, cpEntry, checkpoint, checkpoi
320
320
  MessageFormatter.info(` Creating backup attribute ${backupKey}...`, {
321
321
  prefix: "Migrate",
322
322
  });
323
- await tryAwaitWithRetry(() => adapter.createAttribute({
323
+ await createAttributeIfNotExists(adapter, {
324
324
  databaseId,
325
325
  tableId: collectionId,
326
326
  key: backupKey,
@@ -328,7 +328,7 @@ async function migrateOneAttribute(adapter, entry, cpEntry, checkpoint, checkpoi
328
328
  size: entry.currentSize,
329
329
  required: false, // always optional for backup
330
330
  array: entry.isArray,
331
- }));
331
+ });
332
332
  const available = await waitForAttribute(adapter, databaseId, collectionId, backupKey);
333
333
  if (!available)
334
334
  throw new Error(`Backup attribute ${backupKey} stuck`);
@@ -385,7 +385,7 @@ async function migrateOneAttribute(adapter, entry, cpEntry, checkpoint, checkpoi
385
385
  if (entry.hasDefault && entry.defaultValue !== undefined) {
386
386
  createParams.default = entry.defaultValue;
387
387
  }
388
- await tryAwaitWithRetry(() => adapter.createAttribute(createParams));
388
+ await createAttributeIfNotExists(adapter, createParams);
389
389
  const available = await waitForAttribute(adapter, databaseId, collectionId, attributeKey);
390
390
  if (!available)
391
391
  throw new Error(`New attribute ${attributeKey} stuck after creation`);
@@ -507,7 +507,7 @@ async function verifyDataCopy(adapter, databaseId, collectionId, sourceKey, targ
507
507
  for (const doc of docs) {
508
508
  if (!(sourceKey in doc))
509
509
  continue;
510
- if (doc[sourceKey] !== doc[targetKey]) {
510
+ if (JSON.stringify(doc[sourceKey]) !== JSON.stringify(doc[targetKey])) {
511
511
  throw new Error(`Verification failed: doc ${doc.$id} has ${sourceKey}=${JSON.stringify(doc[sourceKey])} but ${targetKey}=${JSON.stringify(doc[targetKey])}`);
512
512
  }
513
513
  }
@@ -515,7 +515,7 @@ async function verifyDataCopy(adapter, databaseId, collectionId, sourceKey, targ
515
515
  // ────────────────────────────────────────────────────────
516
516
  // Helper: wait for attribute to become available
517
517
  // ────────────────────────────────────────────────────────
518
- async function waitForAttribute(adapter, databaseId, collectionId, key, maxWaitMs = 60_000) {
518
+ async function waitForAttribute(adapter, databaseId, collectionId, key, maxWaitMs = 120_000) {
519
519
  const start = Date.now();
520
520
  const checkInterval = 2000;
521
521
  while (Date.now() - start < maxWaitMs) {
@@ -724,8 +724,181 @@ function printDryRunSummary(plan) {
724
724
  console.log("");
725
725
  }
726
726
  // ────────────────────────────────────────────────────────
727
+ // Update local collection YAML after migration
728
+ // ────────────────────────────────────────────────────────
729
+ async function updateCollectionYaml(collectionName, entries, checkpoint) {
730
+ // Find candidate YAML files
731
+ const candidates = findYamlFiles(process.cwd(), collectionName);
732
+ if (candidates.length === 0) {
733
+ MessageFormatter.warning(`No YAML file found for collection "${collectionName}". Skipping local config update.`, { prefix: "YAML" });
734
+ return;
735
+ }
736
+ let yamlPath;
737
+ if (candidates.length === 1) {
738
+ const { usePath } = await inquirer.prompt([
739
+ {
740
+ type: "confirm",
741
+ name: "usePath",
742
+ message: `Found: ${candidates[0]}. Use this file?`,
743
+ default: true,
744
+ },
745
+ ]);
746
+ if (!usePath) {
747
+ const { customPath } = await inquirer.prompt([
748
+ {
749
+ type: "input",
750
+ name: "customPath",
751
+ message: "Enter path to collection YAML file:",
752
+ },
753
+ ]);
754
+ if (!customPath || !fs.existsSync(customPath)) {
755
+ MessageFormatter.warning("Invalid path. Skipping YAML update.", {
756
+ prefix: "YAML",
757
+ });
758
+ return;
759
+ }
760
+ yamlPath = customPath;
761
+ }
762
+ else {
763
+ yamlPath = candidates[0];
764
+ }
765
+ }
766
+ else {
767
+ const { selectedPath } = await inquirer.prompt([
768
+ {
769
+ type: "list",
770
+ name: "selectedPath",
771
+ message: `Multiple YAML files found for "${collectionName}". Select one:`,
772
+ choices: [
773
+ ...candidates.map((c) => ({ name: c, value: c })),
774
+ { name: "Enter custom path", value: "__custom__" },
775
+ ],
776
+ },
777
+ ]);
778
+ if (selectedPath === "__custom__") {
779
+ const { customPath } = await inquirer.prompt([
780
+ {
781
+ type: "input",
782
+ name: "customPath",
783
+ message: "Enter path to collection YAML file:",
784
+ },
785
+ ]);
786
+ if (!customPath || !fs.existsSync(customPath)) {
787
+ MessageFormatter.warning("Invalid path. Skipping YAML update.", {
788
+ prefix: "YAML",
789
+ });
790
+ return;
791
+ }
792
+ yamlPath = customPath;
793
+ }
794
+ else {
795
+ yamlPath = selectedPath;
796
+ }
797
+ }
798
+ // Load and parse YAML
799
+ let doc;
800
+ try {
801
+ const content = fs.readFileSync(yamlPath, "utf8");
802
+ doc = yaml.load(content);
803
+ }
804
+ catch (err) {
805
+ MessageFormatter.error(`Failed to parse ${yamlPath}: ${err.message}`, undefined, { prefix: "YAML" });
806
+ return;
807
+ }
808
+ if (!doc || !Array.isArray(doc.attributes)) {
809
+ MessageFormatter.warning(`No "attributes" array found in ${yamlPath}. Skipping.`, { prefix: "YAML" });
810
+ return;
811
+ }
812
+ // Update attributes that were successfully migrated
813
+ let updated = 0;
814
+ for (const entry of entries) {
815
+ const cp = findCheckpointEntry(checkpoint, entry);
816
+ if (cp?.phase !== "completed")
817
+ continue;
818
+ const attr = doc.attributes.find((a) => a.key === entry.attributeKey && a.type === "string");
819
+ if (!attr)
820
+ continue;
821
+ attr.type = entry.targetType;
822
+ if (entry.targetType !== "varchar") {
823
+ delete attr.size;
824
+ }
825
+ updated++;
826
+ }
827
+ if (updated === 0) {
828
+ MessageFormatter.info("No matching string attributes found in YAML to update.", { prefix: "YAML" });
829
+ return;
830
+ }
831
+ // Write back
832
+ try {
833
+ const output = yaml.dump(doc, {
834
+ lineWidth: 120,
835
+ noRefs: true,
836
+ sortKeys: false,
837
+ });
838
+ fs.writeFileSync(yamlPath, output, "utf8");
839
+ MessageFormatter.success(`Updated ${updated} attribute(s) in ${yamlPath}`, { prefix: "YAML" });
840
+ }
841
+ catch (err) {
842
+ MessageFormatter.error(`Failed to write ${yamlPath}: ${err.message}`, undefined, { prefix: "YAML" });
843
+ }
844
+ }
845
+ function findYamlFiles(searchRoot, collectionName) {
846
+ const results = [];
847
+ const lowerName = collectionName.toLowerCase();
848
+ function walk(dir) {
849
+ let dirEntries;
850
+ try {
851
+ dirEntries = fs.readdirSync(dir, { withFileTypes: true });
852
+ }
853
+ catch {
854
+ return; // skip inaccessible directories
855
+ }
856
+ for (const ent of dirEntries) {
857
+ const fullPath = path.join(dir, ent.name);
858
+ if (ent.isDirectory()) {
859
+ // Skip node_modules, .git, and other common non-config dirs
860
+ if (ent.name === "node_modules" || ent.name === ".git" || ent.name === "dist")
861
+ continue;
862
+ walk(fullPath);
863
+ }
864
+ else if (ent.isFile() &&
865
+ ent.name.toLowerCase() === `${lowerName}.yaml`) {
866
+ // Verify it looks like a collection config (has a name field)
867
+ try {
868
+ const content = fs.readFileSync(fullPath, "utf8");
869
+ const parsed = yaml.load(content);
870
+ if (parsed && parsed.name === collectionName) {
871
+ results.push(fullPath);
872
+ }
873
+ }
874
+ catch {
875
+ // skip unparseable files
876
+ }
877
+ }
878
+ }
879
+ }
880
+ walk(searchRoot);
881
+ return results;
882
+ }
883
+ // ────────────────────────────────────────────────────────
727
884
  // Utility
728
885
  // ────────────────────────────────────────────────────────
886
+ async function createAttributeIfNotExists(adapter, params) {
887
+ try {
888
+ await tryAwaitWithRetry(() => adapter.createAttribute(params), 0, true);
889
+ }
890
+ catch (err) {
891
+ const code = err?.code || err?.originalError?.code;
892
+ const type = err?.originalError?.type || "";
893
+ if (code === 409 || type === "column_already_exists") {
894
+ MessageFormatter.info(` (backup attribute already exists, reusing)`, {
895
+ prefix: "Migrate",
896
+ });
897
+ return;
898
+ }
899
+ throw err;
900
+ }
901
+ }
729
902
  function delay(ms) {
730
903
  return new Promise((resolve) => setTimeout(resolve, ms));
731
904
  }
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.4",
4
+ "version": "1.11.6",
5
5
  "main": "dist/main.js",
6
6
  "type": "module",
7
7
  "repository": {
@@ -398,10 +398,7 @@ export async function executeMigrationPlan(
398
398
  },
399
399
  ]);
400
400
  if (updateYaml) {
401
- MessageFormatter.info(
402
- "Local YAML update: use your editor to change 'type: string' to the new types in your collection YAML files.",
403
- { prefix: "Execute" }
404
- );
401
+ await updateCollectionYaml(first.collectionName, entries, checkpoint);
405
402
  }
406
403
  }
407
404
  }
@@ -458,17 +455,15 @@ async function migrateOneAttribute(
458
455
  MessageFormatter.info(` Creating backup attribute ${backupKey}...`, {
459
456
  prefix: "Migrate",
460
457
  });
461
- await tryAwaitWithRetry(() =>
462
- adapter.createAttribute({
463
- databaseId,
464
- tableId: collectionId,
465
- key: backupKey,
466
- type: "string", // backup keeps original type
467
- size: entry.currentSize,
468
- required: false, // always optional for backup
469
- array: entry.isArray,
470
- })
471
- );
458
+ await createAttributeIfNotExists(adapter, {
459
+ databaseId,
460
+ tableId: collectionId,
461
+ key: backupKey,
462
+ type: "string", // backup keeps original type
463
+ size: entry.currentSize,
464
+ required: false, // always optional for backup
465
+ array: entry.isArray,
466
+ });
472
467
  const available = await waitForAttribute(
473
468
  adapter,
474
469
  databaseId,
@@ -560,7 +555,7 @@ async function migrateOneAttribute(
560
555
  createParams.default = entry.defaultValue;
561
556
  }
562
557
 
563
- await tryAwaitWithRetry(() => adapter.createAttribute(createParams as any));
558
+ await createAttributeIfNotExists(adapter, createParams as any);
564
559
  const available = await waitForAttribute(
565
560
  adapter,
566
561
  databaseId,
@@ -756,7 +751,7 @@ async function verifyDataCopy(
756
751
  const docs = res?.documents || res?.rows || [];
757
752
  for (const doc of docs) {
758
753
  if (!(sourceKey in doc)) continue;
759
- if (doc[sourceKey] !== doc[targetKey]) {
754
+ if (JSON.stringify(doc[sourceKey]) !== JSON.stringify(doc[targetKey])) {
760
755
  throw new Error(
761
756
  `Verification failed: doc ${doc.$id} has ${sourceKey}=${JSON.stringify(doc[sourceKey])} but ${targetKey}=${JSON.stringify(doc[targetKey])}`
762
757
  );
@@ -773,7 +768,7 @@ async function waitForAttribute(
773
768
  databaseId: string,
774
769
  collectionId: string,
775
770
  key: string,
776
- maxWaitMs: number = 60_000
771
+ maxWaitMs: number = 120_000
777
772
  ): Promise<boolean> {
778
773
  const start = Date.now();
779
774
  const checkInterval = 2000;
@@ -1077,10 +1072,218 @@ function printDryRunSummary(plan: MigrationPlan): void {
1077
1072
  console.log("");
1078
1073
  }
1079
1074
 
1075
+ // ────────────────────────────────────────────────────────
1076
+ // Update local collection YAML after migration
1077
+ // ────────────────────────────────────────────────────────
1078
+
1079
+ async function updateCollectionYaml(
1080
+ collectionName: string,
1081
+ entries: MigrationPlanEntry[],
1082
+ checkpoint: MigrationCheckpoint
1083
+ ): Promise<void> {
1084
+ // Find candidate YAML files
1085
+ const candidates = findYamlFiles(process.cwd(), collectionName);
1086
+
1087
+ if (candidates.length === 0) {
1088
+ MessageFormatter.warning(
1089
+ `No YAML file found for collection "${collectionName}". Skipping local config update.`,
1090
+ { prefix: "YAML" }
1091
+ );
1092
+ return;
1093
+ }
1094
+
1095
+ let yamlPath: string;
1096
+
1097
+ if (candidates.length === 1) {
1098
+ const { usePath } = await inquirer.prompt([
1099
+ {
1100
+ type: "confirm",
1101
+ name: "usePath",
1102
+ message: `Found: ${candidates[0]}. Use this file?`,
1103
+ default: true,
1104
+ },
1105
+ ]);
1106
+ if (!usePath) {
1107
+ const { customPath } = await inquirer.prompt([
1108
+ {
1109
+ type: "input",
1110
+ name: "customPath",
1111
+ message: "Enter path to collection YAML file:",
1112
+ },
1113
+ ]);
1114
+ if (!customPath || !fs.existsSync(customPath)) {
1115
+ MessageFormatter.warning("Invalid path. Skipping YAML update.", {
1116
+ prefix: "YAML",
1117
+ });
1118
+ return;
1119
+ }
1120
+ yamlPath = customPath;
1121
+ } else {
1122
+ yamlPath = candidates[0];
1123
+ }
1124
+ } else {
1125
+ const { selectedPath } = await inquirer.prompt([
1126
+ {
1127
+ type: "list",
1128
+ name: "selectedPath",
1129
+ message: `Multiple YAML files found for "${collectionName}". Select one:`,
1130
+ choices: [
1131
+ ...candidates.map((c) => ({ name: c, value: c })),
1132
+ { name: "Enter custom path", value: "__custom__" },
1133
+ ],
1134
+ },
1135
+ ]);
1136
+ if (selectedPath === "__custom__") {
1137
+ const { customPath } = await inquirer.prompt([
1138
+ {
1139
+ type: "input",
1140
+ name: "customPath",
1141
+ message: "Enter path to collection YAML file:",
1142
+ },
1143
+ ]);
1144
+ if (!customPath || !fs.existsSync(customPath)) {
1145
+ MessageFormatter.warning("Invalid path. Skipping YAML update.", {
1146
+ prefix: "YAML",
1147
+ });
1148
+ return;
1149
+ }
1150
+ yamlPath = customPath;
1151
+ } else {
1152
+ yamlPath = selectedPath;
1153
+ }
1154
+ }
1155
+
1156
+ // Load and parse YAML
1157
+ let doc: any;
1158
+ try {
1159
+ const content = fs.readFileSync(yamlPath, "utf8");
1160
+ doc = yaml.load(content);
1161
+ } catch (err: any) {
1162
+ MessageFormatter.error(
1163
+ `Failed to parse ${yamlPath}: ${err.message}`,
1164
+ undefined,
1165
+ { prefix: "YAML" }
1166
+ );
1167
+ return;
1168
+ }
1169
+
1170
+ if (!doc || !Array.isArray(doc.attributes)) {
1171
+ MessageFormatter.warning(
1172
+ `No "attributes" array found in ${yamlPath}. Skipping.`,
1173
+ { prefix: "YAML" }
1174
+ );
1175
+ return;
1176
+ }
1177
+
1178
+ // Update attributes that were successfully migrated
1179
+ let updated = 0;
1180
+ for (const entry of entries) {
1181
+ const cp = findCheckpointEntry(checkpoint, entry);
1182
+ if (cp?.phase !== "completed") continue;
1183
+
1184
+ const attr = doc.attributes.find(
1185
+ (a: any) => a.key === entry.attributeKey && a.type === "string"
1186
+ );
1187
+ if (!attr) continue;
1188
+
1189
+ attr.type = entry.targetType;
1190
+ if (entry.targetType !== "varchar") {
1191
+ delete attr.size;
1192
+ }
1193
+ updated++;
1194
+ }
1195
+
1196
+ if (updated === 0) {
1197
+ MessageFormatter.info(
1198
+ "No matching string attributes found in YAML to update.",
1199
+ { prefix: "YAML" }
1200
+ );
1201
+ return;
1202
+ }
1203
+
1204
+ // Write back
1205
+ try {
1206
+ const output = yaml.dump(doc, {
1207
+ lineWidth: 120,
1208
+ noRefs: true,
1209
+ sortKeys: false,
1210
+ });
1211
+ fs.writeFileSync(yamlPath, output, "utf8");
1212
+ MessageFormatter.success(
1213
+ `Updated ${updated} attribute(s) in ${yamlPath}`,
1214
+ { prefix: "YAML" }
1215
+ );
1216
+ } catch (err: any) {
1217
+ MessageFormatter.error(
1218
+ `Failed to write ${yamlPath}: ${err.message}`,
1219
+ undefined,
1220
+ { prefix: "YAML" }
1221
+ );
1222
+ }
1223
+ }
1224
+
1225
+ function findYamlFiles(searchRoot: string, collectionName: string): string[] {
1226
+ const results: string[] = [];
1227
+ const lowerName = collectionName.toLowerCase();
1228
+
1229
+ function walk(dir: string): void {
1230
+ let dirEntries: fs.Dirent[];
1231
+ try {
1232
+ dirEntries = fs.readdirSync(dir, { withFileTypes: true });
1233
+ } catch {
1234
+ return; // skip inaccessible directories
1235
+ }
1236
+ for (const ent of dirEntries) {
1237
+ const fullPath = path.join(dir, ent.name);
1238
+ if (ent.isDirectory()) {
1239
+ // Skip node_modules, .git, and other common non-config dirs
1240
+ if (ent.name === "node_modules" || ent.name === ".git" || ent.name === "dist") continue;
1241
+ walk(fullPath);
1242
+ } else if (
1243
+ ent.isFile() &&
1244
+ ent.name.toLowerCase() === `${lowerName}.yaml`
1245
+ ) {
1246
+ // Verify it looks like a collection config (has a name field)
1247
+ try {
1248
+ const content = fs.readFileSync(fullPath, "utf8");
1249
+ const parsed = yaml.load(content) as any;
1250
+ if (parsed && parsed.name === collectionName) {
1251
+ results.push(fullPath);
1252
+ }
1253
+ } catch {
1254
+ // skip unparseable files
1255
+ }
1256
+ }
1257
+ }
1258
+ }
1259
+
1260
+ walk(searchRoot);
1261
+ return results;
1262
+ }
1263
+
1080
1264
  // ────────────────────────────────────────────────────────
1081
1265
  // Utility
1082
1266
  // ────────────────────────────────────────────────────────
1083
1267
 
1268
+ async function createAttributeIfNotExists(
1269
+ adapter: DatabaseAdapter,
1270
+ params: Parameters<DatabaseAdapter["createAttribute"]>[0]
1271
+ ): Promise<void> {
1272
+ try {
1273
+ await tryAwaitWithRetry(() => adapter.createAttribute(params), 0, true);
1274
+ } catch (err: any) {
1275
+ const code = err?.code || err?.originalError?.code;
1276
+ const type = err?.originalError?.type || "";
1277
+ if (code === 409 || type === "column_already_exists") {
1278
+ MessageFormatter.info(` (backup attribute already exists, reusing)`, {
1279
+ prefix: "Migrate",
1280
+ });
1281
+ return;
1282
+ }
1283
+ throw err;
1284
+ }
1285
+ }
1286
+
1084
1287
  function delay(ms: number): Promise<void> {
1085
1288
  return new Promise((resolve) => setTimeout(resolve, ms));
1086
1289
  }