UNPKG

@ocfl/ocfl

Version:
414 lines (370 loc) 14.8 kB
//@ts-check /** Models a set of ocfl object modifying operations as a transaction that is as close as atomic as possible */ const { createReadStream } = require('fs'); const { stat, readdir } = require('fs/promises'); const path = require('path'); const { parallelize, dataSourceAsIterable } = require('./utils'); const { OcflDigest } = require('./digest'); const { INVENTORY_NAME } = require('./constants').OcflConstants; //const { pipeline } = require('stream/promises'); //const { OcflObjectImpl, OcflObjectInventory } = require('./object'); /** * @typedef {import('./object').OcflObjectImpl} OcflObjectImpl * @typedef {import('./store').OcflStore} OcflStore * @typedef {import('./inventory').OcflObjectInventoryMut} OcflObjectInventoryMut * @typedef {import('stream').Writable} Writable * @typedef {import('stream').StreamOptions} StreamOptions */ /** * A Transaction represent an atomic update on an OCFL Object. * When a transaction is commited, all changes are saved as a new version. * This is a wrapper class to hide all the storage specific implementation. */ class OcflObjectTransaction { /** * Discard all the changes made in this transaction, * @abstract */ async rollback() { throw new Error('Not Implemented'); } /** * Commit all the changes made in this transaction to the actual OCFL Object. * @abstract * @param {Object} [options] - Commit options. message and user will be included in the version entry of the inventory.json * @param {string} [options.message] - Freeform text used to record the rationale for creating the current version * @param {Object} [options.user] - Identify the user or agent that created the current Version * @param {string} options.user.name - Any readable name of the user, e.g., a proper name, user ID, agent ID. * @param {string} [options.user.address] - A URI: either a mailto URI [RFC6068] with the e-mail address of the user or a URL to a personal identifier, e.g., an ORCID iD. */ async commit(options) { throw new Error('Not Implemented'); } /** * Completely remove a file path and corresponding content from all versions of an OCFL Object. * This is a special case that is not supported as part of regular OCFL versioning operations. * @param {*} logicalPath */ async purge(logicalPath) { return await this.remove(logicalPath, { purge: true }); } /** * Write data to a file at `logicalPath` * @abstract * @param {string} logicalPath - A path relative to the object content directory * @param {*} data - The same data as https://nodejs.org/api/fs.html#fs_fs_writefile_file_data_options_callback * @param {Object|string} [options] - The same options as https://nodejs.org/api/fs.html#fs_fs_writefile_file_data_options_callback */ async write(logicalPath, data, options) { throw new Error('Not Implemented'); } /** * Import content by recursively copying a file or directory from local file system to the * OCFL Object content. For example, `copy('/home/john/data/b','data/b')` will * copy `/home/john/data/b` from the local filesystem to the content `data/b` in the OFCL Object. * Set target to empty string ('') to copy a directory content to the object's content root. * If target is not specified, the base name of the source will be used. * @abstract * @param {string} source - A path to a file or directory in the local filesystem to be copied * @param {string} [target] - A logical path in the OCFL Object content. */ async import(source, target) { throw new Error('Not Implemented'); } /** * Duplicate the logical path `source` as a different logical path `target`, both paths will then point * to the same content path and bitstreams. * @abstract * @param {string} source - The logical path to be copied * @param {string} target - The target logical path */ async copy(source, target) { throw new Error('Not Implemented'); } /** * Changes the logical path of existing content. This is similar to move operation in file systems * This operation will NOT rename the actual file name referenced by the manifest. * @abstract * @param {*} source * @param {*} target */ async rename(source, target) { throw new Error('Not Implemented'); } /** * Changes the file path of existing content. This is similar to move operation in file systems * @param {*} source * @param {*} target */ async move(source, target) { return this.rename(source, target); } /** * Remove a file or directory * @abstract * @param {string} logicalPath - A path relative to the object content directory * @param {Object} [options] */ async remove(logicalPath, options) { throw new Error('Not Implemented'); } /** * Remove a file or directory * @param {string} logicalPath - A path relative to the object content directory * @param {Object} [options] */ async delete(logicalPath, options) { return this.remove(logicalPath, options); } /** * Makes content from a version earlier than the previous version available in the current version of an OCFL Object. * @abstract * @param {string} logicalPath * @param {string} versionName * @return {Promise<number>} */ async reinstate(logicalPath, versionName) { throw new Error('Not Implemented'); } /** * Create a writable stream * @abstract * @param {string} logicalPath * @param {BufferEncoding|StreamOptions} [options] * @return {Promise<import('stream').Writable>} */ async createWriteStream(logicalPath, options) { throw new Error('Not Implemented'); } } /** * Base class for any transaction implementation on different storage backend */ class OcflObjectTransactionImpl extends OcflObjectTransaction { _contentRoot; _object; _inventory; _committed = false; _unfinished = 0; /** * * @param {OcflObjectImpl} ocflObject * @param {OcflStore} ocflStore * @param {OcflObjectInventoryMut} inventory * @param {string} workspaceVersionPath * @param {string} createdDir */ constructor(ocflObject, ocflStore, inventory, workspaceVersionPath, createdDir) { super(); this._workspaceVersionPath = workspaceVersionPath; this._contentRoot = path.join(workspaceVersionPath, inventory.contentDirectory); this._object = ocflObject; this._store = ocflStore; this._inventory = inventory; this._createdDir = createdDir; this._queue = []; } _getRealPath(logicalPath) { let p = path.normalize(logicalPath); if (p.startsWith('/') || p.startsWith('..')) throw new Error('logicalPath must be a relative path without `..`'); return path.join(this._contentRoot, p); } async rollback() { if (!this._committed) { await Promise.all(this._queue); if (this._createdDir) await this._store.remove(this._createdDir); this._committed = true; } } async commit(options) { // console.log('commit'); // console.log(this._inventory.toString()); if (!this._committed) { let count = this._unfinished; if (count > 0) { await this.rollback(); throw new Error(`Unfinished operations detected (${count}). Make sure that updater callback is async function and there are no floating promises inside.`); } // if there is no changes, abort commit if (!this._inventory.isChanged) { return await this.rollback(); } await this._commit(options); this._object._setInventory(this._inventory); this._committed = true; } } async _commit(options = {}) { // set commit info this._inventory.created = (new Date()).toISOString(); if (options.message) this._inventory.message = options.message; if (options.user) this._inventory.user = options.user; let workspaceVersionPath = this._workspaceVersionPath; let objectVersionPath = path.join(this._object.root, this._inventory.head); /* eg: /workspace/aa/bb/cc/object-1/v1/inventory.json */ let invPath = path.join(workspaceVersionPath, INVENTORY_NAME); /* eg: /workspace/aa/bb/cc/object-1/v1/inventory.json.sha512 */ let invDigestPath = invPath + '.' + this._inventory.digestAlgorithm; // calculate digest let digest = this._inventory.digest() + ' ' + INVENTORY_NAME; // write inventory.json to workspace await this._store.writeFile(invPath, this._inventory.toString()); await this._store.writeFile(invDigestPath, digest); if (workspaceVersionPath !== objectVersionPath) { try { await this._object._ensureNamaste(); await this._store.move(workspaceVersionPath, objectVersionPath); await this._store.remove(this._createdDir); } catch (error) { await this.rollback(); throw error; } } // replace root inventory /* eg: /data/aa/bb/cc/object-1/inventory.json */ let rootInvPath = path.join(this._object.root, INVENTORY_NAME); /* eg: /data/aa/bb/cc/object-1/v1/inventory.json */ invPath = path.join(objectVersionPath, INVENTORY_NAME); invDigestPath = invPath + '.' + this._inventory.digestAlgorithm; let rootInvDigestPath = rootInvPath + '.' + this._inventory.digestAlgorithm; await this._store.copyFile(invPath, rootInvPath + '.tmp'); await Promise.all([ this._store.move(rootInvPath + '.tmp', rootInvPath), this._store.copyFile(invDigestPath, rootInvDigestPath)]); } /** * * @param {string} logicalPath * @param {Object} options * @return {Promise<import('stream').Writable>} */ async createWriteStream(logicalPath, options) { let realPath = this._getRealPath(logicalPath); let ws = await this._store.createWriteStream(realPath, options); let hs = OcflDigest.createStream(this._inventory.digestAlgorithm); const wwrite = ws.write; function nwrite(chunk, encoding, cb) { hs.update(chunk, encoding); return wwrite.apply(ws, arguments); }; ws.write = nwrite; ws.on('close', async () => { let digest = hs.digest('hex'); if (this._inventory.getContentPath(digest)) await this._store.remove(realPath); this._inventory.add(logicalPath, digest); }); return ws; } async write(logicalPath, data, options) { let realPath = this._getRealPath(logicalPath); let dataSrc = dataSourceAsIterable(data); let digest; if (Array.isArray(dataSrc)) { // if data is not stream, check digest first digest = await OcflDigest.digestAsync(this._inventory.digestAlgorithm, dataSrc); if (!this._inventory.getContentPath(digest)) { // no digest yet, write the file to storage backend await this._store.writeFile(realPath, data, options); } } else { // save the stream first let hs = OcflDigest.createStreamThrough(this._inventory.digestAlgorithm); data.pipe(hs); await this._store.writeFile(realPath, hs, options); digest = hs.digest(); // already exists, delete temp file if (this._inventory.getContentPath(digest)) await this._store.remove(realPath); } this._inventory.add(logicalPath, digest); // console.log(this._inventory.toString()); } async importFile(source, target) { if (!target) throw new TypeError('Target logical path must not be empty if source is a file.'); let realPath = this._getRealPath(target); let digest = await OcflDigest.digestFromFile(this._inventory.digestAlgorithm, source); if (!this._inventory.getContentPath(digest)) { await this._store.copyFile(source, realPath); } this._inventory.add(target, digest); } async import(source, target) { target = target ?? path.basename(source); let srcStat = await stat(source); if (srcStat.isFile()) { await this.importFile(source, target); } else if (srcStat.isDirectory()) { const files = await readdir(source); await parallelize(files, async (filename) => { await this.import(path.join(source, filename), path.join(target, filename)); }); } } async copy(source, target) { return this._inventory.copy(source, target); } async rename(source, target) { // if file already exists in the workspace, rename the actual file try { let realSource = this._getRealPath(source); let realTarget = this._getRealPath(target); await this._store.move(realSource, realTarget); } catch (error) { if (error.code !== 'ENOENT') throw error; } return this._inventory.rename(source, target); } async reinstate(logicalPath, versionName) { //let digest = this._inventory.getDigestFromLogical(logicalPath); let count = this._inventory.reinstate(logicalPath, versionName); // if (count) { // // ensure no existing file with the same logical path in the workspace // try { // await this._store.remove(this._getRealPath(logicalPath)); // } catch (error) { // if (error.code !== 'ENOENT') throw error; // } // } return count; } async remove(logicalPath, options) { if (this._inventory.delete(logicalPath)) { try { await this._store.remove(this._getRealPath(logicalPath)); } catch (error) { } } } } const transactionInterface = new OcflObjectTransaction(); const methodWrapper = { createWriteStream: async function (logicalPath, options) { if (this._committed) throw new Error('Transaction already commited'); this._unfinished++; let result = await this.createWriteStream(logicalPath, options); result.on('close', () => --this._unfinished); return result; } }; ['write', 'import', 'copy', 'rename', 'remove', 'reinstate'].forEach(name => { methodWrapper[name] = async function () { if (this._committed) throw new Error('Transaction already commited'); this._unfinished++; let p = this[name](...arguments); this._queue.push(p); try { let result = await p; this._unfinished--; return result; } catch (error) { this._unfinished--; throw error; } }; }); const proxyHandler = { /** * @param {OcflObjectTransactionImpl} target * @param {string} prop */ get(target, prop) { if (prop in transactionInterface) { let p = target[prop]; if (typeof p === 'function') { return methodWrapper[prop]?.bind(target) ?? p.bind(target); } else { return p; } } } } /** * Return any OcflObjectTransactionImpl implementation as OcflObjectTransaction. * @param {OcflObjectTransactionImpl} impl * @return {OcflObjectTransaction} */ function createTransactionProxy(impl) { return new Proxy(impl, proxyHandler); } module.exports = { createTransactionProxy, OcflObjectTransaction, OcflObjectTransactionImpl };