sanctuary-js / sanctuary

:see_no_evil: Refuge from unsafe JavaScript
https://sanctuary.js.org
MIT License
3.03k stars 94 forks source link

Getting started #390

Open janwirth opened 7 years ago

janwirth commented 7 years ago

The docs are completely bereft of a 'getting started' section. How is anyone who is not overly familiar with the matter supposed to use this?

This lib looks awesome, I have experience with elm, ramda, lodash/fp but I still have no clue how to use it.

janwirth commented 7 years ago

I'm not trying to be overly whiney, but nothing on the website tells me what it really does.

Docs by example would be a good practice here.

In the meantime, playing around with S in the REPL

janwirth commented 7 years ago

Also, how does it relate to ramda, except that it's stricter? Where is lens or converge?

janwirth commented 7 years ago

What is z referring to?

Avaq commented 7 years ago

Hello @FranzSkuffka, thank you for highlighting these issues. I'll do my best to provide answers to your questions and when things become clearer, maybe you can provide your thoughts on how we can improve the documentation for future newcomers.

Am I supposed to use sanctuay during compile- or runtime

Sanctuary is a standard library of sorts. You can use it like you would Ramda or Lodash, the only difference being that there is an optional way to configure it before using.

How does it relate to build-systems like webpack?

No relation.

What is a sanctuary module? Does it replace commonjs modules?

I'm not sure what you are referring to. Usually when we talk about "module" (like the return value from create) we are referring to the exports object from commonjs (node) modules.

How do I create a simple, type-checked String -> String function?

Sanctuary itself does not provide a way to do this. If you are interested in defining your own type-checked functions, you are looking for sanctuary-def.

The docs are completely bereft of a 'getting started' section Also, how does it relate to ramda, except that it's stricter?

377 Adds a section to the README which should serve as somewhat of an introduction to users already familiar with Ramda. Some of your questions are answered by this text.

What is Z referring to?

Z is the name of the export from sanctuary-type-classes. Most of the occurrences of Z in the Sanctuary documentation are links to the appropriate documentation in sanctuary-type-classes. Might I ask which occurrence got you wondering? We may have forgotten to link it.

janwirth commented 7 years ago

Thank you very much for clearing this up.

Might I ask which occurrence got you wondering?

I was just browsing through the issues and assumed there is some kind of convention. I think in the particular instance I remember, Z was referring to the fantasyland standard library.

I will keep following this project and try everything out.

If I need lenses or the sort, I should just use https://github.com/fantasyland/fantasy-lenses, is that correct? Is it compatible with sanctuary?

janwirth commented 7 years ago

Another question: Why is this ok: S.fromMaybe(['nuffin'], S.tail([])) but not S.pipe(S.tail, S.fromMaybe(['nuffin']))? What am I missing?

Avaq commented 7 years ago

The error message should give you some idea about what's going on. Let's examine it:

1.  pipe :: Array Function -> a -> b
2.          ^^^^^^^^^^^^^^
3.                1
4.  
5.  1)  tail :: List a -> Maybe (List a) :: Function, (??? -> ???)
6.  
7.  The value at position 1 is not a member of ‘Array Function’.

Line 1 shows us the type signature of pipe (Array Function -> a -> b), indicating that something didn't conform to the constraints enforced by S.pipe. Line 2 underlines the constraint which has been violated Line 3 assigns a reference to the underlined part, so we can show the actual types at each of the underlined constraints Line 5 shows the actual type which was encountered at constraint 1, namely: a Function called tail of type List a -> Maybe (List a). Line 7 explains that this type is incompatible with the Array Function constraint.

So in other words: You provided a Function where we expected an Array of Function. This probably happened because you assumed the S.pipe signature to be the same as R.pipe. However, Sanctuary avoids using variadic functions, and therefore S.pipe takes an Array:

S.pipe([S.tail, S.fromMaybe(['nuffin'])])
janwirth commented 7 years ago

Like lodash/fp. Cool.

Avaq commented 7 years ago

If you decide to continue using the library, and have any further questions, you might also try asking them in the Gitter channel. Generally the response there comes faster.

davidchambers commented 7 years ago

