diff --git a/package-lock.json b/package-lock.json index 26e033b..0629921 100644 --- a/package-lock.json +++ b/package-lock.json @@ -437,6 +437,12 @@ } } }, + "@stoplight/path": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@stoplight/path/-/path-1.3.2.tgz", + "integrity": "sha512-lyIc6JUlUA8Ve5ELywPC8I2Sdnh1zc1zmbYgVarhXIp9YeAB0ReeqmGEOWNtlHkbP2DAA1AL65Wfn2ncjK/jtQ==", + "dev": true + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -4910,6 +4916,12 @@ "fast-text-encoding": "^1.0.0" } }, + "isomorphic.js": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.1.5.tgz", + "integrity": "sha512-MkX5lLQApx/8IAIU31PKvpAZosnu2Jqcj1rM8TzxyA4CR96tv3SgMKQNTCxL58G7696Q57zd7ubHV/hTg+5fNA==", + "dev": true + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -5243,6 +5255,15 @@ "type-check": "~0.3.2" } }, + "lib0": { + "version": "0.2.34", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.34.tgz", + "integrity": "sha512-cqsVIMPgFlDtgQcpkt7HOY6W3sbYPIe3qxMnbRSwHTgiQancgm+TRDPx28mC6GUZ6lG6Nr0bIWf4Nog6dWUNUg==", + "dev": true, + "requires": { + "isomorphic.js": "^0.1.3" + } + }, "libbase64": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-0.1.0.tgz", @@ -11044,6 +11065,14 @@ "tough-cookie": "~2.4.3", "tunnel-agent": "^0.6.0", "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + } } }, "requestretry": { @@ -12813,12 +12842,6 @@ "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", "dev": true }, - "uuid": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", - "dev": true - }, "uws": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/uws/-/uws-9.14.0.tgz", @@ -12996,6 +13019,14 @@ "requires": { "ansi-colors": "^3.0.0", "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + } } }, "webpack-sources": { @@ -13255,6 +13286,15 @@ "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=", "dev": true }, + "yjs": { + "version": "13.4.5", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.4.5.tgz", + "integrity": "sha512-UcsNFLOQRPz1Ddr5bpln7qWEdfXAtFjfLuze3QnCCT2s+DDQRe/sEzG1a8ZvnD5CQGWaKIqBnLw+mqeXM+qgRw==", + "dev": true, + "requires": { + "lib0": "^0.2.33" + } + }, "zip-stream": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-1.2.0.tgz", diff --git a/package.json b/package.json index be27fce..4f62c1c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "just-once": "1.1.0" }, "devDependencies": { + "@stoplight/path": "^1.3.2", "karma": "2.0.5", "karma-browserstack-launcher": "^1.5.1", "karma-chrome-launcher": "3.1.0", @@ -39,7 +40,8 @@ "puppeteer": "^1.10.0", "semantic-release": "16.0.3", "webpack": "^4.28.2", - "webpack-cli": "^3.1.2" + "webpack-cli": "^3.1.2", + "yjs": "^13.4.2" }, "repository": { "type": "git", diff --git a/src/backends/yjs/YjsBackend.js b/src/backends/yjs/YjsBackend.js new file mode 100644 index 0000000..85edb64 --- /dev/null +++ b/src/backends/yjs/YjsBackend.js @@ -0,0 +1,408 @@ +const { encode } = require("isomorphic-textencoder"); +const path = require("@stoplight/path"); + +const { EEXIST, ENOENT, ENOTDIR, ENOTEMPTY } = require("./errors.js"); + +const TYPE = 't'; +const MTIME = 'm'; +const MODE = 'o'; +const CONTENT = 'c'; +const PATH = 'p'; +const PARENT = 0; +const BASENAME = 1; + +function ID (client, clock) { + this.client = client; + this.clock = clock; +} + +// https://www.ecma-international.org/ecma-262/#sec-number.prototype.tostring +const MAX_RADIX = 36; + +function serializeID (id) { + // Numbers are encoded in base 36 to save space. + return `${id.client.toString(MAX_RADIX)}-${id.clock.toString(MAX_RADIX)}`; +} + +function parseID (arr) { + if (!arr) return arr; + const id = arr.indexOf('-'); + const client = parseInt(arr.slice(0, id), MAX_RADIX); + const clock = parseInt(arr.slice(id + 1), MAX_RADIX); + return new ID(client, clock); +} + +function sameID (id1, id2) { + if (id1 == null && id2 == null) return true; + if (id1 == null || id2 == null) return false; + return id1.client === id2.client && id1.clock === id2.clock; +} + +function ylast (yarr) { + return yarr.get(yarr.length - 1); +} + +function splitPath(path) { + if (path.length === 0) return []; + if (path === "/") return ["/"]; + let parts = path.split("/"); + if (parts[parts.length - 1] === '') { + parts.pop(); + } + if (path[0] === "/") { + parts[0] = "/"; + } else { + if (parts[0] !== ".") { + parts.unshift("."); + } + } + return parts; +} + +module.exports = class YjsBackend { + constructor(Y, ydoc, find) { + this.Y = Y; + this._ydoc = ydoc; + this._find = find; + this._inodes = this._ydoc.getArray('!inodes'); + if (this._inodes.length === 0) { + const rootdir = new this.Y.Map(); + const mtimeMs = Date.now(); + const mode = 0o777; + + rootdir.set(MODE, mode); + rootdir.set(TYPE, 'dir'); + rootdir.set(MTIME, mtimeMs); + rootdir.set(CONTENT, true); + + const _path = new this.Y.Array(); + _path.push([[null, '/']]); + rootdir.set(PATH, _path); + this._inodes.push([rootdir]); + } + } + async init() { + return; // TODO: Could connect to server, wait for documents to sync + } + async activate() { + return; + } + async deactivate() { + return; + } + async saveSuperblock() { + return; + } + getYTypeByIno(ino) { + let id = typeof ino === 'string' ? parseID(ino) : ino; + const item = this._find(this._ydoc.store, id); + return item.content.type; + } + getPathForIno(ino) { + let id = typeof ino === 'string' ? parseID(ino) : ino; + const parts = []; + while (id !== null) { + const item = this._find(this._ydoc.store, id); + const map = item.content.type; + const last = ylast(map.get(PATH)); + parts.unshift(last[BASENAME]); + id = parseID(last[PARENT]); + } + return path.join(...parts); + } + _getInode(ino) { + const id = parseID(ino); + const item = this._find(this._ydoc.store, id) + const node = item.content.type; + return node; + } + _childrenOf(id) { + const children = []; + for (const value of this._inodes) { + const last = ylast(value.get(PATH)); + const parent = parseID(last[PARENT]); + if (parent && sameID(parent, id) && value.get(CONTENT)) children.push(value); + } + return children; + } + _findChild(id, basename) { + for (const value of this._inodes) { + const last = ylast(value.get(PATH)); + const parent = parseID(last[PARENT]) + if (parent && sameID(parent, id) && last[BASENAME] === basename && value.get(CONTENT)) return value; + } + return; + } + _lookup(filepath, follow = true) { + let dir = this._inodes.get(0); + if (filepath === '/') return dir; + let partialPath = '/' + let parts = splitPath(filepath) + // TODO: Actually, given we can reconstruct paths from the bottom up, + // it might be faster to search by matching against the basepath and then + // narrowing that set. The problem would be dealing with symlinks. + for (let i = 1; i < parts.length; i++) { + let part = parts[i]; + dir = this._findChild(dir._item.id, part); + if (!dir) throw new ENOENT(filepath); + // Follow symlinks + if (follow || i < parts.length - 1) { + if (dir.get(TYPE) === 'symlink') { + let target = path.resolve(partialPath, dir.get(CONTENT)) + dir = this._lookup(target) + } + if (!partialPath) { + partialPath = part + } else { + partialPath = path.join(partialPath, part) + } + } + } + return dir; + } + mkdir(filepath, opts) { + const { mode = 0o777 } = opts; + if (filepath === "/") throw new EEXIST(); + let dir = this._lookup(path.dirname(filepath)); + let basename = path.basename(filepath); + for (const child of this._childrenOf(dir._item.id)) { + const last = ylast(child.get(PATH)) + if (last[BASENAME] === basename) { + throw new EEXIST(); + } + } + const mtimeMs = Date.now(); + this._ydoc.transact(() => { + let node = new this.Y.Map() + node.set(MODE, mode); + node.set(TYPE, 'dir'); + node.set(MTIME, mtimeMs); + node.set(CONTENT, true); // must be truthy or else directory is in a "deleted" state + + const _path = new this.Y.Array(); + _path.push([[serializeID(dir._item.id), basename]]); + node.set(PATH, _path); + this._inodes.push([node]); + }, 'mkdir'); + } + rmdir(filepath) { + // Never allow deleting the root directory. + if (filepath === "/") { + throw new ENOTEMPTY(); + } + let dir = this._lookup(filepath); + if (dir.get(TYPE) !== 'dir') throw new ENOTDIR(); + const ino = dir._item.id; + // check it's empty + if (this._childrenOf(ino).length > 0) throw new ENOTEMPTY(); + // delete inode + this._ydoc.transact(() => { + dir.set(CONTENT, false); + }, 'rmdir'); + } + readdir(filepath) { + let dir = this._lookup(filepath); + if (dir.get(TYPE) !== 'dir') throw new ENOTDIR(); + return this._childrenOf(dir._item.id).map(node => ylast(node.get(PATH))[BASENAME]); + } + writeFile(filepath, data, opts) { + let { mode, encoding = "utf8" } = opts; + if (encoding !== "utf8") { + throw new Error('Only "utf8" encoding is supported in writeFile'); + } + let node + try { + node = this._lookup(filepath); + if (mode == null) { + mode = node.get(MODE); + } + } catch (err) {} + if (mode == null) { + mode = 0o666; + } + let newData; + if (typeof data === "string") { + // Use a Y.Text + newData = new this.Y.Text(); + newData.insert(0, data); + } else if (data instanceof this.Y.AbstractType) { + newData = data; + } else { + // Yjs will fail if data.constructor !== Uint8Array + if (data.constructor.name === 'Buffer') { + newData = new Uint8Array(data.buffer); + } else { + newData = data; + } + } + this._ydoc.transact(() => { + if (!node) { + node = new this.Y.Map(); + node.set(MODE, mode); + node.set(TYPE, 'file'); + + const _path = new this.Y.Array(); + let dir = this._lookup(path.dirname(filepath)); + let parentId = dir._item.id; + let basename = path.basename(filepath); + _path.push([[serializeID(parentId), basename]]) + node.set(PATH, _path); + this._inodes.push([node]); + } else { + if (mode !== node.get(MODE)) node.set(MODE, mode); + node.set(TYPE, 'file'); + } + const mtimeMs = Date.now(); + node.set(MTIME, mtimeMs); + node.set(CONTENT, newData); + }, 'writeFile'); + } + readFile(filepath, opts) { + let { encoding } = opts; + if (encoding && encoding !== "utf8") { + throw new Error('Only "utf8" encoding is supported in readFile'); + } + let node = this._lookup(filepath, true); + let data = node.get(CONTENT); + if (data instanceof this.Y.Text) { + data = data.toString(); + if (!encoding) { + data = encode(data); + } + } + return data; + } + unlink(filepath) { + let node = this._lookup(filepath, false); + // delete inode + this._ydoc.transact(() => { + node.set(CONTENT, false); + }, 'unlink'); + } + rename(oldFilepath, newFilepath) { + // Note: do both lookups before making any changes + // so if lookup throws, we don't lose data (issue #23) + // grab references + let node = this._lookup(oldFilepath); + let destDir = this._lookup(path.dirname(newFilepath)); + const basename = path.basename(newFilepath); + // Update parent + this._ydoc.transact(() => { + const newParent = serializeID(destDir._item.id); + node.get(PATH).push([[newParent, basename]]); + }, 'rename'); + } + stat(filepath) { + const node = this._lookup(filepath); + const stat = { + mode: node.get(MODE), + type: node.get(TYPE), + size: this._size(node), + mtimeMs: node.get(MTIME), + ino: serializeID(node._item.id), + }; + return stat; + } + lstat(filepath) { + const node = this._lookup(filepath, false); + const stat = { + mode: node.get(MODE), + type: node.get(TYPE), + size: this._size(node), + mtimeMs: node.get(MTIME), + ino: serializeID(node._item.id), + }; + return stat; + } + readlink(filepath) { + return this._lookup(filepath, false).get(CONTENT); + } + symlink(target, filepath) { + let mode, node; + try { + node = this._lookup(filepath); + if (mode === null) { + mode = node.get(MODE); + } + } catch (err) {} + if (mode == null) { + mode = 0o120000; + } + let dir = this._lookup(path.dirname(filepath)); + let parentId = dir._item.id; + let basename = path.basename(filepath); + const mtimeMs = Date.now(); + + this._ydoc.transact(() => { + if (!node) { + node = new this.Y.Map(); + node.set(MODE, mode); + node.set(TYPE, 'symlink'); + node.set(MTIME, mtimeMs); + node.set(CONTENT, target); + + const _path = new this.Y.Array(); + _path.push([[serializeID(parentId), basename]]); + node.set(PATH, _path); + this._inodes.push([node]); + } else { + node.set(MODE, mode); + node.set(TYPE, 'symlink'); + node.set(MTIME, mtimeMs); + node.set(CONTENT, target); + } + }, 'symlink'); + const stat = this.lstat(filepath); + return stat; + } + _du (dir) { + let size = 0; + const type = dir.get(TYPE) + if (type === 'file') { + size += this._size(dir); + } else if (type === 'dir') { + for (const entry of this._childrenOf(dir._item.id)) { + size += this._du(entry); + } + } + return size; + } + du (filepath) { + let dir = this._lookup(filepath); + return this._du(dir); + } + openYType(filepath) { + let node = this._lookup(filepath, false); + let data = node.get(CONTENT) + if (data instanceof this.Y.AbstractType) { + return data; + } + } + + saveSuperblock(superblock) { + return + } + loadSuperblock() { + return + } + wipe() { + return // TODO + } + close() { + return + } + + _size(node) { + if (node.get(TYPE) !== 'file') return 0; + + const content = node.get(CONTENT); + + if (content instanceof this.Y.Text || typeof content === 'string') { + return content.length; + } else if (content instanceof Uint8Array) { + return content.byteLength; + } else { + return 0; + } + } +} diff --git a/src/backends/yjs/YjsBackend.spec.js b/src/backends/yjs/YjsBackend.spec.js new file mode 100755 index 0000000..bf0710c --- /dev/null +++ b/src/backends/yjs/YjsBackend.spec.js @@ -0,0 +1,537 @@ +// jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000 + +import * as Y from 'yjs'; +import { find } from 'yjs/src/utils/StructStore'; + +import FS from "../../index.js"; + +import YjsBackend from './YjsBackend.js'; + +const ydoc = new Y.Doc(); +const backend = new YjsBackend(Y, ydoc, find); +const fs = new FS("testfs-yjs", { wipe: true, backend }).promises; + +const HELLO = new Uint8Array([72, 69, 76, 76, 79]); + +if (!Promise.prototype.finally) { + Promise.prototype.finally = function (onFinally) { + this.then(onFinally, onFinally); + } +} + +describe("YjsBackend", () => { + describe("mkdir", () => { + it("root directory already exists", (done) => { + fs.mkdir("/").catch(err => { + expect(err).not.toBe(null); + expect(err.code).toEqual("EEXIST"); + done(); + }); + }); + it("create empty directory", done => { + fs.mkdir("/mkdir-test") + .then(() => { + fs.stat("/mkdir-test").then(stat => { + done(); + }).catch(err => { + expect(err).toBeUndefined(); + }); + }) + .catch(err => { + expect(err.code).toEqual("EEXIST"); + done(); + }); + }); + }); + + describe("writeFile", () => { + it("create file", done => { + fs.mkdir("/writeFile").finally(() => { + fs.writeFile("/writeFile/writeFile-uint8.txt", HELLO).then(() => { + fs.stat("/writeFile/writeFile-uint8.txt").then(stats => { + expect(stats.size).toEqual(5); + done(); + }); + }); + }); + }); + it("create file (from string)", done => { + fs.mkdir("/writeFile").finally(() => { + fs.writeFile("/writeFile/writeFile-string.txt", "HELLO").then(() => { + fs.stat("/writeFile/writeFile-string.txt").then(stats => { + expect(stats.size).toEqual(5); + done(); + }); + }); + }); + }); + it("write file perserves old inode", done => { + fs.mkdir("/writeFile").finally(() => { + fs.writeFile("/writeFile/writeFile-inode.txt", "HELLO").then(() => { + fs.stat("/writeFile/writeFile-inode.txt").then(stats => { + let inode = stats.ino; + fs.writeFile("/writeFile/writeFile-inode.txt", "WORLD").then(() => { + fs.stat("/writeFile/writeFile-inode.txt").then(stats => { + expect(stats.ino).toEqual(inode); + done(); + }); + }); + }); + }); + }); + }); + it("write file perserves old mode", done => { + fs.mkdir("/writeFile").finally(() => { + fs.writeFile("/writeFile/writeFile-mode.txt", "HELLO", { mode: 0o635 }).then(() => { + fs.stat("/writeFile/writeFile-mode.txt").then(stats => { + let mode = stats.mode; + expect(mode).toEqual(0o635) + fs.writeFile("/writeFile/writeFile-mode.txt", "WORLD").then(() => { + fs.stat("/writeFile/writeFile-mode.txt").then(stats => { + expect(stats.mode).toEqual(0o635); + done(); + }); + }); + }); + }); + }); + }); + }); + + describe("readFile", () => { + it("read non-existant file throws", done => { + fs.readFile("/readFile/non-existant.txt").catch(err => { + expect(err).not.toBe(null); + done(); + }); + }); + it("read file", done => { + fs.mkdir("/readFile").finally(() => { + fs.writeFile("/readFile/readFile-uint8.txt", "HELLO").then(() => { + fs.readFile("/readFile/readFile-uint8.txt").then(data => { + // instanceof comparisons on Uint8Array's retrieved from IDB are broken in Safari Mobile 11.x (source: https://github.com/dfahlander/Dexie.js/issues/656#issuecomment-391866600) + expect([...data]).toEqual([...HELLO]); + done(); + }); + }); + }); + }); + it("read file (encoding shorthand)", done => { + fs.mkdir("/readFile").finally(() => { + fs.writeFile("/readFile/readFile-encoding-shorthand.txt", "HELLO").then(() => { + fs.readFile("/readFile/readFile-encoding-shorthand.txt", "utf8").then(data => { + expect(data).toEqual("HELLO"); + done(); + }); + }); + }); + }); + it("read file (encoding longhand)", done => { + fs.mkdir("/readFile").finally(() => { + fs.writeFile("/readFile/readFile-encoding-longhand.txt", "HELLO").then(() => { + fs.readFile("/readFile/readFile-encoding-longhand.txt", { encoding: "utf8" }).then(data => { + expect(data).toEqual("HELLO"); + done(); + }); + }); + }); + }); + }); + + describe("readdir", () => { + it("read non-existant dir returns undefined", done => { + fs.readdir("/readdir/non-existant").catch(err => { + expect(err).not.toBe(null); + done(); + }); + }); + it("read root directory", done => { + fs.mkdir("/readdir").finally(() => { + fs.readdir("/").then(data => { + expect(data.includes("readdir")).toBe(true); + done(); + }); + }); + }); + it("read child directory", done => { + fs.mkdir("/readdir").finally(() => { + fs.writeFile("/readdir/1.txt", "").then(() => { + fs.readdir("/readdir").then(data => { + expect(data).toEqual(["1.txt"]) + done(); + }); + }); + }); + }); + it("read a file throws", done => { + fs.mkdir("/readdir2").finally(() => { + fs.writeFile("/readdir2/not-a-dir", "").then(() => { + fs.readdir("/readdir2/not-a-dir").catch(err => { + expect(err).not.toBe(null); + expect(err.code).toBe('ENOTDIR'); + done(); + }); + }) + }) + }); + }); + + describe("rmdir", () => { + it("delete root directory fails", done => { + fs.rmdir("/").catch(err => { + expect(err).not.toBe(null); + expect(err.code).toEqual("ENOTEMPTY"); + done(); + }); + }); + it("delete non-existant directory fails", done => { + fs.rmdir("/rmdir/non-existant").catch(err => { + expect(err).not.toBe(null); + expect(err.code).toEqual("ENOENT"); + done(); + }); + }); + it("delete non-empty directory fails", done => { + fs.mkdir("/rmdir").finally(() => { + fs.mkdir("/rmdir/not-empty").finally(() => { + fs.writeFile("/rmdir/not-empty/file.txt", "").then(() => { + + fs.rmdir("/rmdir/not-empty").catch(err => { + expect(err).not.toBe(null); + expect(err.code).toEqual("ENOTEMPTY"); + done(); + }); + }) + }) + }) + }); + it("delete empty directory", done => { + fs.mkdir("/rmdir").finally(() => { + fs.mkdir("/rmdir/empty").finally(() => { + fs.readdir("/rmdir").then(data => { + let originalSize = data.length; + fs.rmdir("/rmdir/empty").then(() => { + fs.readdir("/rmdir").then(data => { + expect(data.includes("empty")).toBe(false); + expect(data.length === originalSize - 1); + done(); + }); + }); + }); + }); + }); + }); + it("delete a file throws", done => { + fs.mkdir("/rmdir").finally(() => { + fs.writeFile("/rmdir/not-a-dir", "").then(() => { + fs.rmdir("/rmdir/not-a-dir").catch(err => { + expect(err).not.toBe(null); + expect(err.code).toBe('ENOTDIR'); + done(); + }); + }); + }); + }); + }); + + describe("unlink", () => { + it("create and delete file", done => { + fs.mkdir("/unlink").finally(() => { + fs.writeFile("/unlink/file.txt", "").then(() => { + fs.readdir("/unlink").then(data => { + let originalSize = data.length; + fs.unlink("/unlink/file.txt").then(() => { + fs.readdir("/unlink").then(data => { + expect(data.length).toBe(originalSize - 1) + expect(data.includes("file.txt")).toBe(false); + fs.readFile("/unlink/file.txt").catch(err => { + expect(err).not.toBe(null) + expect(err.code).toBe("ENOENT") + done(); + }); + }); + }); + }); + }); + }); + }); + }); + + describe("rename", () => { + it("create and rename file", done => { + fs.mkdir("/rename").finally(() => { + fs.writeFile("/rename/a.txt", "").then(() => { + fs.rename("/rename/a.txt", "/rename/b.txt").then(() => { + fs.readdir("/rename").then(data => { + expect(data.includes("a.txt")).toBe(false); + expect(data.includes("b.txt")).toBe(true); + fs.readFile("/rename/a.txt").catch(err => { + expect(err).not.toBe(null) + expect(err.code).toBe("ENOENT") + fs.readFile("/rename/b.txt", "utf8").then(data => { + expect(data).toBe("") + done(); + }); + }); + }); + }); + }); + }); + }); + it("create and rename directory", done => { + fs.mkdir("/rename").finally(() => { + fs.mkdir("/rename/a").finally(() => { + fs.writeFile("/rename/a/file.txt", "").then(() => { + fs.rename("/rename/a", "/rename/b").then(() => { + fs.readdir("/rename").then(data => { + expect(data.includes("a")).toBe(false); + expect(data.includes("b")).toBe(true); + fs.readFile("/rename/a/file.txt").catch(err => { + expect(err).not.toBe(null) + expect(err.code).toBe("ENOENT") + fs.readFile("/rename/b/file.txt", "utf8").then(data => { + expect(data).toBe("") + done(); + }); + }); + }); + }); + }); + }); + }); + }); + }); + + describe("symlink", () => { + it("symlink a file and read/write to it", done => { + fs.mkdir("/symlink").finally(() => { + fs.writeFile("/symlink/a.txt", "hello").then(() => { + fs.symlink("/symlink/a.txt", "/symlink/b.txt").then(() => { + fs.readFile("/symlink/b.txt", "utf8").then(data => { + expect(data).toBe("hello") + fs.writeFile("/symlink/b.txt", "world").then(() => { + fs.readFile("/symlink/a.txt", "utf8").then(data => { + expect(data).toBe("world"); + done(); + }) + }) + }); + }); + }); + }); + }); + it("symlink a file and read/write to it (relative)", done => { + fs.mkdir("/symlink-relative").finally(() => { + fs.writeFile("/symlink-relative/a.txt", "hello").then(() => { + fs.symlink("a.txt", "/symlink-relative/b.txt").then(() => { + fs.readFile("/symlink-relative/b.txt", "utf8").then(data => { + expect(data).toBe("hello") + fs.writeFile("/symlink-relative/b.txt", "world").then(() => { + fs.readFile("/symlink-relative/a.txt", "utf8").then(data => { + expect(data).toBe("world"); + done(); + }) + }) + }); + }); + }); + }); + }); + it("symlink a directory and read/write to it", done => { + fs.mkdir("/symlink-dir").finally(() => { + fs.mkdir("/symlink-dir/a").finally(() => { + fs.writeFile("/symlink-dir/a/file.txt", "data").then(() => { + fs.symlink("/symlink-dir/a", "/symlink-dir/b").then(() => { + fs.readdir("/symlink-dir/b").then(data => { + expect(data.includes("file.txt")).toBe(true); + fs.readFile("/symlink-dir/b/file.txt", "utf8").then(data => { + expect(data).toBe("data") + fs.writeFile("/symlink-dir/b/file2.txt", "world").then(() => { + fs.readFile("/symlink-dir/a/file2.txt", "utf8").then(data => { + expect(data).toBe("world"); + done(); + }) + }) + }); + }); + }); + }); + }); + }); + }); + it("symlink a directory and read/write to it (relative)", done => { + fs.mkdir("/symlink-dir-relative").finally(() => { + fs.mkdir("/symlink-dir-relative/a").finally(() => { + fs.mkdir("/symlink-dir-relative/b").finally(() => { + fs.writeFile("/symlink-dir-relative/a/file.txt", "data").then(() => { + fs.symlink("../a", "/symlink-dir-relative/b/c").then(() => { + fs.readdir("/symlink-dir-relative/b/c").then(data => { + expect(data.includes("file.txt")).toBe(true); + fs.readFile("/symlink-dir-relative/b/c/file.txt", "utf8").then(data => { + expect(data).toBe("data") + fs.writeFile("/symlink-dir-relative/b/c/file2.txt", "world").then(() => { + fs.readFile("/symlink-dir-relative/a/file2.txt", "utf8").then(data => { + expect(data).toBe("world"); + done(); + }) + }) + }); + }); + }); + }); + }); + }); + }); + }); + it("unlink doesn't follow symlinks", done => { + fs.mkdir("/symlink").finally(() => { + fs.mkdir("/symlink/del").finally(() => { + fs.writeFile("/symlink/del/file.txt", "data").then(() => { + fs.symlink("/symlink/del/file.txt", "/symlink/del/file2.txt").then(() => { + fs.readdir("/symlink/del").then(data => { + expect(data.includes("file.txt")).toBe(true) + expect(data.includes("file2.txt")).toBe(true) + fs.unlink("/symlink/del/file2.txt").then(data => { + fs.readdir("/symlink/del").then(data => { + expect(data.includes("file.txt")).toBe(true) + expect(data.includes("file2.txt")).toBe(false) + fs.readFile("/symlink/del/file.txt", "utf8").then(data => { + expect(data).toBe("data") + done(); + }) + }); + }); + }); + }); + }); + }); + }); + }); + it("lstat doesn't follow symlinks", done => { + fs.mkdir("/symlink").finally(() => { + fs.mkdir("/symlink/lstat").finally(() => { + fs.writeFile("/symlink/lstat/file.txt", "data").then(() => { + fs.symlink("/symlink/lstat/file.txt", "/symlink/lstat/file2.txt").then(() => { + fs.stat("/symlink/lstat/file2.txt").then(stat => { + expect(stat.isFile()).toBe(true) + expect(stat.isSymbolicLink()).toBe(false) + fs.lstat("/symlink/lstat/file2.txt").then(stat => { + expect(stat.isFile()).toBe(false) + expect(stat.isSymbolicLink()).toBe(true) + done(); + }); + }); + }); + }); + }); + }); + }); + }); + + describe("readlink", () => { + it("readlink returns the target path", done => { + fs.mkdir("/readlink").finally(() => { + fs.writeFile("/readlink/a.txt", "hello").then(() => { + fs.symlink("/readlink/a.txt", "/readlink/b.txt").then(() => { + fs.readlink("/readlink/b.txt", "utf8").then(data => { + expect(data).toBe("/readlink/a.txt") + done(); + }); + }); + }); + }); + }); + it("readlink operates on paths with symlinks", done => { + fs.mkdir("/readlink").finally(() => { + fs.symlink("/readlink", "/readlink/sub").then(() => { + fs.writeFile("/readlink/c.txt", "hello").then(() => { + fs.symlink("/readlink/c.txt", "/readlink/d.txt").then(() => { + fs.readlink("/readlink/sub/d.txt").then(data => { + expect(data).toBe("/readlink/c.txt") + done(); + }); + }); + }); + }); + }); + }); + }); + + describe("du", () => { + it("du returns the total file size of a path", done => { + fs.mkdir("/du").finally(() => { + fs.writeFile("/du/a.txt", "hello").then(() => { + fs.writeFile("/du/b.txt", "hello").then(() => { + fs.mkdir("/du/sub").then(() => { + fs.writeFile("/du/sub/a.txt", "hello").then(() => { + fs.writeFile("/du/sub/b.txt", "hello").then(() => { + fs.du("/du/sub/a.txt").then(size => { + expect(size).toBe(5) + fs.du("/du/sub").then(size => { + expect(size).toBe(10) + fs.du("/du").then(size => { + expect(size).toBe(20) + done(); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + }); + + xdescribe("benchmark", () => { + it("10 dir x 10 dir x 100 files", done => { + const range = n => [...Array(n).keys()]; + const start = performance.now(); + fs.mkdir(`/benchmark`) + .then(() =>Promise.all(range(10).map( + i => fs.mkdir(`/benchmark/dir${i}`).then( + () => Promise.all(range(10).map( + j => fs.mkdir(`/benchmark/dir${i}/sub${j}`).then( + () => Promise.all(range(100).map( + k => fs.writeFile(`/benchmark/dir${i}/sub${j}/file${k}`, 'A', 'utf8') + )) + ) + )) + ) + ))) + .then(() => fs.du('/benchmark')) + .then(size => { + expect(size).toBe(10000); + const end = performance.now(); + console.log(`TIME: ${end - start}ms`); + // const keys = [...ydoc.getMap('!inodes').keys()]; + const inodes = ydoc.getArray('!inodes'); + let size2 = 0 + const ids = inodes.map(map => map._item.id); + + const idstart = performance.now() + const idset = new Set() + for (const id of ids) { + const item = find(ydoc.store, id) + const node = item.content.type; + const content = node.get('c'); + // idset.add(node.get('p')); + if (content && content.length) { + size2 += content.length + } + } + const idend = performance.now() + + console.log(`LOOKUP TIME: ${idend - idstart}ms`); + expect(size2).toBe(size); + + const update = Y.encodeStateAsUpdate(ydoc); + console.log(`YJS SIZE: ${update.byteLength}`); + // for (const id of idset) console.log(id); + done(); + }); + }); + }); + +}); diff --git a/src/backends/yjs/errors.js b/src/backends/yjs/errors.js new file mode 100755 index 0000000..24aa0fe --- /dev/null +++ b/src/backends/yjs/errors.js @@ -0,0 +1,21 @@ +function Err(name) { + return class extends Error { + constructor(...args) { + super(...args); + this.code = name; + if (this.message) { + this.message = name + ": " + this.message; + } else { + this.message = name; + } + } + }; +} + +const EEXIST = Err("EEXIST"); +const ENOENT = Err("ENOENT"); +const ENOTDIR = Err("ENOTDIR"); +const ENOTEMPTY = Err("ENOTEMPTY"); +const ETIMEDOUT = Err("ETIMEDOUT"); + +module.exports = { EEXIST, ENOENT, ENOTDIR, ENOTEMPTY, ETIMEDOUT };