@njdamstra/appwrite-utils-cli 1.11.5 → 1.11.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.
@@ -275,6 +275,27 @@ export async function executeMigrationPlan(adapter, options) {
275
275
  MessageFormatter.error(` ${entry.attributeKey}: FAILED — ${cpEntry.error}`, undefined, { prefix: "Execute" });
276
276
  }
277
277
  }
278
+ // Restore required flags after all attributes in this collection are done.
279
+ // This must happen AFTER all migrations to avoid partial-update validation
280
+ // errors (Appwrite rejects updateRow if a required attribute is missing
281
+ // from the payload, even for partial updates).
282
+ const completedRequired = entries.filter((e) => {
283
+ const cp = findCheckpointEntry(checkpoint, e);
284
+ return cp?.phase === "completed" && e.isRequired;
285
+ });
286
+ for (const entry of completedRequired) {
287
+ try {
288
+ await tryAwaitWithRetry(() => adapter.updateAttribute({
289
+ databaseId: entry.databaseId,
290
+ tableId: entry.collectionId,
291
+ key: entry.attributeKey,
292
+ required: true,
293
+ }));
294
+ }
295
+ catch {
296
+ MessageFormatter.info(` Warning: could not set ${entry.attributeKey} back to required`, { prefix: "Execute" });
297
+ }
298
+ }
278
299
  // After collection completes, offer to update local YAML
279
300
  const successInGroup = entries.filter((e) => {
280
301
  const cp = findCheckpointEntry(checkpoint, e);
@@ -290,7 +311,7 @@ export async function executeMigrationPlan(adapter, options) {
290
311
  },
291
312
  ]);
292
313
  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" });
314
+ await updateCollectionYaml(first.collectionName, entries, checkpoint);
294
315
  }
295
316
  }
296
317
  }
@@ -426,21 +447,8 @@ async function migrateOneAttribute(adapter, entry, cpEntry, checkpoint, checkpoi
426
447
  advance("backup_deleted");
427
448
  }
428
449
  // Step 9: Mark completed
429
- // If the original attribute was required, update it now (after data is in place)
430
- if (entry.isRequired) {
431
- try {
432
- await tryAwaitWithRetry(() => adapter.updateAttribute({
433
- databaseId,
434
- tableId: collectionId,
435
- key: attributeKey,
436
- required: true,
437
- }));
438
- }
439
- catch {
440
- // Non-fatal — attribute is migrated, just not set back to required
441
- MessageFormatter.info(` Warning: could not set ${attributeKey} back to required`, { prefix: "Migrate" });
442
- }
443
- }
450
+ // NOTE: required flag is restored AFTER all attributes in the collection
451
+ // are migrated, to avoid partial-update validation errors on other attributes.
444
452
  advance("completed");
445
453
  }
446
454
  // ────────────────────────────────────────────────────────
@@ -724,6 +732,163 @@ function printDryRunSummary(plan) {
724
732
  console.log("");
725
733
  }
726
734
  // ────────────────────────────────────────────────────────
