From 75b0057952bd5e9ad6f795838c91a8b58a34e2eb Mon Sep 17 00:00:00 2001 From: Aldwin Vlasblom Date: Thu, 29 Jun 2023 16:02:01 +0200 Subject: [PATCH] Add everything --- .github/workflows/pr.yml | 34 ++++ .gitignore | 2 + LICENSE.md | 13 ++ README.md | 237 +++++++++++++++++++++++ example/index.ts | 15 ++ example/services/app.ts | 47 +++++ example/services/database.ts | 51 +++++ example/services/env.ts | 27 +++ example/services/index.ts | 23 +++ example/services/logger.ts | 30 +++ example/services/server.ts | 41 ++++ package-lock.json | 251 ++++++++++++++++++++++++ package.json | 33 ++++ src/Bracket.test.ts | 364 +++++++++++++++++++++++++++++++++++ src/Bracket.ts | 163 ++++++++++++++++ src/Service.ts | 22 +++ src/index.ts | 2 + test/assert.ts | 33 ++++ test/index.ts | 51 +++++ tsconfig.build.json | 7 + tsconfig.json | 20 ++ 21 files changed, 1466 insertions(+) create mode 100644 .github/workflows/pr.yml create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 example/index.ts create mode 100644 example/services/app.ts create mode 100644 example/services/database.ts create mode 100644 example/services/env.ts create mode 100644 example/services/index.ts create mode 100644 example/services/logger.ts create mode 100644 example/services/server.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/Bracket.test.ts create mode 100644 src/Bracket.ts create mode 100644 src/Service.ts create mode 100644 src/index.ts create mode 100644 test/assert.ts create mode 100644 test/index.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..3316b76 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,34 @@ +name: PR + +on: + pull_request: + +jobs: + test: + name: Test + runs-on: ubuntu-latest + env: + node-version: 20.x + + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Install NodeJS + uses: actions/setup-node@v3 + with: + node-version: ${{ env.node-version }} + + - name: Cache Node Modules + uses: actions/cache@v3 + id: cache-node-modules + with: + path: node_modules + key: ${{ runner.OS }}-node${{ env.node-version }}-ci-${{ hashFiles('**/package-lock.json') }} + + - name: Install Dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm install --ignore-scripts + + - name: Run unit tests + run: npm run test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca54677 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/lib/ +/node_modules/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f0fa667 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,13 @@ +Copyright 2023 Aldwin Vlasblom + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/README.md b/README.md index f5fd7e3..c55439f 100644 --- a/README.md +++ b/README.md @@ -1 +1,238 @@ # FP-TS Bootstrap + +This is a module aimed at application bootstrapping using types from [fp-ts][]. +Its ideas and most of the code were ported from the [fluture-hooks][] library. + +This module mainly provides a [Bracket type](#bracket) with accompanying type +class instances. The Bracket type is a drop-in replacement for the Cont type +from [fp-ts-cont][], but specialized in returning `TaskEither`. This solves the +problem stipulated at the end of [application bootstrapping with fp-ts][] by +allowing the return type to be threaded through the program. Furthermore, it +makes the `ApplicativePar` instance possible, which allows for parallel +composition of bracketed resources. + +Besides the Bracket type, this module also provides a [Service type](#service) +which is a small layer on top for managing dependencies through the Reader monad. + +[fp-ts]: https://gcanti.github.io/fp-ts/ +[fluture-hooks]: https://github.com/fluture-js/fluture-hooks +[fp-ts-cont]: https://github.com/joshburgess/fp-ts-cont +[application bootstrapping with fp-ts]: https://dev.to/avaq/application-bootstrapping-with-fp-ts-59b5 + +## Example + +Define your service. See the full example in +[`./example/services/server.ts`](./example/services/server.ts). + +```ts +export const withServer: Service.Service = ( + ({port, app}) => Bracket.bracket( + () => new Promise(resolve => { + const server = HTTP.createServer(app); + server.listen(port, () => resolve(E.right(server))); + }), + server => () => new Promise(resolve => { + server.close((e: unknown) => resolve( + e instanceof Error ? E.left(e) : E.right(undefined) + )); + }), + ) +); +``` + +Combine multiple such services with ease using Do notation. See the full example +in [`./example/services/index.ts`](./example/services/index.ts). + +```ts +export const withServices = pipe( + withEnv, + Bracket.bindTo('env'), + Bracket.bind('logger', ({env}) => withLogger({level: env.LOG_LEVEL})), + Bracket.bind('database', ({env, logger}) => withDatabase({ + url: env.DATABASE_URL, + logger: logger + })), + Bracket.bind('app', ({database}) => withApp({database})), + Bracket.bind('server', ({env, app}) => withServer({ + port: env.PORT, + app: app, + })), +); +``` + +Consume your service. See the full example in [`./example/index.ts`](./example/index.ts). + +```ts +const program = withServices(({server, logger}) => pipe( + TE.fromIO(logger.info(`Server listening on ${JSON.stringify(server.address())}`)), + TE.apSecond(TE.fromTask(() => new Promise(resolve => { + process.once('SIGINT', resolve); + }))), + TE.chain(() => TE.fromIO(logger.info('Shutting down app'))), +)); +``` + +And finally, run your program: + +```ts +program().then(E.fold(console.error, console.log), console.error); +``` + +## Types + +### Bracket + +```ts +import {Bracket} from 'fp-ts-bootstrap'; +``` + +```ts +type Bracket = ( + (consume: (resource: R) => TaskEither) => TaskEither +); +``` + +The Bracket type aliases the structure that's encountered when using a curried +variant of [fp-ts' `TaskEither.bracket` function][]. This curried variant is +also exported from the Bracket module as `bracket`. It models a bracketed +resource for which the consumption hasn't been specified yet. + +[fp-ts' `TaskEither.bracket` function]: https://gcanti.github.io/fp-ts/modules/TaskEither.ts.html#bracket + +The Bracket module defines various type class instances for `Bracket` that allow +you to compose and combine multiple bracketed resources. From most instances, +some derivative functions are exported as well. + +- Pointed: `of`, `Do` +- Functor: `map`, `flap`, `bindTo`, `let` +- Apply: `ap`, `apFirst`, `apSecond`, `apS`, `getApplySemigroup`, `sequenceT`, `sequenceS` +- Applicative: Pointed Apply +- Chain: `chain`, `chainFirst`, `bind` +- Monad: Pointed Chain +- ApplyPar: `apPar`, `apFirstPar`, `apSecondPar`, `apSPar`, `getApplySemigroupPar`, `sequenceTPar`, `sequenceSPar` +- ApplicativePar: Pointed ApplyPar + +### Service + +```ts +import {Service} from 'fp-ts-bootstrap'; +``` + +```ts +type Service = Reader>; +``` + +The Service type is a small layer on top of Reader that formalizes the +type of a Bracket with dependencies. The Service type can also be composed and +combined using the utilities provided by `ReaderT`. These utilities +are re-exported from [the Service module](./src/Service.ts). + +## Cookbook + +### Defining a service with acquisition and disposal + +```ts +import * as FS from 'fs/promises'; +import * as TE from 'fp-ts/TaskEither'; +import * as E from 'fp-ts/Either'; +import {Bracket} from 'fp-ts-bootstrap'; + +const acquireFileHandle = (url: string) => ( + TE.tryCatch(() => FS.open(url, 'a'), E.toError) +); + +const disposeFileHandle = (file: FS.FileHandle) => ( + TE.tryCatch(() => file.close(), E.toError) +); + +const withMyFile = Bracket.bracket( + acquireFileHandle('/tmp/my-file.txt'), + disposeFileHandle, +); +``` + +### Defining a service with dependencies + +This recipe builds on the previous one by adding dependencies to the service. + +```ts +import {Service} from 'fp-ts-bootstrap/lib/Service'; + +type Dependencies = { + url: string; +}; + +const withMyFile: Service = ( + ({url}) => Bracket.bracket( + acquireFileHandle(url), + disposeFileHandle, + ) +); +``` + +### Combining services in parallel + +The Bracket type has a sequential `Applicative` instance that it uses by +default, but there's also a parallel `ApplicativePar` instance that you can use +to combine services in parallel\*. Two very useful derivative function using +`ApplicativePar` are + +- `sequenceSPar` for building a Struct of resources from a Struct of Brackets; and +- `apSPar` for adding another property to an existing Struct of services: + +```ts +import {pipe} from 'fp-ts/function'; +import {Bracket} from 'fp-ts-bootstrap'; + +const withServices = pipe( + Bracket.sequenceSPar({ + env: withEnv, + logger: withLogger({level: 'info'}), + }), + Bracket.apSPar('database', withDatabase({url: 'postgres://localhost:5432'})) +); + +const program = withServices(({env, logger, database}) => pipe( + // ... +)); +``` + +\* By "in parallel" we mean that the services are *acquired* in parallel, but +disposed in sequence. This is a technical limitation that exists to ensure that +the `ApplyPar` instance is lawful. + +### Threading dependencies during service composition + +```ts +import {pipe} from 'fp-ts/function'; +import {Bracket} from 'fp-ts-bootstrap'; + +const withServices = pipe( + withEnv, + Bracket.bindTo('env'), + Bracket.bind('logger', ({env}) => withLogger({level: env.LOG_LEVEL})), + Bracket.bind('database', ({env, logger}) => withDatabase({ + url: env.DATABASE_URL, + logger: logger + })), + Bracket.bind('server', ({env, database}) => withServer({ + port: env.PORT, + app: app, + database: database, + })), +); +``` + +### Creating a full-fledged program by composing services + +There's a fully working example app in the [`./example`](./example) directory. +To run it, clone this repo and run the following commands: + +```console +$ npm install +$ ./node_modules/.bin/ts-node ./example/index.ts +``` + +You should now be able to visit http://localhost:3000/arbitrary/path, +which should give you a Hello World response, and log your request URL +to `./database.txt`. diff --git a/example/index.ts b/example/index.ts new file mode 100644 index 0000000..54d0bb0 --- /dev/null +++ b/example/index.ts @@ -0,0 +1,15 @@ +import {pipe} from 'fp-ts/function'; +import * as TE from 'fp-ts/TaskEither'; +import * as E from 'fp-ts/Either'; + +import {withServices} from './services'; + +const program = withServices(({server, logger}) => pipe( + TE.fromIO(logger.info(`Server listening on ${JSON.stringify(server.address())}`)), + TE.apSecond(TE.fromTask(() => new Promise(resolve => { + process.once('SIGINT', resolve); + }))), + TE.chain(() => TE.fromIO(logger.info('Shutting down app'))), +)); + +program().then(E.fold(console.error, console.log), console.error); diff --git a/example/services/app.ts b/example/services/app.ts new file mode 100644 index 0000000..dd30d6c --- /dev/null +++ b/example/services/app.ts @@ -0,0 +1,47 @@ +import * as HTTP from 'node:http'; + +import {pipe} from 'fp-ts/function'; +import * as TE from 'fp-ts/TaskEither'; +import * as E from 'fp-ts/Either'; + +import * as Bracket from '../../src/Bracket'; +import * as Service from '../../src/Service'; + +import {Database} from './database'; + +/*\ + * + * This service provides an HTTP Request Listener that logs the request URL + * and the current time to the database, and returns a "Hello, world!" response. + * + * Its purpose is to demonstrate how to use the Bracket module to create a + * service that depends on other services. + * +\*/ + +export type Dependencies = { + database: Database; +}; + +export const withApp: Service.Service = ( + ({database}) => Bracket.of((req, res) => { + const task = pipe( + TE.fromIO(() => new Date()), + TE.chain(now => database.save(`Visit to ${req.url} at ${now.toISOString()}`)), + TE.map(() => 'Hello, world!'), + ); + + task().then(E.fold( + e => { + res.writeHead(500, {'Content-Type': 'text/plain'}); + res.end(e.message); + }, + data => { + res.writeHead(200, {'Content-Type': 'text/plain'}); + res.end(data); + }, + )) + }) +); + +export type App = Service.ResourceOf; diff --git a/example/services/database.ts b/example/services/database.ts new file mode 100644 index 0000000..abaa14e --- /dev/null +++ b/example/services/database.ts @@ -0,0 +1,51 @@ +import * as FS from 'node:fs/promises' + +import * as TE from 'fp-ts/TaskEither'; +import {toError} from 'fp-ts/Either'; +import {pipe} from 'fp-ts/function'; + +import * as Bracket from '../../src/Bracket'; +import {Service} from '../../src/Service'; + +import {Logger} from './logger'; + +/*\ + * + * This service provides a contrived "database" object. Its only method simply + * appends a string to a file. + * + * Its purpose is to demonstrate how to use the Bracket module to create a + * service that acquires and disposes of a resource, but does not expose the + * resource itself. This is achieved by using `Bracket.map` to transform the + * resource into a new object with a different interface. + * +\*/ + +export type Dependencies = { + url: string; + logger: Logger; +}; + +export type Database = { + save: (data: string) => TE.TaskEither; +}; + +const acquireFileHandle = (url: string) => ( + TE.tryCatch(() => FS.open(url, 'a'), toError) +); + +const disposeFileHandle = (file: FS.FileHandle) => ( + TE.tryCatch(() => file.close(), toError) +); + +export const withDatabase: Service = ( + ({url, logger}) => pipe( + Bracket.bracket(acquireFileHandle(url), disposeFileHandle), + Bracket.map(file => ({ + save: data => pipe( + TE.fromIO(logger.info(`Saving ${data} to ${url}`)), + TE.apSecond(TE.tryCatch(() => file.writeFile(`${data}\n`), toError)), + ), + })), + ) +); diff --git a/example/services/env.ts b/example/services/env.ts new file mode 100644 index 0000000..79f7e95 --- /dev/null +++ b/example/services/env.ts @@ -0,0 +1,27 @@ +import * as TE from 'fp-ts/TaskEither'; + +import * as Bracket from '../../src/Bracket'; + +/*\ + * + * This is the service that provides the environment variables. + * + * Its purpose is to demonstrate how to use the Bracket module to create a + * service that does not depend on any other services, and merely runs some + * IO for its acquisition, and has no disposal. + * +\*/ + +export type Env = { + PORT: number; + LOG_LEVEL: string; + DATABASE_URL: string; +}; + +export const withEnv: Bracket.Bracket = Bracket.fromTaskEither( + TE.fromIO(() => ({ + PORT: process.env.PORT ? parseInt(process.env.PORT) : 3000, + LOG_LEVEL: process.env.LOG_LEVEL ?? 'info', + DATABASE_URL: process.env.DATABASE_URL ?? './database.txt', + })), +); diff --git a/example/services/index.ts b/example/services/index.ts new file mode 100644 index 0000000..87df5fe --- /dev/null +++ b/example/services/index.ts @@ -0,0 +1,23 @@ +import {pipe} from 'fp-ts/function'; +import * as Bracket from '../../src/Bracket'; + +import {withEnv} from './env'; +import {withServer} from './server'; +import {withDatabase} from './database'; +import {withLogger} from './logger'; +import {withApp} from './app'; + +export const withServices = pipe( + withEnv, + Bracket.bindTo('env'), + Bracket.bind('logger', ({env}) => withLogger({level: env.LOG_LEVEL})), + Bracket.bind('database', ({env, logger}) => withDatabase({ + url: env.DATABASE_URL, + logger: logger + })), + Bracket.bind('app', ({database}) => withApp({database})), + Bracket.bind('server', ({env, app}) => withServer({ + port: env.PORT, + app: app, + })), +); diff --git a/example/services/logger.ts b/example/services/logger.ts new file mode 100644 index 0000000..e17b583 --- /dev/null +++ b/example/services/logger.ts @@ -0,0 +1,30 @@ +import * as Console from 'fp-ts/Console'; +import {constVoid, constant} from 'fp-ts/function'; + +import * as Bracket from '../../src/Bracket'; +import * as Service from '../../src/Service'; + +/*\ + * + * This is the service that provides the logger. + * + * Its purpose is to demonstrate how to use the Bracket module to create a + * service that only transforms its dependencies as an acquisition step, and + * does not have a disposal step. + * +\*/ + +export type Dependencies = { + level: string; +}; + +export const withLogger: Service.Service = ( + ({level}) => Bracket.of({ + log: level === 'log' ? Console.log : constant(constVoid), + info: level === 'info' ? Console.info : constant(constVoid), + warn: level === 'warn' ? Console.warn : constant(constVoid), + error: level === 'error' ? Console.error : constant(constVoid), + }) +); + +export type Logger = Service.ResourceOf; diff --git a/example/services/server.ts b/example/services/server.ts new file mode 100644 index 0000000..68d2594 --- /dev/null +++ b/example/services/server.ts @@ -0,0 +1,41 @@ +import * as HTTP from 'node:http'; +import * as E from 'fp-ts/Either'; + +import * as Bracket from '../../src/Bracket'; +import * as Service from '../../src/Service'; + +/*\ + * + * This service provides a server that listens on the specified port and uses + * the provided request listener. + * + * Its purpose is to demonstrate how to use the Bracket module to create a + * service that depends on other services, has an acquisition step, and has a + * disposal step. This should represent the most common use case for the + * Bracket module. + * +\*/ + + +type Dependencies = { + port: number; + app: HTTP.RequestListener; +}; + +export const withServer: Service.Service = ( + ({port, app}) => Bracket.bracket( + () => new Promise(resolve => { + const server = HTTP.createServer(app); + server.once('error', e => resolve(E.left(e))); + server.listen(port, () => resolve(E.right(server))); + }), + server => () => new Promise(resolve => { + server.removeAllListeners('error'); + server.close((e: unknown) => resolve( + e instanceof Error ? E.left(e) : E.right(undefined) + )); + }), + ) +); + +export type Server = Service.ResourceOf; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..679d187 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,251 @@ +{ + "name": "fp-ts-bootstrap", + "version": "0.0.4", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fp-ts-bootstrap", + "version": "0.0.4", + "license": "ISC", + "dependencies": { + "fp-ts": "^2.16.0" + }, + "devDependencies": { + "@types/node": "^18.0.0", + "fast-check": "^3.10.0", + "ts-node": "^10.9.1", + "typescript": "^4.6.4" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "18.16.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.18.tgz", + "integrity": "sha512-/aNaQZD0+iSBAGnvvN2Cx92HqE5sZCPZtx2TsK+4nvV23fFe09jVDvpArXr2j9DnYlzuU9WuoykDDc6wqvpNcw==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/fast-check": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.10.0.tgz", + "integrity": "sha512-I2FldZwnCbcY6iL+H0rp9m4D+O3PotuFu9FasWjMCzUedYHMP89/37JbSt6/n7Yq/IZmJDW0B2h30sPYdzrfzw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^6.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fp-ts": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.0.tgz", + "integrity": "sha512-bLq+KgbiXdTEoT1zcARrWEpa5z6A/8b7PcDW7Gef3NSisQ+VS7ll2Xbf1E+xsgik0rWub/8u0qP/iTTjj+PhxQ==" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/pure-rand": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.2.tgz", + "integrity": "sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, + "node_modules/ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..63f69f0 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "fp-ts-bootstrap", + "version": "0.0.4", + "description": "Application bootstrapping utilities for fp-ts", + "main": "lib/index.js", + "type": "commonjs", + "scripts": { + "test": "tsc -p tsconfig.json && ts-node src/Bracket.test.ts", + "build": "tsc -p tsconfig.build.json" + }, + "repository": { + "type": "git", + "url": "https://github.com/avaq/fp-ts-bootstrap.git" + }, + "homepage": "https://github.com/avaq/fp-ts-bootstrap", + "files": [ + "lib/", + "package.json", + "README.md", + "LICENSE.md" + ], + "author": "Aldwin Vlasblom (https://github.com/Avaq)", + "license": "ISC", + "devDependencies": { + "@types/node": "^18.0.0", + "fast-check": "^3.10.0", + "ts-node": "^10.9.1", + "typescript": "^4.6.4" + }, + "dependencies": { + "fp-ts": "^2.16.0" + } +} diff --git a/src/Bracket.test.ts b/src/Bracket.test.ts new file mode 100644 index 0000000..2a53bf5 --- /dev/null +++ b/src/Bracket.test.ts @@ -0,0 +1,364 @@ +import * as $E from 'fp-ts/Eq'; +import * as $S from 'fp-ts/Show'; +import * as Bracket from './Bracket'; +import * as TE from 'fp-ts/TaskEither'; +import * as T from 'fp-ts/Task'; +import * as E from 'fp-ts/Either'; +import * as R from 'fp-ts/Record'; +import * as O from 'fp-ts/Option'; +import * as Str from 'fp-ts/string'; +import {constant, identity, pipe} from 'fp-ts/function'; +import * as FC from 'fast-check'; + +import {hold} from '../test'; +import {ShowUnknown, eqBy} from '../test/assert'; + +type BracketResult = + | {_tag: 'Success', resource: R} + | {_tag: 'AcquisitionFailure', error: E} + | {_tag: 'DisposalFailure', error: E, resource: R}; + +const BracketResultEq = (EqE: $E.Eq, EqR: $E.Eq) => $E.fromEquals>( + (a, b) => { + if (a._tag === 'Success' && b._tag === 'Success') { + return EqR.equals(a.resource, b.resource); + } else if (a._tag === 'AcquisitionFailure' && b._tag === 'AcquisitionFailure') { + return EqE.equals(a.error, b.error); + } else if (a._tag === 'DisposalFailure' && b._tag === 'DisposalFailure') { + return EqE.equals(a.error, b.error) && EqR.equals(a.resource, b.resource); + } else { + return false; + } + } +); + +const BracketResultShow = ( + ShowE: $S.Show, + ShowR: $S.Show +): $S.Show> => ({ + show: (x) => { + switch (x._tag) { + case 'Success': + return `Success(${ShowR.show(x.resource)})`; + case 'AcquisitionFailure': + return `AcquisitionFailure(${ShowE.show(x.error)})`; + case 'DisposalFailure': + return `DisposalFailure(${ShowE.show(x.error)}, ${ShowR.show(x.resource)})`; + } + } +}); + +const runBracket = (consume: (resource: R) => TE.TaskEither) => ( + (bracket: Bracket.Bracket): Promise> => ( + new Promise((resolve, reject) => { + let acquired: O.Option = O.none; + bracket(x => { + acquired = O.some(x); + return consume(x); + })().then(result => { + if (O.isNone(acquired) && E.isLeft(result)) { + resolve({_tag: 'AcquisitionFailure', error: result.left}); + } else if (O.isSome(acquired) && E.isLeft(result)) { + resolve({_tag: 'DisposalFailure', error: result.left, resource: acquired.value}); + } else if (O.isSome(acquired) && E.isRight(result)) { + resolve({_tag: 'Success', resource: acquired.value}); + } else { + reject(new Error('runBracket state corruption')); + } + }) + }) + ) +); + +type BracketResults = { + onSuccesfulConsumption: BracketResult; + onFailedConsumption: BracketResult; +}; + +const BracketResultsEq = (Eq: $E.Eq>) => $E.struct({ + onSuccesfulConsumption: Eq, + onFailedConsumption: Eq, +}); + +const BracketResultsShow = (Show: $S.Show>) => $S.struct({ + onSuccesfulConsumption: Show, + onFailedConsumption: Show, +}); + +const runBracketTwice = (error: E) => ( + async (bracket: Bracket.Bracket): Promise> => { + const onSuccesfulConsumption = await runBracket(TE.of)(bracket); + const onFailedConsumption = await runBracket(constant(TE.left(error)))(bracket); + return {onSuccesfulConsumption, onFailedConsumption}; + } +); + +const equivalence = ( + EqR: $E.Eq, + EqE: $E.Eq, + ShowR: $S.Show = ShowUnknown, + ShowE: $S.Show = ShowUnknown, +) => { + const eq = eqBy( + BracketResultsEq(BracketResultEq(EqE, EqR)), + BracketResultsShow(BracketResultShow(ShowE, ShowR)) + ); + + return (e: E) => (a: Bracket.Bracket, b: Bracket.Bracket) => ( + Promise.all([runBracketTwice(e)(a), runBracketTwice(e)(b)]) + .then(([resultA, resultB]) => eq(resultA, resultB)) + ); +}; + +type Err = {error: string}; +const ErrEq: $E.Eq = $E.struct({error: Str.Eq}); +const ErrShow: $S.Show = ({show: (e) => `Err(${Str.Show.show(e.error)})`}); +const ErrArb = FC.string().map((s): Err => ({error: s})); + +const StringFunctionArb = FC.tuple(FC.nat({max: 10}), FC.string({maxLength: 3})).map(([n, sep]) => ( + (s: string) => (s + sep).repeat(n) +)); + +const BracketFunctionArb = StringFunctionArb.map(f => (s: string) => ( + Bracket.of(f(s)) +)); + +const TaskEitherErrArb = (ValueArb: FC.Arbitrary) => ( + FC.tuple(ValueArb, ErrArb, FC.boolean(), FC.nat({max: 10})).map(([r, e, fail, delay]) => pipe( + fail ? TE.left(e) : TE.of(r), + delay > 0 ? T.delay(delay) : identity, + )) +); + +const BracketArb = (ResourceArb: FC.Arbitrary) => FC.tuple( + TaskEitherErrArb(ResourceArb), + TaskEitherErrArb(FC.nat()), +).map(([acquire, dispose]) => Bracket.bracket(acquire, constant(dispose))); + +const testErr = {error: 'test error'}; + +const strErrEquivalence = equivalence(Str.Eq, ErrEq, Str.Show, ErrShow)(testErr); + +const recordErrEquivalence = equivalence( + R.getEq(Str.Eq), + ErrEq, + R.getShow(Str.Ord)(Str.Show), + ErrShow +)(testErr); + +const noDispose = () => TE.of(undefined); +type Strstr = (str: string) => string; +const composeStrstr = (f: Strstr) => (g: Strstr) => (x: string) => f(g(x)); + +// +// Monadic laws +// + +hold('Functor identity', FC.asyncProperty( + BracketArb(FC.string()), + (mx) => strErrEquivalence( + Bracket.Functor.map(mx, identity), + mx + ) +)); + +hold('Functor composition', FC.asyncProperty( + BracketArb(FC.string()), + StringFunctionArb, + StringFunctionArb, + (mx, f, g) => strErrEquivalence( + Bracket.Functor.map(Bracket.Functor.map(mx, f), g), + Bracket.Functor.map(mx, composeStrstr(g)(f)) + ) +)); + +hold('Apply composition', FC.asyncProperty( + BracketArb(FC.string()), + BracketArb(StringFunctionArb), + BracketArb(StringFunctionArb), + (mx, mf, mg) => strErrEquivalence( + Bracket.Apply.ap(mg, Bracket.Apply.ap(mf, mx)), + Bracket.Apply.ap(Bracket.Apply.ap(Bracket.Apply.map(mg, composeStrstr), mf), mx) + ) +)); + +hold('Applicative identity', FC.asyncProperty( + BracketArb(FC.string()), + (mx) => strErrEquivalence( + Bracket.Applicative.ap(Bracket.Applicative.of(identity), mx), + mx, + ) +)); + +hold('Applicative homomorphism', FC.asyncProperty( + FC.string(), + StringFunctionArb, + (x, f) => strErrEquivalence( + Bracket.Applicative.ap(Bracket.of(f), Bracket.Applicative.of(x)), + Bracket.Applicative.of(f(x)) + ) +)); + +hold('Applicative interchange', FC.asyncProperty( + FC.string(), + BracketArb(StringFunctionArb), + (x, mf) => strErrEquivalence( + Bracket.Applicative.ap(mf, Bracket.Applicative.of(x)), + Bracket.Applicative.ap(Bracket.Applicative.of string>(f => f(x)), mf) + ) +)); + +hold('Applicative map', FC.asyncProperty( + BracketArb(FC.string()), + StringFunctionArb, + (mx, f) => strErrEquivalence( + Bracket.Applicative.map(mx, f), + Bracket.Applicative.ap(Bracket.Applicative.of(f), mx) + ) +)); + +hold('ApplyPar composition', FC.asyncProperty( + BracketArb(FC.string()), + BracketArb(StringFunctionArb), + BracketArb(StringFunctionArb), + (mx, mf, mg) => strErrEquivalence( + Bracket.ApplyPar.ap(mg, Bracket.ApplyPar.ap(mf, mx)), + Bracket.ApplyPar.ap(Bracket.ApplyPar.ap(Bracket.ApplyPar.map(mg, composeStrstr), mf), mx) + ) +)); + +hold('ApplicativePar identity', FC.asyncProperty( + BracketArb(FC.string()), + (mx) => strErrEquivalence( + Bracket.ApplicativePar.ap(Bracket.ApplicativePar.of(identity), mx), + mx, + ) +)); + +hold('ApplicativePar homomorphism', FC.asyncProperty( + FC.string(), + StringFunctionArb, + (x, f) => strErrEquivalence( + Bracket.ApplicativePar.ap(Bracket.of(f), Bracket.ApplicativePar.of(x)), + Bracket.ApplicativePar.of(f(x)) + ) +)); + +hold('ApplicativePar interchange', FC.asyncProperty( + FC.string(), + BracketArb(StringFunctionArb), + (x, mf) => strErrEquivalence( + Bracket.ApplicativePar.ap(mf, Bracket.ApplicativePar.of(x)), + Bracket.ApplicativePar.ap(Bracket.ApplicativePar.of string>(f => f(x)), mf) + ) +)); + +hold('ApplicativePar map', FC.asyncProperty( + BracketArb(FC.string()), + StringFunctionArb, + (mx, f) => strErrEquivalence( + Bracket.ApplicativePar.map(mx, f), + Bracket.ApplicativePar.ap(Bracket.ApplicativePar.of(f), mx) + ) +)); + +hold('Chain associativity', FC.asyncProperty( + BracketArb(FC.string()), + BracketFunctionArb, + BracketFunctionArb, + (mx, fm, gm) => strErrEquivalence( + Bracket.Chain.chain(Bracket.Chain.chain(mx, fm), gm), + Bracket.Chain.chain(mx, x => Bracket.Chain.chain(fm(x), gm)) + ) +)); + +hold('Monad left identity', FC.asyncProperty( + FC.string(), + BracketFunctionArb, + (x, fm) => strErrEquivalence( + Bracket.Monad.chain(Bracket.Monad.of(x), fm), + fm(x) + ) +)); + +hold('Monad right identity', FC.asyncProperty( + BracketArb(FC.string()), + (mx) => strErrEquivalence( + Bracket.Monad.chain(mx, x => Bracket.Monad.of(x)), + mx + ) +)); + +hold('Monadic map', FC.asyncProperty( + BracketArb(FC.string()), + StringFunctionArb, + (mx, f) => strErrEquivalence( + Bracket.Monad.map(mx, f), + Bracket.Monad.chain(mx, x => Bracket.Monad.of(f(x))) + ) +)); + +hold('Monadic ap', FC.asyncProperty( + BracketArb(FC.string()), + BracketArb(StringFunctionArb), + (mx, mf) => strErrEquivalence( + Bracket.Monad.ap(mf, mx), + Bracket.Monad.chain(mf, f => Bracket.Monad.map(mx, f)) + ) +)); + +// +// Custom properties +// + +hold('bracket(acquire, K(dispose)) = bracket(acquire, K(dispose))', FC.asyncProperty( + TaskEitherErrArb(FC.string()), + TaskEitherErrArb(FC.nat()), + (acquire, dispose) => strErrEquivalence( + Bracket.bracket(acquire, constant(dispose)), + Bracket.bracket(acquire, constant(dispose)) + ) +)); + +hold('of(x) = bracket(TE.of(x), noDispose)', FC.asyncProperty( + FC.string(), + (x) => strErrEquivalence( + Bracket.of(x), + Bracket.bracket(TE.of(x), noDispose) + ) +)); + +hold('ap(mx)(mf) = apPar(mx)(mf)', FC.asyncProperty( + BracketArb(FC.string()), + BracketArb(StringFunctionArb), + (mx, mf) => strErrEquivalence( + Bracket.ap(mx)(mf), + Bracket.apPar(mx)(mf) + ) +)); + +hold('apFirst(ma)(mb) = apFirstPar(ma)(mb)', FC.asyncProperty( + BracketArb(FC.string()), + BracketArb(FC.string()), + (mx, mb) => strErrEquivalence( + Bracket.apFirst(mx)(mb), + Bracket.apFirstPar(mx)(mb) + ) +)); + +hold('apSecond(ma)(mb) = apSecondPar(ma)(mb)', FC.asyncProperty( + BracketArb(FC.string()), + BracketArb(FC.string()), + (mx, mb) => strErrEquivalence( + Bracket.apSecond(mx)(mb), + Bracket.apSecondPar(mx)(mb) + ) +)); + +hold('sequenceS(ms) = sequenceSPar(ms)', FC.asyncProperty( + FC.dictionary(FC.string(), BracketArb(FC.string()), {minKeys: 1}), + (ms) => recordErrEquivalence( + Bracket.sequenceS(ms), + Bracket.sequenceSPar(ms) + ) +)); diff --git a/src/Bracket.ts b/src/Bracket.ts new file mode 100644 index 0000000..86846ac --- /dev/null +++ b/src/Bracket.ts @@ -0,0 +1,163 @@ +import * as $Pointed from 'fp-ts/Pointed'; +import * as $Functor from 'fp-ts/Functor'; +import * as $Apply from 'fp-ts/Apply'; +import * as $Applicative from 'fp-ts/Applicative'; +import * as $Chain from 'fp-ts/Chain'; +import * as $Monad from 'fp-ts/Monad'; +import * as TE from 'fp-ts/TaskEither'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import {NaturalTransformation22} from 'fp-ts/NaturalTransformation'; +import {pipe} from 'fp-ts/function'; + +export const URI = 'fp-ts-bootstrap/Bracket'; +export type URI = typeof URI; + +declare module 'fp-ts/HKT' { + interface URItoKind2 { + readonly [URI]: Bracket; + } +} + +export type Bracket = ( + (consume: (resource: R) => TE.TaskEither) => TE.TaskEither +); + +export type ResourceOf> = ( + B extends Bracket ? R : never +); + +export const bracket = ( + acquire: TE.TaskEither, + dispose: (resource: R) => TE.TaskEither +): Bracket => consume => TE.bracket(acquire, consume, dispose); + +export const Pointed: $Pointed.Pointed2 = { + URI: URI, + of: x => use => use(x) +}; + +export const of = (x: T): Bracket => Pointed.of(x); +export const Do = of({}); + +export const Functor: $Functor.Functor2 = { + URI: URI, + map: (fa, f) => use => fa(a => use(f(a))), +}; + +export const map = (f: (a: A) => B) => (fa: Bracket) => ( + Functor.map(fa, f) +); + +export const flap = $Functor.flap(Functor); +export const bindTo = $Functor.bindTo(Functor); +const let_ = $Functor.let(Functor); +export {let_ as let}; + +export const Apply: $Apply.Apply2 = { + ...Functor, + ap: (fab, fa) => use => fab(ab => fa(a => use(ab(a)))), +}; + +export const ap = (fa: Bracket) => ( + (fab: Bracket B>) => Apply.ap(fab, fa) +); + +export const apFirst = $Apply.apFirst(Apply); +export const apSecond = $Apply.apSecond(Apply); +export const apS = $Apply.apS(Apply); +export const getApplySemigroup = $Apply.getApplySemigroup(Apply); +export const sequenceT = $Apply.sequenceT(Apply); +export const sequenceS = $Apply.sequenceS(Apply); + +export const Applicative: $Applicative.Applicative2 = {...Pointed, ...Apply}; + +export const ApplyPar: $Apply.Apply2 = { + ...Functor, + ap: (fab: Bracket B>, fa: Bracket) => ( + (consume: (resource: B) => TE.TaskEither): TE.TaskEither => ( + () => { + let ab: O.Option<(a: A) => B> = O.none; + let a: O.Option = O.none; + + let resolvedFa: O.Option> = O.none; + let resolveFa = (value: E.Either) => { + resolvedFa = O.some(value); + }; + + let resolvedFab: O.Option> = O.none; + let resolveFab = (value: E.Either) => { + resolvedFab = O.some(value); + }; + + const promiseFa = fa(x => () => { + if (O.isSome(resolvedFa)) { + return Promise.resolve(resolvedFa.value); + } + if (O.isSome(ab)) { + return consume(ab.value(x))(); + } + return new Promise>(resolve => { + a = O.some(x); + resolveFa = resolve; + }); + })().then(ea => { + resolveFab(ea); + return ea; + }); + + const promiseFab = fab(f => () => { + if (O.isSome(resolvedFab)) { + return Promise.resolve(resolvedFab.value); + } + if (O.isSome(a)) { + return consume(f(a.value))().then(ret => { + resolveFa(ret); + return promiseFa.then(retFa => pipe(retFa, E.apSecond(ret))); + }); + } + return new Promise>(resolve => { + ab = O.some(f); + resolveFab = resolve; + }); + })().then(eab => { + resolveFa(eab); + return eab; + }); + + return Promise.all([promiseFab, promiseFa]).then(([eab]) => eab); + } + ) + ), +}; + +export const apPar = (fa: Bracket) => ( + (fab: Bracket B>) => ApplyPar.ap(fab, fa) +); + +export const apFirstPar = $Apply.apFirst(ApplyPar); +export const apSecondPar = $Apply.apSecond(ApplyPar); +export const apSPar = $Apply.apS(ApplyPar); +export const getApplySemigroupPar = $Apply.getApplySemigroup(ApplyPar); +export const sequenceTPar = $Apply.sequenceT(ApplyPar); +export const sequenceSPar = $Apply.sequenceS(ApplyPar); + +export const ApplicativePar: $Applicative.Applicative2 = {...Pointed, ...ApplyPar}; + +export const Chain: $Chain.Chain2 = { + ...Apply, + chain: (fa, f) => use => fa(a => f(a)(use)) +}; + +export const chain = (f: (a: A) => Bracket) => ( + (fa: Bracket) => Chain.chain(fa, f) +); + +export const chainFirst = $Chain.chainFirst(Chain); +export const bind = $Chain.bind(Chain); + +export const Monad: $Monad.Monad2 = {...Pointed, ...Chain}; + +export const fromTaskEither: NaturalTransformation22 = ( + task => use => pipe(task, TE.chain(use)) +); diff --git a/src/Service.ts b/src/Service.ts new file mode 100644 index 0000000..1f7c0d0 --- /dev/null +++ b/src/Service.ts @@ -0,0 +1,22 @@ +import * as R from 'fp-ts/Reader'; +import * as RT from 'fp-ts/ReaderT'; +import * as B from './Bracket'; + +export type Service = R.Reader>; + +export type ResourceOf> = ( + S extends Service ? R : never +); + +export const of: (x: S) => Service = ( + RT.of(B.Pointed) +); + +export const map = RT.map(B.Functor); +export const ap = RT.ap(B.Apply); +export const apPar = RT.ap(B.ApplyPar); +export const chain = RT.chain(B.Chain); + +export const fromReader: (reader: R.Reader) => Service = ( + RT.fromReader(B.Pointed) +); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..65bd8b5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * as Bracket from './Bracket'; +export * as Service from './Service'; diff --git a/test/assert.ts b/test/assert.ts new file mode 100644 index 0000000..3896099 --- /dev/null +++ b/test/assert.ts @@ -0,0 +1,33 @@ +import {AssertionError} from 'node:assert'; +import {inspect} from 'node:util'; +import * as $S from 'fp-ts/Show'; +import * as $E from 'fp-ts/Eq'; + +export const ShowUnknown: $S.Show = { + show: (x: unknown) => inspect(x, {depth: Infinity, customInspect: true}), +}; + +export const assertionError = ( + message: string, + expected: A, + actual: B, + S: $S.Show = ShowUnknown +) => new AssertionError({ + expected: expected, + actual: actual, + message: `${message}\nExpected: ${S.show(expected)}\nActual: ${S.show(actual)}`, +}); + +export const eq = ( + expected: A, + actual: B, + E: $E.Eq, + S: $S.Show = ShowUnknown +) => { + if (E.equals(expected, actual)) { return; } + throw assertionError('Inputs not equal', expected, actual, S); +}; + +export const eqBy = (E: $E.Eq, S: $S.Show = ShowUnknown) => ( + (expected: A, actual: B) => eq(expected, actual, E, S) +); diff --git a/test/index.ts b/test/index.ts new file mode 100644 index 0000000..69242c7 --- /dev/null +++ b/test/index.ts @@ -0,0 +1,51 @@ +import * as path from 'path'; +import * as FC from 'fast-check'; +import {pipe} from 'fp-ts/function'; +import * as O from 'fp-ts/Option'; +import * as T from 'fp-ts/Task'; +import * as IO from 'fp-ts/IO'; + +const root = path.relative(path.resolve(__dirname, '../src'), process.argv[1]); + +const filter = O.fromNullable(process.env.TEST_FILTER); + +let tests = 0; +let okays = 0; +let fails = 0; + +export const test = async(name: string, task: T.Task | IO.IO) => { + if (pipe(filter, O.fold(() => false, f => !name.includes(f)))) { + console.log('[skip]', root, '⟫', name); + return; + } + + tests = tests + 1; + try { + await task(); + console.log('[okay]', root, '⟫', name); + okays = okays + 1; + } catch (e) { + console.error('[fail]', root, '⟫', name, '', e); + fails = fails + 1; + } +}; + +export const hold = (name: string, prop: FC.IRawProperty, opts?: FC.Parameters) => ( + test(`'${name}' holds`, () => FC.assert(prop, opts)) +); + +process.once('beforeExit', code => { + if (code > 0) { + console.error('[done]', root, '⟫ Exiting with non-zero exit code'); + process.exit(code); + } + if (tests > okays + fails) { + console.error('[done]', root, '⟫ A number of tests never completed'); + process.exit(1); + } + if (fails > 0) { + console.error('[done]', root, '⟫', `${fails} Tests have failed`); + process.exit(1); + } + console.log('[done]', root, `⟫ All ${tests} tests okay`); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..37dee63 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["./test", "./example", "./src/**/*.test.ts"], + "compilerOptions": { + "noEmit": false + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..32fbd44 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "noEmit": true, + "outDir": "./lib", + "target": "es5", + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": false, + "sourceMap": false, + "declaration": true, + "strict": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "stripInternal": true + }, + "include": ["./src", "./test", "./example"] +}