@njdamstra/appwrite-utils-cli 1.11.2 → 1.11.4

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,6 @@
1
1
  import inquirer from "inquirer";
2
2
  import path from "node:path";
3
+ import fs from "node:fs";
3
4
  import { MessageFormatter } from "@njdamstra/appwrite-utils-helpers";
4
5
  import { analyzeStringAttributes, executeMigrationPlan, } from "../../migrations/migrateStrings.js";
5
6
  export const migrateCommands = {
@@ -94,6 +95,27 @@ export const migrateCommands = {
94
95
  default: path.join(process.cwd(), "migrate-strings-plan.yaml"),
95
96
  },
96
97
  ]);
98
+ // Check for existing checkpoint
99
+ let freshRun = false;
100
+ const checkpointPath = planPath.replace(/\.ya?ml$/, ".checkpoint.json");
101
+ if (fs.existsSync(checkpointPath)) {
102
+ const { cpAction } = await inquirer.prompt([
103
+ {
104
+ type: "list",
105
+ name: "cpAction",
106
+ message: "Found an existing checkpoint from a previous run. What would you like to do?",
107
+ choices: [
108
+ { name: "Resume — continue from where it left off", value: "resume" },
109
+ { name: "Start fresh — delete checkpoint and start over", value: "fresh" },
110
+ { name: "Cancel", value: "cancel" },
111
+ ],
112
+ },
113
+ ]);
114
+ if (cpAction === "cancel")
115
+ return;
116
+ if (cpAction === "fresh")
117
+ freshRun = true;
118
+ }
97
119
  const { keepBackups } = await inquirer.prompt([
98
120
  {
99
121
  type: "confirm",
@@ -114,6 +136,7 @@ export const migrateCommands = {
114
136
  planPath,
115
137
  keepBackups,
116
138
  dryRun,
139
+ freshRun,
117
140
  };
118
141
  try {
119
142
  const results = await executeMigrationPlan(controller.adapter, options);
package/dist/main.js CHANGED
@@ -485,6 +485,11 @@ const argv = yargs(hideBin(process.argv))
485
485
  alias: ["migrate-strings-dry-run"],
486
486
  type: "boolean",
487
487
  description: "Dry run — show what would happen without making changes",
488
+ })
489
+ .option("migrateStringsFresh", {
490
+ alias: ["migrate-strings-fresh"],
491
+ type: "boolean",
492
+ description: "Ignore existing checkpoint and start migration fresh",
488
493
  })
489
494
  .parse();
490
495
  async function main() {
@@ -674,6 +679,7 @@ async function main() {
674
679
  planPath: argv.migrateStringsExecute,
675
680
  keepBackups: argv.migrateStringsKeepBackups ?? true,
676
681
  dryRun: argv.migrateStringsDryRun ?? false,
682
+ freshRun: argv.migrateStringsFresh ?? false,
677
683
  });
678
684
  if (results.failed > 0) {
679
685
  process.exit(1);
@@ -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";
@@ -193,6 +194,12 @@ export async function executeMigrationPlan(adapter, options) {
193
194
  // Load or create checkpoint
194
195
  const checkpointPath = options.checkpointPath ||
195
196
  options.planPath.replace(/\.ya?ml$/, ".checkpoint.json");
197
+ if (options.freshRun && fs.existsSync(checkpointPath)) {
198
+ fs.unlinkSync(checkpointPath);
199
+ MessageFormatter.info("Deleted old checkpoint — starting fresh.", {
200
+ prefix: "Checkpoint",
201
+ });
202
+ }
196
203
  const checkpoint = loadOrCreateCheckpoint(checkpointPath, options.planPath);
197
204
  const batchSize = options.batchSize || 100;
198
205
  const batchDelayMs = options.batchDelayMs || 50;
@@ -455,6 +462,7 @@ async function copyAttributeData(adapter, databaseId, collectionId, sourceKey, t
455
462
  title: ` Copy ${sourceKey} → ${targetKey}`,
456
463
  })
457
464
  : undefined;
465
+ const limit = pLimit(5);
458
466
  while (true) {
459
467
  const queries = [Query.limit(batchSize)];
460
468
  if (lastId)
@@ -463,34 +471,18 @@ async function copyAttributeData(adapter, databaseId, collectionId, sourceKey, t
463
471
  const docs = res?.documents || res?.rows || [];
464
472
  if (docs.length === 0)
465
473
  break;
466
- // Batch update: copy sourceKey → targetKey
467
- if (adapter.supportsBulkOperations() && adapter.bulkUpsertRows) {
468
- const rows = docs
469
- .filter((d) => d[sourceKey] !== undefined)
470
- .map((d) => ({
471
- id: d.$id,
472
- data: { [targetKey]: d[sourceKey] },
473
- }));
474
- if (rows.length > 0) {
475
- await tryAwaitWithRetry(() => adapter.bulkUpsertRows({
476
- databaseId,
477
- tableId: collectionId,
478
- rows,
479
- }));
480
- }
481
- }
482
- else {
483
- for (const doc of docs) {
484
- if (doc[sourceKey] === undefined)
485
- continue;
486
- await tryAwaitWithRetry(() => adapter.updateRow({
487
- databaseId,
488
- tableId: collectionId,
489
- id: doc.$id,
490
- data: { [targetKey]: doc[sourceKey] },
491
- }));
492
- }
493
- }
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);
494
486
  totalCopied += docs.length;
495
487
  lastId = docs[docs.length - 1].$id;
496
488
  progress?.update(totalCopied);
@@ -513,7 +505,7 @@ async function verifyDataCopy(adapter, databaseId, collectionId, sourceKey, targ
513
505
  }));
514
506
  const docs = res?.documents || res?.rows || [];
515
507
  for (const doc of docs) {
516
- if (doc[sourceKey] === undefined)
508
+ if (!(sourceKey in doc))
517
509
  continue;
518
510
  if (doc[sourceKey] !== doc[targetKey]) {
519
511
  throw new Error(`Verification failed: doc ${doc.$id} has ${sourceKey}=${JSON.stringify(doc[sourceKey])} but ${targetKey}=${JSON.stringify(doc[targetKey])}`);
@@ -191,6 +191,7 @@ export interface ExecuteOptions {
191
191
  batchSize?: number;
192
192
  batchDelayMs?: number;
193
193
  checkpointPath?: string;
194
+ freshRun?: boolean;
194
195
  }
195
196
  export declare function suggestTargetType(size: number, hasIndex: boolean): MigrationTargetType;
196
197
  export declare function generateBackupKey(originalKey: string): string;
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.2",
4
+ "version": "1.11.4",
5
5
  "main": "dist/main.js",
6
6
  "type": "module",
7
7
  "repository": {
@@ -1,5 +1,6 @@
1
1
  import inquirer from "inquirer";
2
2
  import path from "node:path";
3
+ import fs from "node:fs";
3
4
  import { MessageFormatter } from "@njdamstra/appwrite-utils-helpers";
4
5
  import type { InteractiveCLI } from "../../interactiveCLI.js";
5
6
  import {
@@ -121,6 +122,26 @@ export const migrateCommands = {
121
122
  },
122
123
  ]);
123
124
 
125
+ // Check for existing checkpoint
126
+ let freshRun = false;
127
+ const checkpointPath = planPath.replace(/\.ya?ml$/, ".checkpoint.json");
128
+ if (fs.existsSync(checkpointPath)) {
129
+ const { cpAction } = await inquirer.prompt([
130
+ {
131
+ type: "list",
132
+ name: "cpAction",
133
+ message: "Found an existing checkpoint from a previous run. What would you like to do?",
134
+ choices: [
135
+ { name: "Resume — continue from where it left off", value: "resume" },
136
+ { name: "Start fresh — delete checkpoint and start over", value: "fresh" },
137
+ { name: "Cancel", value: "cancel" },
138
+ ],
139
+ },
140
+ ]);
141
+ if (cpAction === "cancel") return;
142
+ if (cpAction === "fresh") freshRun = true;
143
+ }
144
+
124
145
  const { keepBackups } = await inquirer.prompt([
125
146
  {
126
147
  type: "confirm",
@@ -143,6 +164,7 @@ export const migrateCommands = {
143
164
  planPath,
144
165
  keepBackups,
145
166
  dryRun,
167
+ freshRun,
146
168
  };
147
169
 
148
170
  try {
package/src/main.ts CHANGED
@@ -89,6 +89,7 @@ interface CliOptions {
89
89
  migrateStringsDbIds?: string;
90
90
  migrateStringsKeepBackups?: boolean;
91
91
  migrateStringsDryRun?: boolean;
92
+ migrateStringsFresh?: boolean;
92
93
  }
93
94
 
94
95
  type ParsedArgv = ArgumentsCamelCase<CliOptions>;
@@ -659,6 +660,11 @@ const argv = yargs(hideBin(process.argv))
659
660
  type: "boolean",
660
661
  description: "Dry run — show what would happen without making changes",
661
662
  })
663
+ .option("migrateStringsFresh", {
664
+ alias: ["migrate-strings-fresh"],
665
+ type: "boolean",
666
+ description: "Ignore existing checkpoint and start migration fresh",
667
+ })
662
668
  .parse() as ParsedArgv;
663
669
 
664
670
  async function main() {
@@ -915,6 +921,7 @@ async function main() {
915
921
  planPath: argv.migrateStringsExecute,
916
922
  keepBackups: argv.migrateStringsKeepBackups ?? true,
917
923
  dryRun: argv.migrateStringsDryRun ?? false,
924
+ freshRun: argv.migrateStringsFresh ?? false,
918
925
  });
919
926
  if (results.failed > 0) {
920
927
  process.exit(1);
@@ -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,
@@ -264,6 +265,12 @@ export async function executeMigrationPlan(
264
265
  const checkpointPath =
265
266
  options.checkpointPath ||
266
267
  options.planPath.replace(/\.ya?ml$/, ".checkpoint.json");
268
+ if (options.freshRun && fs.existsSync(checkpointPath)) {
269
+ fs.unlinkSync(checkpointPath);
270
+ MessageFormatter.info("Deleted old checkpoint — starting fresh.", {
271
+ prefix: "Checkpoint",
272
+ });
273
+ }
267
274
  const checkpoint = loadOrCreateCheckpoint(checkpointPath, options.planPath);
268
275
 
269
276
  const batchSize = options.batchSize || 100;
@@ -682,6 +689,8 @@ async function copyAttributeData(
682
689
  })
683
690
  : undefined;
684
691
 
692
+ const limit = pLimit(5);
693
+
685
694
  while (true) {
686
695
  const queries: string[] = [Query.limit(batchSize)];
687
696
  if (lastId) queries.push(Query.cursorAfter(lastId));
@@ -693,37 +702,26 @@ async function copyAttributeData(
693
702
  const docs = res?.documents || res?.rows || [];
694
703
  if (docs.length === 0) break;
695
704
 
696
- // Batch update: copy sourceKey → targetKey
697
- if (adapter.supportsBulkOperations() && adapter.bulkUpsertRows) {
698
- const rows = docs
699
- .filter((d: any) => d[sourceKey] !== undefined)
700
- .map((d: any) => ({
701
- id: d.$id,
702
- data: { [targetKey]: d[sourceKey] },
703
- }));
704
-
705
- if (rows.length > 0) {
706
- await tryAwaitWithRetry(() =>
707
- adapter.bulkUpsertRows!({
708
- databaseId,
709
- tableId: collectionId,
710
- rows,
711
- })
712
- );
713
- }
714
- } else {
715
- for (const doc of docs) {
716
- if (doc[sourceKey] === undefined) continue;
717
- await tryAwaitWithRetry(() =>
718
- adapter.updateRow({
719
- databaseId,
720
- tableId: collectionId,
721
- id: doc.$id,
722
- data: { [targetKey]: doc[sourceKey] },
723
- })
724
- );
725
- }
726
- }
705
+ // Partial update: copy sourceKey → targetKey using updateRow (not bulkUpsert
706
+ // which requires complete document structure and fails on partial payloads)
707
+ const updatePromises = docs
708
+ .filter((d: any) => d[sourceKey] !== undefined)
709
+ .map((d: any) =>
710
+ limit(() =>
711
+ tryAwaitWithRetry(
712
+ () =>
713
+ adapter.updateRow({
714
+ databaseId,
715
+ tableId: collectionId,
716
+ id: d.$id,
717
+ data: { [targetKey]: d[sourceKey] },
718
+ }),
719
+ 0,
720
+ true // throwError — surface 400s immediately
721
+ )
722
+ )
723
+ );
724
+ await Promise.all(updatePromises);
727
725
 
728
726
  totalCopied += docs.length;
729
727
  lastId = docs[docs.length - 1].$id;
@@ -757,7 +755,7 @@ async function verifyDataCopy(
757
755
  );
758
756
  const docs = res?.documents || res?.rows || [];
759
757
  for (const doc of docs) {
760
- if (doc[sourceKey] === undefined) continue;
758
+ if (!(sourceKey in doc)) continue;
761
759
  if (doc[sourceKey] !== doc[targetKey]) {
762
760
  throw new Error(
763
761
  `Verification failed: doc ${doc.$id} has ${sourceKey}=${JSON.stringify(doc[sourceKey])} but ${targetKey}=${JSON.stringify(doc[targetKey])}`
@@ -118,6 +118,7 @@ export interface ExecuteOptions {
118
118
  batchSize?: number;
119
119
  batchDelayMs?: number;
120
120
  checkpointPath?: string;
121
+ freshRun?: boolean;
121
122
  }
122
123
 
123
124
  // ── Helper: suggest target type from size + index presence ──