native-file-system-adapter
Version:
311 lines (285 loc) • 8.7 kB
JavaScript
/* global indexedDB, Blob, File, DOMException */
import { errors } from '../util.js'
const { INVALID, GONE, MISMATCH, MOD_ERR, SYNTAX, ABORT } = errors
/**
* @param {IDBTransaction} tx
* @param {(e) => {}} onerror
*/
function setupTxErrorHandler (tx, onerror) {
tx.onerror = () => onerror(tx.error)
tx.onabort = () => onerror(tx.error || new DOMException(...ABORT))
}
class Sink {
/**
* @param {IDBDatabase} db
* @param {IDBValidKey} id
* @param {number} size
* @param {File} file
*/
constructor (db, id, size, file) {
this.db = db
this.id = id
this.size = size
this.position = 0
this.file = file
}
write (chunk) {
if (typeof chunk === 'object') {
if (chunk.type === 'write') {
if (Number.isInteger(chunk.position) && chunk.position >= 0) {
if (this.size < chunk.position) {
this.file = new File(
[this.file, new ArrayBuffer(chunk.position - this.size)],
this.file.name,
this.file
)
}
this.position = chunk.position
}
if (!('data' in chunk)) {
throw new DOMException(...SYNTAX('write requires a data argument'))
}
chunk = chunk.data
} else if (chunk.type === 'seek') {
if (Number.isInteger(chunk.position) && chunk.position >= 0) {
if (this.size < chunk.position) {
throw new DOMException(...INVALID)
}
this.position = chunk.position
return
} else {
throw new DOMException(...SYNTAX('seek requires a position argument'))
}
} else if (chunk.type === 'truncate') {
if (Number.isInteger(chunk.size) && chunk.size >= 0) {
let file = this.file
file = chunk.size < this.size
? new File([file.slice(0, chunk.size)], file.name, file)
: new File([file, new Uint8Array(chunk.size - this.size)], file.name, file)
this.size = file.size
if (this.position > file.size) {
this.position = file.size
}
this.file = file
return
} else {
throw new DOMException(...SYNTAX('truncate requires a size argument'))
}
}
}
chunk = new Blob([chunk])
let blob = this.file
// Calc the head and tail fragments
const head = blob.slice(0, this.position)
const tail = blob.slice(this.position + chunk.size)
// Calc the padding
let padding = this.position - head.size
if (padding < 0) {
padding = 0
}
blob = new File([
head,
new Uint8Array(padding),
chunk,
tail
], blob.name)
this.size = blob.size
this.position += chunk.size
this.file = blob
}
close () {
return new Promise((resolve, reject) => {
const [tx, table] = store(this.db)
table.get(this.id).onsuccess = (evt) => {
evt.target.result
? table.put(this.file, this.id)
: reject(new DOMException(...GONE))
}
tx.oncomplete = () => resolve()
tx.onerror = reject
tx.onabort = reject
})
}
}
class FileHandle {
/**
* @param {IDBDatabase} db
* @param {IDBValidKey} id
* @param {string} name
*/
constructor (db, id, name) {
this._db = db
this._id = id
this.name = name
this.kind = 'file'
this.readable = true
this.writable = true
}
/** @param {FileHandle} other */
async isSameEntry (other) {
return this._id === other._id
}
async getFile () {
/** @type {File} */
const file = await new Promise((resolve, reject) => {
const req = store(this._db)[1].get(this._id)
req.onsuccess = evt => resolve(evt.target.result)
req.onerror = evt => reject(evt.target.error)
})
if (!file) throw new DOMException(...GONE)
return file
}
async createWritable (opts) {
let file = await this.getFile() // Used directly to test existences
file = opts.keepExistingData ? file : new File([], this.name)
return new Sink(this._db, this._id, file.size, file)
}
}
/**
* @param {IDBDatabase} db
* @returns {[IDBTransaction, IDBObjectStore]}
*/
function store (db) {
const tx = db.transaction('entries', 'readwrite', { durability: 'relaxed' })
return [tx, tx.objectStore('entries')]
}
function rimraf (evt, toDelete, recursive = true) {
const { source, result } = evt.target
for (const [id, isFile] of Object.values(toDelete || result)) {
if (isFile) source.delete(id)
else if (recursive) {
source.get(id).onsuccess = rimraf
source.delete(id)
} else {
source.get(id).onsuccess = (evt) => {
if (Object.keys(evt.target.result).length !== 0) {
evt.target.transaction.abort()
} else {
source.delete(id)
}
}
}
}
}
class FolderHandle {
/**
* @param {IDBDatabase} db
* @param {IDBValidKey} id
* @param {string} name
*/
constructor (db, id, name) {
this._db = db
this._id = id
this.kind = 'directory'
this.name = name
this.readable = true
this.writable = true
}
/** @returns {AsyncGenerator<[string, FileHandle | FolderHandle]>} */
async * entries () {
const req = store(this._db)[1].get(this._id);
await new Promise((rs, rj) => {
req.onsuccess = () => rs()
req.onerror = () => rj(req.error)
})
const entries = req.result
if (!entries) throw new DOMException(...GONE)
for (const [name, [id, isFile]] of Object.entries(entries)) {
yield [name, isFile
? new FileHandle(this._db, id, name)
: new FolderHandle(this._db, id, name)
]
}
}
/** @param {FolderHandle} other */
isSameEntry (other) {
return this._id === other._id
}
/**
* @param {string} name
* @param {{ create: boolean; }} opts
*/
getDirectoryHandle (name, opts) {
return new Promise((resolve, reject) => {
const table = store(this._db)[1]
const req = table.get(this._id)
req.onsuccess = () => {
const entries = req.result
const entry = entries[name]
entry // entry exist
? entry[1] // isFile?
? reject(new DOMException(...MISMATCH))
: resolve(new FolderHandle(this._db, entry[0], name))
: opts.create
? table.add({}).onsuccess = evt => {
const id = evt.target.result
entries[name] = [id, false]
table.put(entries, this._id)
.onsuccess = () => resolve(new FolderHandle(this._db, id, name))
}
: reject(new DOMException(...GONE))
}
})
}
/**
* @param {string} name
* @param {{ create: boolean; }} opts
*/
getFileHandle (name, opts) {
return new Promise((resolve, reject) => {
const table = store(this._db)[1]
const query = table.get(this._id)
query.onsuccess = () => {
const entries = query.result
const entry = entries[name]
if (entry && entry[1]) resolve(new FileHandle(this._db, entry[0], name))
if (entry && !entry[1]) reject(new DOMException(...MISMATCH))
if (!entry && !opts.create) reject(new DOMException(...GONE))
if (!entry && opts.create) {
const q = table.put(new File([], name))
q.onsuccess = () => {
const id = q.result
entries[name] = [id, true]
const query = table.put(entries, this._id)
query.onsuccess = () => {
resolve(new FileHandle(this._db, id, name))
}
}
}
}
})
}
async removeEntry (name, opts) {
return new Promise((resolve, reject) => {
const [tx, table] = store(this._db)
const cwdQ = table.get(this._id)
cwdQ.onsuccess = (evt) => {
const cwd = cwdQ.result
const toDelete = { _: cwd[name] }
if (!toDelete._) {
return reject(new DOMException(...GONE))
}
delete cwd[name]
table.put(cwd, this._id)
rimraf(evt, toDelete, !!opts.recursive)
}
tx.oncomplete = resolve
tx.onerror = reject
tx.onabort = () => {
reject(new DOMException(...MOD_ERR))
}
})
}
}
export default (opts = { persistent: false }) => new Promise((resolve) => {
const request = indexedDB.open('fileSystem')
request.onupgradeneeded = () => {
const db = request.result
db.createObjectStore('entries', { autoIncrement: true }).transaction.oncomplete = evt => {
db.transaction('entries', 'readwrite').objectStore('entries').add({})
}
}
request.onsuccess = () => {
resolve(new FolderHandle(request.result, 1, ''))
}
})