Repository: streamich/memfs
Pull request #924 (2023-06-21 10:28:46)

crudfs on top of fs
This implements `CrudApi` abstraction directly on top of `fs`, with no need of FAS in between.

This also adds `crud-and-cas` demo:


https://github.com/streamich/memfs/assets/9773803/02ba339c-6e13-4712-a02f-672674300d27




Patch:
diff --git a/.gitignore b/.gitignore
index f8c29018..d3e9d419 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ package-lock.json
 /lib/
 /gh-pages
 /dist
+/fs-test/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 753bc6a6..d71345d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,216 +1,3 @@
-# [4.1.0-next.1](https://github.com/streamich/memfs/compare/v4.0.0...v4.1.0-next.1) (2023-06-21)
-
-
-### Bug Fixes
-
-* 🐛 allow readin into various kinds of buffers ([361812d](https://github.com/streamich/memfs/commit/361812dc7d05b2c8ed1f52cb2181d0f71d9c2f4f))
-* 🐛 allow readin into various kinds of buffers ([e9c70e9](https://github.com/streamich/memfs/commit/e9c70e97dc2e063848baf7e3d307cd30491116a2))
-* 🐛 allow to seek in file ([c04895b](https://github.com/streamich/memfs/commit/c04895b50b18996e2b7c9c4ba389baaede57fc1f))
-* 🐛 allow to seek in file ([b363689](https://github.com/streamich/memfs/commit/b3636899c8091a8ee1443b148fc23ebb1895ce18))
-* 🐛 do not allow empty children names ([f014fd8](https://github.com/streamich/memfs/commit/f014fd8e19aec12163c717eed429c2a6ddc95437))
-* 🐛 do not allow empty children names ([43da1d6](https://github.com/streamich/memfs/commit/43da1d6279e4c32543ebfd0780bf149d27a07265))
-* 🐛 handle root folder better ([89bbffd](https://github.com/streamich/memfs/commit/89bbffde3fc2bd1e41b2c292888023b9b72cf41d))
-* 🐛 handle root folder better ([76de780](https://github.com/streamich/memfs/commit/76de78002fa4ae613308227fd03ee2f643c41671))
-* 🐛 throw "ENOENT" and "ENOTDIR" when folder or file 404 ([5de4faa](https://github.com/streamich/memfs/commit/5de4faa305748006a45817f6fa8446ed7e4df5b1))
-* 🐛 throw "ENOENT" and "ENOTDIR" when folder or file 404 ([ddd5d56](https://github.com/streamich/memfs/commit/ddd5d565b33009959e32e77db8e1705809c4c29a))
-
-
-### Features
-
-* 🎸 add .truncate() method ([038ab36](https://github.com/streamich/memfs/commit/038ab36d1ac8b24428a11135fe9affe95c99e587))
-* 🎸 add .truncate() method ([085335c](https://github.com/streamich/memfs/commit/085335ca8736b67db05c02bbd11cccf5b9bf000b))
-* 🎸 add ability to close files ([0db56be](https://github.com/streamich/memfs/commit/0db56becb255d4fce72fce16fa8cc120c07ed41a))
-* 🎸 add ability to close files ([d3828a8](https://github.com/streamich/memfs/commit/d3828a8058808ebdcd055493f4c2f72e3bcb58e7))
-* 🎸 add ability to create sub directories ([8f15bd9](https://github.com/streamich/memfs/commit/8f15bd9dd4f6e00360a000aaaf865af984ca2559))
-* 🎸 add ability to create sub directories ([528c807](https://github.com/streamich/memfs/commit/528c807281a0f920d4c819eebef942cd250b4479))
-* 🎸 add ability to remove all files ([76cabc7](https://github.com/streamich/memfs/commit/76cabc763c280bc23abb8d873f5fa304ce8fcef6))
-* 🎸 add ability to remove all files ([566e29b](https://github.com/streamich/memfs/commit/566e29b543825e6bc71c9b881ce05e93ee657eca))
-* 🎸 add appendFileSync() method ([57192fe](https://github.com/streamich/memfs/commit/57192fe98112970fcda201fcf240c57da071cdae))
-* 🎸 add appendFileSync() method ([27411e4](https://github.com/streamich/memfs/commit/27411e40125d8fb86e13977735d9122166d55961))
-* 🎸 add basenem() utility ([8b27695](https://github.com/streamich/memfs/commit/8b276956876c270dda1e5f7930392daa9a97acf5))
-* 🎸 add basenem() utility ([43354e5](https://github.com/streamich/memfs/commit/43354e5a024c8be28a897c53e1d4ea5b46b66d39))
-* 🎸 add copyFile() method ([de2bb0a](https://github.com/streamich/memfs/commit/de2bb0aae0c5380dde78689ac1376862f26c6af9))
-* 🎸 add copyFile() method ([5e207c4](https://github.com/streamich/memfs/commit/5e207c4a60019f4df187510682d83a0597df2468))
-* 🎸 add copyFileSync() method ([7e0137c](https://github.com/streamich/memfs/commit/7e0137c23c4b0254e1cf40fcad0349f282d4a556))
-* 🎸 add copyFileSync() method ([5fc1bac](https://github.com/streamich/memfs/commit/5fc1bacf4a337ca25d0207a0e9f94768c2976528))
-* 🎸 add createSwapFile() method ([dfdb908](https://github.com/streamich/memfs/commit/dfdb90893bf67b035337fc059b44f7ad9dfba6a7))
-* 🎸 add createSwapFile() method ([b07ce79](https://github.com/streamich/memfs/commit/b07ce795e757cab67ab4b81e50c397486e32ba40))
-* 🎸 add crudfs types ([18c0658](https://github.com/streamich/memfs/commit/18c0658124a83e7819022ccb415d163b88e7abbb))
-* 🎸 add existsSync() method ([0492a98](https://github.com/streamich/memfs/commit/0492a988b7413e1ec342be758a0f0b6aa7ca4c67))
-* 🎸 add existsSync() method ([073ec6b](https://github.com/streamich/memfs/commit/073ec6be1ea86296105a7996e1f1da9160e5bbf3))
-* 🎸 add fstatSync() method ([f13ddb7](https://github.com/streamich/memfs/commit/f13ddb71d199406d3ab33c2aec565974b2797667))
-* 🎸 add fstatSync() method ([6b1597a](https://github.com/streamich/memfs/commit/6b1597a74d7dfd48682c92c6f4c660878160bba8))
-* 🎸 add initial writign implementation ([2f9542c](https://github.com/streamich/memfs/commit/2f9542c03c52adeebdcc4fa94281726da3bb2c79))
-* 🎸 add initial writign implementation ([6a50382](https://github.com/streamich/memfs/commit/6a503821e8a7a7031bddf0bed692fd37abdadecf))
-* 🎸 add lstat() and fstat() methods ([ce5dd5e](https://github.com/streamich/memfs/commit/ce5dd5eaff1593d0efd8f0d27d317b66211aa8bb))
-* 🎸 add lstat() and fstat() methods ([e147d58](https://github.com/streamich/memfs/commit/e147d5850870cddf1c64bca747e6708676317817))
-* 🎸 add mkdirSync() method ([57f386b](https://github.com/streamich/memfs/commit/57f386b873bc2993d57354a18d017712cd6ab869))
-* 🎸 add mkdirSync() method ([bcad970](https://github.com/streamich/memfs/commit/bcad970b0b9cf704abf2e7ffd55d72078a54a79d))
-* 🎸 add mkdtempSync() method ([1ac2df4](https://github.com/streamich/memfs/commit/1ac2df458abe9c85f7fe79d6286cb7e5af5fb782))
-* 🎸 add mkdtempSync() method ([68033dd](https://github.com/streamich/memfs/commit/68033dd4ea2aa321511aaca4c939f3c9168cfab8))
-* 🎸 add options to promises.rmdir() method ([ce268bb](https://github.com/streamich/memfs/commit/ce268bbb36cfc0add349d02bad0554936a6bac10))
-* 🎸 add options to promises.rmdir() method ([0628d56](https://github.com/streamich/memfs/commit/0628d56cc9b708ae468d9d3795a3175e1017cd13))
-* 🎸 add pathToLocation() utility ([8e0136a](https://github.com/streamich/memfs/commit/8e0136a9fa777680ef47b60cc3cd7ae752e48e82))
-* 🎸 add pathToLocation() utility ([cb92a99](https://github.com/streamich/memfs/commit/cb92a9953d67aa32a8d6c6d40b1aa34be7b45c92))
-* 🎸 add read/write mode separation ([b4b6fcb](https://github.com/streamich/memfs/commit/b4b6fcb3478e596070126b5fa726a6e26832dde3))
-* 🎸 add read/write mode separation ([60a65c1](https://github.com/streamich/memfs/commit/60a65c1d835dd49a18ef8dfafa75cedb62f05f5a))
-* 🎸 add readdirSync() method ([2178a50](https://github.com/streamich/memfs/commit/2178a504f9f91928e86893af88883d444bc24fde))
-* 🎸 add readdirSync() method ([3689abd](https://github.com/streamich/memfs/commit/3689abdecfeb226c0ef822b83d689cb12610adc0))
-* 🎸 add readlinkSync() method ([f398908](https://github.com/streamich/memfs/commit/f398908b501b5728785105168bea739b13296a79))
-* 🎸 add readlinkSync() method ([8d243a0](https://github.com/streamich/memfs/commit/8d243a08ab6bbc72b83fac3a272c9f90a9817d33))
-* 🎸 add readSync() method ([31383a8](https://github.com/streamich/memfs/commit/31383a830678a900255cc52ee437b1770910d63c))
-* 🎸 add readSync() method ([3729cd0](https://github.com/streamich/memfs/commit/3729cd0ad53f73a03b7a2d224688c5a3d6602f5a))
-* 🎸 add realpathSync() method ([f9a3cbe](https://github.com/streamich/memfs/commit/f9a3cbe4b502e0d25423cfc3b022b82ea2711ca1))
-* 🎸 add realpathSync() method ([75890e0](https://github.com/streamich/memfs/commit/75890e0c44ce9673a58e85aba2f52dfdb02b9a5e))
-* 🎸 add renameSync() method ([a1674e4](https://github.com/streamich/memfs/commit/a1674e48a0fafac76e1288f7b1c7696323698c6e))
-* 🎸 add renameSync() method ([5b1cd63](https://github.com/streamich/memfs/commit/5b1cd63fd6bab83cefda64319bd77dc4ffb87c67))
-* 🎸 add rm() method ([239437d](https://github.com/streamich/memfs/commit/239437d088cdd8c20d01d261cb033b5eef8400fd))
-* 🎸 add rm() method ([29a7dc8](https://github.com/streamich/memfs/commit/29a7dc84feb5676abb470bab626268dd4f71a955))
-* 🎸 add rmdirSync() method ([59ccf3c](https://github.com/streamich/memfs/commit/59ccf3c95058558e9c25479824a08a06ce94bfdc))
-* 🎸 add rmdirSync() method ([695b62a](https://github.com/streamich/memfs/commit/695b62aa7e44fce5b420f1bc5c01c890d03c02d7))
-* 🎸 add rmSync() method ([a39e9a2](https://github.com/streamich/memfs/commit/a39e9a288bbc2ba6b92586b1e06dd0a56e39a69a))
-* 🎸 add rmSync() method ([aa9acb3](https://github.com/streamich/memfs/commit/aa9acb342afd9b81e9abac18aa434631353e73f9))
-* 🎸 add some common objects ([b68ea2a](https://github.com/streamich/memfs/commit/b68ea2ad0c36602ca0888086318e0d946cd66d27))
-* 🎸 add some common objects ([c89744d](https://github.com/streamich/memfs/commit/c89744de5284af50f16fa11496a0cf7c168766c7))
-* 🎸 add sync api ([29c035a](https://github.com/streamich/memfs/commit/29c035aa5f562966cdec32f24812925d9f93f2b5))
-* 🎸 add sync api ([16d6600](https://github.com/streamich/memfs/commit/16d6600258bc01be052b9cfddc5e20373852d5ef))
-* 🎸 add timeout to spin lock ([1e2fc72](https://github.com/streamich/memfs/commit/1e2fc72ab6cfc9b3007c9fcc515fb396aab5ed43))
-* 🎸 add timeout to spin lock ([48e8e74](https://github.com/streamich/memfs/commit/48e8e741fb7a67978a065afb63397bba6d461726))
-* 🎸 add truncateSync() and ftruncateSync() methods ([4caf28b](https://github.com/streamich/memfs/commit/4caf28b13990b95122eec5ac17177deb47f23144))
-* 🎸 add truncateSync() and ftruncateSync() methods ([2b77619](https://github.com/streamich/memfs/commit/2b77619f48f61521679c0fb9bd08068842969206))
-* 🎸 add typed array view support to volume ([a8bee73](https://github.com/streamich/memfs/commit/a8bee73fc9dfcb6cab9051e0f6aff4a38c4d9d06))
-* 🎸 add typed array view support to volume ([7c8439f](https://github.com/streamich/memfs/commit/7c8439f5f92cc0441b5420bdfdf7feedabb6fe4c))
-* 🎸 add unlinkSync() method ([4b3444d](https://github.com/streamich/memfs/commit/4b3444dd5ef16039cb48c02ebfd73f096aa92e8e))
-* 🎸 add unlinkSync() method ([417f911](https://github.com/streamich/memfs/commit/417f911334e195fe83e1171faf910ee0f28dea13))
-* 🎸 add writev() method ([17b0446](https://github.com/streamich/memfs/commit/17b0446953b86a0f2da777c6298554233f5edbdb))
-* 🎸 add writev() method ([8190bfd](https://github.com/streamich/memfs/commit/8190bfd07bb21a27c5515151a4c19036473a08c4))
-* 🎸 create FSA types folder ([c153506](https://github.com/streamich/memfs/commit/c153506cf6a43a2d3ed26716023e790b891c3a21))
-* 🎸 create FSA types folder ([bb0c75a](https://github.com/streamich/memfs/commit/bb0c75a48688d737054590d22a3b681a09b4f517))
-* 🎸 create Node fs api tyeps ([27fd08a](https://github.com/streamich/memfs/commit/27fd08a9e55201cdcf16c616a5fca4a44df282a9))
-* 🎸 create Node fs api tyeps ([4db1321](https://github.com/streamich/memfs/commit/4db13219aec4d6896d55012b83d7ea3f874f2dc1))
-* 🎸 explose FSA from index file ([6865a05](https://github.com/streamich/memfs/commit/6865a05b07ec3cfb83234b27418c0bd471db7fae))
-* 🎸 explose FSA from index file ([77696f5](https://github.com/streamich/memfs/commit/77696f5186ed419c161d529ffe3430371c8ce2ed))
-* 🎸 implement .del() method ([9a7fd37](https://github.com/streamich/memfs/commit/9a7fd37c1394f7757827fe6673344d4ee3dfbc8d))
-* 🎸 implement .drop() method ([1b893a2](https://github.com/streamich/memfs/commit/1b893a2e5039b50b1aa8d62e2c45e00c3e107108))
-* 🎸 implement .get() method ([63aacb6](https://github.com/streamich/memfs/commit/63aacb630eb07ade78b0fb29714b328e043ee939))
-* 🎸 implement .getDirectoryHandle() method ([b6b026a](https://github.com/streamich/memfs/commit/b6b026a2de121838c77a8f8137777a0c668cb272))
-* 🎸 implement .getDirectoryHandle() method ([090980c](https://github.com/streamich/memfs/commit/090980c1a1908fdab744582eae895b94a9887a52))
-* 🎸 implement .getFile() method ([b8601cc](https://github.com/streamich/memfs/commit/b8601cccd6ebbf61f5d61a767fc92d8fd93e0d73))
-* 🎸 implement .getFile() method ([17015a3](https://github.com/streamich/memfs/commit/17015a31559096e83100f01523a5d9cf005b0ddb))
-* 🎸 implement .getFileHandle() method ([71567c9](https://github.com/streamich/memfs/commit/71567c9d97b3c738cc425d4058b32cbc3d740952))
-* 🎸 implement .getFileHandle() method ([40bdc13](https://github.com/streamich/memfs/commit/40bdc13802545dbdd3384a1f14c85bd40ca1ba50))
-* 🎸 implement .isSameEntry() method ([f18d91e](https://github.com/streamich/memfs/commit/f18d91e7bed34de9ddad49333f6589cf71ea4d1c))
-* 🎸 implement .isSameEntry() method ([438806b](https://github.com/streamich/memfs/commit/438806b0f9b5c52d53ccd5c9bdf38fbd78185a56))
-* 🎸 implement .list() method ([4a064cf](https://github.com/streamich/memfs/commit/4a064cfc25d602271c121af5b0919bfcbc0ae66d))
-* 🎸 implement .mkdir method ([2623049](https://github.com/streamich/memfs/commit/2623049cfac984692986b4ea9ae49d0a8f741d39))
-* 🎸 implement .mkdir method ([be1525a](https://github.com/streamich/memfs/commit/be1525acff338c1cd12318bbcd936a20c5f1be63))
-* 🎸 implement .mkdtemp() method ([cd54e9b](https://github.com/streamich/memfs/commit/cd54e9ba0a8b297772c868964fe9659397cd725b))
-* 🎸 implement .mkdtemp() method ([2db4cd0](https://github.com/streamich/memfs/commit/2db4cd0420fbe6ced4bf4067010d5e9df386f38b))
-* 🎸 implement .removeEntry() method ([48617aa](https://github.com/streamich/memfs/commit/48617aadcc1e4e09eff45242736d2580f758d3fc))
-* 🎸 implement .removeEntry() method ([dca57a2](https://github.com/streamich/memfs/commit/dca57a2f60e5ef3784df055a1811cb6b252ceb15))
-* 🎸 implement .resolve() method ([bf47b96](https://github.com/streamich/memfs/commit/bf47b96cec4b1c8c029a0668ea4c1cf8cbcd05f0))
-* 🎸 implement .resolve() method ([9d5669c](https://github.com/streamich/memfs/commit/9d5669cc30b295c2e5e9b7373a8409cf0dd34bd3))
-* 🎸 implement .values() and .entries() ([f13de3b](https://github.com/streamich/memfs/commit/f13de3b65631a2c8ab8776a19b894c120b2b373a))
-* 🎸 implement .values() and .entries() ([177010a](https://github.com/streamich/memfs/commit/177010a266582cb1552faafcee51a99f737e9d86))
-* 🎸 implement .write() for FSA ([8226541](https://github.com/streamich/memfs/commit/8226541458346861966a78e668afda43f055f07a))
-* 🎸 implement .write() for FSA ([6a2fa2d](https://github.com/streamich/memfs/commit/6a2fa2d58c70ce2e410d60336d9f04a74e9f3dec))
-* 🎸 implement access() method ([0a43a1b](https://github.com/streamich/memfs/commit/0a43a1b586cb551c3ce861f7cfbf0876cabc8852))
-* 🎸 implement access() method ([c72390b](https://github.com/streamich/memfs/commit/c72390b66b3bb9bf0585896f8d41cd89d8cc4be2))
-* 🎸 implement accessSync() method ([accebdb](https://github.com/streamich/memfs/commit/accebdb16ea25da4f92908fecf7272f1d30885e5))
-* 🎸 implement accessSync() method ([719a19f](https://github.com/streamich/memfs/commit/719a19f2cd0c177bb076e58d02e59f888d433e31))
-* 🎸 implement basic readdir ([898e221](https://github.com/streamich/memfs/commit/898e22164e668f2efa4388f397e50a8a2e66919d))
-* 🎸 implement basic readdir ([685bc7e](https://github.com/streamich/memfs/commit/685bc7e90d6a490fdc017e3f3d03325384f76b1a))
-* 🎸 implement basic rename() method, only for files ([169662a](https://github.com/streamich/memfs/commit/169662a35ca06884161096ebc669fba30402153f))
-* 🎸 implement basic rename() method, only for files ([4769314](https://github.com/streamich/memfs/commit/476931487f4105982f4825f808bbc8d9cd4c2df7))
-* 🎸 implement basic state() method ([425cad7](https://github.com/streamich/memfs/commit/425cad7e2d482bf5ff084ac8fcac5ee5da51840d))
-* 🎸 implement basic state() method ([4039d64](https://github.com/streamich/memfs/commit/4039d64822a9c7d2c1137f045433f498e6503761))
-* 🎸 implement closeSync() method ([24841fa](https://github.com/streamich/memfs/commit/24841fa7bc2c9ff292204ff1dfd5cef4804dc270))
-* 🎸 implement closeSync() method ([646efaf](https://github.com/streamich/memfs/commit/646efafe2355794509d67ef13e59e5afcbbae8ae))
-* 🎸 implement crudfs .put() method ([505dc20](https://github.com/streamich/memfs/commit/505dc205a50e705429f0594cd32d0c6d08016db3))
-* 🎸 implement Dirent listings ([03e60d0](https://github.com/streamich/memfs/commit/03e60d0d607d859b654d4c6d4569f012f53950c2))
-* 🎸 implement Dirent listings ([5d6f976](https://github.com/streamich/memfs/commit/5d6f97687b5f6bb297ad852f9e0ee7d6cf3e09f0))
-* 🎸 implement exists() method ([615e88f](https://github.com/streamich/memfs/commit/615e88f4f88827a171630ca1bd90b49d6e1d054a))
-* 🎸 implement exists() method ([0753937](https://github.com/streamich/memfs/commit/07539377eeb168140cc80087d23279fde92d18fc))
-* 🎸 implement first version of readFile() method ([e046128](https://github.com/streamich/memfs/commit/e04612879906771ef447319893d450492ce39908))
-* 🎸 implement first version of readFile() method ([629f22a](https://github.com/streamich/memfs/commit/629f22a28eda1da04f28659994015094aa7fca39))
-* 🎸 implement first version of worker ([3662003](https://github.com/streamich/memfs/commit/366200346ffb9494437b78ca6e7d9a8529628427))
-* 🎸 implement first version of worker ([caf8394](https://github.com/streamich/memfs/commit/caf8394ab23b9df40b92c157b791b2658958f1a9))
-* 🎸 implement FSA ReadStream ([bc50fc5](https://github.com/streamich/memfs/commit/bc50fc5c4c3a4cca1ffc53abcd81e5f662f531ea))
-* 🎸 implement FSA ReadStream ([53784d9](https://github.com/streamich/memfs/commit/53784d9a873fad94bdd62c81be0a1fa3ef494618))
-* 🎸 implement info() method ([eea9215](https://github.com/streamich/memfs/commit/eea921552a711d3d588f0b08e1ae44e73a91618e))
-* 🎸 implement initial version of .open() mehtod ([b3983df](https://github.com/streamich/memfs/commit/b3983df908a34f84a262723833009c908798bfb2))
-* 🎸 implement initial version of .open() mehtod ([cb363b5](https://github.com/streamich/memfs/commit/cb363b51451e157d95fcf0373293a9e9e4ca2504))
-* 🎸 implement initial version of appendFile() method ([616be8d](https://github.com/streamich/memfs/commit/616be8db702ed75f5ed280de1a608fb7585ddb93))
-* 🎸 implement initial version of appendFile() method ([65580aa](https://github.com/streamich/memfs/commit/65580aa0ecc050cc9a9412afc913679d029591ae))
-* 🎸 implement initial version of writeFile() method ([80e8499](https://github.com/streamich/memfs/commit/80e84994e05517555a56394b99dfb3a135c8ea0a))
-* 🎸 implement initial version of writeFile() method ([e2b2bfd](https://github.com/streamich/memfs/commit/e2b2bfdfc6aafba9e08d94a3a05886f0d9bc033a))
-* 🎸 implement keys() method ([33f9af0](https://github.com/streamich/memfs/commit/33f9af06a64958299882646b940a1be9b7bfd7b5))
-* 🎸 implement keys() method ([b8e8a4c](https://github.com/streamich/memfs/commit/b8e8a4c3d0e76c4f07450599f68061c5cc0a35a6))
-* 🎸 implement openSync() method ([8ecac69](https://github.com/streamich/memfs/commit/8ecac695797fe995557fb36c91c3b7d767e27770))
-* 🎸 implement openSync() method ([4b7eddd](https://github.com/streamich/memfs/commit/4b7edddf7ae430d5e4dad146b3cae74dcda73420))
-* 🎸 implement read() method ([33bea4b](https://github.com/streamich/memfs/commit/33bea4bb43fd16f49fea8407aa5e4b4fab9b6523))
-* 🎸 implement read() method ([7357c14](https://github.com/streamich/memfs/commit/7357c14289c53d5c8dc13f38c1c696f01245b9a0))
-* 🎸 implement readFileSync() method ([3a1b737](https://github.com/streamich/memfs/commit/3a1b737896fcc0b4a6b5c349b25b7e9dd45f8dd0))
-* 🎸 implement readFileSync() method ([2a07e34](https://github.com/streamich/memfs/commit/2a07e34edf20c6bc29fe1bb88827d0bace45bf57))
-* 🎸 implement readFileSync() method ([953d276](https://github.com/streamich/memfs/commit/953d27640da965cc76b85b41db75fbb36c35c093))
-* 🎸 implement readFileSync() method ([bb803e2](https://github.com/streamich/memfs/commit/bb803e267a6e0c0442a08fcdfdae75f3e7806ad8))
-* 🎸 implement realpath() method ([458a7b2](https://github.com/streamich/memfs/commit/458a7b285cc0403563673d90557908d4057ed39f))
-* 🎸 implement realpath() method ([99b12dc](https://github.com/streamich/memfs/commit/99b12dcf752aeca16e7990ead1fdc63b6b29e004))
-* 🎸 implement rmdir() method ([c06734b](https://github.com/streamich/memfs/commit/c06734b332abbb81c918e85121bfb304bb003350))
-* 🎸 implement rmdir() method ([973af0a](https://github.com/streamich/memfs/commit/973af0a0786db30b3e73bd113fab516ccf6fa3ed))
-* 🎸 implement sync messenger ([1c0bd59](https://github.com/streamich/memfs/commit/1c0bd59877c59ae83c5ba7b1acd3b6d6ea2564bd))
-* 🎸 implement sync messenger ([d221870](https://github.com/streamich/memfs/commit/d221870f475d49e251a8cb9bdd41b9b31d2036f4))
-* 🎸 implement sync write method ([3017ecd](https://github.com/streamich/memfs/commit/3017ecd2729fbbd169860ada11fc2180a03fecd5))
-* 🎸 implement sync write method ([22047da](https://github.com/streamich/memfs/commit/22047dad02f7c6558f3aea1d296b0574c92e8095))
-* 🎸 implement truncation ([af8452e](https://github.com/streamich/memfs/commit/af8452e259f03f0330d80ed2caf30f2f9c0833db))
-* 🎸 implement truncation ([d4469d1](https://github.com/streamich/memfs/commit/d4469d1650754f44107e13e447a9b95d581573e5))
-* 🎸 implement unlink() method ([e11a383](https://github.com/streamich/memfs/commit/e11a383bef0523777f9979536dfdc4e293e669fb))
-* 🎸 implement unlink() method ([6bd3e75](https://github.com/streamich/memfs/commit/6bd3e75fa379bfc3abbb1ff14d6975a87ece9f34))
-* 🎸 implement writeSync() method ([7a2fced](https://github.com/streamich/memfs/commit/7a2fced74c8aa1a80b7077b99707869dbac2f72c))
-* 🎸 implement writeSync() method ([12a8b3f](https://github.com/streamich/memfs/commit/12a8b3f442183460ea9b76cf5443bcaff4e63680))
-* 🎸 improve mkdir method ([e6dd59e](https://github.com/streamich/memfs/commit/e6dd59ef75c871792f3605235d7c3280757f88dc))
-* 🎸 improve mkdir method ([c393f6c](https://github.com/streamich/memfs/commit/c393f6c47ae55f93d4b363b0d1a43115ebd61b6a))
-* 🎸 improve read stream interfaces ([7e1a844](https://github.com/streamich/memfs/commit/7e1a844f82fd4f14cc6a3da26b1b626ef96259e0))
-* 🎸 improve read stream interfaces ([6d5de0c](https://github.com/streamich/memfs/commit/6d5de0c6f2edfc44e7f25af4c836aed8bf72f5e2))
-* 🎸 improve stat method ([505a1d9](https://github.com/streamich/memfs/commit/505a1d96c6a4026ce42e97f490877ebdff6a33d7))
-* 🎸 improve stat method ([c6eeab4](https://github.com/streamich/memfs/commit/c6eeab42fdfe700863ed35e7e6f5c19a86c75c79))
-* 🎸 improve write stream, better flag handling ([531f2a7](https://github.com/streamich/memfs/commit/531f2a77ed1d35e4504543b735be28bf05d8ce2e))
-* 🎸 improve write stream, better flag handling ([7b9e0a3](https://github.com/streamich/memfs/commit/7b9e0a319ee14b13988655e7a0d52bc788119e33))
-* 🎸 improve writing at offset logic ([71b6afc](https://github.com/streamich/memfs/commit/71b6afc81b8940c20183fc9c56b8132ee1bd4ed8))
-* 🎸 improve writing at offset logic ([392932a](https://github.com/streamich/memfs/commit/392932a6e95224abb30dca8ee54790448411a39b))
-* 🎸 improve writing to file ([1de0e06](https://github.com/streamich/memfs/commit/1de0e06365e612a2a8ad3a802dd12022bf4d3858))
-* 🎸 improve writing to file ([3edcac1](https://github.com/streamich/memfs/commit/3edcac1c992c3bd7edaca793024c50934dbc028d))
-* 🎸 include "writeSync" method for sync writer ([b267f78](https://github.com/streamich/memfs/commit/b267f7838516f7d6ed482ab510d71fedef6dd258))
-* 🎸 include "writeSync" method for sync writer ([b006b2d](https://github.com/streamich/memfs/commit/b006b2d9ca040a674e02d80defd884ce54f5e725))
-* 🎸 introduce FSA context ([f603262](https://github.com/streamich/memfs/commit/f603262a0ff49482b8f566edc2277ffb01f077e4))
-* 🎸 introduce FSA context ([b696e09](https://github.com/streamich/memfs/commit/b696e09c426494ae54ace33d738523da9410ef13))
-* 🎸 make basic WriteStream work ([69281ff](https://github.com/streamich/memfs/commit/69281ff284934f47d6f0e509c293d6032380a953))
-* 🎸 make basic WriteStream work ([c109af1](https://github.com/streamich/memfs/commit/c109af189e1410789af62861c91dc032248fc31f))
-* 🎸 make statSync() resolve the path correctly ([0d81728](https://github.com/streamich/memfs/commit/0d81728f43bd4c25b4255172cb226c169d63c950))
-* 🎸 make statSync() resolve the path correctly ([7801533](https://github.com/streamich/memfs/commit/78015333c698c105313aaa02f2ecfbeaae6ba339))
-* 🎸 normalize adapter rpc ([16ce2cf](https://github.com/streamich/memfs/commit/16ce2cf6f22fe9841e1af98aa583ea1fdd968ef7))
-* 🎸 normalize adapter rpc ([96b8374](https://github.com/streamich/memfs/commit/96b8374542ecc07294afe94bb18c77d4e7cb0fec))
-* 🎸 progress on writable stream ([a568afd](https://github.com/streamich/memfs/commit/a568afd37a5cdfa91945c4074a8b326d391f0573))
-* 🎸 progress on writable stream ([9900423](https://github.com/streamich/memfs/commit/9900423ff6a91449bc0bf51c265013b390df378d))
-* 🎸 setup fsa to node utility ([50d2a1e](https://github.com/streamich/memfs/commit/50d2a1ee8214a2ae379687e39d56d8076fe6f5ef))
-* 🎸 setup fsa to node utility ([5fa0d61](https://github.com/streamich/memfs/commit/5fa0d61dfedc7234315030e84276763546ee7733))
-* 🎸 setup node-to-fsa folder ([a4268c6](https://github.com/streamich/memfs/commit/a4268c65c5893083ae154861937212e3d87cafc0))
-* 🎸 setup node-to-fsa folder ([16e78e3](https://github.com/streamich/memfs/commit/16e78e35807a12d601d4a6cb8b22e882918a871a))
-* 🎸 setup webfs ([68f0014](https://github.com/streamich/memfs/commit/68f0014025f5fba9c11a0fa84e462cd7f3804fa3))
-* 🎸 setup webfs ([99c915f](https://github.com/streamich/memfs/commit/99c915f632d0f22533a828069c082dec6d378e3c))
-* 🎸 standartize message contents ([b3254f6](https://github.com/streamich/memfs/commit/b3254f6b32b3e06ccd323019f5812c0f583d8dd3))
-* 🎸 standartize message contents ([c254dc7](https://github.com/streamich/memfs/commit/c254dc7e75756c81761c7125698a1ded0c1f40b0))
-* 🎸 start synchronous file handle implementation ([f9b0f73](https://github.com/streamich/memfs/commit/f9b0f7302f789d0e658cb4db252be042f1e201bc))
-* 🎸 start synchronous file handle implementation ([d05c407](https://github.com/streamich/memfs/commit/d05c4074eac562b72fe0f04142c43e05fcc7d70c))
-* 🎸 start WriteStream implementation ([5971c39](https://github.com/streamich/memfs/commit/5971c391ebf12b357ac8ed083c530a616fd34f49))
-* 🎸 start WriteStream implementation ([32e13a8](https://github.com/streamich/memfs/commit/32e13a80863424ead759b64e0e35af20762ef849))
-* 🎸 throw exception on closed files ([7adff27](https://github.com/streamich/memfs/commit/7adff27ebd7d455efea7d25b988a01a0d6e4e0c6))
-* 🎸 throw exception on closed files ([5119b8f](https://github.com/streamich/memfs/commit/5119b8f1bfd33ff7a289e522bb2fb3198391a5d7))
-* 🎸 track number of written bytes ([b80f7b7](https://github.com/streamich/memfs/commit/b80f7b71d80c50c9d50ae5abf1c8409127f329de))
-* 🎸 track number of written bytes ([7a65daa](https://github.com/streamich/memfs/commit/7a65daa7cb9c12750f7174192fffcf81d8ae8dc5))
-* 🎸 write through a swap file ([84cecec](https://github.com/streamich/memfs/commit/84cecec3a78e35a854ce4ba9975e08b39d962751))
-* 🎸 write through a swap file ([5134766](https://github.com/streamich/memfs/commit/513476627892d2e5a3b32f11ad12303eb712cc34))
-
 # [4.1.0](https://github.com/streamich/memfs/compare/v4.0.0...v4.1.0) (2023-06-20)
 
 
diff --git a/README.md b/README.md
index 1c8a6baf..36cbca6e 100644
--- a/README.md
+++ b/README.md
@@ -33,19 +33,20 @@ npm i memfs
 - [Git on in-memory file system](demo/git/README.md)
 - [`fs` in browser, creates a `.tar` file in real folder](demo/fsa-to-node-zipfile/README.md)
 - [`fs` in browser, synchronous API, writes to real folder](demo/fsa-to-node-sync-tests/README.md)
+- [`crudfs` and `casfs` in browser and Node.js interoperability](demo/crud-and-cas/README.md)
 
 
 ## See also
 
-- [`spyfs`][spyfs] - spies on filesystem actions
 - [`unionfs`][unionfs] - creates a union of multiple filesystem volumes
-- [`linkfs`][linkfs] - redirects filesystem paths
 - [`fs-monkey`][fs-monkey] - monkey-patches Node's `fs` module and `require` function
+- [`linkfs`][linkfs] - redirects filesystem paths
+- [`spyfs`][spyfs] - spies on filesystem actions
 
-[spyfs]: https://github.com/streamich/spyfs
 [unionfs]: https://github.com/streamich/unionfs
-[linkfs]: https://github.com/streamich/linkfs
 [fs-monkey]: https://github.com/streamich/fs-monkey
+[linkfs]: https://github.com/streamich/linkfs
+[spyfs]: https://github.com/streamich/spyfs
 
 
 ## License
diff --git a/demo/crud-and-cas/README.md b/demo/crud-and-cas/README.md
new file mode 100644
index 00000000..da74ceb4
--- /dev/null
+++ b/demo/crud-and-cas/README.md
@@ -0,0 +1,16 @@
+This demo showcases `crudfs` and `casfs` interoperability in browser and Node.js.
+
+First, in browser an object is stored into `casfs` (Content Addressable Storage)
+and its hash (CID) is persisted using `crudfs`, in a real user folder.
+
+Then, from Node.js, the CID is retrieved using `crudfs` and the object is read
+using `casfs`.
+
+https://github.com/streamich/memfs/assets/9773803/02ba339c-6e13-4712-a02f-672674300d27
+
+
+Run:
+
+```
+yarn demo:git-fsa
+```
diff --git a/demo/crud-and-cas/main.ts b/demo/crud-and-cas/main.ts
new file mode 100644
index 00000000..03222f4c
--- /dev/null
+++ b/demo/crud-and-cas/main.ts
@@ -0,0 +1,44 @@
+import { FsaCrud } from '../../src/fsa-to-crud';
+import { CrudCas } from '../../src/crud-to-cas';
+import type * as fsa from '../../src/fsa/types';
+
+const hash = async (data: Uint8Array): Promise<string> => {
+  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
+  const hashArray = Array.from(new Uint8Array(hashBuffer));
+  const hashHex = hashArray
+    .map((b) => b.toString(16).padStart(2, "0"))
+    .join("");
+  return hashHex;
+};
+
+const demo = async (dir: fsa.IFileSystemDirectoryHandle) => {
+  try {
+
+    const crud = new FsaCrud(dir);
+    const cas = new CrudCas(await crud.from(['objects']), {hash});
+
+    // Store "Hello, world!" in object storage
+    const cid = await cas.put(new TextEncoder()
+      .encode('Hello, world!'));
+
+    // Store the CID in the refs/heads/main.txt file
+    await crud.put(['refs', 'heads'], 'main.txt',
+      new TextEncoder().encode(cid));
+
+  } catch (error) {
+    console.log(error);
+    console.log((<any>error).name);
+  }
+};
+
+const main = async () => {
+  const button = document.createElement("button");
+  button.textContent = "Select an empty folder";
+  document.body.appendChild(button);
+  button.onclick = async () => {
+    const dir = await (window as any).showDirectoryPicker({id: 'demo', mode: 'readwrite'});
+    await demo(dir);
+  };
+};
+
+main();
diff --git a/demo/crud-and-cas/node.ts b/demo/crud-and-cas/node.ts
new file mode 100644
index 00000000..11150779
--- /dev/null
+++ b/demo/crud-and-cas/node.ts
@@ -0,0 +1,34 @@
+// Run: npx ts-node demo/crud-and-cas/node.ts
+
+import { NodeCrud } from '../../src/node-to-crud';
+import { CrudCas } from '../../src/crud-to-cas';
+import * as fs from 'fs';
+const root = require('app-root-path');
+const path = require('path');
+
+const hash = async (data: Uint8Array): Promise<string> => {
+  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
+  const hashArray = Array.from(new Uint8Array(hashBuffer));
+  const hashHex = hashArray
+    .map((b) => b.toString(16).padStart(2, "0"))
+    .join("");
+  return hashHex;
+};
+
+const main = async () => {
+  const dir = path.resolve(root.path, 'fs-test');
+  const crud = new NodeCrud({fs: <any>fs.promises, dir});
+  const cas = new CrudCas(await crud.from(['objects']), {hash});
+
+  // Retrieve the CID from the refs/heads/main.txt file
+  const cid = await crud.get(['refs', 'heads'], 'main.txt');
+  const cidText = Buffer.from(cid).toString();
+  console.log('CID:', cidText);
+
+  // Retrieve the data from the object storage
+  const data = await cas.get(cidText);
+  const dataText = Buffer.from(data).toString();
+  console.log('Content:', dataText);
+};
+
+main();
diff --git a/demo/crud-and-cas/webpack.config.js b/demo/crud-and-cas/webpack.config.js
new file mode 100644
index 00000000..e150ba1d
--- /dev/null
+++ b/demo/crud-and-cas/webpack.config.js
@@ -0,0 +1,36 @@
+const path = require('path');
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const root = require('app-root-path');
+
+module.exports = {
+  mode: 'development',
+  devtool: 'inline-source-map',
+  entry: {
+    bundle: __dirname + '/main',
+  },
+  plugins: [
+    new HtmlWebpackPlugin({
+      title: 'Development',
+    }),
+  ],
+  module: {
+    rules: [
+      {
+        test: /\.tsx?$/,
+        exclude: /node_modules/,
+        loader: 'ts-loader',
+      },
+    ],
+  },
+  resolve: {
+    extensions: ['.tsx', '.ts', '.js'],
+  },
+  output: {
+    filename: '[name].js',
+    path: path.resolve(root.path, 'dist'),
+  },
+  devServer: {
+    port: 9876,
+    hot: false,
+  },
+};
diff --git a/docs/crudfs/index.md b/docs/crudfs/index.md
index c8a676e2..997686cd 100644
--- a/docs/crudfs/index.md
+++ b/docs/crudfs/index.md
@@ -58,6 +58,18 @@ await crud.drop(['user', 'files']);
 
 ### `crudfs` from Node.js `fs` module
 
+You can run `crudfs` on top of Node.js `fs` module like so:
+
+```js
+import * as fs from 'fs';
+import { NodeCrud } from 'memfs/lib/node-to-crud';
+
+const crud = new NodeCrud({fs: fs.promises, dir: '/path/to/folder'});
+```
+
+
+#### Indirectly with FAS in-between
+
 You can run `crudfs` on top of Node.js `fs` module by using File System Access API
 adapter on top of the  Node.js `fs` module:
 
diff --git a/docs/fsa/fs-to-fsa.md b/docs/fsa/fs-to-fsa.md
index fa043e80..61a3014d 100644
--- a/docs/fsa/fs-to-fsa.md
+++ b/docs/fsa/fs-to-fsa.md
@@ -1,11 +1,10 @@
 # Node `fs` API to File System Access API
 
 `memfs` implements the web [File System Access (FSA) API][fsa] (formerly known as
-Native File System API) on top of Node's `fs`-like filesystem API.
-
-This means you can instantiate an FSA-compatible API on top of Node.js `fs` module,
-or on top of `memfs` [in-memory filesystem](../node/index.md), or on top of any other filesystem
-that implements Node's `fs` API.
+Native File System API) on top of Node's `fs`-like filesystem API. This means you
+can instantiate an FSA-compatible API on top of Node.js `fs` module,
+or on top of `memfs` [in-memory filesystem](../node/index.md), or on top of any
+other filesystem that implements Node's `fs` API.
 
 [fsa]: https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API
 
@@ -20,10 +19,10 @@ import { nodeToFsa } from 'memfs/lib/node-to-fsa';
 const dir = nodeToFsa(fs, '/path/to/folder', {mode: 'readwrite'});
 ```
 
-The `fs` Node filesystem API can be the real `fs` module or any, for example,
+The `fs` Node filesystem API can be the real `fs` module or any `fs` like object, for example,
 an in-memory one provided by `memfs`.
 
-Now you can use the `dir` handle to execute any of the File System Access API
+Now you can use the `dir` handle to execute all the File System Access API
 methods, for example, create a new file:
 
 ```js
diff --git a/package.json b/package.json
index 61569a02..0e5b81c1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "memfs",
-  "version": "4.1.0-next.1",
+  "version": "4.1.0",
   "description": "In-memory file-system with Node's fs API.",
   "keywords": [
     "fs",
@@ -44,7 +44,8 @@
     "demo:fsa-to-node-sync-tests": "webpack serve --config ./demo/fsa-to-node-sync-tests/webpack.config.js",
     "demo:fsa-to-node-zipfile": "webpack serve --config ./demo/fsa-to-node-zipfile/webpack.config.js",
     "demo:git-fsa": "webpack serve --config ./demo/git-fsa/webpack.config.js",
-    "demo:git-opfs": "webpack serve --config ./demo/git-opfs/webpack.config.js"
+    "demo:git-opfs": "webpack serve --config ./demo/git-opfs/webpack.config.js",
+    "demo:crud-and-cas": "webpack serve --config ./demo/crud-and-cas/webpack.config.js"
   },
   "commitlint": {
     "extends": [
diff --git a/src/consts/FLAG.ts b/src/consts/FLAG.ts
index a9940e8d..4ad76c11 100644
--- a/src/consts/FLAG.ts
+++ b/src/consts/FLAG.ts
@@ -1,4 +1,9 @@
-// Constants used in `open` system calls, see [open(2)](http://man7.org/linux/man-pages/man2/open.2.html).
+/**
+ * Constants used in `open` system calls, see [open(2)](http://man7.org/linux/man-pages/man2/open.2.html).
+ *
+ * @see http://man7.org/linux/man-pages/man2/open.2.html
+ * @see https://www.gnu.org/software/libc/manual/html_node/Open_002dtime-Flags.html
+ */
 export const enum FLAG {
   O_RDONLY = 0,
   O_WRONLY = 1,
diff --git a/src/crud-to-cas/index.ts b/src/crud-to-cas/index.ts
new file mode 100644
index 00000000..21d4ae5e
--- /dev/null
+++ b/src/crud-to-cas/index.ts
@@ -0,0 +1,1 @@
+export { CrudCas, CrudCasOptions } from './CrudCas';
diff --git a/src/crud/types.ts b/src/crud/types.ts
index e75cb8c0..13a100c8 100644
--- a/src/crud/types.ts
+++ b/src/crud/types.ts
@@ -53,6 +53,14 @@ export interface CrudApi {
    * @returns List of resources of the given type, and sub-types.
    */
   list: (collection: CrudCollection) => Promise<CrudCollectionEntry[]>;
+
+  /**
+   * Creates a new CrudApi instance, with the given collection as root.
+   *
+   * @param collection Type of the resource, collection name.
+   * @returns A new CrudApi instance, with the given collection as root.
+   */
+  from: (collection: CrudCollection) => Promise<CrudApi>;
 }
 
 export type CrudCollection = string[];
diff --git a/src/crud/util.ts b/src/crud/util.ts
new file mode 100644
index 00000000..a009bc34
--- /dev/null
+++ b/src/crud/util.ts
@@ -0,0 +1,7 @@
+import { CrudCollection } from './types';
+import { assertName } from '../node-to-fsa/util';
+
+export const assertType = (type: CrudCollection, method: string, klass: string): void => {
+  const length = type.length;
+  for (let i = 0; i < length; i++) assertName(type[i], method, klass);
+};
diff --git a/src/fsa-to-crud/FsaCrud.ts b/src/fsa-to-crud/FsaCrud.ts
index c45fc1bc..9108ac64 100644
--- a/src/fsa-to-crud/FsaCrud.ts
+++ b/src/fsa-to-crud/FsaCrud.ts
@@ -1,7 +1,8 @@
 import type * as crud from '../crud/types';
 import type * as fsa from '../fsa/types';
 import { assertName } from '../node-to-fsa/util';
-import { assertType } from './util';
+import { assertType } from '../crud/util';
+import { newExistsError, newFile404Error, newFolder404Error, newMissingError } from './util';
 
 export class FsaCrud implements crud.CrudApi {
   public constructor(
@@ -22,8 +23,7 @@ export class FsaCrud implements crud.CrudApi {
       }
       return [dir, parent];
     } catch (error) {
-      if (error.name === 'NotFoundError')
-        throw new DOMException(`Collection /${collection.join('/')} does not exist`, 'CollectionNotFound');
+      if (error.name === 'NotFoundError') throw newFolder404Error(collection);
       throw error;
     }
   }
@@ -37,8 +37,7 @@ export class FsaCrud implements crud.CrudApi {
       const file = await dir.getFileHandle(id, { create: false });
       return [dir, file];
     } catch (error) {
-      if (error.name === 'NotFoundError')
-        throw new DOMException(`Resource "${id}" in /${collection.join('/')} not found`, 'ResourceNotFound');
+      if (error.name === 'NotFoundError') throw newFile404Error(collection, id);
       throw error;
     }
   }
@@ -57,7 +56,7 @@ export class FsaCrud implements crud.CrudApi {
       case 'exists': {
         try {
           file = await dir.getFileHandle(id, { create: false });
-          throw new DOMException('Resource already exists', 'Exists');
+          throw newExistsError();
         } catch (e) {
           if (e.name !== 'NotFoundError') throw e;
           file = await dir.getFileHandle(id, { create: true });
@@ -68,7 +67,7 @@ export class FsaCrud implements crud.CrudApi {
         try {
           file = await dir.getFileHandle(id, { create: false });
         } catch (e) {
-          if (e.name === 'NotFoundError') throw new DOMException('Resource is missing', 'Missing');
+          if (e.name === 'NotFoundError') throw newMissingError();
           throw e;
         }
         break;
@@ -157,4 +156,10 @@ export class FsaCrud implements crud.CrudApi {
     }
     return entries;
   };
+
+  public readonly from = async (collection: crud.CrudCollection): Promise<crud.CrudApi> => {
+    assertType(collection, 'from', 'crudfs');
+    const [dir] = await this.getDir(collection, true);
+    return new FsaCrud(dir);
+  };
 }
diff --git a/src/fsa-to-crud/index.ts b/src/fsa-to-crud/index.ts
new file mode 100644
index 00000000..8b36fd97
--- /dev/null
+++ b/src/fsa-to-crud/index.ts
@@ -0,0 +1,1 @@
+export { FsaCrud } from './FsaCrud';
diff --git a/src/fsa-to-crud/util.ts b/src/fsa-to-crud/util.ts
index a112c936..4964c8c8 100644
--- a/src/fsa-to-crud/util.ts
+++ b/src/fsa-to-crud/util.ts
@@ -1,7 +1,11 @@
-import { CrudCollection } from '../crud/types';
-import { assertName } from '../node-to-fsa/util';
+import type * as crud from '../crud/types';
 
-export const assertType = (type: CrudCollection, method: string, klass: string): void => {
-  const length = type.length;
-  for (let i = 0; i < length; i++) assertName(type[i], method, klass);
-};
+export const newFile404Error = (collection: crud.CrudCollection, id: string) =>
+  new DOMException(`Resource "${id}" in /${collection.join('/')} not found`, 'ResourceNotFound');
+
+export const newFolder404Error = (collection: crud.CrudCollection) =>
+  new DOMException(`Collection /${collection.join('/')} does not exist`, 'CollectionNotFound');
+
+export const newExistsError = () => new DOMException('Resource already exists', 'Exists');
+
+export const newMissingError = () => new DOMException('Resource is missing', 'Missing');
diff --git a/src/fsa-to-node/FsaNodeCore.ts b/src/fsa-to-node/FsaNodeCore.ts
index 6da020b0..a03e5bd5 100644
--- a/src/fsa-to-node/FsaNodeCore.ts
+++ b/src/fsa-to-node/FsaNodeCore.ts
@@ -4,6 +4,7 @@ import { ERRSTR } from '../node/constants';
 import { FsaToNodeConstants } from './constants';
 import { FsaNodeFsOpenFile } from './FsaNodeFsOpenFile';
 import { FLAG } from '../consts/FLAG';
+import * as util from '../node/util';
 import type * as fsa from '../fsa/types';
 import type * as misc from '../node/types/misc';
 import type { FsaNodeSyncAdapter } from './types';
@@ -82,9 +83,9 @@ export class FsaNodeCore {
     path: string[],
     name: string,
     funcName?: string,
-    create?: boolean,
   ): Promise<fsa.IFileSystemFileHandle | fsa.IFileSystemDirectoryHandle> {
     const dir = await this.getDir(path, false, funcName);
+    if (!name) return dir;
     try {
       const file = await dir.getFileHandle(name);
       return file;
@@ -123,15 +124,11 @@ export class FsaNodeCore {
     return this.getFileByFd(fd, funcName);
   }
 
-  public async __getFileById(
-    id: misc.TFileId,
-    funcName?: string,
-    create?: boolean,
-  ): Promise<fsa.IFileSystemFileHandle> {
+  public async __getFileById(id: misc.TFileId, funcName?: string): Promise<fsa.IFileSystemFileHandle> {
     if (typeof id === 'number') return (await this.getFileByFd(id, funcName)).file;
     const filename = pathToFilename(id);
     const [folder, name] = pathToLocation(filename);
-    return await this.getFile(folder, name, funcName, create);
+    return await this.getFile(folder, name, funcName);
   }
 
   protected async getFileByIdOrCreate(id: misc.TFileId, funcName?: string): Promise<fsa.IFileSystemFileHandle> {
@@ -144,9 +141,42 @@ export class FsaNodeCore {
 
   protected async __open(filename: string, flags: number, mode: number): Promise<FsaNodeFsOpenFile> {
     const [folder, name] = pathToLocation(filename);
-    const createIfMissing = !!(flags & FLAG.O_CREAT);
-    const fsaFile = await this.getFile(folder, name, 'open', createIfMissing);
-    return this.__open2(fsaFile, filename, flags, mode);
+    const throwIfExists = !!(flags & FLAG.O_EXCL);
+    if (throwIfExists) {
+      try {
+        await this.getFile(folder, name, 'open', false);
+        throw util.createError('EEXIST', 'writeFile');
+      } catch (error) {
+        const file404 =
+          error && typeof error === 'object' && (error.code === 'ENOENT' || error.name === 'NotFoundError');
+        if (!file404) {
+          if (error && typeof error === 'object') {
+            switch (error.name) {
+              case 'TypeMismatchError':
+                throw createError('ENOTDIR', 'open', filename);
+              case 'NotFoundError':
+                throw createError('ENOENT', 'open', filename);
+            }
+          }
+          throw error;
+        }
+      }
+    }
+    try {
+      const createIfMissing = !!(flags & FLAG.O_CREAT);
+      const fsaFile = await this.getFile(folder, name, 'open', createIfMissing);
+      return this.__open2(fsaFile, filename, flags, mode);
+    } catch (error) {
+      if (error && typeof error === 'object') {
+        switch (error.name) {
+          case 'TypeMismatchError':
+            throw createError('ENOTDIR', 'open', filename);
+          case 'NotFoundError':
+            throw createError('ENOENT', 'open', filename);
+        }
+      }
+      throw error;
+    }
   }
 
   protected __open2(
@@ -161,6 +191,13 @@ export class FsaNodeCore {
     return file;
   }
 
+  protected async __close(fd: number): Promise<void> {
+    const openFile = await this.getFileByFdAsync(fd, 'close');
+    await openFile.close();
+    const deleted = this.fds.delete(fd);
+    if (deleted) this.releasedFds.push(fd);
+  }
+
   protected getFileName(id: misc.TFileId): string {
     if (typeof id === 'number') {
       const openFile = this.fds.get(id);
diff --git a/src/fsa-to-node/FsaNodeFs.ts b/src/fsa-to-node/FsaNodeFs.ts
index 5c3c5ba2..6f5e5d9e 100644
--- a/src/fsa-to-node/FsaNodeFs.ts
+++ b/src/fsa-to-node/FsaNodeFs.ts
@@ -6,7 +6,6 @@ import { ERRSTR, MODE } from '../node/constants';
 import { strToEncoding } from '../encoding';
 import { FsaToNodeConstants } from './constants';
 import { FsaNodeDirent } from './FsaNodeDirent';
-import { FLAG } from '../consts/FLAG';
 import { AMODE } from '../consts/AMODE';
 import { constants } from '../constants';
 import { FsaNodeStats } from './FsaNodeStats';
@@ -63,18 +62,10 @@ export class FsaNodeFs extends FsaNodeCore implements FsCallbackApi, FsSynchrono
 
   public readonly close: FsCallbackApi['close'] = (fd: number, callback: misc.TCallback<void>): void => {
     util.validateFd(fd);
-    this.getFileByFdAsync(fd, 'close')
-      .then(file => file.close())
-      .then(
-        () => {
-          this.fds.delete(fd);
-          this.releasedFds.push(fd);
-          callback(null);
-        },
-        error => {
-          callback(error);
-        },
-      );
+    this.__close(fd).then(
+      () => callback(null),
+      error => callback(error),
+    );
   };
 
   public readonly read: FsCallbackApi['read'] = (
@@ -115,16 +106,27 @@ export class FsaNodeFs extends FsaNodeCore implements FsCallbackApi, FsSynchrono
       optHelpers.getReadFileOptions,
     )(a, b);
     const flagsNum = util.flagsToNumber(opts.flag);
-    return this.__getFileById(id, 'readFile')
-      .then(file => file.getFile())
-      .then(file => file.arrayBuffer())
-      .then(data => {
-        const buffer = Buffer.from(data);
-        callback(null, util.bufferToEncoding(buffer, opts.encoding));
-      })
-      .catch(error => {
-        callback(error);
-      });
+    (async () => {
+      let fd: number = typeof id === 'number' ? id : -1;
+      const originalFd = fd;
+      try {
+        if (fd === -1) {
+          const filename = util.pathToFilename(id as misc.PathLike);
+          fd = (await this.__open(filename, flagsNum, 0)).fd;
+        }
+        const handle = await this.__getFileById(fd, 'readFile');
+        const file = await handle.getFile();
+        const buffer = Buffer.from(await file.arrayBuffer());
+        return util.bufferToEncoding(buffer, opts.encoding);
+      } finally {
+        try {
+          const idWasFd = typeof originalFd === 'number' && originalFd >= 0;
+          if (idWasFd) await this.__close(originalFd);
+        } catch {}
+      }
+    })()
+      .then(data => callback(null, data))
+      .catch(error => callback(error));
   };
 
   public readonly write: FsCallbackApi['write'] = (
@@ -198,11 +200,23 @@ export class FsaNodeFs extends FsaNodeCore implements FsCallbackApi, FsSynchrono
     const modeNum = util.modeToNumber(opts.mode);
     const buf = util.dataToBuffer(data, opts.encoding);
     (async () => {
-      const createIfMissing = !!(flagsNum & FLAG.O_CREAT);
-      const file = await this.__getFileById(id, 'writeFile', createIfMissing);
-      const writable = await file.createWritable({ keepExistingData: false });
-      await writable.write(buf);
-      await writable.close();
+      let fd: number = typeof id === 'number' ? id : -1;
+      const originalFd = fd;
+      try {
+        if (fd === -1) {
+          const filename = util.pathToFilename(id as misc.PathLike);
+          fd = (await this.__open(filename, flagsNum, modeNum)).fd;
+        }
+        const file = await this.__getFileById(fd, 'writeFile');
+        const writable = await file.createWritable({ keepExistingData: false });
+        await writable.write(buf);
+        await writable.close();
+      } finally {
+        try {
+          const idWasFd = typeof originalFd === 'number' && originalFd >= 0;
+          if (idWasFd) await this.__close(originalFd);
+        } catch {}
+      }
     })().then(
       () => cb(null),
       error => cb(error),
diff --git a/src/fsa-to-node/util.ts b/src/fsa-to-node/util.ts
index 32623ab4..d040888e 100644
--- a/src/fsa-to-node/util.ts
+++ b/src/fsa-to-node/util.ts
@@ -4,6 +4,7 @@ import type { FsLocation } from './types';
 
 export const pathToLocation = (path: string): FsLocation => {
   if (path[0] === FsaToNodeConstants.Separator) path = path.slice(1);
+  if (path[path.length - 1] === FsaToNodeConstants.Separator) path = path.slice(0, -1);
   const lastSlashIndex = path.lastIndexOf(FsaToNodeConstants.Separator);
   if (lastSlashIndex === -1) return [[], path];
   const file = path.slice(lastSlashIndex + 1);
diff --git a/src/node-to-crud/NodeCrud.ts b/src/node-to-crud/NodeCrud.ts
new file mode 100644
index 00000000..c5953129
--- /dev/null
+++ b/src/node-to-crud/NodeCrud.ts
@@ -0,0 +1,218 @@
+import { assertName } from '../node-to-fsa/util';
+import { assertType } from '../crud/util';
+import { FLAG } from '../consts/FLAG';
+import { newExistsError, newFile404Error, newFolder404Error, newMissingError } from '../fsa-to-crud/util';
+import type { FsPromisesApi } from '../node/types';
+import type * as crud from '../crud/types';
+import type { IDirent } from '../node/types/misc';
+
+export interface NodeCrudOptions {
+  readonly fs: FsPromisesApi;
+  readonly dir: string;
+  readonly separator?: string;
+}
+
+export class NodeCrud implements crud.CrudApi {
+  protected readonly fs: FsPromisesApi;
+  protected readonly dir: string;
+  protected readonly separator: string;
+
+  public constructor(protected readonly options: NodeCrudOptions) {
+    this.separator = options.separator ?? '/';
+    let dir = options.dir;
+    const last = dir[dir.length - 1];
+    if (last !== this.separator) dir = dir + this.separator;
+    this.dir = dir;
+    this.fs = options.fs;
+  }
+
+  protected async checkDir(collection: crud.CrudCollection): Promise<string> {
+    const dir = this.dir + (collection.length ? collection.join(this.separator) + this.separator : '');
+    const fs = this.fs;
+    try {
+      const stats = await fs.stat(dir);
+      if (!stats.isDirectory()) throw newFolder404Error(collection);
+      return dir;
+    } catch (error) {
+      if (error && typeof error === 'object') {
+        switch (error.code) {
+          case 'ENOENT':
+          case 'ENOTDIR':
+            throw newFolder404Error(collection);
+        }
+      }
+      throw error;
+    }
+  }
+
+  public readonly put = async (
+    collection: crud.CrudCollection,
+    id: string,
+    data: Uint8Array,
+    options?: crud.CrudPutOptions,
+  ): Promise<void> => {
+    assertType(collection, 'put', 'crudfs');
+    assertName(id, 'put', 'crudfs');
+    const dir = this.dir + (collection.length ? collection.join(this.separator) + this.separator : '');
+    const fs = this.fs;
+    if (dir.length > 1) await fs.mkdir(dir, { recursive: true });
+    const filename = dir + id;
+    switch (options?.throwIf) {
+      case 'exists': {
+        try {
+          await fs.writeFile(filename, data, { flag: FLAG.O_CREAT | FLAG.O_EXCL });
+        } catch (error) {
+          if (error && typeof error === 'object' && error.code === 'EEXIST') throw newExistsError();
+          throw error;
+        }
+        break;
+      }
+      case 'missing': {
+        try {
+          await fs.writeFile(filename, data, { flag: FLAG.O_RDWR });
+        } catch (error) {
+          if (error && typeof error === 'object' && error.code === 'ENOENT') throw newMissingError();
+          throw error;
+        }
+        break;
+      }
+      default: {
+        await fs.writeFile(filename, data);
+      }
+    }
+  };
+
+  public readonly get = async (collection: crud.CrudCollection, id: string): Promise<Uint8Array> => {
+    assertType(collection, 'get', 'crudfs');
+    assertName(id, 'get', 'crudfs');
+    const dir = await this.checkDir(collection);
+    const filename = dir + id;
+    const fs = this.fs;
+    try {
+      const buf = (await fs.readFile(filename)) as Buffer;
+      return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
+    } catch (error) {
+      if (error && typeof error === 'object') {
+        switch (error.code) {
+          case 'ENOENT':
+            throw newFile404Error(collection, id);
+        }
+      }
+      throw error;
+    }
+  };
+
+  public readonly del = async (collection: crud.CrudCollection, id: string, silent?: boolean): Promise<void> => {
+    assertType(collection, 'del', 'crudfs');
+    assertName(id, 'del', 'crudfs');
+    try {
+      const dir = await this.checkDir(collection);
+      const filename = dir + id;
+      await this.fs.unlink(filename);
+    } catch (error) {
+      if (!!silent) return;
+      if (error && typeof error === 'object') {
+        switch (error.code) {
+          case 'ENOENT':
+            throw newFile404Error(collection, id);
+        }
+      }
+      throw error;
+    }
+  };
+
+  public readonly info = async (collection: crud.CrudCollection, id?: string): Promise<crud.CrudResourceInfo> => {
+    assertType(collection, 'info', 'crudfs');
+    if (id) {
+      assertName(id, 'info', 'crudfs');
+      await this.checkDir(collection);
+      try {
+        const stats = await this.fs.stat(this.dir + collection.join(this.separator) + this.separator + id);
+        if (!stats.isFile()) throw newFile404Error(collection, id);
+        return {
+          type: 'resource',
+          id,
+          size: <number>stats.size,
+          modified: <number>stats.mtimeMs,
+        };
+      } catch (error) {
+        if (error && typeof error === 'object') {
+          switch (error.code) {
+            case 'ENOENT':
+              throw newFile404Error(collection, id);
+          }
+        }
+        throw error;
+      }
+    } else {
+      await this.checkDir(collection);
+      try {
+        const stats = await this.fs.stat(this.dir + collection.join(this.separator));
+        if (!stats.isDirectory()) throw newFolder404Error(collection);
+        return {
+          type: 'collection',
+          id: '',
+        };
+      } catch (error) {
+        if (error && typeof error === 'object') {
+          switch (error.code) {
+            case 'ENOENT':
+            case 'ENOTDIR':
+              throw newFolder404Error(collection);
+          }
+        }
+        throw error;
+      }
+    }
+  };
+
+  public readonly drop = async (collection: crud.CrudCollection, silent?: boolean): Promise<void> => {
+    assertType(collection, 'drop', 'crudfs');
+    try {
+      const dir = await this.checkDir(collection);
+      const isRoot = dir === this.dir;
+      if (isRoot) {
+        const list = (await this.fs.readdir(dir)) as string[];
+        for (const entry of list) await this.fs.rmdir(dir + entry, { recursive: true });
+      } else {
+        await this.fs.rmdir(dir, { recursive: true });
+      }
+    } catch (error) {
+      if (!silent) throw error;
+    }
+  };
+
+  public readonly list = async (collection: crud.CrudCollection): Promise<crud.CrudCollectionEntry[]> => {
+    assertType(collection, 'drop', 'crudfs');
+    const dir = await this.checkDir(collection);
+    const dirents = (await this.fs.readdir(dir, { withFileTypes: true })) as IDirent[];
+    const entries: crud.CrudCollectionEntry[] = [];
+    for await (const entry of dirents) {
+      if (entry.isFile()) {
+        entries.push({
+          type: 'resource',
+          id: '' + entry.name,
+        });
+      } else if (entry.isDirectory()) {
+        entries.push({
+          type: 'collection',
+          id: '' + entry.name,
+        });
+      }
+    }
+    return entries;
+  };
+
+  public readonly from = async (collection: crud.CrudCollection): Promise<crud.CrudApi> => {
+    assertType(collection, 'from', 'crudfs');
+    const dir = this.dir + (collection.length ? collection.join(this.separator) + this.separator : '');
+    const fs = this.fs;
+    if (dir.length > 1) await fs.mkdir(dir, { recursive: true });
+    await this.checkDir(collection);
+    return new NodeCrud({
+      dir,
+      fs: this.fs,
+      separator: this.separator,
+    });
+  };
+}
diff --git a/src/node-to-crud/index.ts b/src/node-to-crud/index.ts
new file mode 100644
index 00000000..9c478b59
--- /dev/null
+++ b/src/node-to-crud/index.ts
@@ -0,0 +1,1 @@
+export { NodeCrud, NodeCrudOptions } from './NodeCrud';
diff --git a/src/node-to-fsa/NodeFileSystemDirectoryHandle.ts b/src/node-to-fsa/NodeFileSystemDirectoryHandle.ts
index 36843978..a943d4ee 100644
--- a/src/node-to-fsa/NodeFileSystemDirectoryHandle.ts
+++ b/src/node-to-fsa/NodeFileSystemDirectoryHandle.ts
@@ -24,13 +24,13 @@ import type {
  */
 export class NodeFileSystemDirectoryHandle extends NodeFileSystemHandle implements IFileSystemDirectoryHandle {
   protected readonly ctx: Partial<NodeFsaContext>;
-  public constructor(
-    protected readonly fs: NodeFsaFs,
-    public readonly __path: string,
-    ctx: Partial<NodeFsaContext> = {},
-  ) {
-    super('directory', basename(__path, ctx.separator || '/'));
+  /** Directory path with trailing slash. */
+  public readonly __path: string;
+
+  public constructor(protected readonly fs: NodeFsaFs, path: string, ctx: Partial<NodeFsaContext> = {}) {
+    super('directory', basename(path, ctx.separator || '/'));
     this.ctx = createCtx(ctx);
+    this.__path = path[path.length - 1] === this.ctx.separator ? path : path + this.ctx.separator;
   }
 
   /**
@@ -84,7 +84,7 @@ export class NodeFileSystemDirectoryHandle extends NodeFileSystemHandle implemen
     options?: GetDirectoryHandleOptions,
   ): Promise<IFileSystemDirectoryHandle> {
     assertName(name, 'getDirectoryHandle', 'FileSystemDirectoryHandle');
-    const filename = this.__path + this.ctx.separator! + name;
+    const filename = this.__path + name;
     try {
       const stats = await this.fs.promises.stat(filename);
       if (!stats.isDirectory()) throw newTypeMismatchError();
@@ -121,7 +121,7 @@ export class NodeFileSystemDirectoryHandle extends NodeFileSystemHandle implemen
    */
   public async getFileHandle(name: string, options?: GetFileHandleOptions): Promise<IFileSystemFileHandle> {
     assertName(name, 'getFileHandle', 'FileSystemDirectoryHandle');
-    const filename = this.__path + this.ctx.separator! + name;
+    const filename = this.__path + name;
     try {
       const stats = await this.fs.promises.stat(filename);
       if (!stats.isFile()) throw newTypeMismatchError();
@@ -159,7 +159,7 @@ export class NodeFileSystemDirectoryHandle extends NodeFileSystemHandle implemen
   public async removeEntry(name: string, { recursive = false }: RemoveEntryOptions = {}): Promise<void> {
     assertCanWrite(this.ctx.mode!);
     assertName(name, 'removeEntry', 'FileSystemDirectoryHandle');
-    const filename = this.__path + this.ctx.separator! + name;
+    const filename = this.__path + name;
     const promises = this.fs.promises;
     try {
       const stats = await promises.stat(filename);
diff --git a/src/node-to-fsa/types.ts b/src/node-to-fsa/types.ts
index 95c903a4..ae607981 100644
--- a/src/node-to-fsa/types.ts
+++ b/src/node-to-fsa/types.ts
@@ -1,20 +1,13 @@
-import type { IFs } from '..';
+import type { FsPromisesApi, FsSynchronousApi } from '../node/types';
+import type { FsCommonObjects } from '../node/types/FsCommonObjects';
 
 /**
  * Required Node.js `fs` module functions for File System Access API.
  */
-export type NodeFsaFs = Pick<
-  IFs,
-  | 'promises'
-  | 'constants'
-  | 'openSync'
-  | 'fsyncSync'
-  | 'statSync'
-  | 'closeSync'
-  | 'readSync'
-  | 'truncateSync'
-  | 'writeSync'
->;
+export type NodeFsaFs = Pick<FsCommonObjects, 'constants'> & { promises: FsPromisesApi } & Pick<
+    FsSynchronousApi,
+    'openSync' | 'fsyncSync' | 'statSync' | 'closeSync' | 'readSync' | 'truncateSync' | 'writeSync'
+  >;
 
 export interface NodeFsaContext {
   separator: '/' | '\\';
diff --git a/src/node-to-fsa/util.ts b/src/node-to-fsa/util.ts
index 5a1ec7f5..429b8fde 100644
--- a/src/node-to-fsa/util.ts
+++ b/src/node-to-fsa/util.ts
@@ -13,6 +13,7 @@ export const ctx = (partial: Partial<NodeFsaContext> = {}): NodeFsaContext => {
 };
 
 export const basename = (path: string, separator: string) => {
+  if (path[path.length - 1] === separator) path = path.slice(0, -1);
   const lastSlashIndex = path.lastIndexOf(separator);
   return lastSlashIndex === -1 ? path : path.slice(lastSlashIndex + 1);
 };


Test patch:
diff --git a/src/__tests__/volume/readFile.test.ts b/src/__tests__/volume/readFile.test.ts
new file mode 100644
index 00000000..ec33f435
--- /dev/null
+++ b/src/__tests__/volume/readFile.test.ts
@@ -0,0 +1,17 @@
+import { of } from 'thingies';
+import { memfs } from '../..';
+
+describe('.readFile()', () => {
+  it('can read a file', async () => {
+    const fs = memfs({ '/dir/test.txt': '01234567' });
+    const data = await fs.promises.readFile('/dir/test.txt', { encoding: 'utf8' });
+    expect(data).toBe('01234567');
+  });
+
+  it('throws if file does not exist', async () => {
+    const fs = memfs({ '/dir/test.txt': '01234567' });
+    const [, err] = await of(fs.promises.readFile('/dir/test-NOT-FOUND.txt', { encoding: 'utf8' }));
+    expect(err).toBeInstanceOf(Error);
+    expect((<any>err).code).toBe('ENOENT');
+  });
+});
diff --git a/src/crud-to-cas/__tests__/CrudCas.test.ts b/src/crud-to-cas/__tests__/CrudCas.test.ts
index 1f16a18c..4c4e2750 100644
--- a/src/crud-to-cas/__tests__/CrudCas.test.ts
+++ b/src/crud-to-cas/__tests__/CrudCas.test.ts
@@ -1,127 +1,38 @@
-import { of } from 'thingies';
-import { createHash } from 'crypto';
 import { memfs } from '../..';
 import { onlyOnNode20 } from '../../__tests__/util';
 import { NodeFileSystemDirectoryHandle } from '../../node-to-fsa';
 import { FsaCrud } from '../../fsa-to-crud/FsaCrud';
 import { CrudCas } from '../CrudCas';
-import { hashToLocation } from '../util';
-
-const hash = async (blob: Uint8Array): Promise<string> => {
-  const shasum = createHash('sha1');
-  shasum.update(blob);
-  return shasum.digest('hex');
-};
-
-const setup = () => {
-  const fs = memfs();
-  const fsa = new NodeFileSystemDirectoryHandle(fs, '/', { mode: 'readwrite' });
-  const crud = new FsaCrud(fsa);
-  const cas = new CrudCas(crud, { hash });
-  return { fs, fsa, crud, cas, snapshot: () => (<any>fs).__vol.toJSON() };
-};
-
-const b = (str: string) => {
-  const buf = Buffer.from(str);
-  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
-};
-
-onlyOnNode20('CrudCas', () => {
-  describe('.put()', () => {
-    test('can store a blob', async () => {
-      const blob = b('hello world');
-      const { cas, snapshot } = setup();
-      const hash = await cas.put(blob);
-      expect(hash).toBe('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed');
-      expect(snapshot()).toMatchSnapshot();
-    });
-  });
-
-  describe('.get()', () => {
-    test('can retrieve existing blob', async () => {
-      const blob = b('hello world');
-      const { cas } = setup();
-      const hash = await cas.put(blob);
-      const blob2 = await cas.get(hash);
-      expect(blob2).toStrictEqual(blob);
-    });
-
-    test('throws if blob does not exist', async () => {
-      const blob = b('hello world 2');
-      const { cas } = setup();
-      const hash = await cas.put(blob);
-      const [, err] = await of(cas.get('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed'));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<any>err).name).toBe('BlobNotFound');
-    });
-
-    test('throws if blob contents does not match the hash', async () => {
-      const blob = b('hello world');
-      const { cas, crud } = setup();
-      const hash = await cas.put(blob);
-      const location = hashToLocation(hash);
-      await crud.put(location[0], location[1], b('hello world!'));
-      const [, err] = await of(cas.get(hash));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<any>err).name).toBe('Integrity');
-    });
-
-    test('does not throw if integrity check is skipped', async () => {
-      const blob = b('hello world');
-      const { cas, crud } = setup();
-      const hash = await cas.put(blob);
-      const location = hashToLocation(hash);
-      await crud.put(location[0], location[1], b('hello world!'));
-      const blob2 = await cas.get(hash, { skipVerification: true });
-      expect(blob2).toStrictEqual(b('hello world!'));
-    });
-  });
-
-  describe('.info()', () => {
-    test('can retrieve existing blob info', async () => {
-      const blob = b('hello world');
-      const { cas } = setup();
-      const hash = await cas.put(blob);
-      const info = await cas.info(hash);
-      expect(info.size).toBe(11);
-    });
-
-    test('throws if blob does not exist', async () => {
-      const blob = b('hello world 2');
-      const { cas } = setup();
-      const hash = await cas.put(blob);
-      const [, err] = await of(cas.info('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed'));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<any>err).name).toBe('BlobNotFound');
-    });
-  });
-
-  describe('.del()', () => {
-    test('can delete an existing blob', async () => {
-      const blob = b('hello world');
-      const { cas } = setup();
-      const hash = await cas.put(blob);
-      const info = await cas.info(hash);
-      await cas.del(hash);
-      const [, err] = await of(cas.info(hash));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<any>err).name).toBe('BlobNotFound');
-    });
+import { testCasfs, hash } from './testCasfs';
+import { NodeCrud } from '../../node-to-crud/NodeCrud';
+
+onlyOnNode20('CrudCas on FsaCrud', () => {
+  const setup = () => {
+    const fs = memfs();
+    const fsa = new NodeFileSystemDirectoryHandle(fs, '/', { mode: 'readwrite' });
+    const crud = new FsaCrud(fsa);
+    const cas = new CrudCas(crud, { hash });
+    return { fs, fsa, crud, cas, snapshot: () => (<any>fs).__vol.toJSON() };
+  };
+  testCasfs(setup);
+});
 
-    test('throws if blob does not exist', async () => {
-      const blob = b('hello world 2');
-      const { cas } = setup();
-      const hash = await cas.put(blob);
-      const [, err] = await of(cas.del('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed'));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<any>err).name).toBe('BlobNotFound');
-    });
+onlyOnNode20('CrudCas on NodeCrud at root', () => {
+  const setup = () => {
+    const fs = memfs();
+    const crud = new NodeCrud({ fs: fs.promises, dir: '/' });
+    const cas = new CrudCas(crud, { hash });
+    return { fs, crud, cas, snapshot: () => (<any>fs).__vol.toJSON() };
+  };
+  testCasfs(setup);
+});
 
-    test('does not throw if "silent" flag is provided', async () => {
-      const blob = b('hello world 2');
-      const { cas } = setup();
-      const hash = await cas.put(blob);
-      await cas.del('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', true);
-    });
-  });
+onlyOnNode20('CrudCas on NodeCrud at in sub-folder', () => {
+  const setup = () => {
+    const fs = memfs({ '/a/b/c': null });
+    const crud = new NodeCrud({ fs: fs.promises, dir: '/a/b/c' });
+    const cas = new CrudCas(crud, { hash });
+    return { fs, crud, cas, snapshot: () => (<any>fs).__vol.toJSON() };
+  };
+  testCasfs(setup);
 });
diff --git a/src/crud-to-cas/__tests__/__snapshots__/CrudCas.test.ts.snap b/src/crud-to-cas/__tests__/__snapshots__/CrudCas.test.ts.snap
index 48f1ceea..1a2a9682 100644
--- a/src/crud-to-cas/__tests__/__snapshots__/CrudCas.test.ts.snap
+++ b/src/crud-to-cas/__tests__/__snapshots__/CrudCas.test.ts.snap
@@ -1,6 +1,18 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`CrudCas .put() can store a blob 1`] = `
+exports[`CrudCas on FsaCrud .put() can store a blob 1`] = `
+Object {
+  "/ed/46/2aae6c35c94fcfb415dbe95f408b9ce91ee846ed": "hello world",
+}
+`;
+
+exports[`CrudCas on NodeCrud at in sub-folder .put() can store a blob 1`] = `
+Object {
+  "/a/b/c/ed/46/2aae6c35c94fcfb415dbe95f408b9ce91ee846ed": "hello world",
+}
+`;
+
+exports[`CrudCas on NodeCrud at root .put() can store a blob 1`] = `
 Object {
   "/ed/46/2aae6c35c94fcfb415dbe95f408b9ce91ee846ed": "hello world",
 }
diff --git a/src/crud-to-cas/__tests__/testCasfs.ts b/src/crud-to-cas/__tests__/testCasfs.ts
new file mode 100644
index 00000000..b03c5ac8
--- /dev/null
+++ b/src/crud-to-cas/__tests__/testCasfs.ts
@@ -0,0 +1,122 @@
+import { of } from 'thingies';
+import { createHash } from 'crypto';
+import { hashToLocation } from '../util';
+import type { CasApi } from '../../cas/types';
+import type { CrudApi } from '../../crud/types';
+
+export const hash = async (blob: Uint8Array): Promise<string> => {
+  const shasum = createHash('sha1');
+  shasum.update(blob);
+  return shasum.digest('hex');
+};
+
+const b = (str: string) => {
+  const buf = Buffer.from(str);
+  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
+};
+
+export type Setup = () => {
+  cas: CasApi;
+  crud: CrudApi;
+  snapshot: () => Record<string, string | null>;
+};
+
+export const testCasfs = (setup: Setup) => {
+  describe('.put()', () => {
+    test('can store a blob', async () => {
+      const blob = b('hello world');
+      const { cas, snapshot } = setup();
+      const hash = await cas.put(blob);
+      expect(hash).toBe('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed');
+      expect(snapshot()).toMatchSnapshot();
+    });
+  });
+
+  describe('.get()', () => {
+    test('can retrieve existing blob', async () => {
+      const blob = b('hello world');
+      const { cas } = setup();
+      const hash = await cas.put(blob);
+      const blob2 = await cas.get(hash);
+      expect(blob2).toStrictEqual(blob);
+    });
+
+    test('throws if blob does not exist', async () => {
+      const blob = b('hello world 2');
+      const { cas } = setup();
+      const hash = await cas.put(blob);
+      const [, err] = await of(cas.get('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed'));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<any>err).name).toBe('BlobNotFound');
+    });
+
+    test('throws if blob contents does not match the hash', async () => {
+      const blob = b('hello world');
+      const { cas, crud } = setup();
+      const hash = await cas.put(blob);
+      const location = hashToLocation(hash);
+      await crud.put(location[0], location[1], b('hello world!'));
+      const [, err] = await of(cas.get(hash));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<any>err).name).toBe('Integrity');
+    });
+
+    test('does not throw if integrity check is skipped', async () => {
+      const blob = b('hello world');
+      const { cas, crud } = setup();
+      const hash = await cas.put(blob);
+      const location = hashToLocation(hash);
+      await crud.put(location[0], location[1], b('hello world!'));
+      const blob2 = await cas.get(hash, { skipVerification: true });
+      expect(blob2).toStrictEqual(b('hello world!'));
+    });
+  });
+
+  describe('.info()', () => {
+    test('can retrieve existing blob info', async () => {
+      const blob = b('hello world');
+      const { cas } = setup();
+      const hash = await cas.put(blob);
+      const info = await cas.info(hash);
+      expect(info.size).toBe(11);
+    });
+
+    test('throws if blob does not exist', async () => {
+      const blob = b('hello world 2');
+      const { cas } = setup();
+      const hash = await cas.put(blob);
+      const [, err] = await of(cas.info('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed'));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<any>err).name).toBe('BlobNotFound');
+    });
+  });
+
+  describe('.del()', () => {
+    test('can delete an existing blob', async () => {
+      const blob = b('hello world');
+      const { cas } = setup();
+      const hash = await cas.put(blob);
+      const info = await cas.info(hash);
+      await cas.del(hash);
+      const [, err] = await of(cas.info(hash));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<any>err).name).toBe('BlobNotFound');
+    });
+
+    test('throws if blob does not exist', async () => {
+      const blob = b('hello world 2');
+      const { cas } = setup();
+      const hash = await cas.put(blob);
+      const [, err] = await of(cas.del('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed'));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<any>err).name).toBe('BlobNotFound');
+    });
+
+    test('does not throw if "silent" flag is provided', async () => {
+      const blob = b('hello world 2');
+      const { cas } = setup();
+      const hash = await cas.put(blob);
+      await cas.del('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', true);
+    });
+  });
+};
diff --git a/src/crud/__tests__/matryoshka.test.ts b/src/crud/__tests__/matryoshka.test.ts
new file mode 100644
index 00000000..3683d012
--- /dev/null
+++ b/src/crud/__tests__/matryoshka.test.ts
@@ -0,0 +1,72 @@
+import { memfs } from '../..';
+import { onlyOnNode20 } from '../../__tests__/util';
+import { NodeFileSystemDirectoryHandle } from '../../node-to-fsa';
+import { FsaNodeFs } from '../../fsa-to-node';
+import { NodeCrud } from '../../node-to-crud';
+import { testCrudfs } from '../../crud/__tests__/testCrudfs';
+import { FsaCrud } from '../../fsa-to-crud';
+
+onlyOnNode20('CRUD matryoshka', () => {
+  describe('crud(memfs)', () => {
+    testCrudfs(() => {
+      const fs = memfs();
+      const crud = new NodeCrud({ fs: fs.promises, dir: '/' });
+      return { crud, snapshot: () => (<any>fs).__vol.toJSON() };
+    });
+  });
+
+  describe('crud(fsa(memfs))', () => {
+    testCrudfs(() => {
+      const fs = memfs();
+      const fsa = new NodeFileSystemDirectoryHandle(fs, '/', { mode: 'readwrite' });
+      const crud = new FsaCrud(fsa);
+      return { crud, snapshot: () => (<any>fs).__vol.toJSON() };
+    });
+  });
+
+  describe('crud(fs(fsa(memfs)))', () => {
+    testCrudfs(() => {
+      const fs = memfs();
+      const fsa = new NodeFileSystemDirectoryHandle(fs, '/', { mode: 'readwrite' });
+      const fs2 = new FsaNodeFs(fsa);
+      const crud = new NodeCrud({ fs: fs2.promises, dir: '/' });
+      return { crud, snapshot: () => (<any>fs).__vol.toJSON() };
+    });
+  });
+
+  describe('crud(fsa(fs(fsa(memfs))))', () => {
+    testCrudfs(() => {
+      const fs = memfs();
+      const fsa = new NodeFileSystemDirectoryHandle(fs, '/', { mode: 'readwrite' });
+      const fs2 = new FsaNodeFs(fsa);
+      const fsa2 = new NodeFileSystemDirectoryHandle(fs2, '/', { mode: 'readwrite' });
+      const crud = new FsaCrud(fsa2);
+      return { crud, snapshot: () => (<any>fs).__vol.toJSON() };
+    });
+  });
+
+  describe('crud(fs(fsa(fs(fsa(memfs)))))', () => {
+    testCrudfs(() => {
+      const fs = memfs();
+      const fsa = new NodeFileSystemDirectoryHandle(fs, '/', { mode: 'readwrite' });
+      const fs2 = new FsaNodeFs(fsa);
+      const fsa2 = new NodeFileSystemDirectoryHandle(fs2, '/', { mode: 'readwrite' });
+      const fs3 = new FsaNodeFs(fsa2);
+      const crud = new NodeCrud({ fs: fs3.promises, dir: '/' });
+      return { crud, snapshot: () => (<any>fs).__vol.toJSON() };
+    });
+  });
+
+  describe('crud(fsa(fs(fsa(fs(fsa(memfs))))))', () => {
+    testCrudfs(() => {
+      const fs = memfs();
+      const fsa = new NodeFileSystemDirectoryHandle(fs, '/', { mode: 'readwrite' });
+      const fs2 = new FsaNodeFs(fsa);
+      const fsa2 = new NodeFileSystemDirectoryHandle(fs2, '/', { mode: 'readwrite' });
+      const fs3 = new FsaNodeFs(fsa2);
+      const fsa3 = new NodeFileSystemDirectoryHandle(fs3, '/', { mode: 'readwrite' });
+      const crud = new FsaCrud(fsa3);
+      return { crud, snapshot: () => (<any>fs).__vol.toJSON() };
+    });
+  });
+});
diff --git a/src/crud/__tests__/testCrudfs.ts b/src/crud/__tests__/testCrudfs.ts
new file mode 100644
index 00000000..b5c2d988
--- /dev/null
+++ b/src/crud/__tests__/testCrudfs.ts
@@ -0,0 +1,313 @@
+import { of } from 'thingies';
+import type { CrudApi } from '../types';
+
+export type Setup = () => {
+  crud: CrudApi;
+  snapshot: () => Record<string, string | null>;
+};
+
+const b = (str: string) => {
+  const buf = Buffer.from(str);
+  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
+};
+
+export const testCrudfs = (setup: Setup) => {
+  describe('.put()', () => {
+    test('throws if the type is not valid', async () => {
+      const { crud } = setup();
+      const [, err] = await of(crud.put(['.', 'foo'], 'bar', new Uint8Array()));
+      expect(err).toBeInstanceOf(TypeError);
+      expect((<any>err).message).toBe("Failed to execute 'put' on 'crudfs': Name is not allowed.");
+    });
+
+    test('throws if id is not valid', async () => {
+      const { crud } = setup();
+      const [, err] = await of(crud.put(['foo'], '..', new Uint8Array()));
+      expect(err).toBeInstanceOf(TypeError);
+      expect((<any>err).message).toBe("Failed to execute 'put' on 'crudfs': Name is not allowed.");
+    });
+
+    test('can store a resource at root', async () => {
+      const { crud, snapshot } = setup();
+      await crud.put([], 'bar', b('abc'));
+      expect(snapshot()).toStrictEqual({
+        '/bar': 'abc',
+      });
+    });
+
+    test('can store a resource in two levels deep collection', async () => {
+      const { crud, snapshot } = setup();
+      await crud.put(['a', 'b'], 'bar', b('abc'));
+      expect(snapshot()).toStrictEqual({
+        '/a/b/bar': 'abc',
+      });
+    });
+
+    test('can overwrite existing resource', async () => {
+      const { crud, snapshot } = setup();
+      await crud.put(['a', 'b'], 'bar', b('abc'));
+      await crud.put(['a', 'b'], 'bar', b('efg'));
+      expect(snapshot()).toStrictEqual({
+        '/a/b/bar': 'efg',
+      });
+    });
+
+    test('can choose to throw if item already exists', async () => {
+      const { crud } = setup();
+      await crud.put(['a', 'b'], 'bar', b('abc'), { throwIf: 'exists' });
+      const [, err] = await of(crud.put(['a', 'b'], 'bar', b('efg'), { throwIf: 'exists' }));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<DOMException>err).name).toBe('Exists');
+    });
+
+    test('can choose to throw if item does not exist', async () => {
+      const { crud, snapshot } = setup();
+      const [, err] = await of(crud.put(['a', 'b'], 'bar', b('1'), { throwIf: 'missing' }));
+      await crud.put(['a', 'b'], 'bar', b('2'));
+      await crud.put(['a', 'b'], 'bar', b('3'), { throwIf: 'missing' });
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<DOMException>err).name).toBe('Missing');
+      expect(snapshot()).toStrictEqual({
+        '/a/b/bar': '3',
+      });
+    });
+  });
+
+  describe('.get()', () => {
+    test('throws if the type is not valid', async () => {
+      const { crud } = setup();
+      const [, err] = await of(crud.get(['', 'foo'], 'bar'));
+      expect(err).toBeInstanceOf(TypeError);
+      expect((<any>err).message).toBe("Failed to execute 'get' on 'crudfs': Name is not allowed.");
+    });
+
+    test('throws if id is not valid', async () => {
+      const { crud } = setup();
+      const [, err] = await of(crud.get(['foo'], ''));
+      expect(err).toBeInstanceOf(TypeError);
+      expect((<any>err).message).toBe("Failed to execute 'get' on 'crudfs': Name is not allowed.");
+    });
+
+    test('throws if collection does not exist', async () => {
+      const { crud } = setup();
+      const [, err] = await of(crud.get(['foo'], 'bar'));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<any>err).name).toBe('CollectionNotFound');
+    });
+
+    test('throws if resource does not exist', async () => {
+      const { crud } = setup();
+      await crud.put(['foo'], 'bar', b('abc'));
+      const [, err] = await of(crud.get(['foo'], 'baz'));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<any>err).name).toBe('ResourceNotFound');
+    });
+
+    test('can fetch an existing resource', async () => {
+      const { crud } = setup();
+      await crud.put(['foo'], 'bar', b('abc'));
+      const blob = await crud.get(['foo'], 'bar');
+      expect(blob).toStrictEqual(b('abc'));
+    });
+  });
+
+  describe('.del()', () => {
+    test('throws if the type is not valid', async () => {
+      const { crud } = setup();
+      const [, err] = await of(crud.del(['sdf\\dd', 'foo'], 'bar'));
+      expect(err).toBeInstanceOf(TypeError);
+      expect((<any>err).message).toBe("Failed to execute 'del' on 'crudfs': Name is not allowed.");
+    });
+
+    test('throws if id is not valid', async () => {
+      const { crud } = setup();
+      const [, err] = await of(crud.del(['foo'], 'asdf/asdf'));
+      expect(err).toBeInstanceOf(TypeError);
+      expect((<any>err).message).toBe("Failed to execute 'del' on 'crudfs': Name is not allowed.");
+    });
+
+    describe('when collection does not exist', () => {
+      test('throws by default', async () => {
+        const { crud } = setup();
+        const [, err] = await of(crud.del(['foo'], 'bar'));
+        expect(err).toBeInstanceOf(DOMException);
+        expect((<any>err).name).toBe('CollectionNotFound');
+      });
+
+      test('does not throw when "silent" flag set', async () => {
+        const { crud } = setup();
+        await crud.del(['foo'], 'bar', true);
+      });
+    });
+
+    describe('when collection is found but resource is not', () => {
+      test('throws by default', async () => {
+        const { crud } = setup();
+        await crud.put(['foo'], 'bar', b('abc'));
+        const [, err] = await of(crud.del(['foo'], 'baz'));
+        expect(err).toBeInstanceOf(DOMException);
+        expect((<any>err).name).toBe('ResourceNotFound');
+      });
+
+      test('does not throw when "silent" flag set', async () => {
+        const { crud } = setup();
+        await crud.put(['foo'], 'bar', b('abc'));
+        await crud.del(['foo'], 'baz', true);
+      });
+    });
+
+    test('deletes an existing resource', async () => {
+      const { crud } = setup();
+      await crud.put(['foo'], 'bar', b('abc'));
+      await crud.get(['foo'], 'bar');
+      await crud.del(['foo'], 'bar');
+      const [, err] = await of(crud.get(['foo'], 'bar'));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<any>err).name).toBe('ResourceNotFound');
+    });
+  });
+
+  describe('.info()', () => {
+    test('throws if the type is not valid', async () => {
+      const { crud } = setup();
+      const [, err] = await of(crud.info(['', 'foo'], 'bar'));
+      expect(err).toBeInstanceOf(TypeError);
+      expect((<any>err).message).toBe("Failed to execute 'info' on 'crudfs': Name is not allowed.");
+    });
+
+    test('throws if id is not valid', async () => {
+      const { crud } = setup();
+      const [, err] = await of(crud.info(['foo'], '/'));
+      expect(err).toBeInstanceOf(TypeError);
+      expect((<any>err).message).toBe("Failed to execute 'info' on 'crudfs': Name is not allowed.");
+    });
+
+    test('can retrieve information about a resource', async () => {
+      const { crud } = setup();
+      await crud.put(['foo'], 'bar', b('abc'));
+      const info = await crud.info(['foo'], 'bar');
+      expect(info).toMatchObject({
+        type: 'resource',
+        id: 'bar',
+        size: 3,
+        modified: expect.any(Number),
+      });
+    });
+
+    test('can retrieve information about a collection', async () => {
+      const { crud } = setup();
+      await crud.put(['foo'], 'bar', b('abc'));
+      const info = await crud.info(['foo']);
+      expect(info).toMatchObject({
+        type: 'collection',
+      });
+    });
+
+    test('throws when resource not found', async () => {
+      const { crud } = setup();
+      await crud.put(['foo'], 'bar', b('abc'));
+      const [, err] = await of(crud.info(['foo'], 'baz'));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<any>err).name).toBe('ResourceNotFound');
+    });
+
+    test('throws when collection not found', async () => {
+      const { crud } = setup();
+      await crud.put(['foo', 'a'], 'bar', b('abc'));
+      const [, err] = await of(crud.info(['foo', 'b'], 'baz'));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<any>err).name).toBe('CollectionNotFound');
+    });
+  });
+
+  describe('.drop()', () => {
+    test('throws if the collection is not valid', async () => {
+      const { crud } = setup();
+      const [, err] = await of(crud.drop(['', 'foo']));
+      expect(err).toBeInstanceOf(TypeError);
+      expect((<any>err).message).toBe("Failed to execute 'drop' on 'crudfs': Name is not allowed.");
+    });
+
+    test('can recursively delete a collection', async () => {
+      const { crud } = setup();
+      await crud.put(['foo', 'a'], 'bar', b('1'));
+      await crud.put(['foo', 'a'], 'baz', b('2'));
+      await crud.put(['foo', 'b'], 'xyz', b('3'));
+      const info = await crud.info(['foo', 'a']);
+      expect(info.type).toBe('collection');
+      await crud.drop(['foo', 'a']);
+      const [, err] = await of(crud.info(['foo', 'a']));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<any>err).name).toBe('CollectionNotFound');
+    });
+
+    test('throws if collection does not exist', async () => {
+      const { crud } = setup();
+      await crud.put(['foo', 'a'], 'bar', b('1'));
+      await crud.put(['foo', 'a'], 'baz', b('2'));
+      await crud.put(['foo', 'b'], 'xyz', b('3'));
+      const [, err] = await of(crud.drop(['gg']));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<any>err).name).toBe('CollectionNotFound');
+    });
+
+    test('when "silent" flag set, does not throw if collection does not exist', async () => {
+      const { crud } = setup();
+      await crud.put(['foo', 'a'], 'bar', b('1'));
+      await crud.put(['foo', 'a'], 'baz', b('2'));
+      await crud.put(['foo', 'b'], 'xyz', b('3'));
+      await crud.drop(['gg'], true);
+    });
+
+    test('can recursively delete everything from root', async () => {
+      const { crud, snapshot } = setup();
+      await crud.put(['foo', 'a'], 'bar', b('1'));
+      await crud.put(['baz', 'a'], 'baz', b('2'));
+      await crud.put(['bar', 'b'], 'xyz', b('3'));
+      const info = await crud.info(['foo', 'a']);
+      expect(info.type).toBe('collection');
+      await crud.drop([]);
+      expect(snapshot()).toEqual({});
+    });
+  });
+
+  describe('.list()', () => {
+    test('throws if the collection is not valid', async () => {
+      const { crud } = setup();
+      const [, err] = await of(crud.list(['./..', 'foo']));
+      expect(err).toBeInstanceOf(TypeError);
+      expect((<any>err).message).toBe("Failed to execute 'drop' on 'crudfs': Name is not allowed.");
+    });
+
+    test('can retrieve a list of resources and collections at root', async () => {
+      const { crud } = setup();
+      await crud.put(['foo'], 'bar', b('1'));
+      await crud.put([], 'baz', b('1'));
+      await crud.put([], 'qux', b('2'));
+      const list = await crud.list([]);
+      expect(list.length).toBe(3);
+      expect(list.find(x => x.id === 'baz')).toMatchObject({
+        type: 'resource',
+        id: 'baz',
+      });
+      expect(list.find(x => x.id === 'qux')).toMatchObject({
+        type: 'resource',
+        id: 'qux',
+      });
+      expect(list.find(x => x.id === 'foo')).toMatchObject({
+        type: 'collection',
+        id: 'foo',
+      });
+    });
+
+    test('throws when try to list a non-existing collection', async () => {
+      const { crud } = setup();
+      await crud.put(['foo'], 'bar', b('1'));
+      await crud.put([], 'baz', b('1'));
+      await crud.put([], 'qux', b('2'));
+      const [, err] = await of(crud.list(['gg']));
+      expect(err).toBeInstanceOf(DOMException);
+      expect((<any>err).name).toBe('CollectionNotFound');
+    });
+  });
+};
diff --git a/src/fsa-to-crud/__tests__/FsaCrud.test.ts b/src/fsa-to-crud/__tests__/FsaCrud.test.ts
index 2ee2038c..bac306c0 100644
--- a/src/fsa-to-crud/__tests__/FsaCrud.test.ts
+++ b/src/fsa-to-crud/__tests__/FsaCrud.test.ts
@@ -1,8 +1,8 @@
-import { of } from 'thingies';
 import { memfs } from '../..';
 import { onlyOnNode20 } from '../../__tests__/util';
 import { NodeFileSystemDirectoryHandle } from '../../node-to-fsa';
 import { FsaCrud } from '../FsaCrud';
+import { testCrudfs } from '../../crud/__tests__/testCrudfs';
 
 const setup = () => {
   const fs = memfs();
@@ -11,308 +11,6 @@ const setup = () => {
   return { fs, fsa, crud, snapshot: () => (<any>fs).__vol.toJSON() };
 };
 
-const b = (str: string) => {
-  const buf = Buffer.from(str);
-  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
-};
-
 onlyOnNode20('FsaCrud', () => {
-  describe('.put()', () => {
-    test('throws if the type is not valid', async () => {
-      const { crud } = setup();
-      const [, err] = await of(crud.put(['.', 'foo'], 'bar', new Uint8Array()));
-      expect(err).toBeInstanceOf(TypeError);
-      expect((<any>err).message).toBe("Failed to execute 'put' on 'crudfs': Name is not allowed.");
-    });
-
-    test('throws if id is not valid', async () => {
-      const { crud } = setup();
-      const [, err] = await of(crud.put(['foo'], '..', new Uint8Array()));
-      expect(err).toBeInstanceOf(TypeError);
-      expect((<any>err).message).toBe("Failed to execute 'put' on 'crudfs': Name is not allowed.");
-    });
-
-    test('can store a resource at root', async () => {
-      const { crud, snapshot } = setup();
-      await crud.put([], 'bar', b('abc'));
-      expect(snapshot()).toStrictEqual({
-        '/bar': 'abc',
-      });
-    });
-
-    test('can store a resource in two levels deep collection', async () => {
-      const { crud, snapshot } = setup();
-      await crud.put(['a', 'b'], 'bar', b('abc'));
-      expect(snapshot()).toStrictEqual({
-        '/a/b/bar': 'abc',
-      });
-    });
-
-    test('can overwrite existing resource', async () => {
-      const { crud, snapshot } = setup();
-      await crud.put(['a', 'b'], 'bar', b('abc'));
-      await crud.put(['a', 'b'], 'bar', b('efg'));
-      expect(snapshot()).toStrictEqual({
-        '/a/b/bar': 'efg',
-      });
-    });
-
-    test('can choose to throw if item already exists', async () => {
-      const { crud } = setup();
-      await crud.put(['a', 'b'], 'bar', b('abc'), { throwIf: 'exists' });
-      const [, err] = await of(crud.put(['a', 'b'], 'bar', b('efg'), { throwIf: 'exists' }));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<DOMException>err).name).toBe('Exists');
-    });
-
-    test('can choose to throw if item does not exist', async () => {
-      const { crud, snapshot } = setup();
-      const [, err] = await of(crud.put(['a', 'b'], 'bar', b('1'), { throwIf: 'missing' }));
-      await crud.put(['a', 'b'], 'bar', b('2'));
-      await crud.put(['a', 'b'], 'bar', b('3'), { throwIf: 'missing' });
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<DOMException>err).name).toBe('Missing');
-      expect(snapshot()).toStrictEqual({
-        '/a/b/bar': '3',
-      });
-    });
-  });
-
-  describe('.get()', () => {
-    test('throws if the type is not valid', async () => {
-      const { crud } = setup();
-      const [, err] = await of(crud.get(['', 'foo'], 'bar'));
-      expect(err).toBeInstanceOf(TypeError);
-      expect((<any>err).message).toBe("Failed to execute 'get' on 'crudfs': Name is not allowed.");
-    });
-
-    test('throws if id is not valid', async () => {
-      const { crud } = setup();
-      const [, err] = await of(crud.get(['foo'], ''));
-      expect(err).toBeInstanceOf(TypeError);
-      expect((<any>err).message).toBe("Failed to execute 'get' on 'crudfs': Name is not allowed.");
-    });
-
-    test('throws if collection does not exist', async () => {
-      const { crud } = setup();
-      const [, err] = await of(crud.get(['foo'], 'bar'));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<any>err).name).toBe('CollectionNotFound');
-    });
-
-    test('throws if resource does not exist', async () => {
-      const { crud } = setup();
-      await crud.put(['foo'], 'bar', b('abc'));
-      const [, err] = await of(crud.get(['foo'], 'baz'));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<any>err).name).toBe('ResourceNotFound');
-    });
-
-    test('can fetch an existing resource', async () => {
-      const { crud } = setup();
-      await crud.put(['foo'], 'bar', b('abc'));
-      const blob = await crud.get(['foo'], 'bar');
-      expect(blob).toStrictEqual(b('abc'));
-    });
-  });
-
-  describe('.del()', () => {
-    test('throws if the type is not valid', async () => {
-      const { crud } = setup();
-      const [, err] = await of(crud.del(['sdf\\dd', 'foo'], 'bar'));
-      expect(err).toBeInstanceOf(TypeError);
-      expect((<any>err).message).toBe("Failed to execute 'del' on 'crudfs': Name is not allowed.");
-    });
-
-    test('throws if id is not valid', async () => {
-      const { crud } = setup();
-      const [, err] = await of(crud.del(['foo'], 'asdf/asdf'));
-      expect(err).toBeInstanceOf(TypeError);
-      expect((<any>err).message).toBe("Failed to execute 'del' on 'crudfs': Name is not allowed.");
-    });
-
-    describe('when collection does not exist', () => {
-      test('throws by default', async () => {
-        const { crud } = setup();
-        const [, err] = await of(crud.del(['foo'], 'bar'));
-        expect(err).toBeInstanceOf(DOMException);
-        expect((<any>err).name).toBe('CollectionNotFound');
-      });
-
-      test('does not throw when "silent" flag set', async () => {
-        const { crud } = setup();
-        await crud.del(['foo'], 'bar', true);
-      });
-    });
-
-    describe('when collection is found but resource is not', () => {
-      test('throws by default', async () => {
-        const { crud } = setup();
-        await crud.put(['foo'], 'bar', b('abc'));
-        const [, err] = await of(crud.del(['foo'], 'baz'));
-        expect(err).toBeInstanceOf(DOMException);
-        expect((<any>err).name).toBe('ResourceNotFound');
-      });
-
-      test('does not throw when "silent" flag set', async () => {
-        const { crud } = setup();
-        await crud.put(['foo'], 'bar', b('abc'));
-        await crud.del(['foo'], 'baz', true);
-      });
-    });
-
-    test('deletes an existing resource', async () => {
-      const { crud } = setup();
-      await crud.put(['foo'], 'bar', b('abc'));
-      await crud.get(['foo'], 'bar');
-      await crud.del(['foo'], 'bar');
-      const [, err] = await of(crud.get(['foo'], 'bar'));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<any>err).name).toBe('ResourceNotFound');
-    });
-  });
-
-  describe('.info()', () => {
-    test('throws if the type is not valid', async () => {
-      const { crud } = setup();
-      const [, err] = await of(crud.info(['', 'foo'], 'bar'));
-      expect(err).toBeInstanceOf(TypeError);
-      expect((<any>err).message).toBe("Failed to execute 'info' on 'crudfs': Name is not allowed.");
-    });
-
-    test('throws if id is not valid', async () => {
-      const { crud } = setup();
-      const [, err] = await of(crud.info(['foo'], '/'));
-      expect(err).toBeInstanceOf(TypeError);
-      expect((<any>err).message).toBe("Failed to execute 'info' on 'crudfs': Name is not allowed.");
-    });
-
-    test('can retrieve information about a resource', async () => {
-      const { crud } = setup();
-      await crud.put(['foo'], 'bar', b('abc'));
-      const info = await crud.info(['foo'], 'bar');
-      expect(info).toMatchObject({
-        type: 'resource',
-        id: 'bar',
-        size: 3,
-        modified: expect.any(Number),
-      });
-    });
-
-    test('can retrieve information about a collection', async () => {
-      const { crud } = setup();
-      await crud.put(['foo'], 'bar', b('abc'));
-      const info = await crud.info(['foo']);
-      expect(info).toMatchObject({
-        type: 'collection',
-      });
-    });
-
-    test('throws when resource not found', async () => {
-      const { crud } = setup();
-      await crud.put(['foo'], 'bar', b('abc'));
-      const [, err] = await of(crud.info(['foo'], 'baz'));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<any>err).name).toBe('ResourceNotFound');
-    });
-
-    test('throws when collection not found', async () => {
-      const { crud } = setup();
-      await crud.put(['foo', 'a'], 'bar', b('abc'));
-      const [, err] = await of(crud.info(['foo', 'b'], 'baz'));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<any>err).name).toBe('CollectionNotFound');
-    });
-  });
-
-  describe('.drop()', () => {
-    test('throws if the collection is not valid', async () => {
-      const { crud } = setup();
-      const [, err] = await of(crud.drop(['', 'foo']));
-      expect(err).toBeInstanceOf(TypeError);
-      expect((<any>err).message).toBe("Failed to execute 'drop' on 'crudfs': Name is not allowed.");
-    });
-
-    test('can recursively delete a collection', async () => {
-      const { crud } = setup();
-      await crud.put(['foo', 'a'], 'bar', b('1'));
-      await crud.put(['foo', 'a'], 'baz', b('2'));
-      await crud.put(['foo', 'b'], 'xyz', b('3'));
-      const info = await crud.info(['foo', 'a']);
-      expect(info.type).toBe('collection');
-      await crud.drop(['foo', 'a']);
-      const [, err] = await of(crud.info(['foo', 'a']));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<any>err).name).toBe('CollectionNotFound');
-    });
-
-    test('throws if collection does not exist', async () => {
-      const { crud } = setup();
-      await crud.put(['foo', 'a'], 'bar', b('1'));
-      await crud.put(['foo', 'a'], 'baz', b('2'));
-      await crud.put(['foo', 'b'], 'xyz', b('3'));
-      const [, err] = await of(crud.drop(['gg']));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<any>err).name).toBe('CollectionNotFound');
-    });
-
-    test('when "silent" flag set, does not throw if collection does not exist', async () => {
-      const { crud } = setup();
-      await crud.put(['foo', 'a'], 'bar', b('1'));
-      await crud.put(['foo', 'a'], 'baz', b('2'));
-      await crud.put(['foo', 'b'], 'xyz', b('3'));
-      await crud.drop(['gg'], true);
-    });
-
-    test('can recursively delete everything from root', async () => {
-      const { crud, snapshot } = setup();
-      await crud.put(['foo', 'a'], 'bar', b('1'));
-      await crud.put(['baz', 'a'], 'baz', b('2'));
-      await crud.put(['bar', 'b'], 'xyz', b('3'));
-      const info = await crud.info(['foo', 'a']);
-      expect(info.type).toBe('collection');
-      await crud.drop([]);
-      expect(snapshot()).toEqual({});
-    });
-  });
-
-  describe('.list()', () => {
-    test('throws if the collection is not valid', async () => {
-      const { crud } = setup();
-      const [, err] = await of(crud.list(['./..', 'foo']));
-      expect(err).toBeInstanceOf(TypeError);
-      expect((<any>err).message).toBe("Failed to execute 'drop' on 'crudfs': Name is not allowed.");
-    });
-
-    test('can retrieve a list of resources and collections at root', async () => {
-      const { crud } = setup();
-      await crud.put(['foo'], 'bar', b('1'));
-      await crud.put([], 'baz', b('1'));
-      await crud.put([], 'qux', b('2'));
-      const list = await crud.list([]);
-      expect(list.length).toBe(3);
-      expect(list.find(x => x.id === 'baz')).toMatchObject({
-        type: 'resource',
-        id: 'baz',
-      });
-      expect(list.find(x => x.id === 'qux')).toMatchObject({
-        type: 'resource',
-        id: 'qux',
-      });
-      expect(list.find(x => x.id === 'foo')).toMatchObject({
-        type: 'collection',
-        id: 'foo',
-      });
-    });
-
-    test('throws when try to list a non-existing collection', async () => {
-      const { crud } = setup();
-      await crud.put(['foo'], 'bar', b('1'));
-      await crud.put([], 'baz', b('1'));
-      await crud.put([], 'qux', b('2'));
-      const [, err] = await of(crud.list(['gg']));
-      expect(err).toBeInstanceOf(DOMException);
-      expect((<any>err).name).toBe('CollectionNotFound');
-    });
-  });
+  testCrudfs(setup);
 });
diff --git a/src/fsa-to-node/__tests__/FsaNodeFs.test.ts b/src/fsa-to-node/__tests__/FsaNodeFs.test.ts
index d35408f7..56fe6db6 100644
--- a/src/fsa-to-node/__tests__/FsaNodeFs.test.ts
+++ b/src/fsa-to-node/__tests__/FsaNodeFs.test.ts
@@ -5,6 +5,7 @@ import { IDirent, IStats } from '../../node/types/misc';
 import { FsaNodeFs } from '../FsaNodeFs';
 import { tick, until, of } from 'thingies';
 import { onlyOnNode20 } from '../../__tests__/util';
+import { FLAG } from '../../consts/FLAG';
 
 const setup = (json: NestedDirectoryJSON | null = null, mode: 'read' | 'readwrite' = 'readwrite') => {
   const mfs = memfs({ mountpoint: json }) as IFsWithVolume;
@@ -26,6 +27,17 @@ onlyOnNode20('FsaNodeFs', () => {
       expect(mfs.statSync('/mountpoint/test').isDirectory()).toBe(true);
     });
 
+    test('can create a sub-folder with trailing slash', async () => {
+      const { fs, mfs } = setup();
+      await new Promise<void>((resolve, reject) =>
+        fs.mkdir('/test/', err => {
+          if (err) return reject(err);
+          return resolve();
+        }),
+      );
+      expect(mfs.statSync('/mountpoint/test').isDirectory()).toBe(true);
+    });
+
     test('throws when creating sub-sub-folder', async () => {
       const { fs } = setup();
       try {
@@ -654,6 +666,20 @@ onlyOnNode20('FsaNodeFs', () => {
         '/mountpoint/f.html': 'test',
       });
     });
+
+    test('throws "EEXIST", if file already exists and O_EXCL flag set', async () => {
+      const { fs } = setup({ folder: { file: 'test' }, 'empty-folder': null, 'f.html': 'test' });
+      const [, err] = await of(fs.promises.writeFile('/folder/file', 'bar', { flag: 'wx' }));
+      expect(err).toBeInstanceOf(Error);
+      expect((<any>err).code).toBe('EEXIST');
+    });
+
+    test('throws "ENOENT", if file does not exist and O_CREAT flag not set', async () => {
+      const { fs } = setup({ folder: { file: 'test' }, 'empty-folder': null, 'f.html': 'test' });
+      const [, err] = await of(fs.promises.writeFile('/folder/file2', 'bar', { flag: FLAG.O_RDWR }));
+      expect(err).toBeInstanceOf(Error);
+      expect((<any>err).code).toBe('ENOENT');
+    });
   });
 
   describe('.read()', () => {
diff --git a/src/fsa-to-node/__tests__/util.test.ts b/src/fsa-to-node/__tests__/util.test.ts
index 7e065ed1..a43bde59 100644
--- a/src/fsa-to-node/__tests__/util.test.ts
+++ b/src/fsa-to-node/__tests__/util.test.ts
@@ -13,6 +13,10 @@ describe('pathToLocation()', () => {
     expect(pathToLocation('scary.exe')).toStrictEqual([[], 'scary.exe']);
   });
 
+  test('strips trailing slash', () => {
+    expect(pathToLocation('scary.exe/')).toStrictEqual([[], 'scary.exe']);
+  });
+
   test('multiple steps in the path', () => {
     expect(pathToLocation('/gg/wp/hf/gl.txt')).toStrictEqual([['gg', 'wp', 'hf'], 'gl.txt']);
     expect(pathToLocation('gg/wp/hf/gl.txt')).toStrictEqual([['gg', 'wp', 'hf'], 'gl.txt']);
diff --git a/src/node-to-crud/__tests__/FsaCrud.test.ts b/src/node-to-crud/__tests__/FsaCrud.test.ts
new file mode 100644
index 00000000..e6ab8fdd
--- /dev/null
+++ b/src/node-to-crud/__tests__/FsaCrud.test.ts
@@ -0,0 +1,17 @@
+import { memfs } from '../..';
+import { onlyOnNode20 } from '../../__tests__/util';
+import { NodeCrud } from '../NodeCrud';
+import { testCrudfs } from '../../crud/__tests__/testCrudfs';
+
+const setup = () => {
+  const fs = memfs();
+  const crud = new NodeCrud({
+    fs: fs.promises,
+    dir: '/',
+  });
+  return { fs, crud, snapshot: () => (<any>fs).__vol.toJSON() };
+};
+
+onlyOnNode20('NodeCrud', () => {
+  testCrudfs(setup);
+});
diff --git a/src/node-to-fsa/__tests__/util.test.ts b/src/node-to-fsa/__tests__/util.test.ts
index 8556f236..751c71d1 100644
--- a/src/node-to-fsa/__tests__/util.test.ts
+++ b/src/node-to-fsa/__tests__/util.test.ts
@@ -9,6 +9,11 @@ describe('basename()', () => {
     expect(basename('scary.exe', '/')).toBe('scary.exe');
   });
 
+  test('ignores slash, if it is the last char', () => {
+    expect(basename('scary.exe/', '/')).toBe('scary.exe');
+    expect(basename('/ab/c/scary.exe/', '/')).toBe('scary.exe');
+  });
+
   test('returns last step in path', () => {
     expect(basename('/gg/wp/hf/gl.txt', '/')).toBe('gl.txt');
     expect(basename('gg/wp/hf/gl.txt', '/')).toBe('gl.txt');