Thank you for raising these issues, @FranzSkuffka. We currently assume new users will bring with them functional programming knowledge from Clojure, Elm, Haskell, PureScript, or another functional language. I'd like Sanctuary to be approachable to those coming from one of the _ libraries or even from vanilla JavaScript. A dedicated document at https://sanctuary.js.org/getting-started/ may be warranted.

Thank you for the helpful and detailed responses, @Avaq! :clap:

OriginalEXE commented 6 years ago

Any chance we could re-open this? A "getting started" docs would be super cool. I am new to the functional programming world and would greatly appreciate an ELI5 intro to using Sanctuary.

janwirth commented 6 years ago

@OriginalEXE For getting started with FP I suggest reading https://github.com/MostlyAdequate/mostly-adequate-guide and also looking into es6 map/filter/reduce + composition. That gives you 80% of the power of FP for 20% the effort.

I feel that Sanctuary really targets 'computer scientests' and very ambitious developers. Fiddling around with Haskell will help you tapping into the power of Sanctuary. I also strongly suggest trying out Elm: https://github.com/franzskuffka/elm-webpack-starter-1

OriginalEXE commented 6 years ago

@FranzSkuffka Thank you very much for your reply.

I am not new to JavaScript, so I am familiar with the array methods and use them every day, but I have to change my mindset.

Also, I would call myself a "very ambitious developer" :) I will look into that guide and the Elm, thanks!

davidchambers commented 6 years ago

Also, I would call myself a "very ambitious developer" :)

Excellent! Please say hello on Gitter if you feel so inclined. :)

danielo515 commented 6 years ago

I subscribe to this issue. A getting started, or some kind of manual is very necessary. Usually opening a project's page and being directly confronted to the API is very scary. A small document in form of initial steps and some examples will help even the most senior developers.

Regards

davidchambers commented 6 years ago

I wholeheartedly agree, @danielo515. I hope to get to this in the coming months. :)

chughes87 commented 4 years ago

I think having a searchable docs page like ramda's https://ramdajs.com/docs would be very helpful for newcomers as well (LIKE ME)

maciejmiklas commented 3 years ago

I was looking for some functional style API for JS, I've read a book and implemented few Monads by myself, so I have some basic idea. But honestly after few hours I have totally no Idea how could I use Sanctuary, it this only a specification, can I use it in real JS programs....

In my case I'm already using Ramda, the only thing that I'm missing there is null-checking and error handling. So I just wanted to add Sanctuary Types to it, like: https://github.com/sanctuary-js/sanctuary-either. Honestly based on this documentation I have no Idea how would I even start....

I'm not talking here about a whole book, but few working code examples would really help. Something like that: https://github.com/ramda/ramda-fantasy/blob/master/docs/Maybe.md

davidchambers commented 3 years ago

Thanks for sharing your experience, @maciejmiklas, and welcome to the community! :wave:

I suggest watching Programming Safely in an Uncertain World, then solving the challenge yourself, with vanilla JavaScript and with Sanctuary. The challenge is contrived, certainly, but dealing with untyped input is very much a practical concern.

You might like to share a link to code you are rewriting in terms of algebraic data types, on Gitter perhaps. :)

maciejmiklas commented 3 years ago

Thanks - I will look into it. My project is on github, but I do not have something "worth showing" yet.

janwirth commented 3 years ago

@maciejmiklas Sanctuary was really valuable to me when I developed a CLI. The CLI does some complicated things like streaming files over websockets. Sanctuary-def helped me tuck types into place by building custom types. Then I added unit tests that would allow me to verify that everything fits together at development-time (not compile-time but when the tests are run).

This saved heaps of time. I did not make a lot of use of sanctuary itself but used it as a basis for development. I added Fluture into the mix to deal with async operations. I find the error messages a lot more helpful than typescript and the system to be more sound, flexible and idiomatic.

CliMsg.mjs

export const WriteOperationSymbol = "WriteOperation";
export const WriteOperationSymbolType = $.NullaryType("WriteOperation")(
    "WriteOperation"
)([])(data => data === "WriteOperation");

export const IdType = $.NullaryType("IdType")("IdType")([])(
    data => typeof data === "string" && data.length >= 9
);

export const PayloadType = $.RecordType({
    type: WriteOperationSymbolType,
    data: Operation.WriteOperationType
});

