diff --git a/.gitignore b/.gitignore index 2882fb7..81093de 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ dist/** .next/** docker/zammad/auto_install/** .npmrc +coverage/ +build/ diff --git a/package-lock.json b/package-lock.json index 539c1d5..617eb15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3369,18 +3369,8 @@ } }, "node_modules/@digiresilience/montar": { - "version": "0.1.6", - "resolved": "https://gitlab.com/api/v4/projects/22248969/packages/npm/@digiresilience/montar/-/@digiresilience/montar-0.1.6.tgz", - "integrity": "sha1-PSVHC+/DCyaCnbYJHi3xH4d5e1o=", - "dependencies": { - "@types/node": "14.14.6", - "debug": "^4.3.2" - } - }, - "node_modules/@digiresilience/montar/node_modules/@types/node": { - "version": "14.14.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.6.tgz", - "integrity": "sha512-6QlRuqsQ/Ox/aJEQWBEJG7A9+u7oSYl3mem/K8IzxXG/kAGbV1YPD9Bg9Zw3vyxC/YP+zONKwy8hGkSt1jxFMw==" + "resolved": "packages/montar", + "link": true }, "node_modules/@digiresilience/node-signald": { "version": "0.0.3", @@ -25757,6 +25747,22 @@ "typescript": "4.9.4" } }, + "packages/montar": { + "version": "0.1.6", + "license": "AGPL-3.0-or-later", + "dependencies": { + "debug": "^4.3.2" + }, + "devDependencies": { + "babel-preset-link": "*", + "eslint-config-link": "*", + "jest-config-link": "*", + "tsconfig-link": "*" + }, + "engines": { + "node": ">=14" + } + }, "packages/tsconfig": { "version": "0.1.4", "extraneous": true, @@ -27612,19 +27618,13 @@ } }, "@digiresilience/montar": { - "version": "0.1.6", - "resolved": "https://gitlab.com/api/v4/projects/22248969/packages/npm/@digiresilience/montar/-/@digiresilience/montar-0.1.6.tgz", - "integrity": "sha1-PSVHC+/DCyaCnbYJHi3xH4d5e1o=", + "version": "file:packages/montar", "requires": { - "@types/node": "14.14.6", - "debug": "^4.3.2" - }, - "dependencies": { - "@types/node": { - "version": "14.14.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.6.tgz", - "integrity": "sha512-6QlRuqsQ/Ox/aJEQWBEJG7A9+u7oSYl3mem/K8IzxXG/kAGbV1YPD9Bg9Zw3vyxC/YP+zONKwy8hGkSt1jxFMw==" - } + "babel-preset-link": "*", + "debug": "^4.3.2", + "eslint-config-link": "*", + "jest-config-link": "*", + "tsconfig-link": "*" } }, "@digiresilience/node-signald": { diff --git a/packages/eslint-config-link/profile/typescript.js b/packages/eslint-config-link/profile/typescript.js index c9d18d2..a5a7175 100644 --- a/packages/eslint-config-link/profile/typescript.js +++ b/packages/eslint-config-link/profile/typescript.js @@ -17,5 +17,12 @@ module.exports = { ], "no-extra-semi": "off", "@typescript-eslint/no-extra-semi": "error", + "@typescript-eslint/ban-ts-comment": [ + "error", + { + "ts-nocheck": "allow-with-description", + "ts-expect-error": "allow-with-description", + }, + ], }, }; diff --git a/packages/montar/.editorconfig b/packages/montar/.editorconfig new file mode 100644 index 0000000..63187fe --- /dev/null +++ b/packages/montar/.editorconfig @@ -0,0 +1,15 @@ +# http://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +max_line_length = 80 +trim_trailing_whitespace = true + +[*.md] +max_line_length = 0 +trim_trailing_whitespace = false diff --git a/packages/montar/.eslintrc.js b/packages/montar/.eslintrc.js new file mode 100644 index 0000000..ec3aad3 --- /dev/null +++ b/packages/montar/.eslintrc.js @@ -0,0 +1,18 @@ +require("eslint-config-link/patch/modern-module-resolution"); +module.exports = { + extends: [ + "eslint-config-link/profile/node", + "eslint-config-link/profile/typescript" + ], + parserOptions: { tsconfigRootDir: __dirname, project: "./tsconfig.spec.json" }, + rules: { + "no-prototype-builtins": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + }, + ], + }, +}; diff --git a/packages/montar/.prettierignore b/packages/montar/.prettierignore new file mode 100644 index 0000000..0e80a3c --- /dev/null +++ b/packages/montar/.prettierignore @@ -0,0 +1,2 @@ +# package.json is formatted by package managers, so we ignore it here +package.json \ No newline at end of file diff --git a/packages/montar/CHANGELOG.md b/packages/montar/CHANGELOG.md new file mode 100644 index 0000000..b40cbd0 --- /dev/null +++ b/packages/montar/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +### [0.1.6](https://gitlab.com/digiresilience/link/montar/compare/0.1.5...0.1.6) (2021-10-11) + +### [0.1.5](https://gitlab.com/digiresilience/link/montar/compare/0.1.4...0.1.5) (2021-10-08) + +### [0.1.4](https://gitlab.com/digiresilience/link/montar/compare/0.1.3...0.1.4) (2021-10-08) + +### [0.1.3](https://gitlab.com/digiresilience/link/montar/compare/0.1.2...0.1.3) (2021-10-08) + +### [0.1.2](https://gitlab.com/digiresilience/link/montar/compare/0.1.1...0.1.2) (2020-11-12) + + +### Features + +* add startOnly function to start only certain states ([b776b66](https://gitlab.com/digiresilience/link/montar/commit/b776b6640efe52f934b8afecc64f5e8c2b1be0f3)) + + +### Bug Fixes + +* only stop states that were started ([178bbd2](https://gitlab.com/digiresilience/link/montar/commit/178bbd2296671ed480bcc8d22c5f72736f6335b8)) + +### [0.1.1](https://gitlab.com/digiresilience/link/montar/compare/0.1.0...0.1.1) (2020-11-10) + + +### Features + +* Use debug module for optional debugging ([0cb617c](https://gitlab.com/digiresilience/link/montar/commit/0cb617cf4eac9178cd56af8834bc429f656beba5)) + +## 0.1.0 (2020-11-10) diff --git a/packages/montar/Makefile b/packages/montar/Makefile new file mode 100644 index 0000000..a3c5c33 --- /dev/null +++ b/packages/montar/Makefile @@ -0,0 +1,37 @@ +test: build + mkdir -p coverage + yarn test + +.PHONY: build +build: node_modules/ + yarn build + +lint: + yarn test:lint + +fmt: + yarn fix:prettier + yarn fix:lint + +doc: + yarn doc + +publish: test + npm publish + +node_modules/: .npmrc + test -d node_modules || yarn + +.npmrc: +ifdef CI_JOB_TOKEN + echo '@guardianproject-ops:registry=https://gitlab.com/api/v4/packages/npm/' > .npmrc + echo '//gitlab.com/api/v4/packages/npm/:_authToken=${CI_JOB_TOKEN}' >> .npmrc + echo '//gitlab.com/api/v4/projects/:_authToken=${CI_JOB_TOKEN}' >> .npmrc + echo '//gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${CI_JOB_TOKEN}' >> .npmrc +endif + +clean: + rm -rf build tmp coverage yarn-error.log + +distclean: clean + rm -rf node_modules diff --git a/packages/montar/README.md b/packages/montar/README.md new file mode 100644 index 0000000..23a9749 --- /dev/null +++ b/packages/montar/README.md @@ -0,0 +1,138 @@ +# montar + +manage state in typescript. inspired by [tolitius/mount](https://github.com/tolitius/mount) from clojure. + +What's this all about? Watch this [video from Stuart Sierra](https://www.youtube.com/watch?v=13cmHf_kt-Q) to learn more about the background of component, mount, and montar. + +## Install + +```console +$ yarn add -D @digiresilience/montar +``` + +## Usage + +``` +import { defState } from "@digiresilience/montar" +``` + +### Creating State + +``` +// db.ts +const db = defState("db", { + start: createDbConnection +}) + +export default db +``` + +where the createDbConnection function creates a connection (for example to a database) and is defined elsewhere. + +### Starting state + +``` +import { start } from "@digiresilience/montar" + +const bootYourApp = async () => { + // .. prepare for app boot + // .. + + // boot! + return start() +} +``` + +### Using State + +But wait, there is more.. this state is a top level being, which means it can be simply imported by other namespaces: + +For example let's say an app needs a connection above. No problem: + +``` +// server.ts +import db from "./db" + + +async function doStuff() { + return db.executeSql("foo") +} +``` + +Note that before `start()` is called, `db` will be uninitialized and therefore unusable. You must call `start()` before accessing any states. + +### Starting/Stopping State + +`montar` has start and stop functions that will walk all the states created with `defState` and start / stop them accordingly: i.e. will call their start and stop defined functions. + +When testing you might be interested in starting only certain states. You can do this with + +- `startOnly(string[])` - only start the given states. WARNING: dependencies are not auto started, so you must ensure all dependencies are passed. +- `startWithout(string[])` - start all states _except_ those passed + +### Start and Stop Order + +Since dependencies are "injected" by requiring on the namespace level, montar trusts the typescript compiler and node.js runtime to maintain the start and stop order for all the defStates. + +The "start" order is then recorded and replayed on each subsequent start + +The "stop" order is simply the reverse of the start order. + +### Troubleshooting / Debugging + +Set the `DEBUG=montar` environment variable for runtime debug logs. + +## Differences to mount + +montar was created to bring management of top-level application state under +control. It steals greedily from +[tolitius/mount](https://github.com/tolitius/mount) a well-known library in the +clojure and clojurescript community. + +The problem solved is: how to load different sub-systems of your application while respecting dependency order? Also, how do achieve this without complicated dependency injection containers that make the code harder to read. + +With montar once you define your state, then every other module can import and +use your state without special syntax or wrappers. + +montar differs from mount in these ways: + +- **start/stop are async** - the start and stop functions are async +- **no var rebinding** - instead we use the little-known [`Proxy`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) + - this comes with limitations: only functions, arrays, objects can be defined as state. scalars do not work. +- **not concerned with reloading** - in clj/cljs you have the repl and great hot-code reloading (thanks to immutable and persistent data structures). we do not have this in javascript. + +## Credits + +Copyright © 2020-present [Center for Digital Resilience][cdr] + +### Contributors + +| [![Abel Luck][abelxluck_avatar]][abelxluck_homepage]
[Abel Luck][abelxluck_homepage] | +| ---------------------------------------------------------------------------------------- | + + +[abelxluck_homepage]: https://gitlab.com/abelxluck +[abelxluck_avatar]: https://secure.gravatar.com/avatar/0f605397e0ead93a68e1be26dc26481a?s=100&d=identicon + +### License + +[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0.en.html) + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +[cdrtech]: https://digiresilience.org/tech/ +[cdr]: https://digiresilience.org diff --git a/packages/montar/babel.config.json b/packages/montar/babel.config.json new file mode 100644 index 0000000..708ea0e --- /dev/null +++ b/packages/montar/babel.config.json @@ -0,0 +1,5 @@ +{ + "presets": [ + "babel-preset-link" + ] +} diff --git a/packages/montar/jest.config.json b/packages/montar/jest.config.json new file mode 100644 index 0000000..294020f --- /dev/null +++ b/packages/montar/jest.config.json @@ -0,0 +1,4 @@ +{ + "preset": "jest-config-link" +} + diff --git a/packages/montar/package.json b/packages/montar/package.json new file mode 100644 index 0000000..69f24c8 --- /dev/null +++ b/packages/montar/package.json @@ -0,0 +1,33 @@ +{ + "name": "@digiresilience/montar", + "version": "0.1.6", + "description": "manage typescript state", + "main": "build/main/index.js", + "typings": "build/main/index.d.ts", + "module": "build/module/index.js", + "author": "Abel Luck ", + "license": "AGPL-3.0-or-later", + "private": false, + "scripts": { + "build": "tsc -p tsconfig.json", + "fix:lint": "eslint src --ext .ts --fix", + "fmt": "prettier \"src/**/*.ts\" --write", + "test": "DEBUG=montar jest --config jest.config.json", + "lint": "eslint src --ext .ts", + "lint-fmt": "prettier \"src/**/*.ts\" --list-different", + "doc": "typedoc src/ --exclude '**/*.test.ts' --exclude '**/*.spec.ts' --name $npm_package_name --readme README.md --target es2019 --mode file --out build/docs", + "watch:build": "tsc -p tsconfig.json -w" + }, + "engines": { + "node": ">=14" + }, + "devDependencies": { + "tsconfig-link": "*", + "eslint-config-link": "*", + "jest-config-link": "*", + "babel-preset-link": "*" + }, + "dependencies": { + "debug": "^4.3.2" + } +} diff --git a/packages/montar/src/index.spec.ts b/packages/montar/src/index.spec.ts new file mode 100644 index 0000000..422cc3e --- /dev/null +++ b/packages/montar/src/index.spec.ts @@ -0,0 +1,73 @@ +import { defState, start, stop, isStarted } from "."; + +const _inc41 = (): number => 41 + 1; +const _inc = (n: number): number => n + 1; + +const obj = defState("obj", { + start: async () => ({ + value: 42, + }), +}); + +const mutableObj = defState("mutableObj", { + start: async () => ({ + value: 41, + }), +}); + +type FortyOneAdder = () => number; + +type Incrementer = (n: number) => number; + +const inc41 = defState("inc41", { + isFunction: true, + start: async () => _inc41, +}); +const inc = defState("inc", { + isFunction: true, + start: async () => _inc, +}); + +describe("defstate", () => { + beforeEach(async () => { + await start(); + }); + afterEach(async () => { + await stop(); + }); + + test("obj", async () => { + expect(obj.value).toBe(42); + }); + + test("mutable obj", async () => { + expect(mutableObj.value).toBe(41); + mutableObj.value++; + expect(mutableObj.value).toBe(42); + }); + + test("inc41", async () => { + expect(inc41()).toBe(42); + expect(isStarted("inc41")).toBe(true); + }); + test("inc", async () => { + expect(inc(41)).toBe(42); + }); +}); + +describe("errors", () => { + test("doesn't exist", () => { + expect(() => isStarted("not-real")).toThrow(); + }); + test("invalid type", () => { + defState("invalid", { start: () => 42 as any }); + expect(start()).rejects.toThrow(); + }); + + test("multiple defs", () => { + defState("foo", { async start() {} }); + expect(() => { + defState("foo", { async start() {} }); + }).toThrow(); + }); +}); diff --git a/packages/montar/src/index.ts b/packages/montar/src/index.ts new file mode 100644 index 0000000..d556e6a --- /dev/null +++ b/packages/montar/src/index.ts @@ -0,0 +1,136 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/* eslint-disable no-await-in-loop */ +import Debug from "debug"; +import { mutableProxyFactory } from "./proxy"; + +const debug = Debug("montar"); + +export type Start = () => Promise; + +interface StateMeta { + name: string; + start: Start; + stop(): Promise; + isFunction: boolean; + setHandler(handler: ProxyHandler): void; + setTarget(target: T): void; +} + +interface DefState { + start: Start; + stop?(): Promise; + isFunction?: boolean; +} +interface StateMetaMap { + [name: string]: StateMeta; +} + +interface StateMap { + [name: string]: any; +} + +const defaultStop = async () => {}; +const states: StateMap = {}; +const statesMeta: StateMetaMap = {}; +const statesOrder: string[] = []; + +function getState(name: string) { + if (!statesMeta.hasOwnProperty(name)) { + throw new Error(`State ${name} not started.`); + } + + return states[name]; +} + +function setState(name: string, state: any) { + states[name] = state; +} + +function getStateMeta(name: string) { + return statesMeta[name]; +} + +function setStateMeta(name: string, meta: StateMeta) { + statesMeta[name] = meta; +} + +export function isStarted(name: string): boolean { + return getState(name) !== undefined; +} + +const canary = { + error: + "I am the bare proxy. You shouldn't see me. If you see me, then a montar state was not started.", +}; +export function defState(name: string, meta: DefState): any { + if (statesMeta.hasOwnProperty(name)) { + throw new Error(`Already registered ${name}`); + } + + const { start, stop = defaultStop, isFunction = false } = meta; + + let initialTarget: any = canary; + if (isFunction) initialTarget = () => canary.error; + const { proxy, setTarget, setHandler } = mutableProxyFactory(initialTarget); + + setStateMeta(name, { + name, + start, + stop, + setTarget, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + setHandler, + isFunction, + }); + statesOrder.push(name); + + debug(`defined: ${name}`); + return proxy; +} + +async function _start(toStart?: string[]): Promise { + for (const name of toStart) { + debug(` >> starting.. ${name}`); + const meta = getStateMeta(name); + const state = await meta.start(); + const stateType = typeof state; + if (!["object", "function"].includes(stateType)) { + throw new Error( + `error ${name}'s start() returned a non-object, non-function type` + ); + } + + meta.setTarget(state); + setState(name, state); + } +} + +export async function start(): Promise { + return _start(statesOrder); +} + +export async function startWithout(excluded: string[]): Promise { + const toStart = statesOrder.filter((name) => !excluded.includes(name)); + return _start(toStart); +} + +export async function startOnly(included: string[]): Promise { + const toStart = statesOrder.filter((name) => included.includes(name)); + debug(" startOnly ", included); + return _start(toStart); +} + +export async function stop(): Promise { + for (let i = statesOrder.length - 1; i >= 0; i--) { + const name = statesOrder[i]; + if (states[name] === undefined) continue; + const meta = statesMeta[name]; + await meta.stop(); + delete states[name]; + debug(`<< stopping.. ${name}`); + } +} diff --git a/packages/montar/src/proxy.spec.ts b/packages/montar/src/proxy.spec.ts new file mode 100644 index 0000000..c7d7531 --- /dev/null +++ b/packages/montar/src/proxy.spec.ts @@ -0,0 +1,91 @@ +// @ts-nocheck PITA for this file.. revisit later. +import { mutableProxyFactory, PProxyHandler } from "./proxy"; + +describe("mutable proxy types", () => { + test("function", async () => { + const { proxy, setTarget } = mutableProxyFactory(() => 42); + expect(proxy()).toBe(42); + setTarget(() => 43); + expect(proxy()).toBe(43); + }); + + test("object", async () => { + const { proxy, setTarget } = mutableProxyFactory({ value: 42 }); + expect(proxy.value).toBe(42); + setTarget({ value: 43 }); + expect(proxy.value).toBe(43); + }); + + test("array", async () => { + const { proxy, setTarget } = mutableProxyFactory([42]); + expect(proxy[0]).toBe(42); + setTarget([43]); + expect(proxy[0]).toBe(43); + }); + + test("object to function", async () => { + const { proxy, setTarget } = mutableProxyFactory({ value: 42 }); + expect(proxy.value).toBe(42); + setTarget(() => 43); + expect(() => proxy()).toThrow(); + }); + test("scalar", async () => { + expect(() => { + mutableProxyFactory(42); + }).toThrow(); + }); + + test("boolean", async () => { + expect(() => { + mutableProxyFactory(false); + }).toThrow(); + }); + + test("null", async () => { + expect(() => { + mutableProxyFactory(null); // eslint-disable-line unicorn/no-null + }).toThrow(); + }); + + test("undefined", async () => { + expect(() => { + mutableProxyFactory(); + }).toThrow(); + }); + + test("setHandler", async () => { + const { proxy, setHandler } = mutableProxyFactory({ value: 42 }); + setHandler(new PProxyHandler()); + expect(proxy.value).toBe(42); + }); + + test("setTarget", async () => { + const { proxy, setTarget } = mutableProxyFactory({ value: 42 }); + setTarget([43]); + expect(proxy[0]).toBe(43); + }); + + test("setHandler simple", async () => { + const { proxy, setHandler } = mutableProxyFactory({ value: 41 }); + expect(proxy.value).toBe(41); + setHandler({ + get: () => 42, + }); + expect(proxy.value).toBe(42); + }); + + test("getHandler", async () => { + const { getHandler, setHandler, proxy } = mutableProxyFactory({ + value: 42, + }); + const handler = new PProxyHandler(); + setHandler(handler); + expect(getHandler()).toBe(handler); + expect(proxy.value).toBe(42); + }); + + test("getTarget", async () => { + const { getTarget } = mutableProxyFactory({ value: 42 }); + expect(getTarget().value).toBe(42); + }); +}); diff --git a/packages/montar/src/proxy.ts b/packages/montar/src/proxy.ts new file mode 100644 index 0000000..de365a6 --- /dev/null +++ b/packages/montar/src/proxy.ts @@ -0,0 +1,130 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +/* eslint-disable no-new,no-useless-call */ +// mutableProxyFactory from https://stackoverflow.com/a/54460544 +// (C) Alex Hall https://stackoverflow.com/users/2482744/alex-hall +// License CC BY-SA 3.0 +/* eslint-disable @typescript-eslint/ban-types */ + +export class PProxyHandler implements ProxyHandler { + getPrototypeOf?(target: T): object | null { + return Reflect.getPrototypeOf(target); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setPrototypeOf?(target: T, v: any): boolean { + return Reflect.setPrototypeOf(target, v); + } + + isExtensible?(target: T): boolean { + return Reflect.isExtensible(target); + } + + preventExtensions?(target: T): boolean { + return Reflect.preventExtensions(target); + } + + getOwnPropertyDescriptor?( + target: T, + p: PropertyKey + ): PropertyDescriptor | undefined { + return Reflect.getOwnPropertyDescriptor(target, p); + } + + has?(target: T, p: PropertyKey): boolean { + return Reflect.has(target, p); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get?(target: T, p: PropertyKey, receiver: any): any { + return Reflect.get(target, p, receiver); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set?(target: T, p: PropertyKey, value: any, receiver: any): boolean { + return Reflect.set(target, p, value, receiver); + } + + deleteProperty?(target: T, p: PropertyKey): boolean { + return Reflect.deleteProperty(target, p); + } + + defineProperty?( + target: T, + p: PropertyKey, + attributes: PropertyDescriptor + ): boolean { + return Reflect.defineProperty(target, p, attributes); + } + + enumerate?(target: T): PropertyKey[] { + return Reflect.ownKeys(target); + } + + ownKeys?(target: T): PropertyKey[] { + return Reflect.ownKeys(target); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + apply?(target: T, thisArg: any, argArray?: any): any { + return Reflect.apply(target as Function, thisArg, argArray); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + construct?(target: T, argArray: any, newTarget?: any): object { + return Reflect.construct(target as Function, argArray, newTarget); + } +} + +interface MutableProxy { + setTarget(target: T): void; + setHandler(handler: PProxyHandler): void; + getTarget(): T; + getHandler(): ProxyHandler; + proxy: T; +} + +export function mutableProxyFactory( + mutableTarget: T, + mutableHandler?: ProxyHandler +): MutableProxy { + if (!mutableHandler) mutableHandler = new PProxyHandler(); + return { + setTarget(target: T): void { + new Proxy(target, {}); // test target validity + mutableTarget = target; + }, + setHandler(handler: PProxyHandler): void { + new Proxy({}, handler); // test handler validity + Object.keys(handler).forEach((key) => { + const value = handler[key]; + if (Reflect[key] && typeof value !== "function") { + throw new Error(`Trap "${key}: ${value}" is not a function`); + } + }); + mutableHandler = handler; + }, + getTarget(): T { + return mutableTarget; + }, + getHandler(): PProxyHandler { + return mutableHandler; + }, + proxy: new Proxy( + mutableTarget, + new Proxy( + {}, + { + // Dynamically forward all the traps to the associated methods on the mutable handler + get(target, property) { + return (_target, ...args) => + mutableHandler[property].apply(mutableHandler, [ + mutableTarget, + ...args, + ]); + }, + } + ) + ), + }; +} diff --git a/packages/montar/src/starting.spec.ts b/packages/montar/src/starting.spec.ts new file mode 100644 index 0000000..02733b5 --- /dev/null +++ b/packages/montar/src/starting.spec.ts @@ -0,0 +1,37 @@ +import { defState, stop, startOnly, startWithout } from "."; + +const startOnlyState = defState("startOnlyState", { + start: async () => ({ + value: 42, + }), +}); + +const startedState = defState("startState", { + start: async () => ({ + value: 42, + }), +}); + +const neverStartedState = defState("neverStartedState", { + start: async () => ({ + value: 42, + }), +}); + +describe("starting", () => { + afterEach(async () => stop()); + + test("startOnly", async () => { + await startOnly(["startOnlyState"]); + expect(startOnlyState.value).toBe(42); + expect(startedState.value).toBe(undefined); + expect(neverStartedState.value).toBe(undefined); + }); + + test("startWithout", async () => { + await startWithout(["neverStartedState"]); + expect(startOnlyState.value).toBe(42); + expect(startedState.value).toBe(42); + expect(neverStartedState.value).toBe(undefined); + }); +}); diff --git a/packages/montar/tsconfig.json b/packages/montar/tsconfig.json new file mode 100644 index 0000000..77496bb --- /dev/null +++ b/packages/montar/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "tsconfig-link", + "compilerOptions": { + "incremental": true, + "outDir": "build/main", + "rootDir": "src", + "baseUrl": "./", + "types": ["jest", "node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules/**"] +} diff --git a/packages/montar/tsconfig.spec.json b/packages/montar/tsconfig.spec.json new file mode 100644 index 0000000..9674ab1 --- /dev/null +++ b/packages/montar/tsconfig.spec.json @@ -0,0 +1,4 @@ +{ + "exclude": ["node_modules"], + "extends": "./tsconfig.json" +}