Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Single Bucket - Move object #74

Merged
merged 6 commits into from
Aug 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
bstopp marked this conversation as resolved.
Show resolved Hide resolved
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