export const MsgType = $.RecordType({
    id: IdType,
    payload: PayloadType
});

export const SocketType = $.NullaryType("SocketType")("SocketType")([])(
    socket => !!socket.emit
);

export const fromOperation = def("fromOperation")({})([
    Operation.WriteOperationType,
    MsgType
])(fileOperation => {
    const id_ = id();
    const msg = {
        id: id_,
        payload: { type: WriteOperationSymbol, data: fileOperation }
    };
    return msg;
});

var pendingOperations = {};

export const emit = def("emit")({})([
    MsgType,
    $.Maybe(SocketType),
    Future($.Error)(MsgType)
])(msg => socket =>
    F.Future((reject, resolve) => {
        if (S.isJust(socket)) {
            const s = S.maybeToNullable(socket);
            s.emit("msg", msg);
            resolve(msg);
            return () => "";
        }
        resolve(msg);
        return () => "";
    })
);

dev.mjs


export const addToTransactionLog = def("addToTransactionLog")({})([
    Operations.WriteOperationType,
    $.Any,
    $.Any
])(operation => transactionLog => {
    transactionLog[operation.path] = hasha(operation.contents);
    return transactionLog;
});
export const removeFromTransactionLog = def("removeFromTransactionLog")({})([
    Operations.WriteOperationType,
    $.Any,
    $.Any
])(operation => transactionLog => {
    delete transactionLog[operation.path];
    return transactionLog;
});
export const isInTransactionLog = def("isInTransactionLog")({})([
    Operations.WriteOperationType,
    $.Any,
    $.Any
])(operation => transactionLog => {
    const isThere = transactionLog[operation.path] == hasha(operation.contents);
    return isThere;
});

const handleMsg = socket => async ({ payload, id }) => {
    try {
        console.log("\n[msg]", id);
        if (payload.type == "WriteOperation") {
            await F.promise(
                Operations.executeWrite(process.cwd())(payload.data)
            );
            transactionLog = addToTransactionLog(payload.data)(transactionLog);
            socket.emit("msgok", { id });
        } else {
            throw `msg type ${payload.type} not supported`;
        }
    } catch (err) {
        console.log(err);
        socket.emit("msgerr", { id, err: err.toString() });
    }
};

Operation.mjs

// Define file system operations to send over the wire
import { def, $, S, F, Future, log } from "./fp.mjs";

// helpers for modifying and generating paths
import * as Path from "path";
import hasha from "hasha";
import slugify from "slugify";

// -- TYPES --

// A local path starts with '/'
const LocalPathType = $.NullaryType("LocalPathType")("LocalPathType")([])(
    data => {
        return typeof data === "string" && data.startsWith("/");
    }
);

// this one starts with root>
const AgnosticFilePathType = $.NullaryType("AgnosticFilePathType")(
    "AgnosticFilePathType"
)([])(data => {
    return typeof data === "string" && data.startsWith("root>");
});

// buffer | string
export const FileContentsType = $.NullaryType("FileContentsType")(
    "FileContentsType"
)([])(data => {
    return (
        typeof data === "string" ||
        data instanceof Buffer ||
        data instanceof ArrayBuffer
    );
});

// Is it a file as in the browser API
const FileType = $.NullaryType("FileType")("FileType")([])(data => {
    return data instanceof File;
});

// Is it a file as in the browser API
const ArrayBufferType = $.NullaryType("ArrayBufferType")("ArrayBufferType")([])(
    data => {
        return data instanceof ArrayBuffer;
    }
);

// the write data to send over the wire
export const WriteOperationType = $.RecordType({
    path: AgnosticFilePathType,
    contents: FileContentsType
});

// the read location to send over the wire
// QUESTION: does this even have a use case
const ReadOperationType = $.RecordType({
    path: AgnosticFilePathType
});

// -- IMPLEMENTATION --

// API
const executeWrite_ = cwd =>
    F.encaseP(async ({ path, contents }) => {
        const localPath = localize(cwd)(path);
        await F.promise(ensureDir(localPath));
        return await fs.promises
            .writeFile(localPath, contents)
            .then(() => path);
    });

export const executeWrite = def("executeWrite")({})([
    LocalPathType,
    WriteOperationType,
    Future($.Error)(AgnosticFilePathType)
])(executeWrite_);