735
+ // Update local collection YAML after migration
736
+ // ────────────────────────────────────────────────────────
737
+ async function updateCollectionYaml(collectionName, entries, checkpoint) {
738
+ // Find candidate YAML files
739
+ const candidates = findYamlFiles(process.cwd(), collectionName);
740
+ if (candidates.length === 0) {
741
+ MessageFormatter.warning(`No YAML file found for collection "${collectionName}". Skipping local config update.`, { prefix: "YAML" });
742
+ return;
743
+ }
744
+ let yamlPath;
745
+ if (candidates.length === 1) {
746
+ const { usePath } = await inquirer.prompt([
747
+ {
748
+ type: "confirm",
749
+ name: "usePath",
750
+ message: `Found: ${candidates[0]}. Use this file?`,
751
+ default: true,
752
+ },
753
+ ]);
754
+ if (!usePath) {
755
+ const { customPath } = await inquirer.prompt([
756
+ {
757
+ type: "input",
758
+ name: "customPath",
759
+ message: "Enter path to collection YAML file:",
760
+ },
761
+ ]);
762
+ if (!customPath || !fs.existsSync(customPath)) {
763
+ MessageFormatter.warning("Invalid path. Skipping YAML update.", {
764
+ prefix: "YAML",
765
+ });
766
+ return;
767
+ }
768
+ yamlPath = customPath;
769
+ }
770
+ else {
771
+ yamlPath = candidates[0];
772
+ }
773
+ }
774
+ else {
775
+ const { selectedPath } = await inquirer.prompt([
776
+ {
777
+ type: "list",
778
+ name: "selectedPath",
779
+ message: `Multiple YAML files found for "${collectionName}". Select one:`,
780
+ choices: [
781
+ ...candidates.map((c) => ({ name: c, value: c })),
782
+ { name: "Enter custom path", value: "__custom__" },
783
+ ],
784
+ },
785
+ ]);
786
+ if (selectedPath === "__custom__") {
787
+ const { customPath } = await inquirer.prompt([
788
+ {
789
+ type: "input",
790
+ name: "customPath",
791
+ message: "Enter path to collection YAML file:",
792
+ },
793
+ ]);
794
+ if (!customPath || !fs.existsSync(customPath)) {
795
+ MessageFormatter.warning("Invalid path. Skipping YAML update.", {
796
+ prefix: "YAML",
797
+ });
798
+ return;
799
+ }
800
+ yamlPath = customPath;
801
+ }
802
+ else {
803
+ yamlPath = selectedPath;
804
+ }
805
+ }
806
+ // Load and parse YAML
807
+ let doc;
808
+ try {
809
+ const content = fs.readFileSync(yamlPath, "utf8");
810
+ doc = yaml.load(content);
811
+ }
812
+ catch (err) {
813
+ MessageFormatter.error(`Failed to parse ${yamlPath}: ${err.message}`, undefined, { prefix: "YAML" });
814
+ return;
815
+ }
816
+ if (!doc || !Array.isArray(doc.attributes)) {
817
+ MessageFormatter.warning(`No "attributes" array found in ${yamlPath}. Skipping.`, { prefix: "YAML" });
818
+ return;
819
+ }
820
+ // Update attributes that were successfully migrated
821
+ let updated = 0;
822
+ for (const entry of entries) {
823
+ const cp = findCheckpointEntry(checkpoint, entry);
824
+ if (cp?.phase !== "completed")
825
+ continue;
826
+ const attr = doc.attributes.find((a) => a.key === entry.attributeKey && a.type === "string");
827
+ if (!attr)
828
+ continue;
829
+ attr.type = entry.targetType;
830
+ if (entry.targetType !== "varchar") {
831
+ delete attr.size;
832
+ }
833
+ updated++;
834
+ }
835
+ if (updated === 0) {
836
+ MessageFormatter.info("No matching string attributes found in YAML to update.", { prefix: "YAML" });
837
+ return;
838
+ }
839
+ // Write back
840
+ try {
841
+ const output = yaml.dump(doc, {
842
+ lineWidth: 120,
843
+ noRefs: true,
844
+ sortKeys: false,
845
+ });
846
+ fs.writeFileSync(yamlPath, output, "utf8");
847
+ MessageFormatter.success(`Updated ${updated} attribute(s) in ${yamlPath}`, { prefix: "YAML" });
848
+ }
849
+ catch (err) {
850
+ MessageFormatter.error(`Failed to write ${yamlPath}: ${err.message}`, undefined, { prefix: "YAML" });
851
+ }
852
+ }
853
+ function findYamlFiles(searchRoot, collectionName) {
854
+ const results = [];
855
+ const lowerName = collectionName.toLowerCase();
856
+ function walk(dir) {
857
+ let dirEntries;
858
+ try {
859
+ dirEntries = fs.readdirSync(dir, { withFileTypes: true });
860
+ }
861
+ catch {
862
+ return; // skip inaccessible directories
863
+ }
864
+ for (const ent of dirEntries) {
865
+ const fullPath = path.join(dir, ent.name);
866
+ if (ent.isDirectory()) {
867
+ // Skip node_modules, .git, and other common non-config dirs
868
+ if (ent.name === "node_modules" || ent.name === ".git" || ent.name === "dist")
869
+ continue;
870
+ walk(fullPath);
871
+ }
872
+ else if (ent.isFile() &&
873
+ ent.name.toLowerCase() === `${lowerName}.yaml`) {
874
+ // Verify it looks like a collection config (has a name field)
875
+ try {
876
+ const content = fs.readFileSync(fullPath, "utf8");
877
+ const parsed = yaml.load(content);
878
+ if (parsed && parsed.name === collectionName) {
879
+ results.push(fullPath);
880
+ }
881
+ }
882
+ catch {
883
+ // skip unparseable files
884
+ }
885
+ }
886
+ }
887
+ }
888
+ walk(searchRoot);
889
+ return results;
890
+ }
891
+ // ────────────────────────────────────────────────────────
727
892
  // Utility
728
893
  // ────────────────────────────────────────────────────────
