@ocfl/ocfl 0.1.0
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.
- package/README.md +16 -0
- package/index.js +131 -0
- package/index.mjs +18 -0
- package/lib/constants.js +24 -0
- package/lib/digest.js +138 -0
- package/lib/enum.js +92 -0
- package/lib/error.js +19 -0
- package/lib/extension.js +206 -0
- package/lib/extensions/0001-digest-algorithms.js +41 -0
- package/lib/extensions/0002-flat-direct-storage-layout.js +46 -0
- package/lib/extensions/0003-hash-and-id-n-tuple-storage-layout.js +58 -0
- package/lib/extensions/0004-hashed-n-tuple-storage-layout.js +61 -0
- package/lib/extensions/0005-mutable-head.js +0 -0
- package/lib/extensions/0006-flat-omit-prefix-storage-layout.js +7 -0
- package/lib/extensions/0007-n-tuple-omit-prefix-storage-layout.js +7 -0
- package/lib/extensions/0008-schema-registry.js +0 -0
- package/lib/extensions/index.js +12 -0
- package/lib/index.js +25 -0
- package/lib/inventory.js +393 -0
- package/lib/object.js +530 -0
- package/lib/storage.js +324 -0
- package/lib/store.js +153 -0
- package/lib/temp.js +264 -0
- package/lib/transaction.js +413 -0
- package/lib/types.d.ts +44 -0
- package/lib/utils.js +92 -0
- package/lib/validation.js +174 -0
- package/package.json +63 -0
- package/resources/ocfl_1.0.txt +936 -0
package/lib/temp.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const hasha = require('hasha');
|
|
4
|
+
const uuidv5 = require('uuid/v5');
|
|
5
|
+
const _ = require('lodash');
|
|
6
|
+
const DIGEST_ALGORITHM = 'sha512';
|
|
7
|
+
|
|
8
|
+
class OcflObject {
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
constructor(path) {
|
|
12
|
+
this.path = path;
|
|
13
|
+
this.ocflVersion = '1.0';
|
|
14
|
+
this.contentVersion = null; // No content yet
|
|
15
|
+
this.id = null; // Not set yet
|
|
16
|
+
this.DIGEST_ALGORITHM = DIGEST_ALGORITHM;
|
|
17
|
+
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async writeInventories(inv, version) {
|
|
21
|
+
const main_inv = await fs.writeJson(path.join(this.path, 'inventory.json'), inv, { spaces: 2 });
|
|
22
|
+
const version_inv = await fs.writeJson(path.join(this.path, version, 'inventory.json'), inv, { spaces: 2 });
|
|
23
|
+
const inv_hash = await this.hash_file(path.join(this.path, 'inventory.json'))
|
|
24
|
+
const digest_file_v1 = await fs.writeFile(path.join(this.path, version, 'inventory.json.' + DIGEST_ALGORITHM), inv_hash + " inventory.json");
|
|
25
|
+
const digest_file = await fs.writeFile(path.join(this.path, 'inventory.json.' + DIGEST_ALGORITHM), inv_hash + " inventory.json");
|
|
26
|
+
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// addContent is passed an id and a callback - this takes one argument, the
|
|
30
|
+
// directory into which content is to be written.
|
|
31
|
+
// This is called for both new and merged versions: the incoming version is
|
|
32
|
+
// written as v1 of a new object in the deposit directory, and if it's not the
|
|
33
|
+
// first version it's then merged with the existing most recent version
|
|
34
|
+
|
|
35
|
+
async addContent(id, writeContent) {
|
|
36
|
+
// Copy files into v1
|
|
37
|
+
|
|
38
|
+
const version = "v1" // Always a fresh start as we're not touching an existing repo object
|
|
39
|
+
const versionPath = path.join(this.path, version, "content");
|
|
40
|
+
await fs.ensureDir(versionPath);
|
|
41
|
+
await writeContent(versionPath);
|
|
42
|
+
|
|
43
|
+
// Make an inventory
|
|
44
|
+
const inv = await this.inventory(id, versionPath);
|
|
45
|
+
|
|
46
|
+
// Put the inventory in the root AND version dir
|
|
47
|
+
await this.writeInventories(inv, version)
|
|
48
|
+
this.contentVersion = await this.determineVersion();
|
|
49
|
+
this.id = id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// preserving the old interface here
|
|
53
|
+
|
|
54
|
+
async importDir(id, sourceDir) {
|
|
55
|
+
await this.addContent(id, async (targetDir) => {
|
|
56
|
+
await fs.copy(sourceDir, targetDir);
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async create(path) {
|
|
62
|
+
// Creates a blank object with a content dir but no content at <path>
|
|
63
|
+
if (this.path) {
|
|
64
|
+
throw new Error("This object has already been initialized at: " + this.path)
|
|
65
|
+
}
|
|
66
|
+
this.path = path;
|
|
67
|
+
const stats = await fs.stat(this.path);
|
|
68
|
+
if (await fs.pathExists(this.path) && stats.isDirectory()) {
|
|
69
|
+
const readDir = await fs.readdir(this.path);
|
|
70
|
+
if (readDir.length <= 0) { // empty so initialise an object here
|
|
71
|
+
const generateNamaste = await this.generateNamaste(this.path, this.ocflVersion);
|
|
72
|
+
} else {
|
|
73
|
+
throw new Error('can\'t initialise an object here as there are already files')
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
//else if it doesnt it dies
|
|
77
|
+
throw new Error('directory does not exist');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async load(path) {
|
|
82
|
+
// Tries to load an existing object residing at <path>
|
|
83
|
+
if (this.path) {
|
|
84
|
+
throw new Error("This object has already been initialized at: " + this.path)
|
|
85
|
+
}
|
|
86
|
+
this.path = path;
|
|
87
|
+
const stats = await fs.stat(this.path);
|
|
88
|
+
if (await fs.pathExists(this.path) && stats.isDirectory()) {
|
|
89
|
+
|
|
90
|
+
const ocflVersion = await this.isObject(this.path);
|
|
91
|
+
if (!ocflVersion) {
|
|
92
|
+
throw new Error('can\'t initialise an object here as there are already files')
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
throw new Error(path + ' does not exist or is not a directory');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
getVersionString(i) {
|
|
100
|
+
// Make a version name as per the SHOULD in the spec v1..vn
|
|
101
|
+
// TODO have an option for zero padding
|
|
102
|
+
return "v" + i
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async getInventory() {
|
|
106
|
+
const inventoryPath = path.join(this.path, "inventory.json");
|
|
107
|
+
if (await fs.exists(inventoryPath)) {
|
|
108
|
+
return await JSON.parse(fs.readFileSync(inventoryPath));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async determineVersion() {
|
|
116
|
+
const inv = await this.getInventory();
|
|
117
|
+
if (inv) {
|
|
118
|
+
return inv.head;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
// Here's not how to do it:
|
|
124
|
+
/* var version = 0;
|
|
125
|
+
const dirContents = await fs.readdir(this.path);
|
|
126
|
+
for (let f of dirContents.filter(function(d){return d.match(/^v\d+$/)})){
|
|
127
|
+
const v = parseInt(f.replace("v",""));
|
|
128
|
+
if (v > version) {
|
|
129
|
+
version = v;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return version;10. */
|
|
133
|
+
// Look at each dir that matches v\d+
|
|
134
|
+
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async isObject(aPath) {
|
|
138
|
+
// TODO: Check if this content root with NAMASTE and returns ocfl version
|
|
139
|
+
// 0=ocfl_object_1.0
|
|
140
|
+
// looks at path and see if the content of the file is
|
|
141
|
+
// TODO: Make this look for a namaste file beginning with 0=ocfl_object_ and extract the version
|
|
142
|
+
const theFile = path.join(aPath, "0=" + this.nameVersion(this.ocflVersion));
|
|
143
|
+
return await fs.pathExists(theFile);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
nameVersion(version) {
|
|
147
|
+
return 'ocfl_object_' + version;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async generateNamaste(aPath, version) {
|
|
151
|
+
const fileName = '0=' + this.nameVersion(version);
|
|
152
|
+
const thePath = path.join(aPath, fileName);
|
|
153
|
+
const writeFile = await fs.writeFile(thePath, this.nameVersion(version));
|
|
154
|
+
const contentDir = await fs.mkdir(path.join(aPath, "v1"));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async digest_dir(dir) {
|
|
159
|
+
var items = {};
|
|
160
|
+
const contents = await fs.readdir(dir);
|
|
161
|
+
items = _.flatten(await Promise.all(contents.map(async (p1) => {
|
|
162
|
+
const p = path.join(dir, p1);
|
|
163
|
+
const stats = await fs.stat(p);
|
|
164
|
+
if (stats.isDirectory()) {
|
|
165
|
+
return await this.digest_dir(p);
|
|
166
|
+
} else {
|
|
167
|
+
const h = await this.hash_file(p);
|
|
168
|
+
return [p, h];
|
|
169
|
+
}
|
|
170
|
+
})));
|
|
171
|
+
return items;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async hash_file(p) {
|
|
175
|
+
const hash = await hasha.fromFile(p, { algorithm: DIGEST_ALGORITHM })
|
|
176
|
+
return hash;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async removeEmptyDirectories(folder) {
|
|
181
|
+
// Remove empty directories
|
|
182
|
+
// Adapted (nade async) from: https://gist.github.com/jakub-g/5903dc7e4028133704a4
|
|
183
|
+
if (!folder) {
|
|
184
|
+
folder = this.path;
|
|
185
|
+
}
|
|
186
|
+
const stats = await fs.stat(folder);
|
|
187
|
+
var isDir = await stats.isDirectory();
|
|
188
|
+
if (isDir) {
|
|
189
|
+
var files = await fs.readdir(folder);
|
|
190
|
+
if (files.length > 0) {
|
|
191
|
+
for (let f of files) {
|
|
192
|
+
var fullPath = path.join(folder, f);
|
|
193
|
+
await this.removeEmptyDirectories(fullPath);
|
|
194
|
+
}
|
|
195
|
+
files = await fs.readdir(folder);
|
|
196
|
+
}
|
|
197
|
+
if (files.length == 0) {
|
|
198
|
+
const rm = await fs.rmdir(folder);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async inventory(id, dir) {
|
|
205
|
+
const versionId = "v1";
|
|
206
|
+
const inv = {
|
|
207
|
+
'id': id,
|
|
208
|
+
'type': 'https://ocfl.io/1.0/spec/#inventory',
|
|
209
|
+
'digestAlgorithm': DIGEST_ALGORITHM,
|
|
210
|
+
'head': versionId,
|
|
211
|
+
'versions': {
|
|
212
|
+
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
inv.versions[versionId] = {
|
|
216
|
+
"created": new Date().toISOString(),
|
|
217
|
+
"state": {}
|
|
218
|
+
}
|
|
219
|
+
// TODO Message and state keys in version
|
|
220
|
+
var hashpairs = await this.digest_dir(dir);
|
|
221
|
+
var versionState = inv.versions[versionId].state;
|
|
222
|
+
inv['manifest'] = {};
|
|
223
|
+
for (let i = 0; i < hashpairs.length; i += 2) {
|
|
224
|
+
const thisHash = hashpairs[i + 1]
|
|
225
|
+
const thisPath = path.relative(this.path, hashpairs[i])
|
|
226
|
+
const versionPath = path.relative(path.join("v1", "content"), thisPath)
|
|
227
|
+
if (!inv['manifest'][thisHash]) {
|
|
228
|
+
//Store only ONE path
|
|
229
|
+
inv['manifest'][thisHash] = [thisPath]
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
// TODO DELETE THIS FILE FROM THE OBJECT BEFORE WE STORE IT
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (versionState[thisHash]) {
|
|
236
|
+
// Store all
|
|
237
|
+
versionState[thisHash].push(versionPath)
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
versionState[thisHash] = [versionPath]
|
|
241
|
+
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
}
|
|
245
|
+
return inv
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async getFilePath(filePath, version) {
|
|
249
|
+
const inv = await this.getInventory();
|
|
250
|
+
if (!version) {
|
|
251
|
+
version = inv.head;
|
|
252
|
+
}
|
|
253
|
+
const state = inv.versions[version].state;
|
|
254
|
+
for (let hash of Object.keys(state)) {
|
|
255
|
+
const aPath = state[hash];
|
|
256
|
+
const findPath = _.find(aPath, (p) => p === filePath);
|
|
257
|
+
if (findPath) {
|
|
258
|
+
return inv.manifest[hash][0];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
throw new Error('cannot find file');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Models a set of ocfl object modifying operations as a transaction that is as close as atomic as possible */
|
|
3
|
+
const { createReadStream } = require('fs');
|
|
4
|
+
const { stat, readdir } = require('fs/promises');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { parallelize, dataSourceAsIterable } = require('./utils');
|
|
7
|
+
const { OcflDigest } = require('./digest');
|
|
8
|
+
const { INVENTORY_NAME } = require('./constants').OcflConstants;
|
|
9
|
+
|
|
10
|
+
//const { pipeline } = require('stream/promises');
|
|
11
|
+
//const { OcflObjectImpl, OcflObjectInventory } = require('./object');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {import('./object').OcflObjectImpl} OcflObjectImpl
|
|
15
|
+
* @typedef {import('./store').OcflStore} OcflStore
|
|
16
|
+
* @typedef {import('./inventory').OcflObjectInventoryMut} OcflObjectInventoryMut
|
|
17
|
+
* @typedef {import('stream').Writable} Writable
|
|
18
|
+
* @typedef {import('stream').StreamOptions} StreamOptions
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A Transaction represent an atomic update on an OCFL Object.
|
|
23
|
+
* When a transaction is commited, all changes are saved as a new version.
|
|
24
|
+
* This is a wrapper class to hide all the storage specific implementation.
|
|
25
|
+
*/
|
|
26
|
+
class OcflObjectTransaction {
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Discard all the changes made in this transaction,
|
|
30
|
+
* @abstract
|
|
31
|
+
*/
|
|
32
|
+
async rollback() { throw new Error('Not Implemented'); }
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Commit all the changes made in this transaction to the actual OCFL Object.
|
|
36
|
+
* @abstract
|
|
37
|
+
* @param {Object} [options] - Commit options. message and user will be included in the version entry of the inventory.json
|
|
38
|
+
* @param {string} [options.message] - Freeform text used to record the rationale for creating the current version
|
|
39
|
+
* @param {Object} [options.user] - Identify the user or agent that created the current Version
|
|
40
|
+
* @param {string} options.user.name - Any readable name of the user, e.g., a proper name, user ID, agent ID.
|
|
41
|
+
* @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.
|
|
42
|
+
*/
|
|
43
|
+
async commit(options) { throw new Error('Not Implemented'); }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Completely remove a file path and corresponding content from all versions of an OCFL Object.
|
|
47
|
+
* This is a special case that is not supported as part of regular OCFL versioning operations.
|
|
48
|
+
* @param {*} logicalPath
|
|
49
|
+
*/
|
|
50
|
+
async purge(logicalPath) {
|
|
51
|
+
return await this.remove(logicalPath, { purge: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write data to a file at `logicalPath`
|
|
57
|
+
* @abstract
|
|
58
|
+
* @param {string} logicalPath - A path relative to the object content directory
|
|
59
|
+
* @param {*} data - The same data as https://nodejs.org/api/fs.html#fs_fs_writefile_file_data_options_callback
|
|
60
|
+
* @param {Object|string} [options] - The same options as https://nodejs.org/api/fs.html#fs_fs_writefile_file_data_options_callback
|
|
61
|
+
*/
|
|
62
|
+
async write(logicalPath, data, options) { throw new Error('Not Implemented'); }
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Import content by recursively copying a file or directory from local file system to the
|
|
66
|
+
* OCFL Object content. For example, `copy('/home/john/data/b','data/b')` will
|
|
67
|
+
* copy `/home/john/data/b` from the local filesystem to the content `data/b` in the OFCL Object.
|
|
68
|
+
* Set target to empty string ('') to copy a directory content to the object's content root.
|
|
69
|
+
* If target is not specified, the base name of the source will be used.
|
|
70
|
+
* @abstract
|
|
71
|
+
* @param {string} source - A path to a file or directory in the local filesystem to be copied
|
|
72
|
+
* @param {string} [target] - A logical path in the OCFL Object content.
|
|
73
|
+
*/
|
|
74
|
+
async import(source, target) { throw new Error('Not Implemented'); }
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Duplicate the logical path `source` as a different logical path `target`, both paths will then point
|
|
78
|
+
* to the same content path and bitstreams.
|
|
79
|
+
* @abstract
|
|
80
|
+
* @param {string} source - The logical path to be copied
|
|
81
|
+
* @param {string} target - The target logical path
|
|
82
|
+
*/
|
|
83
|
+
async copy(source, target) { throw new Error('Not Implemented'); }
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Changes the logical path of existing content. This is similar to move operation in file systems
|
|
87
|
+
* This operation will NOT rename the actual file name referenced by the manifest.
|
|
88
|
+
* @abstract
|
|
89
|
+
* @param {*} source
|
|
90
|
+
* @param {*} target
|
|
91
|
+
*/
|
|
92
|
+
async rename(source, target) { throw new Error('Not Implemented'); }
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Changes the file path of existing content. This is similar to move operation in file systems
|
|
96
|
+
* @param {*} source
|
|
97
|
+
* @param {*} target
|
|
98
|
+
*/
|
|
99
|
+
async move(source, target) { return this.rename(source, target); }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Remove a file or directory
|
|
103
|
+
* @abstract
|
|
104
|
+
* @param {string} logicalPath - A path relative to the object content directory
|
|
105
|
+
* @param {Object} [options]
|
|
106
|
+
*/
|
|
107
|
+
async remove(logicalPath, options) { throw new Error('Not Implemented'); }
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Remove a file or directory
|
|
111
|
+
* @param {string} logicalPath - A path relative to the object content directory
|
|
112
|
+
* @param {Object} [options]
|
|
113
|
+
*/
|
|
114
|
+
async delete(logicalPath, options) { return this.remove(logicalPath, options); }
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Makes content from a version earlier than the previous version available in the current version of an OCFL Object.
|
|
118
|
+
* @abstract
|
|
119
|
+
* @param {string} logicalPath
|
|
120
|
+
* @param {string} versionName
|
|
121
|
+
* @return {Promise<number>}
|
|
122
|
+
*/
|
|
123
|
+
async reinstate(logicalPath, versionName) { throw new Error('Not Implemented'); }
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create a writable stream
|
|
127
|
+
* @abstract
|
|
128
|
+
* @param {string} logicalPath
|
|
129
|
+
* @param {BufferEncoding|StreamOptions} [options]
|
|
130
|
+
* @return {Promise<import('stream').Writable>}
|
|
131
|
+
*/
|
|
132
|
+
async createWriteStream(logicalPath, options) { throw new Error('Not Implemented'); }
|
|
133
|
+
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Base class for any transaction implementation on different storage backend
|
|
139
|
+
*/
|
|
140
|
+
class OcflObjectTransactionImpl extends OcflObjectTransaction {
|
|
141
|
+
_contentRoot;
|
|
142
|
+
_object;
|
|
143
|
+
_inventory;
|
|
144
|
+
_committed = false;
|
|
145
|
+
_unfinished = 0;
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
*
|
|
150
|
+
* @param {OcflObjectImpl} ocflObject
|
|
151
|
+
* @param {OcflStore} ocflStore
|
|
152
|
+
* @param {OcflObjectInventoryMut} inventory
|
|
153
|
+
* @param {string} workspaceVersionPath
|
|
154
|
+
* @param {string} createdDir
|
|
155
|
+
*/
|
|
156
|
+
constructor(ocflObject, ocflStore, inventory, workspaceVersionPath, createdDir) {
|
|
157
|
+
super();
|
|
158
|
+
this._workspaceVersionPath = workspaceVersionPath;
|
|
159
|
+
this._contentRoot = path.join(workspaceVersionPath, inventory.contentDirectory);
|
|
160
|
+
this._object = ocflObject;
|
|
161
|
+
this._store = ocflStore;
|
|
162
|
+
this._inventory = inventory;
|
|
163
|
+
this._createdDir = createdDir;
|
|
164
|
+
this._queue = [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_getRealPath(logicalPath) {
|
|
168
|
+
let p = path.normalize(logicalPath);
|
|
169
|
+
if (p.startsWith('/') || p.startsWith('..')) throw new Error('logicalPath must be a relative path without `..`');
|
|
170
|
+
return path.join(this._contentRoot, p);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async rollback() {
|
|
174
|
+
if (!this._committed) {
|
|
175
|
+
await Promise.all(this._queue);
|
|
176
|
+
if (this._createdDir) await this._store.remove(this._createdDir);
|
|
177
|
+
this._committed = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async commit(options) {
|
|
182
|
+
// console.log('commit');
|
|
183
|
+
// console.log(this._inventory.toString());
|
|
184
|
+
if (!this._committed) {
|
|
185
|
+
let count = this._unfinished;
|
|
186
|
+
if (count > 0) {
|
|
187
|
+
await this.rollback();
|
|
188
|
+
throw new Error(`Unfinished operations detected (${count}). Make sure that updater callback is async function and there are no floating promises inside.`);
|
|
189
|
+
}
|
|
190
|
+
// if there is no changes, abort commit
|
|
191
|
+
if (!this._inventory.isChanged) {
|
|
192
|
+
return await this.rollback();
|
|
193
|
+
}
|
|
194
|
+
await this._commit(options);
|
|
195
|
+
this._object._setInventory(this._inventory);
|
|
196
|
+
this._committed = true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async _commit(options = {}) {
|
|
202
|
+
// set commit info
|
|
203
|
+
this._inventory.created = (new Date()).toISOString();
|
|
204
|
+
if (options.message) this._inventory.message = options.message;
|
|
205
|
+
if (options.user) this._inventory.user = options.user;
|
|
206
|
+
|
|
207
|
+
let workspaceVersionPath = this._workspaceVersionPath;
|
|
208
|
+
let objectVersionPath = path.join(this._object.root, this._inventory.head);
|
|
209
|
+
/* eg: /workspace/aa/bb/cc/object-1/v1/inventory.json */
|
|
210
|
+
let invPath = path.join(workspaceVersionPath, INVENTORY_NAME);
|
|
211
|
+
/* eg: /workspace/aa/bb/cc/object-1/v1/inventory.json.sha512 */
|
|
212
|
+
let invDigestPath = invPath + '.' + this._inventory.digestAlgorithm;
|
|
213
|
+
|
|
214
|
+
// calculate digest
|
|
215
|
+
let digest = this._inventory.digest() + ' ' + INVENTORY_NAME;
|
|
216
|
+
// write inventory.json to workspace
|
|
217
|
+
await this._store.writeFile(invPath, this._inventory.toString());
|
|
218
|
+
await this._store.writeFile(invDigestPath, digest);
|
|
219
|
+
if (workspaceVersionPath !== objectVersionPath) {
|
|
220
|
+
try {
|
|
221
|
+
await this._object._ensureNamaste();
|
|
222
|
+
await this._store.move(workspaceVersionPath, objectVersionPath);
|
|
223
|
+
await this._store.remove(this._createdDir);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
await this.rollback();
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// replace root inventory
|
|
230
|
+
/* eg: /data/aa/bb/cc/object-1/inventory.json */
|
|
231
|
+
let rootInvPath = path.join(this._object.root, INVENTORY_NAME);
|
|
232
|
+
/* eg: /data/aa/bb/cc/object-1/v1/inventory.json */
|
|
233
|
+
invPath = path.join(objectVersionPath, INVENTORY_NAME);
|
|
234
|
+
invDigestPath = invPath + '.' + this._inventory.digestAlgorithm;
|
|
235
|
+
let rootInvDigestPath = rootInvPath + '.' + this._inventory.digestAlgorithm;
|
|
236
|
+
await this._store.copyFile(invPath, rootInvPath + '.tmp');
|
|
237
|
+
await Promise.all([
|
|
238
|
+
this._store.move(rootInvPath + '.tmp', rootInvPath),
|
|
239
|
+
this._store.copyFile(invDigestPath, rootInvDigestPath)]);
|
|
240
|
+
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
*
|
|
245
|
+
* @param {string} logicalPath
|
|
246
|
+
* @param {Object} options
|
|
247
|
+
* @return {Promise<import('stream').Writable>}
|
|
248
|
+
*/
|
|
249
|
+
async createWriteStream(logicalPath, options) {
|
|
250
|
+
let realPath = this._getRealPath(logicalPath);
|
|
251
|
+
let ws = await this._store.createWriteStream(realPath, options);
|
|
252
|
+
let hs = OcflDigest.createStream(this._inventory.digestAlgorithm);
|
|
253
|
+
const wwrite = ws.write;
|
|
254
|
+
function nwrite(chunk, encoding, cb) {
|
|
255
|
+
hs.update(chunk, encoding);
|
|
256
|
+
return wwrite.apply(ws, arguments);
|
|
257
|
+
};
|
|
258
|
+
ws.write = nwrite;
|
|
259
|
+
ws.on('close', async () => {
|
|
260
|
+
let digest = hs.digest('hex');
|
|
261
|
+
if (this._inventory.getContentPath(digest)) await this._store.remove(realPath);
|
|
262
|
+
this._inventory.add(logicalPath, digest);
|
|
263
|
+
});
|
|
264
|
+
return ws;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async write(logicalPath, data, options) {
|
|
268
|
+
let realPath = this._getRealPath(logicalPath);
|
|
269
|
+
let dataSrc = dataSourceAsIterable(data);
|
|
270
|
+
let digest;
|
|
271
|
+
if (Array.isArray(dataSrc)) {
|
|
272
|
+
// if data is not stream, check digest first
|
|
273
|
+
digest = await OcflDigest.digestAsync(this._inventory.digestAlgorithm, dataSrc);
|
|
274
|
+
if (!this._inventory.getContentPath(digest)) {
|
|
275
|
+
// no digest yet, write the file to storage backend
|
|
276
|
+
await this._store.writeFile(realPath, data, options);
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
// save the stream first
|
|
280
|
+
let hs = OcflDigest.createStreamThrough(this._inventory.digestAlgorithm);
|
|
281
|
+
data.pipe(hs);
|
|
282
|
+
await this._store.writeFile(realPath, hs, options);
|
|
283
|
+
digest = hs.digest();
|
|
284
|
+
// already exists, delete temp file
|
|
285
|
+
if (this._inventory.getContentPath(digest)) await this._store.remove(realPath);
|
|
286
|
+
}
|
|
287
|
+
this._inventory.add(logicalPath, digest);
|
|
288
|
+
// console.log(this._inventory.toString());
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async importFile(source, target) {
|
|
292
|
+
if (!target) throw new TypeError('Target logical path must not be empty if source is a file.');
|
|
293
|
+
let realPath = this._getRealPath(target);
|
|
294
|
+
let digest = await OcflDigest.digestFromFile(this._inventory.digestAlgorithm, source);
|
|
295
|
+
|
|
296
|
+
if (!this._inventory.getContentPath(digest)) {
|
|
297
|
+
await this._store.copyFile(source, realPath);
|
|
298
|
+
}
|
|
299
|
+
this._inventory.add(target, digest);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async import(source, target) {
|
|
303
|
+
target = target ?? path.basename(source);
|
|
304
|
+
let srcStat = await stat(source);
|
|
305
|
+
if (srcStat.isFile()) {
|
|
306
|
+
await this.importFile(source, target);
|
|
307
|
+
} else if (srcStat.isDirectory()) {
|
|
308
|
+
const files = await readdir(source);
|
|
309
|
+
await parallelize(files, async (filename) => {
|
|
310
|
+
await this.import(path.join(source, filename), path.join(target, filename));
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async copy(source, target) {
|
|
316
|
+
return this._inventory.copy(source, target);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async rename(source, target) {
|
|
320
|
+
// if file already exists in the workspace, rename the actual file
|
|
321
|
+
try {
|
|
322
|
+
let realSource = this._getRealPath(source);
|
|
323
|
+
let realTarget = this._getRealPath(target);
|
|
324
|
+
await this._store.move(realSource, realTarget);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
if (error.code !== 'ENOENT') throw error;
|
|
327
|
+
}
|
|
328
|
+
return this._inventory.rename(source, target);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async reinstate(logicalPath, versionName) {
|
|
332
|
+
//let digest = this._inventory.getDigestFromLogical(logicalPath);
|
|
333
|
+
let count = this._inventory.reinstate(logicalPath, versionName);
|
|
334
|
+
// if (count) {
|
|
335
|
+
// // ensure no existing file with the same logical path in the workspace
|
|
336
|
+
// try {
|
|
337
|
+
// await this._store.remove(this._getRealPath(logicalPath));
|
|
338
|
+
// } catch (error) {
|
|
339
|
+
// if (error.code !== 'ENOENT') throw error;
|
|
340
|
+
// }
|
|
341
|
+
// }
|
|
342
|
+
return count;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async remove(logicalPath, options) {
|
|
346
|
+
if (this._inventory.delete(logicalPath)) {
|
|
347
|
+
try {
|
|
348
|
+
await this._store.remove(this._getRealPath(logicalPath));
|
|
349
|
+
} catch (error) {
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
const transactionInterface = new OcflObjectTransaction();
|
|
358
|
+
const methodWrapper = {
|
|
359
|
+
createWriteStream: async function (logicalPath, options) {
|
|
360
|
+
if (this._committed) throw new Error('Transaction already commited');
|
|
361
|
+
this._unfinished++;
|
|
362
|
+
let result = await this.createWriteStream(logicalPath, options);
|
|
363
|
+
result.on('close', () => --this._unfinished);
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
['write', 'import', 'copy', 'rename', 'remove', 'reinstate'].forEach(name => {
|
|
368
|
+
methodWrapper[name] = async function () {
|
|
369
|
+
if (this._committed) throw new Error('Transaction already commited');
|
|
370
|
+
this._unfinished++;
|
|
371
|
+
let p = this[name](...arguments);
|
|
372
|
+
this._queue.push(p);
|
|
373
|
+
try {
|
|
374
|
+
let result = await p;
|
|
375
|
+
this._unfinished--;
|
|
376
|
+
return result;
|
|
377
|
+
} catch (error) {
|
|
378
|
+
this._unfinished--;
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
const proxyHandler = {
|
|
384
|
+
/**
|
|
385
|
+
* @param {OcflObjectTransactionImpl} target
|
|
386
|
+
* @param {string} prop
|
|
387
|
+
*/
|
|
388
|
+
get(target, prop) {
|
|
389
|
+
if (prop in transactionInterface) {
|
|
390
|
+
let p = target[prop];
|
|
391
|
+
if (typeof p === 'function') {
|
|
392
|
+
return methodWrapper[prop]?.bind(target) ?? p.bind(target);
|
|
393
|
+
} else {
|
|
394
|
+
return p;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Return any OcflObjectTransactionImpl implementation as OcflObjectTransaction.
|
|
402
|
+
* @param {OcflObjectTransactionImpl} impl
|
|
403
|
+
* @return {OcflObjectTransaction}
|
|
404
|
+
*/
|
|
405
|
+
function createTransactionProxy(impl) {
|
|
406
|
+
return new Proxy(impl, proxyHandler);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
module.exports = {
|
|
410
|
+
createTransactionProxy,
|
|
411
|
+
OcflObjectTransaction,
|
|
412
|
+
OcflObjectTransactionImpl
|
|
413
|
+
};
|