const executeRead_ = cwd => ({ path }) => readFileWithFs(localize(cwd)(path));

const readFileWithFs = def("readFileWithFs")({})([
    LocalPathType,
    Future($.Any)($.Buffer)
])(F.encaseP(path => fs.promises.readFile(path)));

export const executeRead = def("executeRead")({})([
    LocalPathType,
    ReadOperationType,
    Future($.Error)($.Buffer)
])(executeRead_);

const toString = def("toString")({})([$.Buffer, $.String])(buffer =>
    buffer.toString()
);

// An uploaded file gets hashed to prevent collisions.
const makeWriteOperationFromUiFileUpload_ = file => {
    const combine = ([fileHash, fileBuffer]) => {
        const parts = slugify(file.name, {
            lower: true,
            replacement: "-"
        }).split(".");
        const ext = parts[parts.length - 1];
        const base = "root>assets";
        const fileName = [...[parts.slice(0, -1)], fileHash, ext].join(".");
        return { path: [base, fileName].join("/"), contents: fileBuffer };
    };

    const addHash = buffer => F.both(hash(buffer))(F.resolve(buffer));
    return F.map(combine)(F.chain(addHash)(readFileWithFileReader(file)));
};

export const makeWriteOperationFromUiFileUpload = def(
    "makeWriteOperationFromUiFileUpload"
)({})([FileType, Future($.Error)(WriteOperationType)])(
    makeWriteOperationFromUiFileUpload_
);

// We need to cut off the local path to make things agnostic
const makeWriteOperationFromLocalFileAddition_ = cwd => path => {
    const readOp = readFileWithFs(path);
    const agnosticPath = "root>" + path.slice(cwd.length + 1);
    const bake = buf => ({ path: agnosticPath, contents: buf });
    return F.map(bake)(readOp);
};

export const makeWriteOperationFromLocalFileAddition = def(
    "makeWriteOperationFromLocalFileAddition"
)({})([LocalPathType, LocalPathType, Future($.Error)(WriteOperationType)])(
    makeWriteOperationFromLocalFileAddition_
);

// For the browser
export const readFileWithFileReader = def("readFileWithFileReader")({})([
    FileType,
    Future($.Error)($.Buffer)
])(
    F.encaseP(
        file =>
            new Promise((resolve, reject) => {
                var reader = new FileReader();
                reader.addEventListener("loadend", function() {
                    resolve(reader.result);
                });
                reader.readAsArrayBuffer(file);
                return function() {
                    reader.abort();
                };
            })
    )
);

export const arrayBufferToString = def("arrayBufferToString")({})([
    ArrayBufferType,
    $.String
])(buf => {
    const enc = new TextDecoder();
    return enc.decode(buf);
});

// helpers

const hash = def("hash")({})([FileContentsType, Future($.Void)($.String)])(
    F.encaseP(async data => {
        try {
            return await hasha.async(data, { algorithm: "md5" });
        } catch {
            return await hasha.async(data.toString(), { algorithm: "md5" });
        }
    })
);
// window.hash = x => F.promise(hash(x))

export const localize = def("localize")({})([
    LocalPathType,
    AgnosticFilePathType,
    LocalPathType
])(cwd => path => {
    const withoutPrefix = path.slice(5);
    return Path.join(cwd, withoutPrefix);
});

const ensureDir = def("ensureDir")({})([
    LocalPathType,
    Future($.Error)($.Null)
])(
    F.encaseP(async path => {
        const dir_ = path
            .split("/")
            .slice(0, -1)
            .join("/");
        const dir = dir_ === "" ? "/" : dir_;
        try {
            await fs.promises.mkdir(dir);
            return;
        } catch (e) {
            if (e.code == "EEXIST") {
                return;
            } else {
                throw e;
            }
        }
    })
);
janwirth commented 3 years ago

Using zora for testing I slowly evolve code from unit tests into usable modules:

compile.mjs

/**
 * A declarative file system.
 * Works like VDOM
 */

import { def, log, F, S, $, Future } from "../src/fp.mjs";
import * as Operation from "../src/Operation.mjs";
import { test } from "zora";
import * as FileSystem from "../src/FileSystem.mjs";
import hasha from "hasha";

