Skip to content

Commit

Permalink
Single Bucket - Move object (#74)
Browse files Browse the repository at this point in the history
* Update Move operation to single bucket & add tests.

* Extract reusable logic to a util.

* Update move operation to use R2 bindings.

* Add comments and tests.

* Bump min code coverage.

* Put the jsdoc back.
  • Loading branch information
bstopp authored Aug 30, 2024
1 parent f5b941a commit 8cac067
Show file tree
Hide file tree
Showing 22 changed files with 1,172 additions and 358 deletions.
8 changes: 4 additions & 4 deletions .nycrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
"check-coverage": true,
"all": true,
"include": ["src/**/*.js"],
"lines": 60,
"branches": 80,
"statements": 60,
"functions": 60
"statements": 75,
"branches": 90,
"functions": 70,
"lines": 75
}
4 changes: 0 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
"devDependencies": {
"@adobe/eslint-config-helix": "2.0.6",
"@redocly/cli": "^1.4.1",
"aws-sdk-client-mock": "^4.0.0",
"c8": "^8.0.1",
"eslint": "8.56.0",
"esmock": "^2.6.4",
Expand All @@ -38,9 +37,6 @@
"*.cjs": "eslint"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.456.0",
"@aws-sdk/s3-request-presigner": "^3.468.0",
"@ssttevee/cfw-formdata-polyfill": "^0.2.1",
"jose": "^5.1.3"
}
}
10 changes: 5 additions & 5 deletions src/helpers/copy.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
*/