729
894
  async function createAttributeIfNotExists(adapter, params) {
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.5",
4
+ "version": "1.11.7",
5
5
  "main": "dist/main.js",
6
6
  "type": "module",
7
7
  "repository": {
@@ -382,6 +382,32 @@ export async function executeMigrationPlan(
382
382
  }
383
383
  }
384
384
 
385
+ // Restore required flags after all attributes in this collection are done.
386
+ // This must happen AFTER all migrations to avoid partial-update validation
387
+ // errors (Appwrite rejects updateRow if a required attribute is missing
388
+ // from the payload, even for partial updates).
389
+ const completedRequired = entries.filter((e) => {
390
+ const cp = findCheckpointEntry(checkpoint, e);
391
+ return cp?.phase === "completed" && e.isRequired;
392
+ });
393
+ for (const entry of completedRequired) {
394
+ try {
395
+ await tryAwaitWithRetry(() =>
396
+ adapter.updateAttribute({
397
+ databaseId: entry.databaseId,
398
+ tableId: entry.collectionId,
399
+ key: entry.attributeKey,
400
+ required: true,
401
+ } as any)
402
+ );
403
+ } catch {
404
+ MessageFormatter.info(
405
+ ` Warning: could not set ${entry.attributeKey} back to required`,
406
+ { prefix: "Execute" }
407
+ );
408
+ }
409
+ }
410
+
385
411
  // After collection completes, offer to update local YAML
386
412
  const successInGroup = entries.filter((e) => {
387
413
  const cp = findCheckpointEntry(checkpoint, e);
@@ -398,10 +424,7 @@ export async function executeMigrationPlan(
398
424
  },
399
425
  ]);
400
426
  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
- );
427
+ await updateCollectionYaml(first.collectionName, entries, checkpoint);
405
428
  }
406
429
  }
407
430
  }
@@ -633,25 +656,8 @@ async function migrateOneAttribute(
633
656
  }
634
657
 
635
658
  // Step 9: Mark completed
636
- // If the original attribute was required, update it now (after data is in place)
637
- if (entry.isRequired) {
638
- try {
639
- await tryAwaitWithRetry(() =>
640
- adapter.updateAttribute({
641
- databaseId,
642
- tableId: collectionId,
643
- key: attributeKey,
644
- required: true,
645
- } as any)
646
- );
647
- } catch {
648
- // Non-fatal — attribute is migrated, just not set back to required
649
- MessageFormatter.info(
650
- ` Warning: could not set ${attributeKey} back to required`,
651
- { prefix: "Migrate" }
652
- );
653
- }
654
- }
659
+ // NOTE: required flag is restored AFTER all attributes in the collection
660
+ // are migrated, to avoid partial-update validation errors on other attributes.
655
661
  advance("completed");
656
662
  }
657
663
 
@@ -1075,6 +1081,195 @@ function printDryRunSummary(plan: MigrationPlan): void {
1075
1081
  console.log("");
1076
1082
  }
1077
1083
 
