kura
Version:
The FileSystem API abstraction library.
881 lines (780 loc) • 22.9 kB
text/typescript
import {
isBlob,
isBuffer,
toArrayBuffer,
toBase64,
toBlob,
toBuffer,
} from "./BinaryConverter";
import { ContentsCache } from "./ContentsCache";
import {
AbstractFileError,
InvalidModificationError,
NoModificationAllowedError,
NotFoundError,
NotReadableError,
} from "./FileError";
import { DataType, FileSystem } from "./filesystem";
import {
DIR_SEPARATOR,
INDEX_DIR_PATH,
INDEX_PREFIX,
INDEX_PREFIX_LEN,
} from "./FileSystemConstants";
import { FileNameIndex, Record, RecordCache } from "./FileSystemIndex";
import { FileSystemObject } from "./FileSystemObject";
import { FileSystemOptions } from "./FileSystemOptions";
import {
getName,
getParentPath,
isIllegalObject,
onError,
} from "./FileSystemUtil";
import { objectToText, textToObject } from "./ObjectUtil";
import { textToUint8Array, toText } from "./TextConverter";
export abstract class AbstractAccessor {
protected contentsCache: ContentsCache;
protected recordCache: RecordCache = {};
public abstract readonly filesystem: FileSystem;
public abstract readonly name: string;
constructor(public readonly options: FileSystemOptions) {
this.initialize(options);
}
public clearContentsCache(fullPath: string) {
if (this.contentsCache == null) {
return;
}
this.contentsCache.remove(fullPath);
}
public async createIndexPath(fullPath: string, createDirectory: boolean) {
const name = getName(fullPath);
const parentPath = getParentPath(fullPath);
const indexName = INDEX_PREFIX + name;
let indexDir = INDEX_DIR_PATH + parentPath;
if (!indexDir.endsWith(DIR_SEPARATOR)) {
indexDir += DIR_SEPARATOR;
}
const indexPath = indexDir + indexName;
if (!createDirectory) {
return indexPath;
}
await this.makeDirectory(indexDir);
return indexPath;
}
public async createRecord(obj: FileSystemObject) {
const fullPath = obj.fullPath;
const lastModified = obj.lastModified ?? Date.now();
const size = obj.size;
let record: Record;
try {
record = await this.getRecord(fullPath);
if (record.modified === lastModified) {
return null;
}
record.size = size;
record.modified = lastModified;
delete record.deleted;
} catch (e) {
if (e instanceof NotFoundError) {
record = { modified: lastModified, size };
} else if (e instanceof AbstractFileError) {
throw e;
} else {
throw new NotReadableError(this.name, fullPath, e);
}
}
return record;
}
public async delete(fullPath: string, isFile: boolean, truncate: boolean) {
if (fullPath.startsWith(INDEX_DIR_PATH + "/")) {
try {
await this.doDelete(fullPath, isFile);
} catch (e) {
await this.handleWriteError(e, fullPath, isFile);
}
return;
}
if (this.options.index) {
if (truncate) {
await this.truncateRecord(fullPath);
} else {
await this.deleteRecord(fullPath, isFile);
}
}
if (!this.options.indexOptions?.logicalDelete) {
try {
await this.doDelete(fullPath, isFile);
} catch (e) {
await this.handleWriteError(e, fullPath, isFile);
}
}
if (isFile && this.contentsCache) {
this.contentsCache.remove(fullPath);
}
}
public async deleteRecord(fullPath: string, _isFile: boolean) {
if (!this.options.index) {
return;
}
if (fullPath === INDEX_DIR_PATH) {
return;
}
let record: Record;
try {
record = await this.getRecord(fullPath);
} catch (e) {
if (e instanceof NotFoundError) {
return;
} else if (e instanceof AbstractFileError) {
throw e;
}
throw new NotReadableError(this.name, fullPath, e);
}
if (record.deleted == null) {
record.deleted = Date.now();
}
const indexPath = await this.createIndexPath(fullPath, false);
await this.doSaveRecord(indexPath, record);
const indexObj = await this.doGetObject(indexPath, true);
this.recordCache[indexPath] = {
record,
lastModified: indexObj.lastModified,
};
}
public async deleteRecursively(fullPath: string, truncate: boolean) {
let children: FileSystemObject[];
try {
children = await this.doGetObjects(fullPath);
} catch (e) {
await this.handleReadError(e, fullPath, false);
}
for (const child of children) {
if (child.size == null) {
await this.deleteRecursively(child.fullPath, truncate);
} else {
await this.delete(child.fullPath, true, truncate);
}
}
if (
fullPath !== DIR_SEPARATOR &&
!(this.options?.index && fullPath === INDEX_DIR_PATH)
) {
await this.delete(fullPath, false, truncate);
}
}
public async doPutObject(
obj: FileSystemObject,
content?: Blob | BufferSource | string
): Promise<FileSystemObject> {
const fullPath = obj.fullPath;
let record: Record;
try {
this.debug("putObject", fullPath);
if (content == null) {
// Directory
await this.makeDirectory(obj.fullPath);
record = { modified: Date.now() };
} else {
// File
await this.doWriteContent(fullPath, content);
obj = await this.doGetObject(fullPath, true);
if (this.contentsCache) {
this.contentsCache.put(obj, content);
}
record = await this.createRecord(obj);
}
} catch (e) {
await this.handleWriteError(e, fullPath, obj.size != null);
}
if (record) {
await this.saveRecord(fullPath, record);
}
return obj;
}
public async doWriteContent(
fullPath: string,
content: Blob | BufferSource | string
) {
try {
if (typeof content === "string") {
await this.doWriteBase64(fullPath, content);
} else if (isBlob(content)) {
await this.doWriteBlob(fullPath, content);
} else if (isBuffer(content)) {
await this.doWriteBuffer(fullPath, content);
} else if (ArrayBuffer.isView(content)) {
await this.doWriteUint8Array(fullPath, content as Uint8Array);
} else {
await this.doWriteArrayBuffer(fullPath, content);
}
} catch (e) {
await this.handleWriteError(e, fullPath, true);
}
}
public async getFileNameIndex(dirPath: string) {
const fileNameIndex: FileNameIndex = {};
if (dirPath === INDEX_DIR_PATH) {
return fileNameIndex;
}
const indexDir =
INDEX_DIR_PATH + (dirPath === DIR_SEPARATOR ? "" : dirPath);
let objects: FileSystemObject[];
try {
objects = await this.doGetObjects(indexDir);
} catch (e) {
if (e instanceof NotFoundError) {
return fileNameIndex;
}
await this.handleReadError(e, indexDir, false);
}
if (!dirPath.endsWith("/")) {
dirPath += "/";
}
for (const obj of objects) {
if (obj.size == null) {
// folder
continue;
}
let fullPath;
try {
const name = obj.name.substring(INDEX_PREFIX_LEN);
fullPath = dirPath + name;
const record = await this.getRecord(fullPath);
fileNameIndex[name] = { ...record, fullPath, name };
} catch (e) {
console.warn("getFileNameIndex", obj, e);
}
}
return fileNameIndex;
}
public async getObject(fullPath: string, isFile: boolean) {
this.debug("getObject", fullPath);
let obj: FileSystemObject;
if (this.options.index) {
let record: Record;
try {
record = await this.getRecord(fullPath);
} catch (e) {
if (e instanceof NotFoundError) {
try {
obj = await this.doGetObject(fullPath, isFile);
} catch (e) {
await this.handleReadError(e, fullPath, isFile);
}
await this.saveRecord(fullPath, {
modified: obj.lastModified,
size: obj.size,
});
} else {
throw e;
}
}
if (record?.deleted != null) {
throw new NotFoundError(this.name, fullPath, "getObject");
}
}
if (!obj) {
try {
obj = await this.doGetObject(fullPath, isFile);
} catch (e) {
await this.handleReadError(e, fullPath, isFile);
}
}
if (!(await this.beforeHead(obj))) {
throw new NotFoundError(this.name, obj.fullPath, "beforeHead");
}
this.afterHead(obj);
return obj;
}
public async getObjects(dirPath: string) {
try {
const index = this.options.index;
const objects = await this.doGetObjects(dirPath);
if (index) {
const newObjects: FileSystemObject[] = [];
for (const obj of objects) {
if (obj.fullPath === INDEX_DIR_PATH) {
continue;
}
if (this.options.indexOptions.logicalDelete) {
try {
const record = await this.getRecord(obj.fullPath);
if (record.deleted) {
continue;
}
} catch (e) {
if (e instanceof NotFoundError) {
const record = await this.createRecord(obj);
if (record) {
await this.saveRecord(obj.fullPath, record);
}
} else {
console.warn("getObjects", obj, e);
}
continue;
}
}
if (!(await this.beforeHead(obj))) {
continue;
}
this.afterHead(obj);
newObjects.push(obj);
}
return newObjects;
} else {
return objects;
}
} catch (e) {
await this.handleReadError(e, dirPath, false);
}
}
public async getRecord(fullPath: string) {
const indexPath = await this.createIndexPath(fullPath, false);
let indexObj: FileSystemObject;
try {
indexObj = await this.doGetObject(indexPath, true);
} catch (e) {
if (e instanceof NotFoundError) {
delete this.recordCache[fullPath];
}
throw e;
}
const entry = this.recordCache[indexPath];
if (entry && indexObj.lastModified === entry.lastModified) {
return entry.record;
}
const content = await this.doReadContent(indexPath);
const text = await toText(content);
const record: Record = textToObject(text);
this.recordCache[indexPath] = {
record,
lastModified: indexObj.lastModified,
};
return record;
}
public getURL(
_fullPath: string,
_method?: "GET" | "POST" | "PUT" | "DELETE"
): Promise<string> {
throw new Error("Not implemented");
}
public async makeDirectory(fullPath: string) {
try {
await this.doGetObject(fullPath, false);
} catch (e) {
if (e instanceof NotFoundError) {
await this.doMakeDirectory(fullPath);
} else {
throw new NotReadableError(this.name, fullPath, e);
}
}
}
public async purge() {
await this.deleteRecursively(DIR_SEPARATOR, true);
this.recordCache = {};
if (this.contentsCache) {
this.contentsCache.clear();
}
}
public async putObject(
obj: FileSystemObject,
content?: Blob | BufferSource | string
): Promise<FileSystemObject> {
if (isIllegalObject(obj, this.options.index)) {
const fullPath = obj.fullPath;
throw new InvalidModificationError(
this.name,
fullPath,
`illegal object ${fullPath}`
);
}
const fullPath = obj.fullPath;
let create = false;
try {
obj = await this.doGetObject(fullPath, content != null);
} catch (e) {
if (e instanceof NotFoundError) {
create = true;
} else if (e instanceof AbstractFileError) {
throw e;
} else {
throw new NotReadableError(this.name, fullPath, e);
}
}
if (create) {
await this.beforePost(obj);
} else {
await this.beforePut(obj);
}
obj = await this.doPutObject(obj, content);
if (create) {
this.afterPost(obj);
} else {
this.afterPut(obj);
}
return obj;
}
public async putText(
obj: FileSystemObject,
text: string
): Promise<FileSystemObject> {
const u8 = textToUint8Array(text);
return this.putObject(obj, u8);
}
public async readContent(
obj: FileSystemObject,
type?: DataType
): Promise<Blob | BufferSource | string> {
if (isIllegalObject(obj, this.options.index)) {
const fullPath = obj.fullPath;
throw new InvalidModificationError(
this.name,
fullPath,
`illegal object ${fullPath}`
);
}
const fullPath = obj.fullPath;
this.debug("readContent", fullPath);
if (!(await this.beforeGet(obj))) {
throw new NotFoundError(this.name, obj.fullPath, "beforeGet");
}
const content = await this.readContentInternal(obj, type);
this.afterGet(obj);
return content;
}
public async readContentInternal(
obj: FileSystemObject,
type?: DataType
): Promise<Blob | BufferSource | string> {
const fullPath = obj.fullPath;
let content: string | Blob | BufferSource;
if (this.contentsCache) {
content = this.contentsCache.get(fullPath);
}
let read = false;
if (!content) {
try {
content = await this.doReadContent(fullPath);
} catch (e) {
await this.handleReadError(e, fullPath, true);
}
read = true;
}
if (type === "blob") {
content = toBlob(content);
} else if (type === "buffer") {
content = await toBuffer(content);
} else if (type === "arraybuffer") {
content = await toArrayBuffer(content);
} else if (type === "base64") {
content = await toBase64(content);
}
if (this.contentsCache && read) {
this.contentsCache.put(obj, content);
}
return content;
}
public async readText(obj: FileSystemObject): Promise<string> {
const content = await this.readContent(obj);
const text = await toText(content);
return text;
}
public async remove(obj: FileSystemObject) {
const fullPath = obj.fullPath;
if (fullPath === DIR_SEPARATOR) {
throw new InvalidModificationError(
this.name,
fullPath,
"cannot remove root dir"
);
}
const index = this.options.index;
if (index && fullPath.startsWith(INDEX_DIR_PATH + "/")) {
throw new InvalidModificationError(
this.name,
fullPath,
`cannot remove index dir`
);
}
const isFile = obj.size != null;
if (!isFile) {
// Directory
try {
const objects = await this.getObjects(fullPath);
if (0 < objects.length) {
throw new InvalidModificationError(
this.name,
fullPath,
`directory is not empty - ${objects
.map((obj) => obj.fullPath)
.toString()}`
);
}
} catch (e) {
if (e instanceof NotFoundError) {
await this.handleNotFoundError(fullPath, false);
return;
}
await this.handleReadError(e, fullPath, false);
}
}
this.debug("remove", fullPath);
await this.beforeDelete(obj);
await this.delete(fullPath, isFile, false);
this.afterDelete(obj);
}
public async removeRecursively(obj: FileSystemObject) {
const fullPath = obj.fullPath;
let children: FileSystemObject[];
try {
children = await this.getObjects(fullPath);
} catch (e) {
if (e instanceof NotFoundError) {
await this.handleNotFoundError(fullPath, false);
return;
}
await this.handleReadError(e, fullPath, false);
}
for (const child of children) {
if (child.size == null) {
await this.removeRecursively(child);
} else {
await this.remove(child);
}
}
if (fullPath !== DIR_SEPARATOR) {
await this.remove(obj);
}
}
public async saveRecord(fullPath: string, record: Record) {
if (!this.options.index) {
return;
}
try {
const indexPath = await this.createIndexPath(fullPath, true);
await this.doSaveRecord(indexPath, record);
const indexObj = await this.doGetObject(indexPath, true);
this.recordCache[indexPath] = {
record,
lastModified: indexObj.lastModified,
};
return record;
} catch (e) {
await this.handleWriteError(e, fullPath, true);
}
}
public async truncateRecord(fullPath: string) {
if (!this.options.index) {
return;
}
if (fullPath === INDEX_DIR_PATH) {
return;
}
const indexPath = await this.createIndexPath(fullPath, false);
try {
await this.doDelete(indexPath, true);
} catch (e) {
onError(e);
}
delete this.recordCache[indexPath];
}
public abstract doDelete(fullPath: string, isFile: boolean): Promise<void>;
public abstract doGetObject(
fullPath: string,
isFile: boolean
): Promise<FileSystemObject>;
public abstract doGetObjects(dirPath: string): Promise<FileSystemObject[]>;
public abstract doMakeDirectory(fullPath: string): Promise<void>;
public abstract doReadContent(
fullPath: string
): Promise<Blob | BufferSource | string>;
protected debug(title: string, value: string | FileSystemObject) {
if (!this.options.verbose) {
return;
}
if (typeof value === "string") {
console.debug(`${this.name} - ${title}: fullPath=${value}`);
} else {
console.debug(
`${this.name} - ${title}: fullPath=${value.fullPath}, lastModified=${value.lastModified}, size=${value.size}`
);
}
}
protected async doSaveRecord(indexPath: string, record: Record) {
const text = objectToText(record);
const u8 = textToUint8Array(text);
await this.doWriteContent(indexPath, u8);
}
protected async doWriteUint8Array(
fullPath: string,
view: Uint8Array
): Promise<void> {
const buffer = await toArrayBuffer(view);
await this.doWriteArrayBuffer(fullPath, buffer);
}
protected async handleNotFoundError(fullPath: string, isFile: boolean) {
if (fullPath.startsWith(INDEX_DIR_PATH + "/")) {
return;
}
await this.deleteRecord(fullPath, isFile);
if (isFile) {
this.clearContentsCache(fullPath);
}
}
protected async handleReadError(e: any, fullPath: string, isFile: boolean) {
if (e instanceof NotFoundError) {
await this.handleNotFoundError(fullPath, isFile);
throw e;
} else if (e instanceof AbstractFileError) {
throw e;
}
throw new NotReadableError(this.name, fullPath, e);
}
protected async handleWriteError(e: any, fullPath: string, isFile: boolean) {
if (e instanceof NotFoundError) {
await this.handleNotFoundError(fullPath, isFile);
throw e;
} else if (e instanceof AbstractFileError) {
throw e;
}
throw new InvalidModificationError(this.name, fullPath, e);
}
protected initialize(options: FileSystemOptions) {
this.initializeIndexOptions(options);
if (options.contentsCache == null) {
options.contentsCache = true;
}
this.initializeContentsCacheOptions(options);
this.debug("AbstractAccessor#initialize", JSON.stringify(options));
}
protected initializeContentsCacheOptions(options: FileSystemOptions) {
if (!options.contentsCache) {
return;
}
if (options.contentsCacheOptions == null) {
options.contentsCacheOptions = {};
}
const contentsCacheOptions = options.contentsCacheOptions;
if (!(0 < contentsCacheOptions.capacity)) {
contentsCacheOptions.capacity = 100 * 1024 * 1024; // 100MB
}
if (!(0 < contentsCacheOptions.limitSize)) {
contentsCacheOptions.limitSize = 256 * 1024; // 256KB;
}
if (contentsCacheOptions.capacity < contentsCacheOptions.limitSize) {
contentsCacheOptions.limitSize = contentsCacheOptions.capacity;
}
this.contentsCache = new ContentsCache(this);
}
protected initializeIndexOptions(options: FileSystemOptions) {
if (!options.index) {
return;
}
if (options.indexOptions == null) {
options.indexOptions = {};
}
const indexOptions = options.indexOptions;
if (indexOptions.noCache == null) {
indexOptions.noCache = false;
}
if (indexOptions.logicalDelete == null) {
indexOptions.logicalDelete = false;
}
}
protected abstract doWriteArrayBuffer(
fullPath: string,
buffer: ArrayBuffer
): Promise<void>;
protected abstract doWriteBase64(
fullPath: string,
base64: string
): Promise<void>;
protected abstract doWriteBlob(fullPath: string, blob: Blob): Promise<void>;
protected abstract doWriteBuffer(
fullPath: string,
buffer: Buffer
): Promise<void>;
private afterDelete(obj: FileSystemObject) {
if (!this.options.event.postDelete) {
return;
}
this.options.event.postDelete(obj);
}
private afterGet(obj: FileSystemObject) {
if (!this.options.event.postGet) {
return;
}
this.options.event.postHead(obj);
}
private afterHead(obj: FileSystemObject) {
if (!this.options.event.postHead) {
return;
}
this.options.event.postHead(obj);
}
private afterPost(obj: FileSystemObject) {
if (!this.options.event.postPost) {
return;
}
this.options.event.postPost(obj);
}
private afterPut(obj: FileSystemObject) {
if (!this.options.event.postPut) {
return;
}
this.options.event.postPut(obj);
}
// TODO 親フォルダ
private async beforeDelete(obj: FileSystemObject) {
if (!this.options.event.preDelete) {
return;
}
const result = await this.options.event.preDelete(obj);
if (!result) {
throw new NoModificationAllowedError(
this.name,
obj.fullPath,
"beforeDelete"
);
}
}
// TODO 親フォルダ
private async beforeGet(obj: FileSystemObject) {
if (!this.options.event.preGet) {
return true;
}
return this.options.event.preGet(obj);
}
private async beforeHead(obj: FileSystemObject) {
if (!this.options.event.preHead) {
return true;
}
return this.options.event.preHead(obj);
}
private async beforePost(obj: FileSystemObject) {
if (!this.options.event.prePost) {
return;
}
const result = await this.options.event.prePost(obj);
if (!result) {
throw new NoModificationAllowedError(
this.name,
obj.fullPath,
"beforePost"
);
}
}
private async beforePut(obj: FileSystemObject) {
if (!this.options.event.prePut) {
return;
}
const result = await this.options.event.prePut(obj);
if (!result) {
throw new NoModificationAllowedError(
this.name,
obj.fullPath,
"beforePut"
);
}
}
}