/**
* Creates the copy source/dest object from the current contexts
* @param {Request} req the Request object
* @param {Object} daCtx the DA Context
* @return {Promise<{destination: string, source: string}|{}>}
*/
* Creates the copy source/dest object from the current contexts
* @param {Request} req the Request object
* @param {Object} daCtx the DA Context
* @return {Promise<{destination: string, source: string}|{}>}
*/
export default async function copyHelper(req, daCtx) {
const formData = await req.formData();
if (!formData) return {};
Expand Down
15 changes: 15 additions & 0 deletions src/helpers/move.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ const NO_PARENT_ERROR = {
status: 400,
};

/**
* @typedef MoveDetails
* @property {String=} source the source path
* @property {String=} destination the destination path
* @property {Object=} error the error object
* @property {String=} error.body the error message
* @property {Number=} error.status the error status code
*/

/**
* Creates the copy source/dest object from the current contexts
* @param {Request} req the Request object
* @param {Object} daCtx the DA Context
* @return {Promise<MoveDetails>}
*/
export default async function moveHelper(req, daCtx) {
try {
const formData = await req.formData();
Expand Down
1 change: 1 addition & 0 deletions src/helpers/rename.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

export default async function renameHelper(req, daCtx) {
const formData = await req.formData();
if (!formData) return {};
Expand Down
139 changes: 49 additions & 90 deletions src/storage/object/copy.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,62 +10,9 @@
* governing permissions and limitations under the License.
*/

const limit = 100;

/**
* Copies the specified file from the source to the destination.
* @param {Object} env the CloudFlare environment
* @param {Object} daCtx the DA Context
* @param {String} sourceKey the key for the source file
* @param {String} destinationKey the key for the destination file
* @param {Boolean} isRename whether this is a rename operation
* @return {Promise<Object>} the status of the copy operation
*/
const copyFile = async (env, daCtx, sourceKey, destinationKey, isRename) => {
try {
const obj = await env.DA_CONTENT.get(sourceKey);
if (!obj) {
return { success: false, source: sourceKey, destination: destinationKey };
}
import { copyFile, copyFiles } from '../utils/copy.js';

const body = await obj.text();
const { httpMetadata } = obj;
// We want to keep the history if this was a rename. In case of an actual
// copy we should start with clean history. The history is associated with the
// ID of the object, so we need to generate a new ID for the object and also a
// new ID for the version. We set the user to the user making the copy.
const customMetadata = {
id: crypto.randomUUID(),
version: crypto.randomUUID(),
timestamp: `${Date.now()}`,
users: JSON.stringify(daCtx.users),
path: destinationKey,
};
if (isRename) Object.assign(customMetadata, obj.customMetadata, { path: destinationKey });

await env.DA_CONTENT.put(destinationKey, body, { httpMetadata, customMetadata });
if (isRename) await env.DA_CONTENT.delete(sourceKey);
return { success: true, source: sourceKey, destination: destinationKey };
/* c8 ignore next 4 */
} catch (e) {
// eslint-disable-next-line no-console
console.error(`Failed to copy: ${sourceKey} to ${destinationKey}`, e);
return { success: false, source: sourceKey, destination: destinationKey };
}
};

const copyFiles = async (env, daCtx, detailsList, isRename) => {
const results = [];
while (detailsList.length > 0) {
const promises = [];
do {
const { src, dest } = detailsList.shift();
promises.push(copyFile(env, daCtx, src, dest, isRename));
} while (detailsList.length > 0 && promises.length <= limit);
await Promise.all(promises).then((values) => results.push(...values));
}
return results;
};
const limit = 100;

/**
* Copies a directory (and contents) or a single file to location.
Expand All @@ -78,45 +25,57 @@ const copyFiles = async (env, daCtx, detailsList, isRename) => {
* @return {Promise<{ status }>}
*/
export default async function copyObject(env, daCtx, details, isRename = false) {
if (details.source === details.destination) {
if (details.source === details.destination || details.source === '') {
return { body: '', status: 409 };
}
const results = [];
const src = details.source.length ? `${daCtx.org}/${details.source}` : daCtx.org;
const dest = `${daCtx.org}/${details.destination}`;
const obj = await env.DA_CONTENT.head(src);
// Head won't return for a folder so this must be a file copy.
if (obj) {
await copyFile(env, daCtx, src, dest, isRename).then((value) => results.push(value));
} else {
let cursor;
// The input prefix has a forward slash to prevent (drafts + drafts-new, etc.).
// Which means the list will only pickup children. This adds to the initial list.
const detailsList = [{ src: `${src}.props`, dest: `${dest}.props` }];
do {
const input = {
prefix: `${src}/`,
limit,
cursor,
};
const r2list = await env.DA_CONTENT.list(input);
const { objects } = r2list;
cursor = r2list.cursor;
// List of objects to copy
detailsList.push(...objects
// Do not save root props file to new folder under *original*
.filter(({ key }) => key !== `${src}.props`)
.map(({ key }) => ({ src: key, dest: `${key.replace(src, dest)}` })));
} while (cursor);
await copyFiles(env, daCtx, detailsList, isRename).then((values) => results.push(...values));
}

// Retry failures
const retries = results.filter(({ success }) => !success).map(({ source, destination }) => ({ src: source, dest: destination }));
if (retries.length > 0) {
const retryResults = await copyFiles(env, daCtx, retries, isRename);
results.push(...retryResults);
if (daCtx.isFile) {
const resp = await copyFile(env, daCtx, details.source, details.destination, isRename);
if (isRename && resp.success) {
await env.DA_CONTENT.delete(`${daCtx.org}/${details.source}`);
}
return { status: 204 };
}

// The input prefix has a forward slash to prevent (drafts + drafts-new, etc.).
// Which means the list will only pickup children. This adds to the initial list.
const sourceList = [{ src: `${details.source}.props`, dest: `${details.destination}.props` }];
const results = []; // Keep this?
let cursor;
const prefix = `${daCtx.org}/${details.source}/`;
// The input prefix has a forward slash to prevent (drafts + drafts-new, etc.).
// Which means the list will only pickup children. This adds to the initial list.
do {
const input = {
prefix,
limit,
cursor,
};
const r2list = await env.DA_CONTENT.list(input);
const { objects } = r2list;
cursor = r2list.cursor;
// List of objects to copy
sourceList.push(...objects
.map(({ key }) => {
const src = key.split('/').slice(1).join('/');
return { src, dest: `${src.replace(details.source, details.destination)}` };
}));
} while (cursor);

let idx = 0;
while (results.length !== sourceList.length) {
const files = sourceList.slice(idx, idx + limit);
await copyFiles(env, daCtx, files, isRename).then(async (values) => {
results.push(...values);
if (isRename) {
const successes = values
.filter((item) => item.success)
.map((item) => item.source);
await env.DA_CONTENT.delete(successes);
}
});

idx += limit;
}
return { status: 204 };
}
24 changes: 19 additions & 5 deletions src/storage/object/delete.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,34 @@
* governing permissions and limitations under the License.
*/

export default async function deleteObjects(env, daCtx) {
/**
* Deletes one or more objects in the storage. Object is specified by the key in the daCtx or a list passed in.
* Note: folders can not be specified in the `keys` list.
*
* @param {Object} env the CloudFlare environment
* @param {DaCtx} daCtx the DA Context
* @param {String[]} [keys=[]] the list of keys to delete (excluding the Org)
* @return {Promise<{body: null, status: number}>}
*/
export default async function deleteObjects(env, daCtx, keys = []) {
if (keys.length) {
const fullKeys = keys.map((key) => `${daCtx.org}/${key}`);
await env.DA_CONTENT.delete(fullKeys);
return { body: null, status: 204 };
}

const fullKey = `${daCtx.org}/${daCtx.key}`;
const prefix = `${fullKey}/`;
// The input prefix has a forward slash to prevent (drafts + drafts-new, etc.).
// Which means the list will only pickup children. This adds to the initial list.
const sourceKeys = [fullKey, `${fullKey}.props`];

keys.push(fullKey, `${fullKey}.props`);
let truncated = false;
do {
const r2objects = await env.DA_CONTENT.list({ prefix, limit: 500 });
const { objects } = r2objects;
truncated = r2objects.truncated;
sourceKeys.push(...objects.map(({ key }) => key));
await env.DA_CONTENT.delete(sourceKeys);
keys.push(...objects.map(({ key }) => key));
await env.DA_CONTENT.delete(keys);
} while (truncated);
return { body: null, status: 204 };
}
Loading

0 comments on commit 8cac067

Please sign in to comment.