1084
+ // ────────────────────────────────────────────────────────
1085
+ // Update local collection YAML after migration
1086
+ // ────────────────────────────────────────────────────────
1087
+
1088
+ async function updateCollectionYaml(
1089
+ collectionName: string,
1090
+ entries: MigrationPlanEntry[],
1091
+ checkpoint: MigrationCheckpoint
1092
+ ): Promise<void> {
1093
+ // Find candidate YAML files
1094
+ const candidates = findYamlFiles(process.cwd(), collectionName);
1095
+
1096
+ if (candidates.length === 0) {
1097
+ MessageFormatter.warning(
1098
+ `No YAML file found for collection "${collectionName}". Skipping local config update.`,
1099
+ { prefix: "YAML" }
1100
+ );
1101
+ return;
1102
+ }
1103
+
1104
+ let yamlPath: string;
1105
+
1106
+ if (candidates.length === 1) {
1107
+ const { usePath } = await inquirer.prompt([
1108
+ {
1109
+ type: "confirm",
1110
+ name: "usePath",
1111
+ message: `Found: ${candidates[0]}. Use this file?`,
1112
+ default: true,
1113
+ },
1114
+ ]);
1115
+ if (!usePath) {
1116
+ const { customPath } = await inquirer.prompt([
1117
+ {
1118
+ type: "input",
1119
+ name: "customPath",
1120
+ message: "Enter path to collection YAML file:",
1121
+ },
1122
+ ]);
1123
+ if (!customPath || !fs.existsSync(customPath)) {
1124
+ MessageFormatter.warning("Invalid path. Skipping YAML update.", {
1125
+ prefix: "YAML",
1126
+ });
1127
+ return;
1128
+ }
1129
+ yamlPath = customPath;
1130
+ } else {
1131
+ yamlPath = candidates[0];
1132
+ }
1133
+ } else {
1134
+ const { selectedPath } = await inquirer.prompt([
1135
+ {
1136
+ type: "list",
1137
+ name: "selectedPath",
1138
+ message: `Multiple YAML files found for "${collectionName}". Select one:`,
1139
+ choices: [
1140
+ ...candidates.map((c) => ({ name: c, value: c })),
1141
+ { name: "Enter custom path", value: "__custom__" },
1142
+ ],
1143
+ },
1144
+ ]);
1145
+ if (selectedPath === "__custom__") {
1146
+ const { customPath } = await inquirer.prompt([
1147
+ {
1148
+ type: "input",
1149
+ name: "customPath",
1150
+ message: "Enter path to collection YAML file:",
1151
+ },
1152
+ ]);
1153
+ if (!customPath || !fs.existsSync(customPath)) {
1154
+ MessageFormatter.warning("Invalid path. Skipping YAML update.", {
1155
+ prefix: "YAML",
1156
+ });
1157
+ return;
1158
+ }
1159
+ yamlPath = customPath;
1160
+ } else {
1161
+ yamlPath = selectedPath;
1162
+ }
1163
+ }
1164
+
1165
+ // Load and parse YAML
1166
+ let doc: any;
1167
+ try {
1168
+ const content = fs.readFileSync(yamlPath, "utf8");
1169
+ doc = yaml.load(content);
1170
+ } catch (err: any) {
1171
+ MessageFormatter.error(
1172
+ `Failed to parse ${yamlPath}: ${err.message}`,
1173
+ undefined,
1174
+ { prefix: "YAML" }
1175
+ );
1176
+ return;
1177
+ }
1178
+
1179
+ if (!doc || !Array.isArray(doc.attributes)) {
1180
+ MessageFormatter.warning(
1181
+ `No "attributes" array found in ${yamlPath}. Skipping.`,
1182
+ { prefix: "YAML" }
1183
+ );
1184
+ return;
1185
+ }
1186
+
1187
+ // Update attributes that were successfully migrated
1188
+ let updated = 0;
1189
+ for (const entry of entries) {
1190
+ const cp = findCheckpointEntry(checkpoint, entry);
1191
+ if (cp?.phase !== "completed") continue;
1192
+
1193
+ const attr = doc.attributes.find(
1194
+ (a: any) => a.key === entry.attributeKey && a.type === "string"
1195
+ );
1196
+ if (!attr) continue;
1197
+
1198
+ attr.type = entry.targetType;
1199
+ if (entry.targetType !== "varchar") {
1200
+ delete attr.size;
1201
+ }
1202
+ updated++;
1203
+ }
1204
+
1205
+ if (updated === 0) {
1206
+ MessageFormatter.info(
1207
+ "No matching string attributes found in YAML to update.",
1208
+ { prefix: "YAML" }
1209
+ );
1210
+ return;
1211
+ }
1212
+
1213
+ // Write back
1214
+ try {
1215
+ const output = yaml.dump(doc, {
1216
+ lineWidth: 120,
1217
+ noRefs: true,
1218
+ sortKeys: false,
1219
+ });
1220
+ fs.writeFileSync(yamlPath, output, "utf8");
1221
+ MessageFormatter.success(
1222
+ `Updated ${updated} attribute(s) in ${yamlPath}`,
1223
+ { prefix: "YAML" }
1224
+ );
1225
+ } catch (err: any) {
1226
+ MessageFormatter.error(
1227
+ `Failed to write ${yamlPath}: ${err.message}`,
1228
+ undefined,
1229
+ { prefix: "YAML" }
1230
+ );
1231
+ }
1232
+ }
1233
+
1234
+ function findYamlFiles(searchRoot: string, collectionName: string): string[] {
1235
+ const results: string[] = [];
1236
+ const lowerName = collectionName.toLowerCase();
1237
+
1238
+ function walk(dir: string): void {
1239
+ let dirEntries: fs.Dirent[];
1240
+ try {
1241
+ dirEntries = fs.readdirSync(dir, { withFileTypes: true });
1242
+ } catch {
1243
+ return; // skip inaccessible directories
1244
+ }
1245
+ for (const ent of dirEntries) {
1246
+ const fullPath = path.join(dir, ent.name);
1247
+ if (ent.isDirectory()) {
1248
+ // Skip node_modules, .git, and other common non-config dirs
1249
+ if (ent.name === "node_modules" || ent.name === ".git" || ent.name === "dist") continue;
1250
+ walk(fullPath);
1251
+ } else if (
1252
+ ent.isFile() &&
1253
+ ent.name.toLowerCase() === `${lowerName}.yaml`
1254
+ ) {
1255
+ // Verify it looks like a collection config (has a name field)
1256
+ try {
1257
+ const content = fs.readFileSync(fullPath, "utf8");
1258
+ const parsed = yaml.load(content) as any;
1259
+ if (parsed && parsed.name === collectionName) {
1260
+ results.push(fullPath);
1261
+ }
1262
+ } catch {
1263
+ // skip unparseable files
1264
+ }
1265
+ }
1266
+ }
1267
+ }
1268
+
1269
+ walk(searchRoot);
1270
+ return results;
1271
+ }
1272
+
1078
1273
  // ────────────────────────────────────────────────────────
1079
1274
  // Utility
1080
1275
  // ────────────────────────────────────────────────────────