const packageName = "";
const packageURL = "";

export const LazyType = (name, type) =>
    $.NullaryType(`${packageName}/${name}`)(`${packageURL}#${name}`)([])(x =>
        $.test($.env, type(), x)
    );

export const LazyDirectorySpecType = LazyType(
    "DirectorySpecType",
    () => DirectorySpecType
);

export const FileSpecType = $.RecordType({
    name: $.String,
    value: Operation.FileContentsType
});
export const DiffSummaryType = $.RecordType({
    missingInReplica: $.Array($.String),
    missingInPrimary: $.Array($.String),
    differingSharedFiles: $.Array($.String),
    identicalSharedFiles: $.Array($.String)
});

export const DirectoryOrFile = $.Either(LazyDirectorySpecType)(FileSpecType);

export const DirectorySpecType = $.Array1(DirectoryOrFile);

const ComparisonResultType = $.Either(DiffSummaryType)($.Null);

const compare = def("compare")({})([
    $.String,
    $.String,
    Future($.Error)(ComparisonResultType)
])(pathPrimary =>
    F.encaseP(async pathReplica => {
        // read all files in both folders
        const filesPrimary = (
            await FileSystem.listFiles(pathPrimary)
        ).map(path => path.replace(pathPrimary, ""));
        const filesReplica = (
            await FileSystem.listFiles(pathReplica)
        ).map(path => path.replace(pathReplica, ""));

        const checkPath = async pathOfFileInPrimary => {
            if (filesReplica.indexOf(pathOfFileInPrimary) > -1) {
                const hashInPrimary = await hasha.fromFile(
                    pathPrimary + pathOfFileInPrimary
                );
                const hashInSecondary = await hasha.fromFile(
                    pathReplica + pathOfFileInPrimary
                );
                return S.Just(
                    S.Pair(pathOfFileInPrimary)(
                        hashInPrimary == hashInSecondary
                    )
                );
            } else {
                return S.Nothing;
            }
        };
        const checkProcesses = filesPrimary.map(checkPath);
        const shared = S.justs(await Promise.all(checkProcesses));
        const missingInReplica = filesPrimary.filter(
            path => !(filesReplica.indexOf(path) > -1)
        );
        const missingInPrimary = filesReplica.filter(
            path => !(filesPrimary.indexOf(path) > -1)
        );
        console.log("filesPrimary", filesPrimary);
        console.log("filesReplica", filesReplica);
        console.log("shared", shared);
        console.log("missingInPrimary", missingInPrimary);
        console.log("missingInReplic", missingInReplica);
        const differingSharedFiles = S.map(S.fst)(S.reject(S.snd)(shared));
        const identicalSharedFiles = S.map(S.fst)(S.filter(S.snd)(shared));
        const isInSync =
            missingInPrimary.length === 0 &&
            missingInReplica.length === 0 &&
            differingSharedFiles.length === 0;
        if (isInSync) {
            return S.Right(null);
        } else {
            const diffSummary = {
                missingInPrimary,
                missingInReplica,
                differingSharedFiles,
                identicalSharedFiles
            };
            return S.Left(diffSummary);
        }
        // normalize file paths and match them
        // aggregate
        // rebase file paths and their hashes
    })
);

test("compare matching directories", async t => {
    const compareOperation = compare("./tests/fixtures/matching-directories/a")(
        "./tests/fixtures/matching-directories/b"
    );
    const result = await F.promise(compareOperation);
    t.ok(S.isRight(result));
});

test("compare matching directories", async t => {
    const expected = {
        differingSharedFiles: ["/samename"],
        identicalSharedFiles: ["/justfine"],
        missingInPrimary: ["/bar", "/baz"],
        missingInReplica: ["/foo"]
    };
    const compareOperation = compare(
        "./tests/fixtures/different-directories/a"
    )("./tests/fixtures/different-directories/b");
    const result = await F.promise(compareOperation);
    console.dir(result);
    if (S.isLeft(result)) {
        console.log(result.value);
        t.equals(result.value, expected);
    } else {
        t.ok(false);
    }
});
jceb commented 3 years ago

I just stumbled over these two blog series that introduce fantasy-land in detail and find them really helpful:

I also wasn't aware of daggy which makes it easy to create sum types.