fs-zoo
Version:
File system abstractions and implementations
373 lines (372 loc) • 15.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CowCrud = void 0;
const util_1 = require("../crud/util");
const fromStream_1 = require("@jsonjoy.com/util/lib/streams/fromStream");
const bufferToUint8Array_1 = require("@jsonjoy.com/util/lib/buffers/bufferToUint8Array");
const util_2 = require("../fsa-to-crud/util");
const PrefixedCrud_1 = require("../prefixed/PrefixedCrud");
const toUint8Array_1 = require("@jsonjoy.com/buffers/lib/toUint8Array");
const joinParts = (parts) => (parts.length ? parts.join('/') : '');
const isSubPath = (base, child) => {
// Special-case root: it masks all children, but not the root itself
if (base === '')
return child !== '';
if (child === base)
return true;
return child.startsWith(base + '/');
};
/**
* Copy-on-write CRUD-fs. Accepts two file systems: (1) *base* the source of data;
* (2) *overlay* the writable layer. Initially all reads are served from the
* *base* layer, if a file or folder structure gets "tainted" (modified) a copy
* of those resources are copied and modified in the *overlay* layer. Subsequently,
* the reads of tainted resources will be served from the *overlay* layer.
*/
class CowCrud {
constructor(base, overlay, state) {
this.base = base;
this.overlay = overlay;
this._state = state ?? { files: new Set(), cols: new Set() };
}
_fullPath(path) {
const parts = (0, util_1.parseParts)(path);
return joinParts(parts);
}
_isColTombstoned(full) {
// A collection is tombstoned if any tombstone path is a prefix of this path
for (const col of this._state.cols)
if (isSubPath(col, full))
return true;
return false;
}
_isFileTombstoned(full) {
if (this._isColTombstoned(full.includes('/') ? full.slice(0, full.lastIndexOf('/')) : ''))
return true;
return this._state.files.has(full);
}
async _existsResource(path) {
const full = this._fullPath(path);
if (this._isFileTombstoned(full))
return false;
// overlay present?
const overHas = await this.overlay
.info(path)
.then(info => info.type === 'resource')
.catch(() => false);
if (overHas)
return true;
// base present?
const baseHas = await this.base
.info(path)
.then(info => info.type === 'resource')
.catch(() => false);
return baseHas;
}
async _existsCollection(path) {
const full = this._fullPath(path);
if (this._isColTombstoned(full))
return false;
// Root collection always exists
if (full === '')
return true;
const overHas = await this.overlay
.info(path)
.then(info => info.type === 'collection')
.catch(() => false);
if (overHas)
return true;
const baseHas = await this.base
.info(path)
.then(info => info.type === 'collection')
.catch(() => false);
return baseHas;
}
_clearFileTombstone(full) {
this._state.files.delete(full);
}
async write(path, options) {
const [collection, id] = (0, util_1.parseId)(path);
(0, util_1.assertType)(collection, 'put', 'crudfs');
(0, util_1.assertName)(id, 'put', 'crudfs');
const full = this._fullPath(path);
const exists = await this._existsResource(path);
// throwIf handling against combined view
switch (options?.throwIf) {
case 'exists':
if (exists)
throw (0, util_2.newExistsError)();
break;
case 'missing':
if (!exists)
throw (0, util_2.newMissingError)();
break;
}
// Ensure overlay has current content if doing a positional write and overlay lacks it but base has it
let pos = options?.pos;
const overlayHas = await this.overlay
.info(path)
.then(i => i.type === 'resource')
.catch(() => false);
const baseHas = await this.base
.info(path)
.then(i => i.type === 'resource')
.catch(() => false);
const needCopy = typeof pos === 'number' && !overlayHas && baseHas && !this._state.files.has(full);
if (needCopy) {
// Copy-on-write from base if overlay lacks it but base has it
const dataStream = await this.base.read(path);
const bufOrUint8 = await (0, fromStream_1.fromStream)(dataStream);
const uint8a = (0, bufferToUint8Array_1.bufferToUint8Array)(bufOrUint8);
await this.overlay.put(path, uint8a);
}
// Clear tombstone, as we are (re)creating the file
this._clearFileTombstone(full);
// Delegate to overlay's write method
return await this.overlay.write(path, options);
}
async dir(path, options) {
const [collection, id] = (0, util_1.parseId)(path);
(0, util_1.assertType)(collection, 'dir', 'crudfs');
(0, util_1.assertName)(id, 'dir', 'crudfs');
const full = this._fullPath(path);
const exists = await this._existsCollection(path);
switch (options?.throwIf) {
case 'exists':
if (exists)
throw (0, util_2.newExistsError)();
break;
case 'missing':
if (!exists)
throw (0, util_2.newMissingError)();
break;
}
// Creating dir in overlay (shallow)
await this.overlay.dir(path, options);
// Creating a directory un-masks it
this._state.cols.delete(full);
}
async put(path, data, options) {
const [collection, id] = (0, util_1.parseId)(path);
(0, util_1.assertType)(collection, 'put', 'crudfs');
(0, util_1.assertName)(id, 'put', 'crudfs');
const full = this._fullPath(path);
const exists = await this._existsResource(path);
// throwIf handling against combined view
switch (options?.throwIf) {
case 'exists':
if (exists)
throw (0, util_2.newExistsError)();
break;
case 'missing':
if (!exists)
throw (0, util_2.newMissingError)();
break;
}
// Ensure overlay has current content if doing a positional write and overlay lacks it but base has it
let pos = options?.pos;
const overlayHas = await this.overlay
.info(path)
.then(i => i.type === 'resource')
.catch(() => false);
const baseHas = await this.base
.info(path)
.then(i => i.type === 'resource')
.catch(() => false);
const needCopy = typeof pos === 'number' && !overlayHas && baseHas && !this._state.files.has(full);
if (needCopy) {
// Copy-on-write from base if overlay lacks it but base has it
const dataStream = await this.base.read(path);
const bufOrUint8 = await (0, fromStream_1.fromStream)(dataStream);
const uint8a = (0, bufferToUint8Array_1.bufferToUint8Array)(bufOrUint8);
await this.overlay.put(path, uint8a);
}
// Clear tombstone, as we are (re)creating the file
this._clearFileTombstone(full);
await this.overlay.put(path, data, options);
}
async read(path) {
const [collection, id] = (0, util_1.parseId)(path);
(0, util_1.assertType)(collection, 'get', 'crudfs');
(0, util_1.assertName)(id, 'get', 'crudfs');
const full = this._fullPath(path);
if (this._isFileTombstoned(full))
throw (0, util_2.newFile404Error)(collection, id);
try {
return await this.overlay.read(path);
}
catch (e) {
const name = e && typeof e === 'object' ? e.name : undefined;
if (name === 'ResourceNotFound')
throw e; // collection exists in overlay, resource missing
if (name && name !== 'CollectionNotFound')
throw e;
// If collection missing in overlay, try base unless tombstoned
if (this._isFileTombstoned(full))
throw (0, util_2.newFile404Error)(collection, id);
return await this.base.read(path);
}
}
async file(path) {
const [collection, id] = (0, util_1.parseId)(path);
(0, util_1.assertType)(collection, 'get', 'crudfs');
(0, util_1.assertName)(id, 'get', 'crudfs');
const full = this._fullPath(path);
if (this._isFileTombstoned(full))
throw (0, util_2.newFile404Error)(collection, id);
try {
if (this.overlay.file)
return await this.overlay.file(path);
const stream = await this.read(path);
const buf = await (0, fromStream_1.fromStream)(stream);
const data = (0, toUint8Array_1.toUint8Array)(buf);
return new File([new Blob([data])], id);
}
catch (e) {
const name = e && typeof e === 'object' ? e.name : undefined;
if (name === 'ResourceNotFound')
throw e; // collection exists in overlay, resource missing
if (name && name !== 'CollectionNotFound')
throw e;
// If collection missing in overlay, try base unless tombstoned
if (this._isFileTombstoned(full))
throw (0, util_2.newFile404Error)(collection, id);
if (this.base.file)
return await this.base.file(path);
const stream = await this.read(path);
const buf = await (0, fromStream_1.fromStream)(stream);
const data = (0, toUint8Array_1.toUint8Array)(buf);
return new File([new Blob([data])], id);
}
}
async del(path, silent) {
const [collection, id] = (0, util_1.parseId)(path);
(0, util_1.assertType)(collection, 'del', 'crudfs');
(0, util_1.assertName)(id, 'del', 'crudfs');
const full = this._fullPath(path);
// Check collection existence in combined view
const colExists = await this._existsCollection(collection.join('/'));
if (!colExists) {
if (silent)
return;
throw (0, util_2.newFolder404Error)(collection);
}
const exists = await this._existsResource(path);
if (!exists) {
if (silent)
return;
throw (0, util_2.newFile404Error)(collection, id);
}
// Delete in overlay if exists there
await this.overlay.del(path, true);
// Mark tombstone so base is masked
this._state.files.add(full);
}
async info(path) {
const parts = (0, util_1.parseParts)(path);
(0, util_1.assertType)(parts, 'info', 'crudfs');
const full = this._fullPath(path);
// If the path's collection node is tombstoned, report missing child in existing parent
if (this._isColTombstoned(full)) {
const parent = parts.slice(0, -1);
const id = parts[parts.length - 1] || '';
throw (0, util_2.newFile404Error)(parent, id);
}
// Resource tombstone?
if (parts.length > 0) {
const [col, id] = (0, util_1.parseId)(path);
if (this._isFileTombstoned(this._fullPath(joinParts(col.concat([id])))))
throw (0, util_2.newFile404Error)(col, id);
}
try {
return await this.overlay.info(path);
}
catch (e) {
const name = e && typeof e === 'object' ? e.name : undefined;
if (name === 'ResourceNotFound') {
// If base has resource and file not tombstoned, allow passthrough; otherwise, report not found
if (!this._isFileTombstoned(full)) {
const baseInfo = await this.base
.info(path)
.catch(err => (err && typeof err === 'object' ? err : undefined));
if (baseInfo && baseInfo.type)
return baseInfo;
}
throw e;
}
if (name && name !== 'CollectionNotFound')
throw e;
// If collection missing in overlay, try base
return await this.base.info(path);
}
}
async drop(path, silent) {
const parts = (0, util_1.parseParts)(path);
(0, util_1.assertType)(parts, 'drop', 'crudfs');
const full = this._fullPath(path);
const exists = await this._existsCollection(path);
if (!exists) {
if (silent)
return;
throw (0, util_2.newFolder404Error)(parts);
}
await this.overlay.drop(path, true);
// Mask the collection in base from this point on
this._state.cols.add(full);
}
async *scan(path) {
const parts = (0, util_1.parseParts)(path);
(0, util_1.assertType)(parts, 'scan', 'crudfs');
const full = this._fullPath(path);
if (this._isColTombstoned(full)) {
throw (0, util_2.newFolder404Error)(parts);
}
const map = new Map();
let overlayExists = false;
try {
for await (const e of this.overlay.scan(path)) {
map.set(e.id, e);
}
// If iteration completed without throwing, the collection exists (may be empty)
overlayExists = true;
}
catch (e) {
const name = e && typeof e === 'object' ? e.name : undefined;
if (name && name !== 'CollectionNotFound')
throw e;
}
let baseExists = false;
try {
for await (const e of this.base.scan(path)) {
const childFull = this._fullPath(joinParts([...parts, e.id]));
if (e.type === 'resource' && this._isFileTombstoned(childFull))
continue;
if (e.type === 'collection' && this._isColTombstoned(childFull))
continue;
if (!map.has(e.id))
map.set(e.id, e);
}
// If iteration completed without throwing, the collection exists (may be empty)
baseExists = true;
}
catch (e) {
const name = e && typeof e === 'object' ? e.name : undefined;
if (name && name !== 'CollectionNotFound')
throw e;
}
if (!overlayExists && !baseExists)
throw (0, util_2.newFolder404Error)(parts);
for (const v of map.values())
yield v;
}
async list(path) {
const entries = [];
for await (const e of this.scan(path))
entries.push(e);
return entries;
}
async from(path) {
return new PrefixedCrud_1.PrefixedCrud(this, path);
}
}
exports.CowCrud = CowCrud;