UNPKG

fs-zoo

Version:

File system abstractions and implementations

373 lines (372 loc) 15.2 kB
"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;