diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..cf9d0a5 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,16 @@ +ARG VARIANT=bullseye +FROM --platform=linux/amd64 mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} + +ARG DEBIAN_FRONTEND=noninteractive + +# install deno +ENV DENO_INSTALL=/usr/local +RUN /bin/bash -c "$(curl -fsSL https://deno.land/install.sh)" \ + && chown ${USER:-vscode} /usr/local/bin/deno + +# update system packages, install gcc and git-extras, cleanup cache +RUN sudo apt-get -y update \ + && sudo apt-get -y install --no-install-recommends gcc git-extras \ + && sudo apt-get -y upgrade && sudo rm -rf /var/lib/apt/lists/* + +RUN echo 'PATH="$HOMEBREW_PREFIX/bin:$PATH"; eval "$(brew shellenv 2>/dev/null)"; if command -v starship &>/dev/null || brew install starship 2>/dev/null; then eval "$(starship init bash)"; fi;' >> /home/${USER:-vscode}/.bashrc diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4f238c0 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,46 @@ +{ + "name": "@decorators", + "dockerFile": "Dockerfile", + "remoteUser": "vscode", + "features": { + "git": "latest", + "sshd": "latest", + "node": "latest", + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/meaningful-ooo/devcontainer-features/homebrew:2": {}, + "ghcr.io/devcontainers-contrib/features/act:1": {}, + "ghcr.io/devcontainers-contrib/features/actionlint:1": {} + }, + // settings for the vscode editor + "customizations": { + "vscode": { + "settings": { + "deno.enable": true, + "deno.lint": true, + "deno.codeLens.test": true, + "deno.cacheOnSave": true, + "editor.tabSize": 2, + "editor.stickyTabStops": true, + "editor.linkedEditing": true, + "editor.minimap.enabled": false, + "editor.defaultFormatter": "denoland.vscode-deno", + "editor.formatOnPaste": false, + "editor.formatOnSave": true + }, + "extensions": [ + "github.theme", + "github.vscode-github-actions", + "github.copilot", + "denoland.vscode-deno", + "editorconfig.editorconfig", + "vsls-contrib.gistfs", + "redhat.vscode-yaml", + "antfu.browse-lite", + "bierner.markdown-preview-github-styles", + "mutantdino.resourcemonitor" + ] + } + }, + "postCreateCommand": "if command -v deno &>/dev/null; then\ndeno upgrade --canary;\ndeno completions bash > /etc/bash_completion.d/deno.bash; fi;", + "postAttachCommand": ". /home/vscode/.bashrc" +} diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b7abc00 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,131 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at [INSERT CONTACT +METHOD]. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..f3b5809 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,145 @@ +# Contributing Guide + +Thank you for your interest in contributing to the @decorators monorepo. Your +contributions make this project better for everyone! + +## How to Contribute + +### Reporting Issues + +- Check the [issue tracker](https://github.com/nberlette/decorators/issues) to + see if the issue already exists. +- When reporting a new issue, provide a descriptive title and clear steps to + reproduce the problem. + +### Proposing Changes + +1. [Open an Issue] to discuss your proposed changes. + - This helps us understand the context and purpose of your changes. + - If your change is a bug fix, please include the issue number in your + description (e.g., `Fixes #123`). + +2. Fork the repository and clone it to your local machine. + +```bash +gh repo fork --clone nberlette/decorators +``` + +3. Create a feature branch for your specific change. Keep it focused. + +```bash +git checkout -b feature/your-feature +``` + +4. Commit your changes following the [conventional commit format]. + +````bash + git add . # or `git add ` + git commit -m "feat: your-feature" + +```bash + git add . # or `git add ` + git commit -m "Add feature: your-feature" +```` + +5. Push your branch and open a pull request. + +```bash +git push origin feature/your-feature +``` + +6. In your pull request, provide a clear description of the changes and why they + are necessary. Include any relevant issue numbers and links to related + discussions or documentation. + +7. Request a review from the maintainers. + +[conventional commit format]: https://www.conventionalcommits.org/en/v1.0.0/#specification "Conventional Commits Specification" + +Please ensure: + +- Each pull request addresses a single issue. +- Your changes include appropriate tests and documentation if applicable. +- Your code adheres to the repository’s [style guidelines]. +- Your commit messages follow the [conventional commit format]. +- Your code is well-documented and easy to understand. +- Each new feature is accompanied by a corresponding test. + - A good rule of thumb is: 1 new `.ts` file = 1 new `.test.ts` file. +- You have run the tests and they all pass. + +## Style Guidelines + +We follow the same code style as the [Deno project](https://deno.land), and use +the [Deno CLI](https://docs.deno.com/go/cli) for linting, formatting, testing, +benchmarking, documentation, and more. + +### Code Style and Formatting + +- Lines are limited to 80 characters. +- Use 2 spaces for indentation, no tabs. +- Use double quotes for strings. +- Use semicolons at the end of statements. +- Arrow functions should have parentheses around the parameters. +- Always include explicit return types and parameter types on public functions + and methods. Use `as const` on literals when possible. +- Avoid using `any` type. Use specific types, generics, or `unknown` instead. + +### Other Guidelines and Best Practices + +- Use `const` and `let` instead of `var`. + +### Documentation + +We use the `deno doc` tool for generating documentation, as does [JSR], our main +distribution channel for packages in the `@decorators/*` namespace. + +Deno's documentation tool requires JSDoc comments and type annotations to be on +all public APIs. Some of the rules we follow are: + +- Wrap JSDoc comments to 80 characters as well. +- Use `@param` and `@returns` in JSDoc comments. +- Use `@example` in JSDoc comments for examples. +- Use `@see` in JSDoc comments for references. +- Use `@category` and `@tags` in JSDoc comments for categorization. +- Each module file must begin with a `@module` doc comment. +- Internal modules and features can be commented with `@internal`. + +### Code of Conduct + +All contributors are expected to follow the guidelines outlined in our +[Code of Conduct](./CODE_OF_CONDUCT.md). Please report any unacceptable +behavior. + +### Testing + +Before submitting your pull request, make sure all tests pass: + +```bash +npm install +npm run test +``` + +## License + +By contributing to this project, you agree that your contributions will be +licensed under the [MIT License](https://nick.mit-license.org/2024). + +--- + +We appreciate your support and look forward to your contributions! + +
+ +**[MIT]** © **[Nicholas Berlette]**. All rights reserved. + +[GitHub] · [Issues] · [JSR] + +
+ +[MIT]: https://nick.mit-license.org "MIT © 2024+ Nicholas Berlette. All rights reserved." +[Nicholas Berlette]: https://github.com/nberlette "Nicholas Berlette on GitHub" +[GitHub]: https://github.com/nberlette/decorators#readme "Check out all the '@decorators/*' packages over at the GitHub monorepo!" +[Issues]: https://github.com/nberlette/decorators/issues "GitHub Issue Tracker for '@decorators/*' packages" +[Open an Issue]: https://github.com/nberlette/decorators/issues/new "Open an Issue on nberlette/decorators" +[JSR]: https://jsr.io/@decorators "View @decorators/* packages on JSR" +[Code of Conduct]: ./CODE_OF_CONDUCT.md "Code of Conduct" diff --git a/.gitignore b/.gitignore index 6836292..d1df8b6 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,20 @@ dist .pnp.* *.lock* *-lock.* + +# Windows system files +Thumbs.db +ehthumbs.db +Icon? +Desktop.ini +$RECYCLE.BIN/ +# macOS system files +.AppleDouble +.LSOverride +.DS_Store +# Thumbnails +._* +# Files that might appear on external disk +.Spotlight-V100 +.Trashes +.VolumeIcon.icns diff --git a/LICENSE b/LICENSE index 56ea05a..232649a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,20 @@ -MIT License +The MIT License (MIT) -Copyright (c) 2024 Nicholas Berlette (https://github.com/nberlette) +Copyright (c) 2024-2025 Nicholas Berlette (https://github.com/nberlette) -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 818106d..6d3cc39 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,21 @@ -# [![@][@]decorators][docs] +
+ +# [@decorators][jsr] Monorepo for packages published under the [`@decorators/*`][JSR] scope on [JSR]. +
+ --- ## Packages -#### [`@decorators/alias`][@decorators/alias] +### [`@decorators/alias`] -> Alias class members to simplify stack traces. +Create aliases between class members and simplify stack traces. + +- **[➥ API Documentation](https://jsr.io/@decorators/alias)** +- **[➥ View the README](./packages/alias/README.md)** ```ts import { alias } from "@decorators/alias"; @@ -39,20 +46,27 @@ console.assert(foo.qux === foo.bar); // OK console.assert(foo.nurp === foo.bar); // OK ``` -#### [`@decorators/bind`][@decorators/bind] +--- + +### [`@decorators/bind`] + +Bind methods, getters, and setters to the appropriate context object, with +support for static members and inheritance. -> Bind methods, getters, and setters to the appropriate context object, with -> support for static members and inheritance. +- **[➥ API Documentation](https://jsr.io/@decorators/bind)** +- **[➥ View the README](./packages/bind/README.md)** ```ts import { bind } from "@decorators/bind"; class Foo { - @bind bar(): Foo { + @bind + bar(): Foo { return this; } - @bind static self(): typeof Foo { + @bind + static self(): typeof Foo { return this; } } @@ -64,22 +78,68 @@ console.log(bar() instanceof Foo); // true --- +### [`@decorators/lru`] + +Highly configurable LRU cache decorator for class methods, with support for TTL, +max size, custom key generation, pre- and post-processing, lifecycle event +handlers, and much more. + +- **[➥ API Documentation](https://jsr.io/@decorators/lru)** +- **[➥ View the README](./packages/lru/README.md)** + +```ts +import { lru } from "@decorators/lru"; + +class BasicExample { + @lru({ maxSize: 64, ttl: 1000 }) + memoized(arg1: string, arg2: number): string { + return `${arg1}-${arg2}`; + } +} + +const example = new BasicExample(); +console.log(example.memoizedMethod("foo", 42)); // "foo-42" +console.log(example.memoizedMethod("foo", 42)); // "foo-42" (cached) +``` + +--- + +### Contributing + +Contributions are warmly welcomed! Please read the [Contributing Guide] for +details on our code of conduct, and the process for submitting pull requests. + +If you find a bug, please [open an issue] and we will get to it as soon as +possible. Alternatively, if you feel up to fixing it yourself, please create the +issue anyways (so we can track it) and submit a pull request with the fix! + +--- + +### Further Reading + +- **[TC39 Decorators Proposal]** - The official TC39 proposal for decorators. +- **[Stage 3 Decorators in Deno]** - A microsite we created that's dedicated to + cover the [TC39 decorators proposal] and its landmark implementation in Deno. + +--- +
-##### **[MIT]** © **[Nicholas Berlette]**. All rights reserved. +##### [MIT] © [Nicholas Berlette]. All rights reserved. -###### [GitHub] · [Issues] · [Docs] +###### [GitHub] · [Issues] · [JSR]
-[@decorators/alias]: https://github.com/nberlette/decorators/tree/main/packages/alias#readme "Check out '@decorators/alias' and more over at the GitHub monorepo!" -[@decorators/bind]: https://github.com/nberlette/decorators/tree/main/packages/bind#readme "Check out '@decorators/bind' and more over at the GitHub monorepo!" -[GitHub]: https://github.com/nberlette/decorators/tree/main/packages/bind#readme "Check out all the '@decorators/*' packages over at the GitHub monorepo!" +[`@decorators/alias`]: https://jsr.io/@decorators/alias "Check out the '@decorators/alias' package" +[`@decorators/bind`]: https://jsr.io/@decorators/bind "Check out the '@decorators/bind' package" +[`@decorators/lru`]: https://jsr.io/@decorators/lru "Check out the '@decorators/lru' package" +[GitHub]: https://github.com/nberlette/decorators#readme "Check out all the '@decorators/*' packages over at the GitHub monorepo!" [MIT]: https://nick.mit-license.org "MIT © 2024+ Nicholas Berlette. All rights reserved." [Nicholas Berlette]: https://github.com/nberlette "Nicholas Berlette on GitHub" [Issues]: https://github.com/nberlette/decorators/issues "GitHub Issue Tracker for '@decorators/*' packages" [Open an Issue]: https://github.com/nberlette/decorators/issues/new?assignees=nberlette&labels=bugs&title=%5Bbind%5D+ "Found a bug? Let's squash it!" -[Docs]: https://n.berlette.com/decorators "View @decorators API docs" [JSR]: https://jsr.io/@decorators "View @decorators/* packages on JSR" -[Stage 3 Decorators]: https://github.com/tc39/proposal-decorators "TC39 Proposal: Decorators" -[@]: https://api.iconify.design/streamline:mail-sign-at-email-at-sign-read-address.svg?width=2.5rem&height=1.4rem&color=%23fb0 +[TC39 Decorators Proposal]: https://github.com/tc39/proposal-decorators "TC39 Proposal: Decorators" +[Stage 3 Decorators in Deno]: https://decorators.deno.dev "Stage 3 Decorators in Deno" +[Contributing Guide]: ./.github/CONTRIBUTING.md "Contributing Guide" diff --git a/deno.json b/deno.json index 77d3f91..efe83aa 100644 --- a/deno.json +++ b/deno.json @@ -1,32 +1,48 @@ { - "name": "@decorators/main", - "version": "0.1.2", - "tasks": { - "test": "deno test --parallel --allow-all --no-check=remote --coverage=.coverage ./packages/**/* ./internal/**/*", - "test:watch": "deno test --watch --parallel --allow-all --no-check=remote --coverage=.coverage ./packages/**/* ./internal/**/*", - "coverage": "deno coverage --html .coverage && mkdir docs && mv .coverage/html docs/coverage", - "coverage:open": "deno task coverage && open docs/coverage/index.html", - "fmt": "deno fmt ./packages/**/* ./internal/**/*", - "fmt:check": "deno fmt --check ./packages/**/* ./internal/**/*", - "fmt:watch": "deno fmt --watch ./packages/**/* ./internal/**/*", - "lint": "deno lint ./packages/**/* ./internal/**/*", - "lint:json": "deno lint --json ./packages/**/* ./internal/**/*", - "lint:watch": "deno lint --watch ./packages/**/* ./internal/**/*", - "docs": "deno doc --html --output=docs --name=decorators ./packages/**/*", - "docs:check": "deno doc --lint ./packages/**/*.ts", - "docs:open": "deno task docs && open docs/index.html", - "prepublish": "deno task fmt:check && deno task test && deno task lint && deno task docs && deno task coverage", - "bump": "deno task prepublish && deno run -Arq ./scripts/bump.ts", - "publish": "deno task bump && deno publish", - "publish:check": "deno task bump --dry-run && deno publish --dry-run", - "publish:major": "deno task bump --major && deno publish", - "publish:minor": "deno task bump --minor && deno publish", - "publish:patch": "deno task bump --patch && deno publish", - "clean": "rm -rf .coverage docs" + "license": "MIT", + "author": { + "name": "Nicholas Berlette", + "email": "nick@berlette.com", + "url": "https://github.com/nberlette/decorators" }, - "workspaces": [ + "workspace": [ "./internal", "./packages/alias", - "./packages/bind" + "./packages/bind", + "./packages/lru" + ], + "tasks": { + "test:check": "deno test --parallel -A --clean --coverage=.coverage", + "test": "deno task test:check --no-check=remote", + "test:cov": "deno coverage --html .coverage", + "test:nocheck": "deno task test:check --no-check", + "docs": "deno doc --html --name=\"@decorators/$(basename $(pwd))\" mod.ts", + "docs:json": "deno doc --json mod.ts > docs/api.json", + "docs:lint": "deno doc --lint ./[!_.]*.ts", + "docs:test": "deno task test --doc --permit-no-files", + "fmt": "deno fmt --ignore=docs --ignore=.coverage", + "fmt:check": "deno task fmt --check", + "lint": "deno lint --ignore=docs --ignore=.coverage", + "lint:fix": "deno task lint --fix", + "check:all": "deno task lint; deno task docs:lint; deno task fmt:check", + "test:all": "deno task test; deno task docs:test; deno task test:cov", + "prepare": "deno task check:all && deno task test:all && deno task docs", + "publish": "deno task publish:dry && deno publish", + "publish:dry": "deno task prepare && deno publish --dry-run --allow-dirty" + }, + "lock": true, + "vendor": false, + "nodeModulesDir": "auto", + "publish": { + "include": [ + "./{packages,internal}/**/*.{ts,tsx,json,jsonc,md}", + "./{packages,internal}/**/LICENSE" + ], + "exclude": [ + "**/*.test.*" + ] + }, + "exclude": [ + "**/{dist,docs,node_modules}/**" ] } diff --git a/internal/LICENSE b/internal/LICENSE index c34c878..85edf02 100644 --- a/internal/LICENSE +++ b/internal/LICENSE @@ -1,21 +1,20 @@ MIT License -Copyright (c) 2024 Nicholas Berlette (https://github.com/nberlette) +Copyright (c) 2024-2025+ Nicholas Berlette (https://github.com/nberlette) -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/internal/deno.json b/internal/deno.json index 0885678..529a4e6 100644 --- a/internal/deno.json +++ b/internal/deno.json @@ -1,13 +1,17 @@ { "name": "@decorators/internal", - "version": "0.1.2", + "version": "0.2.0", + "license": "MIT", + "author": { + "name": "Nicholas Berlette", + "email": "nick@berlette.com", + "url": "https://github.com/nberlette/decorators" + }, "exports": { ".": "./mod.ts", - "./assert": "./assert.ts", - "./helpers": "./helpers.ts", - "./types": "./types.ts" - }, - "publish": { - "include": ["*.ts", "*.json", "*.md", "LICENSE"] + "./assert": "./src/assert.ts", + "./guards": "./src/guards.ts", + "./helpers": "./src/helpers.ts", + "./types": "./src/types.ts" } } diff --git a/internal/mod.ts b/internal/mod.ts index 0827c1f..a850f7b 100644 --- a/internal/mod.ts +++ b/internal/mod.ts @@ -1,4 +1,8 @@ -export * from "./assert.ts"; -export * from "./helpers.ts"; -export type * from "./types.ts"; -export type { Is } from "./types.ts"; +/** + * @module internal + * @internal + */ +export * from "./src/assert.ts"; +export * from "./src/guards.ts"; +export * from "./src/helpers.ts"; +export * from "./src/types.ts"; diff --git a/internal/assert.ts b/internal/src/assert.ts similarity index 89% rename from internal/assert.ts rename to internal/src/assert.ts index 59e4d75..f87142a 100644 --- a/internal/assert.ts +++ b/internal/src/assert.ts @@ -1,6 +1,7 @@ -import { is } from "jsr:@type/is@0.1.0"; - -export * from "jsr:@type/is@0.1.0"; +/** + * @module assert + */ +import { isError } from "./guards.ts"; /** * Asserts that a given {@link condition} is met, and throws an error if not. @@ -19,9 +20,10 @@ export * from "jsr:@type/is@0.1.0"; * @param condition The condition to assert. * @param [message] An optional message or error object to provide more context * about the failure and the expected condition. - * @param [stackCrawlMark] An optional function reference to use as the starting - * point for the stack trace. Any stack frames above this function are omitted. - * If not provided, the `assert` function itself is used as the starting point. + * @param [stackCrawlMark] An optional function reference to use as the + * starting point for the stack trace. Any stack frames above this function are + * omitted. If not provided, the `assert` function itself is used as the + * starting point. * @throws If the condition is not met, an error is thrown with the provided * @example * ```ts @@ -70,7 +72,7 @@ export function assert( ErrorType: typeof Error = TypeError, ): asserts condition { if (!condition) { - const error = is.error(message) ? message : new ErrorType( + const error = isError(message) ? message : new ErrorType( message ?? "Assertion failed. No additional context provided.", ); Error.captureStackTrace?.(error, stackCrawlMark ?? assert); diff --git a/internal/src/guards.ts b/internal/src/guards.ts new file mode 100644 index 0000000..54d6ce0 --- /dev/null +++ b/internal/src/guards.ts @@ -0,0 +1,147 @@ +// deno-lint-ignore-file ban-types +/** + * @module guards + */ +import type { unknowns } from "./types.ts"; + +const toString = globalThis.Object.prototype.toString; +const Symbol: typeof globalThis.Symbol = globalThis.Symbol; +const SymbolToStringTag: typeof Symbol.toStringTag = Symbol.toStringTag; + +export function isObject(it: unknown): it is object { + return typeof it === "object" && it !== null; +} + +export function isFunction(it: unknown): it is Function { + return typeof it === "function"; +} + +/** + * Checks if the given value appears to be an object or function with the + * expected `Object.prototype.toString` tag. This can be produced either + * by a `Symbol.toStringTag` value, or internally by the JavaScript engine. + * + * This guard makes no attempts to verify the provenance or origin of the + * tag value, it simply verifies that the value produces the expected tag. + * + * @param it The value to check. + * @param tag The expected `Object.prototype.toString` tag (without `[object`, `]`). + * @returns `true` if the value appears to be an object or function with the + * expected `Object.prototype.toString` tag. + * @example + * ```ts + * isTagged(new Error(), "Error"); // true + * isTagged(new Array(), "Array"); // true + * isTagged(new Map(), "Map"); // true + * isTagged(new Set(), "Set"); // true + * isTagged(new WeakMap(), "WeakMap"); // true + * isTagged(new WeakSet(), "WeakSet"); // true + * isTagged(new Date(), "Date"); // true + * ``` + */ +export function isTagged( + it: unknown, + tag: T, +): it is { [Symbol.toStringTag]: T } { + return (isObject(it) || isFunction(it)) && + toString.call(it) === `[object ${tag}]`; +} + +/** + * Checks if the given value appears to be a native object with the given tag, + * such as `Error`, `Array`, `Arguments`, etc. + * + * This builds on the `isTagged` function (which simply checks if a value is an + * object/function that produces the expected `Object.prototype.toString` tag), + * and adds an additional refinement step to verify that it does not have any + * `Symbol.toStringTag` property. Only native objects are capable of producing + * a custom `toStringTag` value without having a `Symbol.toStringTag` somewhere + * in their prototype chain. + * + * @param it The value to check. + * @param tag The expected `Object.prototype.toString` tag (without `[object`, + * `]`). + * @returns `true` if the value appears to be a native object with the given + * tag. + * @example + * ```ts + * isTaggedNative(new Error(), "Error"); // true + * isTaggedNative(new Array(), "Array"); // true + * + * // despite being a native object, `Map` defines a `Symbol.toStringTag`: + * isTaggedNative(new Map(), "Map"); // false + * + * (function foo(){ + * // simple way to validate for an `Arguments` implementation: + * console.assert(isTaggedNative(arguments, "Arguments")); + * })() + * ``` + */ +export function isTaggedNative( + it: unknown, + tag: Tag, +): it is T & { [Symbol.toStringTag]?: never } { + return isTagged(it, tag) && typeof it[SymbolToStringTag] === "undefined"; +} + +/** + * Checks if the given value appears to be a native `Error` object. + */ +export function isNativeError(x: unknown): x is Error { + if ("isError" in Error && isFunction(Error.isError)) return Error.isError(x); + return isTaggedNative(x, "Error"); +} + +/** + * Checks if the given value appears to be an `Error` object. + * + * This is a more permissive check than `isNativeError`, which only checks for + * native `Error` objects. This will also return true for any object that + * inherits from `Error`, including custom error classes. + * + * @param it The value to check. + * @returns `true` if the value appears to be an `Error` object. + */ +export function isError(it: unknown): it is Error { + return isNativeError(it) || (isObject(it) && it instanceof Error); +} + +/** + * Checks if the given value appears to be a native `Array` object. + * + * This is a more permissive check than `isTagged`, which only checks for + * native `Array` objects. This will also return true for any object that + * inherits from `Array`, including custom array-like classes. + * + * @param it The value to check. + * @returns `true` if the value appears to be an `Array` object. + */ +export function isArray(it: unknown): it is unknown[]; +export function isArray(it: ArrayLike | unknowns): it is T[]; +export function isArray( + it: ArrayLike | unknowns, + test: (it: unknown) => it is T, +): it is T[]; +export function isArray( + it: unknown, + test?: (it: unknown) => it is T, +): it is T[]; +export function isArray( + it: unknown, + test?: (it: unknown) => it is T, +): it is T[] { + if (isTaggedNative(it, "Array")) { + if (typeof test === "function") { + const a = it as ArrayLike; + for (let i = 0; i < a.length; i++) { + if (!test(a[i])) return false; + } + } + return true; + } + return false; +} + +export function isArguments(it: unknown): it is IArguments { + return isTaggedNative(it, "Arguments"); +} diff --git a/internal/helpers.ts b/internal/src/helpers.ts similarity index 98% rename from internal/helpers.ts rename to internal/src/helpers.ts index d007437..6cbc307 100644 --- a/internal/helpers.ts +++ b/internal/src/helpers.ts @@ -1,3 +1,6 @@ +/** + * @module helpers + */ import { assert } from "./assert.ts"; /** diff --git a/internal/src/types.ts b/internal/src/types.ts new file mode 100644 index 0000000..f286f2c --- /dev/null +++ b/internal/src/types.ts @@ -0,0 +1,425 @@ +// deno-lint-ignore-file no-explicit-any ban-types +/** + * @module types + * + * This module provides internal utility types used throughout various projects + * in the `@decorators` namespace. These types are not intended for direct use + * by end users, but are instead used internally to provide type safety and + * consistency across the various projects and modules. They're located in this + * module to avoid redundancy and improve maintainability. + * + * @see https://jsr.io/@decorators for more information and a full list of the + * available packages offered by the `@decorators` project namespace. Thanks! + */ + +/** + * Represents a unique symbol that is used to create branded and flavored types + * in TypeScript. This symbol is used to create nominal types and prevent type + * collisions in the type system. + * @see https://michalzalecki.com/nominal-typing-in-typescript/ + * @see {@linkcode Brand} for more information on branded types. + * @see {@linkcode Branded} for a helper type that creates branded types. + * @see {@linkcode Flavor} for more information on flavored types. + * @see {@linkcode Flavored} for a helper type that creates flavored types. + * @category Utility Types + */ +export const BRAND: unique symbol = Symbol("BRAND"); + +/** + * Represents a unique symbol that is used to create branded and flavored types + * in TypeScript. This symbol is used to create nominal types and prevent type + * collisions in the type system. + * @see https://michalzalecki.com/nominal-typing-in-typescript/ + * @see {@linkcode Brand} for more information on branded types. + * @see {@linkcode Branded} for a helper type that creates branded types. + * @see {@linkcode Flavor} for more information on flavored types. + * @see {@linkcode Flavored} for a helper type that creates flavored types. + * @category Utility Types + */ +export type BRAND = typeof BRAND; + +/** + * Represents a unique symbol that can be used to create a simulated partial + * application of type parameters. This is useful for creating branded types + * with a default value for a type parameter. + * @category Utility Types + */ +export const NEVER: unique symbol = Symbol("NEVER"); + +/** + * Represents a unique symbol that can be used to create a simulated partial + * application of type parameters. This is useful for creating branded types + * with a default value for a type parameter. + * @category Utility Types + */ +export type NEVER = typeof NEVER; + +/** + * Represents a unique brand for a type, which can be used to create nominal + * types in TypeScript and prevent type collisions. For a less-strict version + * of this type, see the {@linkcode Flavor} interface. + * + * To create a branded type, you can either use the {@linkcode Branded} helper + * type, or manually extend/intersect another type with this interface. The + * {@linkcode A} type parameter is the type that becomes the brand's value, and + * it defaults to `never`. + */ +export interface Brand { + readonly [BRAND]: B; +} + +/** + * Creates a new branded type by intersecting the given type `V` with the + * {@linkcode Brand} interface. This can be used to create nominal types in + * TypeScript and prevent type collisions. + * + * This is an "overloaded" type that can be used in two ways: + * - If only two type arguments are provided, the first type `V` is branded + * with the second type `T` (e.g. `V & Brand`). + * - If three type arguments are provided, the first type `V` is **unioned** + * with type `T`, which is branded with the third type `B`. This is useful + * for creating things like a string literal union with a brand type. It is + * equivalent to `V | Branded`. + * + * @template V The type to brand with the given type `T`. + * @template T The type to brand the given type `V` with. + * @template [B=NEVER] The type that becomes the brand's value. Defaults to the + * special `NEVER` type, which is a unique symbol that can be used to create a + * simulated partial application of type parameters. + * @category Utility Types + */ +export type Branded = [B] extends [NEVER] ? V & Brand + : V | Branded; + +/** + * Represents a unique flavor for a type, which can be used to create nominal + * types in TypeScript and prevent type collisions. For a stricter version of + * this type, see the {@linkcode Brand} interface. + * + * To create a flavored type, you can either use the {@linkcode Flavored} + * helper type, or manually extend/intersect another type with this interface. + * The {@linkcode F} type parameter is the type that becomes the flavor's + * value, and it defaults to `never`. + */ +export interface Flavor { + readonly [BRAND]?: F | void; +} + +/** + * Creates a new flavored type by intersecting the given type `V` with the + * {@linkcode Flavor} interface. This can be used to create nominal types in + * TypeScript and prevent type collisions. + * + * This is an "overloaded" type that can be used in two ways: + * - If only two type arguments are provided, the first type `V` is flavored + * with the second type `T` (e.g. `V & Flavor`). + * - If three type arguments are provided, the first type `V` is **unioned** + * with type `T`, which is flavored with the third type `F`. This is useful + * for creating things like a string literal union with a flavor type. It + * is equivalent to `V | Flavored`. + * + * @template V The type to flavor with the given type `T`. + * @template T The type to flavor the given type `V` with. + * @template [F=NEVER] The type that becomes the flavor's value. Defaults to + * the special `NEVER` type, which is a unique symbol that can be used to + * create a simulated partial application of type parameters. + * @category Utility Types + */ +export type Flavored = [F] extends [NEVER] ? V & Flavor + : V | Flavored; + +/** + * Represents an abstract constructor function (i.e. an abstract class) that + * **cannot** be directly instantiated, but can be extended by other classes. + * + * This is a supertype of the {@linkcode Constructor} type, and therefore it + * typically can be used in place of a constructor type to represent a normal + * concrete class as well. + * + * @template [T=any] The type of the instances created by the constructor. + * @template {readonly unknown[]} [A=readonly any[]] The type of the arguments + * passed to the constructor. + * @category Utility Types + */ +export type AbstractConstructor< + T = any, + A extends readonly unknown[] = readonly any[], +> = abstract new (...args: A) => T; + +/** + * Represents a constructor function that can be used to create new instances + * of a given type {@linkcode T}. Similar to {@linkcode AbstractConstructor}, + * but this represents a concrete class instead of an abstract one. + * + * Values of this type are subtypes of {@linkcode AbstractConstructor}. + * + * @template [T=any] The type of the instances created by the constructor. + * @template {readonly unknown[]} [A=readonly any[]] The type of the arguments + * passed to the constructor. + * @category Utility Types + */ +export type Constructor< + T = any, + A extends readonly unknown[] = readonly any[], +> // @ts-ignore easter egg + = new (...args: A) => T; + +export type Class< + Prototype extends object | null = any, + Args extends readonly unknown[] = readonly any[], + Static extends {} = {}, +> = + & Constructor + & { readonly prototype: Prototype } + & ({} extends Static ? unknown : { + [K in keyof Static as [Static[K]] extends [never] ? never : K]: + & ThisType> + & Static[K]; + }); + +export type AbstractClass< + Prototype extends object | null = any, + Args extends readonly unknown[] = readonly any[], + Static extends {} = {}, +> = + & AbstractConstructor + & { readonly prototype: Prototype } + & ( + {} extends Static ? unknown + : { + [K in keyof Static as [Static[K]] extends [never] ? never : K]: + & ThisType> + & Static[K]; + } + ); + +/** + * Returns a union of the keys of an object `T` whose values are functions. + * This is useful for extracting the keys of a class's methods. + * @template T The object type to extract keys from. + * @category Utility Types + */ +export type FunctionKeys = { + [K in keyof T]: T[K] extends (...args: any) => any ? K : never; +}[keyof T]; + +/** + * Represents an accessor method's property descriptor. which may only have a + * getter and/or setter, a `configurable` flag, and an `enumerable` flag. The + * `value` and `writable` flags are not allowed. + * + * @template T The type of the accessor's value. + * @category Types + */ +export interface AccessorPropertyDescriptor { + get?(): T; + set?(value: T): void; + configurable?: boolean; + enumerable?: boolean; +} + +/** + * If `A` is `never`, `null`, or `undefined`, returns `B`. Otherwise, as long + * as `A` is assignable to `B`, returns `A`. Otherwise, returns `never`. + */ +export type Or = ([A & {}] extends [never] ? B : A) extends + infer V extends B ? V : never; + +/** + * Casts a type `T` to a type `U`, but only if `T` is assignable to `U`. If + * `T` is not assignable to `U`, and if `T & U` results in `never`, then the + * type `U` is intersected with the type `Omit`. This allows for + * partial type casting, where the two types are merged but defer to `U` where + * possible. If `T` is not assignable to `U`, and if `T & U` does not result in + * `never`, then the last resort is to return the intersection of `T` and `U`. + * + * @template T The type to cast to the type `U`. + * @template U The type to cast the type `T` to. + */ +export type As = T extends U ? U extends T ? U : T + : [T & U] extends [never] ? U & Omit + : T & U; + +/** + * Casts a type `T` to a type `U`, but only if `T` is assignable to `U`. If + * `Extract` results in `never`, then the type `U` is returned as is. + * + * @template T The type to cast to the type `U`. + * @template [U=unknown] The type to cast the type `T` to. + * @category Utility Types + */ +export type Is = Or, U>; + +/** + * Represents the mildest form of a branded generic string type. This type is + * used to create string literal unions that accept any string input, but will + * preserve the literal string union members for autocomplete purposes. + * + * For example, using `strings | "foo" | "bar"` for an argument type will allow + * any string to be passed, but will still suggest `"foo"` or `"bar"` as valid + * suggestions in an editor that supports TypeScript's language server. + * + * > **Note**: the lowercase name was chosen for this type to intentionally + * > convey that it is capable of being assigned any `string` value. It also + * > helps distinguish this type from the built-in `string` type, while still + * > being visually similar. + * + * @category Utility Types + */ +export type strings = string & {}; + +/** + * Represents the mildest form of a branded generic number type. This type is + * used to create number literal unions that accept any number input, but will + * preserve the literal number union members for autocomplete purposes. + * + * Similiar to {@linkcode strings}, this type is useful for creating number + * literal unions that accept any number, but will still suggest the literal + * number union members for autocomplete purposes. + * + * > **Note**: the lowercase name was chosen for this type to intentionally + * > convey that it is capable of being assigned any `number` value. It also + * > helps distinguish this type from the built-in `number` type, while still + * > being visually similar. + * + * @category Utility Types + */ +export type numbers = number & {}; + +/** + * Represents the mildest form of a branded generic symbol type. This type is + * used to create symbol literal unions that accept any symbol input, but will + * preserve the literal symbol union members for autocomplete purposes. + * + * Similiar to {@linkcode strings} and {@linkcode numbers}, this type is useful + * for creating symbol literal unions that accept any symbol, but will still + * suggest the literal symbol union members for autocomplete purposes. + * + * > **Note**: the lowercase name was chosen for this type to intentionally + * > convey that it is capable of being assigned any `symbol` value. It also + * > helps distinguish this type from the built-in `symbol` type, while still + * > being visually similar. + * + * @category Utility Types + */ +// We cannot just intersect `symbol` with `{}`, as this will widen the type to +// `symbol`. Instead, we need to create a new type that is a subtype of symbol, +// with a property that is never used. +export type symbols = symbol & { [BRAND]?: never }; + +/** + * Union of {@linkcode strings}, {@linkcode numbers}, and {@linkcode symbols}, + * this type can be used as an "anchor" type in a literal union of properties. + * This will ensure your literal union is not widened to a `string`, `number`, + * or `symbol` type, but will still accept any of these types as valid inputs. + * + * @category Utility Types + */ +export type PropertyKeys = strings | numbers | symbols; + +export type { PropertyKeys as properties }; + +/** + * Equivalent to the builtin `unknown` type, but supports usage in places where + * `unknown` would "poison" the type, widening it into `unknown`. + * + * For example, if you use `unknown` in any union, it automatically becomes + * `unknown`. This is a bit of a nuisance with generic type parameters, where + * sometimes you want to say that you don't know what the type is exactly, but + * you do know what a couple possibilities are. + * + * That's exactly what this type is for. It can be used as a constituent of any + * union and it will not widen that union to `unknown`. If it's intersected + * with another type, it will behave just like the real `unknown`, which is to + * say it will effectively do nothing, and resolve to the other member it is + * being intersected with. + */ +export type unknowns = {} | null | undefined; + +/** + * Represents a type that is the union of the values of an object `T`. + */ +export type ValueOf = T[keyof T]; + +/** + * This is a "safe" version of the `keyof` operator, which will only return + * keys that are both assignable to `PropertyKey` and are not `never`. This is + * useful for extracting keys from objects that could potentially be empty, and + * always ensuring the resulting type is at least a `PropertyKey`. + * + * If the {@linkcode Strict} type parameter is set to `false`, the union of + * keys will be "anchored" with the {@linkcode PropertyKeys} branded type, to + * allow for literal key unions to be preserved in autocomplete suggestions. + */ +export type KeyOf = + | (Strict extends true ? never : PropertyKeys) + | Is; + +/** + * Extracts the parameters of + */ +// deno-fmt-ignore +export type ParametersOf = + | [T] extends [never] ? Fallback : ParametersOfWorker< + | T extends (...args: any) => any ? T + : ValueOf extends infer U + ? U extends (...args: any) => any ? U : never + : Fallback, + Fallback + > extends infer A extends readonly unknown[] ? A + : Fallback; + +// deno-fmt-ignore +type ParametersOfWorker = + | T extends (...args: infer A) => any ? Readonly : Fallback; + +export type OptionalParametersOf< + T, + Fallback extends readonly unknown[] = never, +> = [T] extends [never] ? Fallback + : ParametersOf extends infer A extends readonly unknown[] + ? OptionalParametersOfWorker + : Fallback; + +type OptionalParametersOfWorker = + A extends readonly [infer F, ...infer R] ? IsEqual< + Exclude, + F, + OptionalParametersOfWorker, + [F, ...OptionalParametersOfWorker] + > + : A; + +export type IsEqual = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? True + : False; + +/** + * Adds a `void` type to the given type `T`, to become `T | void`. + * + * @category Utility Types + */ +export type Voidable = T | void; + +/** + * The type that can be passed to the {@linkcode MaybeVoidable} type for its + * second type parameter, `V`. @see {@linkcode MaybeVoidable} for more info + * on how these types are used. + * + * @category Utility Types + */ +export type VoidableArgument = boolean | void; + +/** + * Used to determine the return type of a decorator function. If the argument + * {@linkcode V} is `true`, then this resolves to `void | T`. If the argument + * `V` is `false`, it resolves to just `T`. If `V` is `void`, it resolves to + * just `void`. + * + * @template T The type of the return value. + * @template {VoidableArgument} V Whether the return type should include `void`. + * @category Utility Types + */ +export type MaybeVoidable = + | ([V] extends [true] | [void] ? void : never) + | ([V] extends [void] ? never : T); diff --git a/internal/types.ts b/internal/types.ts deleted file mode 100644 index d6b14ea..0000000 --- a/internal/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -export type As = T extends U ? U extends T ? U : T - : [T & U] extends [never] ? U & Omit - : T & U; - -export type Is = [T] extends [never] ? [U] extends [never] ? never : U - : T extends unknown ? U - : T extends U ? T - : U; - -export const BRAND: unique symbol = Symbol("BRAND"); -export type BRAND = typeof BRAND; - -export const NEVER: unique symbol = Symbol("NEVER"); -export type NEVER = typeof NEVER; - -export interface Brand { - readonly [BRAND]: B; -} - -export type Branded = [B] extends [NEVER] ? V & Brand - : V | Branded; - -export interface Flavor { - readonly [BRAND]?: F | void; -} - -export type Flavored = [F] extends [NEVER] ? V & Flavor - : V | Flavored; - -// deno-lint-ignore ban-types -export type strings = string & {}; - -export type properties = Flavored; - -export type KeyOf = - | (Strict extends true ? never : strings) - | (string & keyof T); - -export type PropKeys = - | (Strict extends true ? never : properties) - | (PropertyKey & keyof T); - -// deno-lint-ignore no-explicit-any -export type AbstractConstructor = - abstract new (...args: A) => T; - -// deno-lint-ignore no-explicit-any -export type Constructor = { - new (...args: A): T; -}; - -export type FunctionKeys = keyof { - // deno-lint-ignore no-explicit-any - [K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: K; -}; diff --git a/packages/alias/LICENSE b/packages/alias/LICENSE index c34c878..232649a 100644 --- a/packages/alias/LICENSE +++ b/packages/alias/LICENSE @@ -1,21 +1,20 @@ -MIT License +The MIT License (MIT) -Copyright (c) 2024 Nicholas Berlette (https://github.com/nberlette) +Copyright (c) 2024-2025 Nicholas Berlette (https://github.com/nberlette) -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/bind/LICENSE b/packages/bind/LICENSE index c34c878..232649a 100644 --- a/packages/bind/LICENSE +++ b/packages/bind/LICENSE @@ -1,21 +1,20 @@ -MIT License +The MIT License (MIT) -Copyright (c) 2024 Nicholas Berlette (https://github.com/nberlette) +Copyright (c) 2024-2025 Nicholas Berlette (https://github.com/nberlette) -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/bind/README.md b/packages/bind/README.md index 40f48eb..8baffb0 100644 --- a/packages/bind/README.md +++ b/packages/bind/README.md @@ -192,10 +192,10 @@ export function bind( #### Parameters -| Name | Info | -| :------------ | :-------------------------------------------------------------- | +| Name | Info | +| :------------ | :------------------------------------------------------------------ | | **`getter`** | The target **getter** to bind to the class instance or constructor. | -| **`context`** | The getter-specific decorator context object. | +| **`context`** | The getter-specific decorator context object. | #### Returns diff --git a/packages/bind/deno.json b/packages/bind/deno.json index 1ec9a3c..94e1a25 100644 --- a/packages/bind/deno.json +++ b/packages/bind/deno.json @@ -1,9 +1,14 @@ { "name": "@decorators/bind", - "version": "0.1.2", + "version": "0.2.0", + "license": "MIT", + "author": { + "name": "Nicholas Berlette", + "email": "nick@berlette.com", + "url": "https://github.com/nberlette/decorators" + }, "exports": "./mod.ts", - "publish": { - "include": ["*.ts", "*.md", "*.json", "LICENSE"], - "exclude": ["*.test.*"] + "imports": { + "@decorators/internal": "jsr:@decorators/internal@^0.2.0" } } diff --git a/packages/bind/mod.ts b/packages/bind/mod.ts index b7c379a..2652431 100644 --- a/packages/bind/mod.ts +++ b/packages/bind/mod.ts @@ -72,10 +72,7 @@ * console.log(example.value); // ✔️ no error * ``` */ -import { - assert, - setFunctionProperties, -} from "jsr:@decorators/internal@^0.1.1"; +import { assert, setFunctionProperties } from "@decorators/internal"; /** * Stage 3 Decorator to bind a class method to its instance or constructor, diff --git a/packages/lru/LICENSE b/packages/lru/LICENSE new file mode 100644 index 0000000..89967cc --- /dev/null +++ b/packages/lru/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2025 Nicholas Berlette (https://github.com/nberlette) + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/lru/README.md b/packages/lru/README.md new file mode 100644 index 0000000..bbad226 --- /dev/null +++ b/packages/lru/README.md @@ -0,0 +1,749 @@ +
+ +[@decorators/lru][JSR] + +# [`@decorators/lru`][JSR] + +Highly configurable. Strong types. Test-friendly. And really +fast. + +Compatible with Deno, Bun, Node, Cloudflare Workers, and +more. + +![jsr-score][badge-jsr-score] ![jsr-pkg][badge-jsr-pkg] + +"Not your average memoization decorator." + +
+ +--- + +This package provides a powerful and highly configurable decorator factory for +memoizing class methods with a memory-safe LRU (least recently used) cache API. +It supports a wide range of [features] and [options] to help you fine-tune the +cache behavior to your specific needs. + +```ts +import { lru } from "@decorators/lru"; + +class BasicExample { + @lru({ maxSize: 64, ttl: 1000 }) + memoized(arg1: string, arg2: number): string { + return `${arg1}-${arg2}`; + } +} + +const example = new BasicExample(); +console.log(example.memoizedMethod("foo", 42)); // "foo-42" +console.log(example.memoizedMethod("foo", 42)); // "foo-42" (cached) +``` + +Whether you're new to decorators in TypeScript, or a seasoned veteran in the +game, this package aims to elevate your development experience and **_earn its +place_** in your toolbox. + +> **Continue reading to learn about its [usage] and available [features].** +> **_Or jump straight to the [real-world examples] and see it in action!_** + +[features]: #features "Jump to the Features section!" +[usage]: #usage "Jump to the Usage section!" +[real-world examples]: #examples "Jump to the Examples section!" +[options]: #options "Jump to the Options section!" + +## Install + +```sh +deno add jsr:@decorators/lru +``` + +```sh +bunx jsr add @decorators/lru +``` + +```sh +pnpm dlx jsr add @decorators/lru +``` + +```sh +yarn dlx jsr add @decorators/lru +``` + +```sh +npx -y jsr add @decorators/lru +``` + +--- + +## Usage + +Here's a rather contrived example demonstrating the most basic usage of the +`@lru` decorator. If you'd like to see more advanced examples, check out the +[examples section](#examples), which covers several real-world use cases. + +```ts +import { lru } from "@decorators/lru"; + +class Calculator { + @lru({ maxSize: 64, ttl: 1000 }) + fib(n: number): number { + if (n < 2) return n; + return this.fib(n - 1) + this.fib(n - 2); + } +} + +const calc = new Calculator(); +console.log(`Fibonacci(10): ${calc.fib(10)}`); +``` + +> At first glance, this may appear to be another run-of-the-mill memoization +> tool. But don't be fooled. The `@decorators/lru` package is far more powerful +> than your typical simple memoization tool. + +--- + +## Features + +Aside from supporting all the standard configurations/functionality one would +come to expect in a tool like this, this package cranks up the heat with several +distinct [features] of its own, putting it in a league of its own. + +- **TypeScript-First**: Fully typed, well-documented API is a breeze to use. +- **Flexible Caching**: Cache method return values with an LRU strategy (least + recently used) based on the method's arguments at call time. +- **Cache Capacity**: Limit the cache size to a maximum number of entries, + automatically evicting the least recently used entries as needed. + - The default cache capacity is `128` entries. +- **TTL Support**: Automatically expire cache entries after a specified time. +- **Eviction Strategies**: Pick a passive (lazy) or active (scheduled) eviction + strategy to fine-tune the TTL behavior to your project's specific needs. + - The default eviction strategy is `"passive"`. +- **Custom Transformers**: Rehydrate or mutate cached values before returning + them, without sacrificing type safety. Use this to implement more complex + caching strategies, such as [async caching of HTTP responses]. +- **Custom Key Generation**: Custom key generation functions for complex keys + not easily serializable by the default `JSON.stringify`-based keygen. +- **Memory Safe**: Uses an [advanced storage API][storage] to bind the `LRU` + lifetimes to those of their associated class instance (and the class itself). + - This prevents any cache from outliving the class it was created on. + - _See the [storage API section](#storage-api) for a deep dive into this API._ +- **Dependency Injection**: Easily override internal components (e.g., the LRU + cache class, `Map`, `WeakMap`, `Date`). Designed with test-driven development + as a core focus. + +[storage]: ./#storage-api "View the Storage API section" + +> See the [storage API section](#storage-api) for more details on Dependency +> Injection, the [overrides](#overrides) feature, and a cautionary warning. + +### Eviction Strategies + +#### `"passive"` (the default) + +Checks and evicts stale entries on each invocation of the memoized method, as +well as on each cache access ("lazy" eviction). This is the strategy used by +practically every other memoization utility, including Python's +`functools.lru_cache` decorator. + +#### `"active"` + +Setting the `eviction` option to `"active"` causes the `@lru` decorator to adopt +a more aggressive approach to cache eviction. + +Instead of lazily evicting entries that have already expired (potentially +lingering in the cache for an extended period of time beyond their expiration +time), the cache mechanism will now spawn (schedule) a dedicated eviction timer +on all new cache entries. + +> [!NOTE] +> +> Using active eviction with very large LRU caches, especially those that see a +> significant level of churn, could potentially result in a performance hit due +> to accumulated overhead of task-scheduling. Exercise caution when using this +> in any performance-sensitive code paths. + +If an entry has not been accessed by the time its TTL expires, it will be +immediately evicted from the cache and the timer cleared. If the entry is +accessed before the timer expires, the timer is cleared and set again for the +new TTL, ensuring the cache's recency behavior is retained. This usually results +in improved memory usage, especially when caching large objects. + +> [!TIP] +> +> It's strongly encouraged that you adjust the `maxSize` and `ttl` options until +> you find the best balance for your specific use case. + +[async caching of HTTP responses]: ./#custom-transform-examples-async-http-caching "View the async caching of HTTP responses example" + +--- + +## API + +### Options + +| Option | Type | Default Value | Description | +| ----------- | ----------------------------------------------------- | ---------------- | ----------------------------------------------------- | +| `maxSize` | `number` | `128` | Maximum number of entries to store in the cache. | +| `ttl` | `number` | `0` | Time-to-live for cache entries, in milliseconds. | +| `eviction` | `"passive"` \| `"active"` | `"passive"` | Eviction strategy to use for expired cache entries. | +| `key` | [`Keygen`](#keygen) | `JSON.stringify` | Custom key generation function. | +| `prepare` | [`Transform`](#transform) | `(x) => x` | Custom preparation function for arguments. | +| `transform` | [`Transform`](#transform) | `(x) => x` | Custom postprocessing function for cached values. | +| `inspect` | `(entry: CacheEntry) => void` | `undefined` | Callback for when an entry is inspected in the cache. | +| `onHit` | `(value: V, key: K, entry: CacheEntry) => void` | `undefined` | Callback for when an entry is hit in the cache. | +| `onMiss` | `(key: K) => void` | `undefined` | Callback for when an entry is missed in the cache. | +| `onEvict` | `(value: V, key: K, entry: CacheEntry) => void` | `undefined` | Callback for when an entry is evicted from the cache. | +| `onRefresh` | `(value: V, key: K, entry: CacheEntry) => void` | `undefined` | Callback for when an entry is refreshed in the cache. | +| `overrides` | [`Overrides`](#overrides) | `{}` | Overrides for testing with custom internal APIs. | + +#### `Keygen` + +```ts +type Keygen = (...args: Params) => CacheKey; +``` + +Describes a key generation function that takes the method arguments and returns +a cache key as a string. The default implementation uses `JSON.stringify`. + +#### `Transform` + +```ts +typ`e Transform = ( + value: Input, + key: CacheKey, + entry: CacheEntry, +) => Output; +``` + +Describes a transformation function that takes the cached value, the cache key, +and the cache entry as its arguments, and returns a transformed value. + +> [!NOTE] +> +> The type of the output value currently must be the same as that of the input +> value, due to design decisions made in the Decorators Proposal. +> +> This may change in the future, so this type accepts a second type parameter to +> specify separate input and output types, in case that becomes possible. + +#### `Overrides` + +Allows you to override the internal APIs used by the `@lru` decorator for more +advanced use cases, such as mocking and testing. This is an advanced feature +that should be used with caution. It ~~can~~ **_will_** lead to unexpected +behavior if it's not used correctly. + +| Option | Type | Description | +| --------- | -------------------- | ---------------------------------------------------------- | +| `Map` | `MapLikeConstructor` | Override the `Map` constructor used for the LRU cache. | +| `WeakMap` | `MapLikeConstructor` | Override the `WeakMap` constructor used for the LRU cache. | +| `LRU` | `MapLikeConstructor` | Override the `LRU` constructor used for the LRU cache. | +| `Date` | `TimeProvider` | Override the `Date` constructor used for the TTL timer. | +| `storage` | `CacheStorage` | Override the storage API used for the LRU cache. | + +
+ +> [!WARNING] +> +> The `overrides` option is considered an expert feature and is not intended to +> be used lightly. **_Do not use this option_** if you do not know **exactly** +> what it does, what it _can_ do, and actually have a good reason to use it. + +--- + +## Examples + +Below are some examples of how to use the `@decorators/lru` package in various +real-world scenarios. These examples are designed to be as realistic as +possible, while still being simple enough to understand. + +There are also a couple of advanced examples that demonstrate (at length) how +one might leverage the `@decorators/lru` package in a more complex application. + +
Custom Keygens (basic demonstration)
+ +The default keygen implementation uses `JSON.stringify` to serialize arguments. + +```ts +import { lru } from "@decorators/lru"; + +class Calculator { + @lru({ + maxSize: 64, + // serialize the first argument only + key: (arg) => JSON.stringify(arg), + }) + square(n: number): number { + return n * n; + } +} +``` + +
+ +
Advanced Keygens using CBOR for complex data
+ +While the default keygen is sufficient for _most_ cases, occasionally it may be +necessary to use a keygen capable of handling complex data types. + +For example, caching a method that expects a custom class instance for one or +more of its arguments will more than likely require a custom keygen to handle +properly. + +What happens if you don't use a custom keygen? You may end up with poor caching +behavior, as `JSON.stringify` tends to serialize most of the objects it can't +understand into `{}`. No bueno. + +```ts +import { lru } from "jsr:@decorators/lru"; +// using the `@std/cbor` module (compact binary object representation) +import { encodeCbor } from "jsr:@std/cbor"; + +// types for our geospatial service +export interface Review { + user: string; + rating: number; + comment: string; +} + +export type WeekDay = "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun"; + +/** Business hours for a given day. Measured in 24-hour format. */ +export interface Hours { + open: number; + close: number; +} + +export interface Metadata { + category?: string; + hours?: Record; + reviews?: Review[]; + [key: string]: any; +} + +export class Coordinate { + constructor( + public name: string, + public latitude: number, + public longitude: number, + ) {} + + get distance(): number { + return Math.sqrt( + Math.pow(this.latitude, 2) + Math.pow(this.longitude, 2), + ); + } + + toString(): string { + return `${this.name} (${this.latitude}, ${this.longitude})`; + } +} + +export class GeoPoint extends Coordinate { + constructor( + public type: "restaurant" | "park" | "store" | "landmark", + override name: string, + override latitude: number, + override longitude: number, + public metadata?: Metadata, + ) { + super(name, latitude, longitude); + } + + get rating(): number { + return this.metadata?.reviews?.reduce((sum, r) => sum + r.rating, 0) / + (this.metadata?.reviews?.length ?? 1) ?? 0; + } +} + +export interface SearchFilters { + name?: string; + types?: GeoPoint["type"][]; + price?: [min: number, max: number]; + rating?: number; + // Could have circular references or complex nested structures + metadata?: Partial; +} + +export class SpatialDatabase { + constructor( + protected data: GeoPoint[], + ) {} + + // naive implementation of a custom keygen that can handle complex objects + @lru({ key: (...a) => String.fromCharCode(...encodeCbor(a)) }) + // simulating an expensive geospatial querying operation + query( + location: Coordinate, + radius_km: number, + filters?: SearchFilters, + ): GeoPoint[] { + return this.data.filter((point) => { + if (filters?.types && !filters.types.includes(point.type)) return false; + if (filters?.name && !point.name.includes(filters.name)) return false; + if ((filters?.rating ?? 0) > (point.rating ?? 0)) return false; + if (filters?.price) { + const [min, max] = filters.price; + if (point.price < min || point.price > max) return false; + } + if (filters?.metadata) { + for (const [k, v] of Object.entries(filters.metadata)) { + const pv = point.metadata?.[k]; + if (typeof pv === "undefined") return typeof v === "undefined"; + if (typeof pv !== typeof v) return false; + if (k === "reviews" && v.length !== pv.length) return false; + if (typeof v === "object" && v != null) { + if (JSON.stringify(pv) !== JSON.stringify(v)) return false; + } else if (pv !== v) return false; + } + } + // check if the point is within the radius of the location + const a = point.distance, b = location.distance; + const distance = Math.sqrt(Math.pow(a - b, 2)); + const distance_km = distance * 111.32; // convert to km + return distance_km <= radius_km; + }); + } +} + +const db = new SpatialDatabase([ + new GeoPoint("restaurant", "Carmine's Pizza Henderson", 36.04142, 115.03036, { + reviews: [ + { user: "Alice", rating: 5, comment: "Best pizza ever!" }, + { user: "Bob", rating: 4.25, comment: "Killer cannoli!" }, + ], + }), + new GeoPoint( + "restaurant", + "Raising Cane's Chicken Fingers", + 36.03504, + 115.04634, + { + reviews: [ + { user: "Charlie", rating: 4.5, comment: "Great chicken!" }, + { user: "Dave", rating: 4.75, comment: "Love the fries!" }, + ], + }, + ), + new GeoPoint("store", "Walmart Supercenter", 36.1699, 115.1398), + new GeoPoint("landmark", "The Strip", 36.1147, 115.1728), + new GeoPoint("park", "Red Rock Canyon", 36.1162, 115.4167), + new GeoPoint("park", "Mount Charleston", 36.2784, 115.6405), + new GeoPoint("landmark", "Fremont Street Experience", 36.1699, 115.1415), + new GeoPoint("landmark", "Bellagio Fountains", 36.1126, 115.1767), + new GeoPoint("landmark", "The Sphere", 36.12086, 115.16174), +]); + +console.log(db.query(new Coordinate("home", 36.033, -115.05), 5)); +``` + +
+ +
Custom Map Override using a custom Map class
+ +```ts +import { lru } from "@decorators/lru"; + +class CustomMap extends Map { + constructor(entries?: readonly (readonly [K, V])[] | null) { + super(entries); + console.log("CustomMap created"); + } + + override get(key: K): V | undefined { + console.log(`CustomMap.get(${key})`); + return super.get(key); + } +} + +class Calculator { + @lru({ + maxSize: 64, + // override the Map and WeakMap classes which are used internally to store + // the inner LRU cache without leaking memory + overrides: { + Map: CustomMap, + }, + }) + square(n: number): number { + return n * n; + } +} +``` + +
+ +
Custom LRU Override using a custom LRU class
+ + + +The `@decorators/lru` package provides a built-in LRU cache implementation, +which is sufficiently capable for the majority of use cases. However, if you +need to customize the LRU behavior or add additional functionality, you can +either extend the built-in `LRU` class, or create your own from scratch. The +only requirement is that it must implement the `MapLike` interface. + +Once you've got your custom LRU class ready to go, all that's left to do is pass +it to the `@lru` decorator as the `overrides.LRU` option. + +This example demonstrates how to create a custom LRU cache implementation that +logs all operations to the console. This is useful for debugging and +understanding how the LRU cache works under the hood. + +```ts +import { crypto } from "jsr:@std/crypto@1/crypto"; + +import { lru, type MapLike } from "@decorators/lru"; + +// the default implementation we'll be extending +import { LRU } from "@decorators/lru/cache"; + +class CustomLRU extends LRU implements MapLike { + constructor(maxSize?: number) { + super(maxSize); + console.log(`CustomLRU created with maxSize: ${maxSize}`); + } + + override set(key: K, value: V): this { + console.log(`CustomLRU.set(${key}, ${value})`); + super.set(key, value); + return this; + } + + override has(key: K): boolean { + console.log(`CustomLRU.has(${key})`); + return super.has(key); + } + + override get(key: K): V | undefined { + console.log(`CustomLRU.get(${key})`); + return super.get(key); + } + + override delete(key: K): boolean { + console.log(`CustomLRU.delete(${key})`); + return super.delete(key); + } + + override clear(): void { + console.log("CustomLRU.clear()"); + super.clear(); + } +} + +class Hasher { + @lru({ maxSize: 32, overrides: { LRU: CustomLRU } }) + hash(arg: string): string { + console.log(`Expensive operation called with arg: ${arg}`); + const buf = new TextEncoder().encode(arg); + const sha = crypto.subtle.digestSync("SHA-256", buf); + return Array.from(new Uint8Array(sha)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } +} + +const hasher = new Hasher(); + +performance.mark("start:uncached"); +console.log("[uncached]", hasher.hash("hello")); +performance.mark("end:uncached"); + +const t1 = performance.measure("end:uncached", "start:uncached"); +console.debug("[uncached]", t1.duration, "ms"); + +performance.mark("start:cached:1"); +console.log("[cached #1]", hasher.hash("hello")); +performance.mark("end:cached:1"); + +const t2 = performance.measure("end:cached:1", "start:cached:1"); +console.debug("[cached #1]", t2.duration, "ms"); + +performance.mark("start:cached:2"); +console.log("[cached #2]", hasher.hash("hello")); +performance.mark("end:cached:2"); + +const t3 = performance.measure("end:cached:2", "start:cached:2"); +console.debug("[cached #2]", t3.duration, "ms"); +``` + +
+ +
Using transform functions for async caching of HTTP responses
+ + + +```ts +import { lru } from "@decorators/lru"; + +class RemoteService { + constructor( + protected baseUrl: string | URL, + protected init?: RequestInit, + ) {} + + @lru({ + transform: async (res) => (await res).clone(), + ttl: 1e4, /* 10 seconds */ + }) + get(url?: string | URL, init?: RequestInit): Promise { + url = new URL(url ?? "", this.baseUrl); + init = { ...this.init, ...init, method: "GET" }; + return globalThis.fetch(url, init); + } + + async getJson( + url?: string | URL, + init?: RequestInit, + ): Promise<{ headers: Headers; body: T }> { + const res = await this.get(url, init); + return { headers: res.headers, body: await res.json() as T }; + } +} + +const api = new RemoteService( + "https://jsonplaceholder.typicode.com/posts/1", + { headers: { "Content-Type": "application/json" } }, +); + +type Post = { userId: number; id: number; title: string; body: string }; + +const post1 = await api.getJson(); // ~400ms (first fetch) +const post2 = await api.getJson(); // ~1ms (cached) + +// wait a hair over 10s for the cache to become stale +await new Promise((r) => setTimeout(r, 10_010)); + +// ... wait >= 10s for the cache to expire ... +const post3 = await api.getJson(); // ~300ms (expired cache, new fetch) + +// check that these are indeed from the same response object +console.assert(post1.headers.get("date") === post2.headers.get("date")); + +// check that post3 is a new response from 10s later +console.assert(post1.headers.get("date") !== post3.headers.get("date")); +console.assert(post2.headers.get("date") !== post3.headers.get("date")); + +// let's take it a step further and double-check the timestamps, just to be +// certain things are working as expected. post2 and post3 should have at least +// 10_000ms difference between their respective "Date" headers: +console.assert( + Date.parse(post3.headers.get("date")) - + Date.parse(post2.headers.get("date")) >= 1e4, +); // OK! +``` + +
+ +--- + +## Advanced Features + +This section describes some of the more advanced features of the +`@decorators/lru` package, including the `overrides` option. It also describes +the multi-layered storage API used to provide memory safety and prevent leaks. + +### Overrides for Test-Driven Development + +If you're a developer who takes testing seriously, you might appreciate this +section more than others. The `@decorators/lru` package is developed with +test-driven development as one of its core focal points. It supports +**complete** overrides for every aspect of its caching-related functionality, +including: + +- Internal APIs used for `Map`, `WeakMap`, and `LRU` are all overridable. +- Inject a custom `Date` implementation for mocking time-based operations. +- Easily roll your own keygen with custom serialization logic. +- Leverage a custom transform function to rehydrate or mutate returned values. +- Provide a custom `LRU` implementation for full control over the cache. +- Override the `Map` and `WeakMap` classes used to store the LRU instances, for + introspective testing and debugging (real-world examples including overriding + the native `WeakMap` with the `IterableWeakMap` from [`@iter/weak-map`], for + inspecting the weakly-held cache entries). + +> **See the [`Overrides` section](#overrides) for more details on this API.** + +### Storage API + +The `@decorators/lru` package uses a multi-layered `WeakMap` API to bind the +lifetime of every `LRU` instance to that of its associated class, and +additionally ties it to the lifetime of the class constructor itself. This helps +to ensure no cache outlives the class that created it. + +The general layout of the storage API is as follows: + +1. `WeakMap` - keys are class **constructors**, values are #2. + - `'c` lifetime is bound to that of the class constructor + - When available, the `[Symbol.metadata]` object is used as the key. + - Falls back to the class constructor if metadata is unsupported. + - When a constructor is garbage collected, sections 2-4 will immediately + become candidates for collection as well. +2. `WeakMap` - keys are class **instances**, values are #3. + - `'i` lifetime is bound to that of the class instance. + - When the class instance is garbage collected, its entire cache is also + immediately available for collection as well. +3. `Map` - keys are **memoized method names**, values are #4. (lifetime: `'i`) + - Maintains strong references to the actual `LRU` caches. + - Lifetime is limited to that of the class instance. + - Keys are the property names of the memoized methods, providing fast and + convenient access to the underlying caches. +4. `LRU` - the **actual LRU cache** (lifetime: `'i`). + - One dedicated instance for each memoized method. + - Keys are generated by the `options.key` function. + +
Click here for a visual representation
+ +```plaintext +╭╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╮ +╵ 1. WeakMap<'c Constructor<'i T>, WeakMap<'i T, ... >> ╵ +╵ ╔╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╗ ╵ +╵ ╎ 2. WeakMap<'i T, ... > - holds class instances ╎ ╵ +╵ ╎ ┏┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┓ ╎ ╵ +╵ ╎ ┇ 3. Map> - maps key-to-cache ┇ ╎ ╵ +╵ ╎ ┇ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┇ ╎ ╵ +╵ ╎ ┇ ┃ 4. LRU - the actual memoization cache ┃ ┇ ╎ ╵ +╵ ╎ ┇ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ┇ ╎ ╵ +╵ ╎ ┗┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┉┛ ╎ ╵ +╵ ╚╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╝ ╵ +╰╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╶╯ + + ╴╴ weakly-held ┉┉ strongly-held ━━ memoized +``` + +
+ +#### Overriding the Storage API + +As mentioned above, the [`overrides`](#overrides) option allows you to override +any of the constructors used for each of the layers in the storage API. This is +useful for testing, debugging, and introspection. + +Check out the [examples section](#examples) to see a demonstration of how to +override the `Map` constructor for (layer #3) with a custom implementation that +logs to the console whenever a method is called. + +--- + +
+ +**[MIT]** © **[Nicholas Berlette]**. All rights reserved. + +[GitHub] · [Issues] · [JSR] · [Docs] + + + +[![JSR][badge-jsr]][JSR] + +
+
+ +[MIT]: https://nick.mit-license.org/2024 "MIT © 2024-2025+ Nicholas Berlette et al. All rights reserved." +[Nicholas Berlette]: https://github.com/nberlette/decorators#readme "View the @decorators monorepo on GitHub" +[GitHub]: https://github.com/nberlette/decorators/tree/main/packages/lru#readme "View the @decorators/lru project on GitHub" +[Issues]: https://github.com/nberlette/decorators/issues?q=is%3Aopen+is%3Aissue+lru "View issues for the @decorators/lru project on GitHub" +[JSR]: https://jsr.io/@decorators/lru/doc "View the @decorators/lru documentation on jsr.io" +[Docs]: https://jsr.io/@decorators/lru/doc "View the @decorators/lru documentation on jsr.io" +[badge-jsr]: https://jsr.io/badges/@decorators "View all of the @decorators packages on jsr.io" +[badge-jsr-pkg]: https://jsr.io/badges/@decorators/lru "View @decorators/lru on jsr.io" +[badge-jsr-score]: https://jsr.io/badges/@decorators/lru/score "View the score for @decorators/lru on jsr.io" +[`@iter/weak-map`]: https://jsr.io/@iter/weak-map "View the @iter/weak-map package on jsr.io" + diff --git a/packages/lru/deno.json b/packages/lru/deno.json new file mode 100644 index 0000000..7c55a9c --- /dev/null +++ b/packages/lru/deno.json @@ -0,0 +1,22 @@ +{ + "name": "@decorators/lru", + "version": "0.1.2", + "license": "MIT", + "author": { + "name": "Nicholas Berlette", + "email": "nick@berlette.com", + "url": "https://github.com/nberlette/decorators" + }, + "exports": { + ".": "./mod.ts", + "./cache": "./src/lru_cache.ts", + "./decorator": "./src/lru_decorator.ts", + "./options": "./src/options.ts", + "./overrides": "./src/overrides.ts", + "./types": "./src/types.ts" + }, + "imports": { + "@std/expect": "jsr:@std/expect@^1.0.13", + "@std/testing": "jsr:@std/testing@^1.0.9" + } +} diff --git a/packages/lru/examples/01_basic_keygen_json.ts b/packages/lru/examples/01_basic_keygen_json.ts new file mode 100644 index 0000000..fb4e7ff --- /dev/null +++ b/packages/lru/examples/01_basic_keygen_json.ts @@ -0,0 +1,12 @@ +import { lru } from "@decorators/lru"; + +export class Calculator { + @lru({ + maxSize: 64, + // serialize the first argument only + key: (arg) => JSON.stringify(arg), + }) + square(n: number): number { + return n * n; + } +} diff --git a/packages/lru/examples/02_advanced_keygen_cbor.ts b/packages/lru/examples/02_advanced_keygen_cbor.ts new file mode 100644 index 0000000..9895179 --- /dev/null +++ b/packages/lru/examples/02_advanced_keygen_cbor.ts @@ -0,0 +1,159 @@ +// deno-lint-ignore-file no-explicit-any +import { lru } from "jsr:@decorators/lru"; +// using the `@std/cbor` module (compact binary object representation) +import { encodeCbor } from "jsr:@std/cbor"; + +// types for our geospatial service +export interface Review { + user: string; + rating: number; + comment: string; + price?: number; +} + +export type WeekDay = "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun"; + +/** Business hours for a given day. Measured in 24-hour format. */ +export interface Hours { + open: number; + close: number; +} + +export interface Metadata { + category?: string; + hours?: Record; + reviews?: Review[]; + [key: string]: any; +} + +export class Coordinate { + constructor( + public name: string, + public latitude: number, + public longitude: number, + ) {} + + get distance(): number { + return Math.sqrt( + Math.pow(this.latitude, 2) + Math.pow(this.longitude, 2), + ); + } + + toString(): string { + return `${this.name} (${this.latitude}, ${this.longitude})`; + } +} + +export class GeoPoint extends Coordinate { + constructor( + public type: "restaurant" | "park" | "store" | "landmark", + override name: string, + override latitude: number, + override longitude: number, + public metadata?: Metadata, + ) { + super(name, latitude, longitude); + } + + get rating(): number { + return +( + this.metadata?.reviews?.reduce((sum, r) => sum + r.rating, 0) ?? 0 + ) / (this.metadata?.reviews?.length ?? 1); + } + + get price(): number { + return this.metadata?.price ?? ((this.metadata?.reviews?.reduce( + (sum, r) => sum + (r.price ?? 0), + 0, + ) ?? 0) / (this.metadata?.reviews?.length ?? 1)); + } +} + +export interface SearchFilters { + name?: string; + types?: GeoPoint["type"][]; + price?: [min: number, max: number]; + rating?: number; + // Could have circular references or complex nested structures + metadata?: Partial; +} + +export class SpatialDatabase { + constructor( + protected data: GeoPoint[], + ) {} + + // naive implementation of a custom keygen that can handle complex objects + @lru({ key: (...a) => String.fromCharCode(...encodeCbor(a as any)) }) + // simulating an expensive geospatial querying operation + query( + location: Coordinate, + radius_km: number, + filters?: SearchFilters, + ): GeoPoint[] { + return this.data.filter((point) => { + if (filters?.types && !filters.types.includes(point.type)) return false; + if (filters?.name && !point.name.includes(filters.name)) return false; + if ((filters?.rating ?? 0) > (point.rating ?? 0)) return false; + if (filters?.price) { + const [min, max] = filters.price; + if (point.price < min || point.price > max) return false; + } + if (filters?.metadata) { + for (const [k, v] of Object.entries(filters.metadata)) { + const pv = point.metadata?.[k]; + if (typeof pv === "undefined") return typeof v === "undefined"; + if (typeof pv !== typeof v) return false; + if (k === "reviews" && v.length !== pv.length) return false; + if (typeof v === "object" && v != null) { + if (JSON.stringify(pv) !== JSON.stringify(v)) return false; + } else if (pv !== v) return false; + } + } + // check if the point is within the radius of the location + const a = point.distance, b = location.distance; + const distance = Math.sqrt(Math.pow(a - b, 2)); + const distance_km = distance * 111.32; // convert to km + return distance_km <= radius_km; + }); + } +} + +const db = new SpatialDatabase([ + new GeoPoint("restaurant", "Carmine's Pizza Henderson", 36.04142, 115.03036, { + reviews: [ + { user: "Alice", rating: 5, comment: "Best pizza ever!" }, + { user: "Bob", rating: 4.25, comment: "Killer cannoli!" }, + ], + }), + new GeoPoint( + "restaurant", + "Raising Cane's Chicken Fingers", + 36.03504, + 115.04634, + { + reviews: [ + { user: "Charlie", rating: 4.5, comment: "Great chicken!" }, + { user: "Dave", rating: 4.75, comment: "Love the fries!" }, + ], + }, + ), + new GeoPoint("store", "Walmart Supercenter", 36.1699, 115.1398), + new GeoPoint("landmark", "The Strip", 36.1147, 115.1728), + new GeoPoint("park", "Red Rock Canyon", 36.1162, 115.4167), + new GeoPoint("park", "Mount Charleston", 36.2784, 115.6405), + new GeoPoint("landmark", "Fremont Street Experience", 36.1699, 115.1415), + new GeoPoint("landmark", "Bellagio Fountains", 36.1126, 115.1767), + new GeoPoint("landmark", "The Sphere", 36.12086, 115.16174), +]); + +const home = new Coordinate("home", 36.033, -115.05); + +const result1 = db.query(home, 5); +console.assert(result1.length === 2); + +const result2 = db.query(home, 5, { + name: "Carmine's Pizza Henderson", + types: ["restaurant"], +}); +console.assert(result2.length === 1); diff --git a/packages/lru/examples/03_basic_map_override.ts b/packages/lru/examples/03_basic_map_override.ts new file mode 100644 index 0000000..af01eeb --- /dev/null +++ b/packages/lru/examples/03_basic_map_override.ts @@ -0,0 +1,37 @@ +import { lru } from "@decorators/lru"; + +class CustomMap extends Map { + // deno-lint-ignore no-explicit-any + static readonly instances = new Set>(); + + constructor(entries?: readonly (readonly [K, V])[] | null) { + super(entries); + console.log("CustomMap created"); + CustomMap.instances.add(this); + } + + override get(key: K): V | undefined { + console.log(`CustomMap.get(${key})`); + return super.get(key); + } +} + +class Calculator { + @lru({ + maxSize: 64, + // override the internal Map implementation (which is used to associate + // LRU cache instances with the corresponding class method name) + overrides: { Map: CustomMap }, + }) + square(n: number): number { + return n * n; + } +} + +const calc = new Calculator(); + +console.log(calc.square(2)); // computed and cached +console.log(calc.square(2)); // cached + +console.log(calc.square(3)); // computed and cached +console.log(calc.square(3)); // cached diff --git a/packages/lru/examples/04_custom_lru_cache.ts b/packages/lru/examples/04_custom_lru_cache.ts new file mode 100644 index 0000000..0359221 --- /dev/null +++ b/packages/lru/examples/04_custom_lru_cache.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env -S deno run -A --no-check=remote + +import { LRU, lru, type MapLike } from "@decorators/lru"; +import { crypto } from "jsr:@std/crypto@1/crypto"; + +const timer = setTimeout(() => {}, 2_000); +Deno.refTimer(timer); // prevent the event loop from finishing + +class CustomLRU extends LRU implements MapLike { + constructor(maxSize?: number) { + console.log(`[debug] CustomLRU created with maxSize: ${maxSize}`); + super(maxSize); + } + + override get(key: K): V | undefined { + console.log(`[debug] CustomLRU.get(${key})`); + return super.get(key); + } + + override delete(key: K): boolean { + console.log(`[debug] CustomLRU.delete(${key})`); + return super.delete(key); + } +} + +class Hasher { + @lru({ + maxSize: 32, + eviction: "active", + overrides: { LRU: CustomLRU }, + ttl: 1000, + }) + hash(arg: string): string { + const buf = new TextEncoder().encode(arg); + const sha = crypto.subtle.digestSync("SHA3-256", buf); + return Array.from(new Uint8Array(sha)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } +} + +const hasher = new Hasher(); + +console.time("uncached"); +console.timeLog("uncached", hasher.hash("hello")); +console.timeEnd("uncached"); + +console.time("cached"); +console.timeLog("cached", hasher.hash("hello")); +console.timeEnd("cached"); diff --git a/packages/lru/examples/05_advanced_transforms.ts b/packages/lru/examples/05_advanced_transforms.ts new file mode 100644 index 0000000..f4c64df --- /dev/null +++ b/packages/lru/examples/05_advanced_transforms.ts @@ -0,0 +1,57 @@ +import { lru } from "@decorators/lru"; + +export class RemoteService { + constructor( + protected baseUrl: string | URL, + protected init?: RequestInit, + ) {} + + @lru({ + transform: async (res) => (await res).clone(), + ttl: 1e4, /* 10 seconds */ + }) + get(url?: string | URL, init?: RequestInit): Promise { + url = new URL(url ?? "", this.baseUrl); + init = { ...this.init, ...init, method: "GET" }; + return globalThis.fetch(url, init); + } + + async getJson( + url?: string | URL, + init?: RequestInit, + ): Promise<{ headers: Headers; body: T }> { + const res = await this.get(url, init); + return { headers: res.headers, body: await res.json() as T }; + } +} + +const api = new RemoteService( + "https://jsonplaceholder.typicode.com/posts/1", + { headers: { "Content-Type": "application/json" } }, +); + +type Post = { userId: number; id: number; title: string; body: string }; + +const post1 = await api.getJson(); // ~400ms (first fetch) +const post2 = await api.getJson(); // ~1ms (cached) + +// wait a hair over 10s for the cache to become stale +await new Promise((r) => setTimeout(r, 10_010)); + +// ... wait >= 10s for the cache to expire ... +const post3 = await api.getJson(); // ~300ms (expired cache, new fetch) + +// check that these are indeed from the same response object +console.assert(post1.headers.get("date") === post2.headers.get("date")); + +// check that post3 is a new response from 10s later +console.assert(post1.headers.get("date") !== post3.headers.get("date")); +console.assert(post2.headers.get("date") !== post3.headers.get("date")); + +// let's take it a step further and double-check the timestamps, just to be +// certain things are working as expected. post2 and post3 should have at least +// 10_000ms difference between their respective "Date" headers: +console.assert( + Date.parse(post3.headers.get("date") ?? "") - + Date.parse(post2.headers.get("date") ?? "") >= 1e4, +); // OK! diff --git a/packages/lru/lru_icon.png b/packages/lru/lru_icon.png new file mode 100644 index 0000000..6bed114 Binary files /dev/null and b/packages/lru/lru_icon.png differ diff --git a/packages/lru/mod.ts b/packages/lru/mod.ts new file mode 100644 index 0000000..9985791 --- /dev/null +++ b/packages/lru/mod.ts @@ -0,0 +1,10 @@ +/** + * @module lru + */ +export * from "./src/lru_cache.ts"; +export * from "./src/lru_decorator.ts"; +export * from "./src/overrides.ts"; +export * from "./src/options.ts"; +export * from "./src/types.ts"; + +export { default } from "./src/lru_decorator.ts"; diff --git a/packages/lru/src/_internal.ts b/packages/lru/src/_internal.ts new file mode 100644 index 0000000..4ee3c9a --- /dev/null +++ b/packages/lru/src/_internal.ts @@ -0,0 +1,49 @@ +// deno-lint-ignore-file no-explicit-any +import type { Keygen } from "./options.ts"; +import type { CacheKey } from "./types.ts"; + +/** + * Generic class method signature used by {@linkcode ClassMethodDecorator}. + * + * @category Types + * @internal + */ +export type ClassMethod< + This, + Args extends readonly any[] = any[], + Return = any, +> = (this: This, ...args: Args) => Return; + +/** + * Generic method decorator signature returned by the {@linkcode lru} + * decorator. + * + * @category Types + * @internal + */ +export type ClassMethodDecorator> = { + (target: Value, ctx: ClassMethodDecoratorContext): Value | void; +}; + +/** + * Custom extension of the native `ClassAccessorDecoratorContext` object, with + * stronger type inference for the `name` property. Thanks to the significant + * recent advancements TypeScript's type safety + * + * Which provides us with literal types for various properties of a decorator's + * context object **_at compile time_**, we can simply drop this type in as a + * replacement for the standard context object, giving us the ability to infer + * literal property names of the item being decorated. + */ +export interface NamedAccessorDecoratorContext< + This, + Value, + Name extends PropertyKey = PropertyKey, +> extends Omit, "name"> { + name: Name; +} + +const JSON = globalThis.JSON; + +/** @internal */ +export const defaultKey: Keygen = (...args) => JSON.stringify(args) as CacheKey; diff --git a/packages/lru/src/lru_cache.test.ts b/packages/lru/src/lru_cache.test.ts new file mode 100644 index 0000000..2a064e3 --- /dev/null +++ b/packages/lru/src/lru_cache.test.ts @@ -0,0 +1,130 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import { LRU } from "@decorators/lru"; + +describe("LRU (class)", () => { + it("should be a class named 'LRU'", () => { + expect(LRU).toBeInstanceOf(Function); + expect(LRU.name).toBe("LRU"); + }); + + it("should store the values", () => { + const cache = new LRU(3); + cache.set("a", 1).set("b", 2).set("c", 3); + expect(cache.get("a")).toBe(1); + expect(cache.get("b")).toBe(2); + expect(cache.get("c")).toBe(3); + }); + + it("should accurately reflect changes in the cache size", () => { + const cache = new LRU(3); + expect(cache.size).toBe(0); + cache.set("a", 1); + expect(cache.size).toBe(1); + cache.set("b", 2); + expect(cache.size).toBe(2); + cache.set("c", 3); + expect(cache.size).toBe(3); + cache.delete("a"); + expect(cache.size).toBe(2); + }); + + it("should not exceed the maximum size", () => { + const cache = new LRU(3); + expect(cache.maxSize).toBe(3); + expect(cache.size).toBe(0); + cache.set("a", 1); + expect(cache.size).toBe(1); + cache.set("b", 2); + expect(cache.size).toBe(2); + cache.set("c", 3); + expect(cache.size).toBe(3); + cache.set("d", 4); + expect(cache.size).toBe(3); + expect(cache.get("a")).toBe(undefined); + }); + + it("should indicate whether or not it contains a key", () => { + const cache = new LRU(3); + cache.set("a", 1); + expect(cache.has("a")).toBe(true); + expect(cache.has("b")).toBe(false); + cache.delete("a"); + expect(cache.has("a")).toBe(false); + expect(cache.has("b")).toBe(false); + cache.set("b", 2); + expect(cache.has("b")).toBe(true); + expect(cache.has("a")).toBe(false); + cache.clear(); + expect(cache.has("a")).toBe(false); + expect(cache.has("b")).toBe(false); + expect(cache.size).toBe(0); + expect(cache.get("a")).toBe(undefined); + expect(cache.get("b")).toBe(undefined); + }); + + it("should support deleting a key from the cache", () => { + const cache = new LRU(3); + cache.set("a", 1); + expect(cache.size).toBe(1); + expect(cache.get("a")).toBe(1); + expect(cache.delete("a")).toBe(true); + expect(cache.size).toBe(0); + expect(cache.get("a")).toBe(undefined); + }); + + it("should clear the cache", () => { + const cache = new LRU(3); + cache.set("a", 1).set("b", 2).set("c", 3); + expect(cache.size).toBe(3); + expect(cache.clear()).toBe(undefined); + expect(cache.size).toBe(0); + expect(cache.get("a")).toBe(undefined); + expect(cache.get("b")).toBe(undefined); + expect(cache.get("c")).toBe(undefined); + }); + + it("should evict the least recently used values", () => { + const cache = new LRU(2); + cache.set("a", 1).set("b", 2).set("c", 3); + expect(cache.has("a")).toBe(false); + expect(cache.get("a")).toBe(undefined); + expect(cache.get("b")).toBe(2); + expect(cache.get("c")).toBe(3); + }); + + it("should update the order of the keys", () => { + const cache = new LRU(2); + cache.set("a", 1).set("b", 2); + // Access "a" so it becomes most-recent. + cache.get("a"); + cache.set("c", 3); + expect(cache.get("a")).toBe(1); + expect(cache.get("b")).toBe(undefined); + expect(cache.get("c")).toBe(3); + }); + + it("should remove the key", () => { + const cache = new LRU(); + cache.set("a", 1); + // Our API uses delete, not remove. + cache.delete("a"); + expect(cache.get("a")).toBe(undefined); + expect(cache.delete("b")).toBe(false); + }); + + it("should clear the cache", () => { + const cache = new LRU(); + cache.set("a", 1).set("b", 2); + cache.clear(); + expect(cache.get("a")).toBe(undefined); + expect(cache.get("b")).toBe(undefined); + }); + + it("should render the cache as a string when inspected", () => { + const cache = new LRU(3); + cache.set("a", 1).set("b", 2); + expect(Deno.inspect(cache)).toBe('LRU(2/3) { "a" => 1, "b" => 2 }'); + }); +}); diff --git a/packages/lru/src/lru_cache.ts b/packages/lru/src/lru_cache.ts new file mode 100644 index 0000000..388862b --- /dev/null +++ b/packages/lru/src/lru_cache.ts @@ -0,0 +1,193 @@ +/** + * This module provides the {@linkcode LRU} cache which the {@linkcode lru} + * decorator uses as its default mechanism for storing cached values. + * + * @example + * ```ts + * import { LRU } from "@decorators/lru/cache"; + * + * const cache = new LRU(2); + * + * cache.set("a", 1); + * cache.set("b", 2); + * cache.set("c", 3); // "a" is evicted + * console.log(cache.get("a")); // undefined + * ``` + * @module cache + */ +import type { MapLike } from "./types.ts"; + +const kInspect: unique symbol = Symbol.for("Deno.customInspect"); + +/** + * A generic Least Recently Used (LRU) cache. + * + * This class implements a simple LRU cache using a JavaScript Map. + * When a new key is inserted and the cache is full, the oldest key is + * evicted. + * + * @example + * ```ts + * import { LRU } from "@decorators/lru/cache"; + * + * const cache = new LRU(3); + * cache.set("a", 1); + * cache.set("b", 2); + * cache.set("c", 3); + * console.log(cache.get("a")); // 1 (and "a" becomes most-recent) + * cache.set("d", 4); // "b" is evicted (the oldest) + * console.log(cache.has("b")); // false + * ``` + * + * @category Caching + * @tags LRU, cache + */ +export class LRU implements MapLike { + #cache = new Map(); + #maxSize = 128; + + /** + * Creates an instance of LRU. + * + * @param maxSize - The maximum number of entries to store. + * @returns A new LRU instance. + * @example + * ```ts + * import { LRU } from "@decorators/lru/cache"; + * + * const cache = new LRU(128); + * ``` + */ + constructor(maxSize?: number) { + if (maxSize != null) this.#maxSize = +maxSize >>> 0; + + Object.defineProperty(this, kInspect, { + value: this[kInspect].bind(this), + enumerable: false, + configurable: true, + writable: false, + }); + } + + /** Returns the number of entries currently stored in the cache. */ + get size(): number { + return this.#cache.size; + } + + /** Returns the maxSize of this cache instance. */ + get maxSize(): number { + return this.#maxSize; + } + + /** + * Retrieves a value from the cache and updates its recency. + * + * @param key - The key to retrieve. + * @returns The cached value if present; otherwise, undefined. + * @example + * ```ts + * import { LRU } from "@decorators/lru/cache"; + * + * const cache = new LRU(128); + * + * const value = cache.get("a"); + * + * if (value !== undefined) console.log("Cached:", value); + * ``` + */ + get(key: K): V | undefined { + if (this.#cache.has(key)) { + const value = this.#cache.get(key)!; + // update recency: remove and reinsert + this.#cache.delete(key); + this.#cache.set(key, value); + return value; + } + } + + /** + * Checks if a key exists in the cache. + * + * @param key The key to check. + * @returns `true` if the key is in the cache; otherwise, `false`. + * @example + * ```ts + * import { LRU } from "@decorators/lru/cache"; + * + * const cache = new LRU(128); + * + * if (cache.has("a")) { + * console.log("Key 'a' exists!"); + * } + * ``` + */ + has(key: K): boolean { + return this.#cache.has(key); + } + + /** + * Inserts or updates a key-value pair in the cache. If the cache exceeds its + * maxSize, the oldest entry is removed. + * + * @param key - The key to insert/update. + * @param value - The value to associate with the key. + * @returns This LRU instance. + * + * @example + * ```ts + * import { LRU } from "@decorators/lru/cache"; + * + * const cache = new LRU(128); + * + * cache.set("b", 2); + * ``` + */ + set(key: K, value: V): this { + if (this.#cache.has(key)) { + this.#cache.delete(key); + } else if (this.#cache.size >= this.#maxSize) { + // Remove the least-recently used (first) key. + const oldestKey = this.#cache.keys().next().value; + if (typeof oldestKey !== "undefined") this.#cache.delete(oldestKey); + } + this.#cache.set(key, value); + return this; + } + + /** + * Removes a key from the cache. + * + * @param key - The key to remove. + * @returns `true` if the key was removed; otherwise, `false`. + * + * @example + * ```ts + * import { LRU } from "@decorators/lru/cache"; + * + * const cache = new LRU(128); + * + * cache.set("a", 1); + * cache.delete("a"); // true + * cache.delete("a"); // false + * ``` + */ + delete(key: K): boolean { + return this.#cache.delete(key); + } + + /** Clears the cache, removing all entries. */ + clear(): void { + this.#cache.clear(); + } + + /** @internal */ + [kInspect]( + inspect: (value: unknown, options: Deno.InspectOptions) => string, + options: Deno.InspectOptions, + ): string { + const name = this.constructor.name || "LRU"; + const text = inspect(this.#cache, options); + const label = `${name}(${this.size}/${this.maxSize})`; + return text.replace(/\bMap\s*\(\d+\)/, label); + } +} diff --git a/packages/lru/src/lru_decorator.test.ts b/packages/lru/src/lru_decorator.test.ts new file mode 100644 index 0000000..71be264 --- /dev/null +++ b/packages/lru/src/lru_decorator.test.ts @@ -0,0 +1,243 @@ +// deno-lint-ignore-file no-explicit-any +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; + +import lru, { LRU } from "@decorators/lru"; + +describe("lru (decorator)", () => { + it("should be a function named 'lru'", () => { + expect(lru).toBeInstanceOf(Function); + expect(lru.name).toBe("lru"); + }); + + it("should cache the result of a method", () => { + let computeCount = 0; + class Test { + @lru() + fib(n: number): number { + computeCount++; + return n < 2 ? n : this.fib(n - 1) + this.fib(n - 2); + } + } + const t = new Test(); + const res1 = t.fib(5); + const res2 = t.fib(5); + expect(res1).toBe(res2); + // Caching should reduce the number of computations. + expect(computeCount).toBeLessThan(10); + }); + + it("should support a custom key generator", () => { + let computeCount = 0; + const customKey = function (...args: any[]): string { + return args.join("-"); + }; + class Test { + @lru({ key: customKey }) + compute(a: number, b: number): number { + computeCount++; + return a + b; + } + } + const t = new Test(); + expect(t.compute(1, 2)).toBe(3); + expect(t.compute(1, 2)).toBe(3); + expect(computeCount).toBe(1); + }); + + it("should support TTL for passive eviction", async () => { + let computeCount = 0; + class Test { + @lru({ ttl: 100 }) + add(a: number, b: number): number { + computeCount++; + return a + b; + } + } + const t = new Test(); + const res1 = t.add(1, 2); + expect(res1).toBe(3); + expect(computeCount).toBe(1); + + // Immediate subsequent call should hit cache. + const res2 = t.add(1, 2); + expect(res2).toBe(3); + expect(computeCount).toBe(1); + + // Wait for TTL to expire. + await new Promise((resolve) => setTimeout(resolve, 150)); + const res3 = t.add(1, 2); + expect(res3).toBe(3); + expect(computeCount).toBe(2); + }); + + it("should support active eviction", async () => { + let computeCount = 0; + class Test { + @lru({ ttl: 100, eviction: "active" }) + mult(a: number, b: number): number { + computeCount++; + return a * b; + } + } + const t = new Test(); + const res1 = t.mult(2, 3); + expect(res1).toBe(6); + expect(computeCount).toBe(1); + + // Wait for TTL to expire and allow active eviction to remove the cache entry. + await new Promise((resolve) => setTimeout(resolve, 150)); + const res2 = t.mult(2, 3); + expect(res2).toBe(6); + expect(computeCount).toBe(2); + }); + + it("should apply transform function", () => { + let computeCount = 0; + class Test { + @lru({ transform: (val) => val * 2 }) + add(a: number, b: number): number { + computeCount++; + return a + b; + } + } + const t = new Test(); + const res1 = t.add(1, 2); + expect(res1).toBe(6); // (1+2)*2 = 6 + const res2 = t.add(1, 2); + expect(res2).toBe(6); + expect(computeCount).toBe(1); + }); + + describe("Overrides and Edge Cases", () => { + it("should use a custom LRU implementation override", () => { + // Custom LRU that counts its instantiations. + class CustomLRU extends LRU { + static instanceCount = 0; + constructor(maxSize?: number) { + super(maxSize); + CustomLRU.instanceCount++; + } + } + + let computeCount = 0; + class TestCustomLRU { + @lru({ overrides: { LRU: CustomLRU } }) + compute(n: number): number { + computeCount++; + return n * 2; + } + } + + const instance = new TestCustomLRU(); + expect(instance.compute(5)).toBe(10); + expect(instance.compute(5)).toBe(10); + expect(computeCount).toBe(1); + expect(CustomLRU.instanceCount).toBe(1); + }); + + it("should use a custom Date override to control TTL behavior", async () => { + // Fake Date that always returns a fixed time. + const fixedTime = 1_600_000_000_000; + class FakeDate extends Date { + // deno-lint-ignore constructor-super + constructor(value?: string | number | Date) { + if (arguments.length === 0) { + super(fixedTime); + } else { + super(value!); + } + } + static override now(): number { + return fixedTime; + } + } + + let computeCount = 0; + class TestFakeDate { + @lru({ ttl: 100, eviction: "passive", overrides: { Date: FakeDate } }) + add(a: number, b: number): number { + computeCount++; + return a + b; + } + } + + const instance = new TestFakeDate(); + expect(instance.add(1, 2)).toBe(3); + expect(computeCount).toBe(1); + + // Even after waiting, FakeDate.now() remains fixed, so the cached value remains valid. + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(instance.add(1, 2)).toBe(3); + expect(computeCount).toBe(1); + }); + + it("should use a custom storage override", () => { + // Use a custom storage container (a plain Map) instead of the default. + const customStorage = new Map(); + let computeCount = 0; + class TestCustomStorage { + @lru({ overrides: { storage: customStorage } }) + multiply(a: number, b: number): number { + computeCount++; + return a * b; + } + } + + const instance = new TestCustomStorage(); + expect(instance.multiply(2, 3)).toBe(6); + expect(instance.multiply(2, 3)).toBe(6); + expect(computeCount).toBe(1); + expect(customStorage.size).toBeGreaterThan(0); + }); + + it("should use custom Map and WeakMap overrides", () => { + // Dummy Map and WeakMap that count instantiations. + class DummyMap extends Map { + static instanceCount = 0; + constructor(entries?: Iterable | null) { + super(entries); + DummyMap.instanceCount++; + } + } + class DummyWeakMap extends WeakMap { + static instanceCount = 0; + constructor(entries?: readonly (readonly [K, V])[]) { + super(entries); + DummyWeakMap.instanceCount++; + } + } + + let computeCount = 0; + class TestDummyOverrides { + @lru({ overrides: { Map: DummyMap, WeakMap: DummyWeakMap } }) + subtract(a: number, b: number): number { + computeCount++; + return a - b; + } + } + + const instance = new TestDummyOverrides(); + expect(instance.subtract(5, 3)).toBe(2); + expect(instance.subtract(5, 3)).toBe(2); + expect(computeCount).toBe(1); + expect(DummyMap.instanceCount).toBeGreaterThan(0); + expect(DummyWeakMap.instanceCount).toBeGreaterThan(0); + }); + + it("should correctly apply the transform function", () => { + let computeCount = 0; + class TestTransform { + @lru({ transform: (val) => val * 2 }) + add(a: number, b: number): number { + computeCount++; + return a + b; + } + } + const instance = new TestTransform(); + expect(instance.add(3, 4)).toBe(14); + expect(instance.add(3, 4)).toBe(14); + expect(computeCount).toBe(1); + }); + }); +}); diff --git a/packages/lru/src/lru_decorator.ts b/packages/lru/src/lru_decorator.ts new file mode 100644 index 0000000..24a621d --- /dev/null +++ b/packages/lru/src/lru_decorator.ts @@ -0,0 +1,334 @@ +// deno-lint-ignore-file no-explicit-any +/** + * This module provides a Stage 3 decorator factory for caching method calls + * using an advanced Least Recently Used (LRU) strategy with TTL support. + * + * ## Background + * + * Modeled after Python’s `@lru_cache` decorator, this decorator caches the + * target method's return values based on the arguments it receives. + * + * ## Caching + * + * The core caching logic lives in a dedicated {@link LRU} cache instance + * created for each decorated method. The cache is a simple key-value store + * which evicts the least recently used entry when it reaches maxSize. + * + * ### Structure + * + * To prevent memory leaks, all cache instances are contained within two layers + * of ephemeral weak maps: an outer WeakMap that is keyed by the class itself, + * which holds an inner WeakMap keyed by the `this` context (the class instance + * or constructor, for static methods). Inside the inner map, a `Map` is used + * to associate LRU instances with the property keys of the decorated methods. + * + * This structure ensures that no LRU cache can outlive the class that it is + * associated with, effectively tying it to the lifetime of the class instance + * and that of the class itself. This means cache entries can be automatically + * garbage collected once the class is no longer reachable by the program. + * + * ### Key Generation + * + * Keys are generated by serializing the method's arguments with `options.key`, + * which uses `JSON.stringify` by default. This can be overridden by passing a + * custom key generation function. + * + * #### Keygen Determinism + * + * When providing a custom key generator, always ensure it is deterministic and + * capable of serializing the argument types that the decorated method expects. + * This is crucial for cache hit detection and key matching. + * + * ## Options + * + * The decorator accepts an options bag with the following properties: + * + * - `key`: Custom key generator (default: {@link defaultKey}) + * - `maxSize`: Maximum number of entries to cache (default: `128`) + * - `overrides`: Custom API overrides (for testing and advanced use-cases) + * - `transform`: Post-processing function for cached values. + * - `ttl`: Optional time-to-live in milliseconds for each cache entry. + * - `eviction`: Eviction strategy to control when expired entries are removed. + * + * @example Real-world LRU-cached fetch API with TTL and custom keygen: + * ```ts no-eval + * import { lru } from "@decorators/lru"; + * + * class FetchService { + * constructor(protected init?: RequestInit) {} + * + * @lru({ + * key: (url) => url.toString(), + * transform: async (res) => (await res).clone(), + * ttl: 5_000, // cache entries expire after 5 seconds + * }) + * fetch(url: string | URL): Promise { + * return globalThis.fetch(url, this.init); + * } + * } + * + * // ensuring our fetch service is cached and TTL'd as expected + * const f = new FetchService({ headers: { "accept": "application/json" } }); + * + * // first fetch is uncached and takes longer + * let a = performance.now(); + * await (await f.fetch("https://jsonplaceholder.typicode.com/posts/6")).json(); + * console.log(performance.now() - a, "ms"); // ~300ms (uncached) + * + * // subsequent fetch is cached and faster + * a = performance.now(); + * await (await f.fetch("https://jsonplaceholder.typicode.com/posts/6")).json(); + * console.log(performance.now() - a, "ms"); // ~1.8ms (cached) + * + * // ... a few seconds later (5s TTL) ... + * a = performance.now(); + * await (await f.fetch("https://jsonplaceholder.typicode.com/posts/6")).json(); + * console.log(performance.now() - a, "ms"); // ~200ms (re-cached) + * ``` + * @module decorator + */ +import { + type ClassMethod, + type ClassMethodDecorator, + defaultKey, +} from "./_internal.ts"; +import type { EvictionStrategy, Options } from "./options.ts"; +import defaults from "./overrides.ts"; +import type { + CacheEntry, + CacheKey, + ExtendedCacheEntry, + MapLike, + MapLikeConstructor, +} from "./types.ts"; + +lru.defaultOptions = { + maxSize: 128, + key: defaultKey, + transform: (value) => value, + ttl: 0, + eviction: "passive" as EvictionStrategy, + overrides: { ...defaults }, +} satisfies Options; + +/** + * Stage 3 decorator factory that caches the result of a method call using a + * Least Recently Used (LRU) caching strategy, with optional TTL support and + * configurable eviction strategies. + * + * See the module-level documentation for in-depth examples and usage patterns. + * + * @template {object} This The type of the `this` context at runtime. If the + * method is static, this is the class constructor; otherwise, this will be the + * type of the class instance. + * @template {ClassMethod} Value The target call signature. + * @template {readonly any[]} Args The method's argument signature, as a tuple. + * @template [Return] The method's return type. + * @param [options] Configuration options for the cache. + * @returns A method decorator. + * + * @example LRU-cached Fibonacci method with TTL and active eviction: + * ```ts ignore + * import { lru } from "@decorators/lru"; + * + * class Calculator { + * @lru({ maxSize: 64, ttl: 1000, eviction: "active" }) + * fib(n = 2): number { + * if (n < 2) return n; + * return this.fib(n - 1) + this.fib(n - 2); + * } + * } + * + * const calc = new Calculator(); + * console.log(calc.fib(8)); // computed and cached + * ``` + * @category Decorators + * @tags LRU, cache + */ +export function lru< + This extends object, + Value extends ClassMethod, + Args extends readonly any[] = Parameters, + Return = ReturnType, +>( + options?: Options, Return>, +): ClassMethodDecorator { + const { + maxSize = 128, + key: getKey = defaultKey, + overrides = {}, + eviction = "passive", + // onClear = () => {}, + onEvict = () => {}, + onRefresh = () => {}, + onHit = () => {}, + onMiss = () => {}, + inspect = () => {}, + prepare = (value) => value, + transform = (value) => value, + ttl = 0, + // shouldEvict = ({ age, expiresAt = 0 }) => (expiresAt && age > expiresAt), + } = (options ?? {}) as Options; + + const keygen = getKey as (this: This, ...args: Args) => CacheKey; + + const { + Date = defaults.Date, + LRU = defaults.LRU, + Map = defaults.Map as MapLikeConstructor, + WeakMap = defaults.WeakMap as MapLikeConstructor, + storage = defaults.storage, + } = { ...defaults, ...overrides }; + + if (typeof keygen !== "function") { + throw new TypeError("Function expected: options.key"); + } + + return function (method, context) { + let caches: + | MapLike>> + | undefined; + let cache: MapLike> | undefined; + + // Initialize the cache storage for the current context. + context.addInitializer(getCache); + + const fn = function (this: This, ...args: Args): Return { + const key = keygen.call(this, ...args); + cache = getCache.call(this); // in case of a context change + + let evicted = false; + + let entry = cache.get(key); + if (entry?.expiresAt && Date.now() > entry.expiresAt) { + evicted = true; + // Entry has expired, remove it from the cache. + onEvict.call(this, entry.value, key, entry); + // If the entry has expired, clear any active timer. + if (entry.timer) clearTimeout(entry.timer); + cache.delete(key); + entry = undefined; + } + + if (!entry) { + // cache miss + if (!evicted) onMiss.call(this, key); + const value = method.call(this, ...args); + entry = createEntry.call(this, { key, value }); + } else if (!evicted) { + // cache hit + onHit.call(this, entry.value, key, entry); + if (entry.timer) { + // Clear any existing timer before setting a new one. + clearTimeout(entry.timer); + } + } + + // cache hit + if (evicted) onRefresh.call(this, entry.value, key, entry); + + // if a pre-processor is provided, apply it to the value. + const originalValue = entry.value; + const prepared = prepare.call(this, entry.value, key, entry); + entry.value = prepared ?? originalValue; + + if (ttl > 0) setCacheTimer.call(this, entry, ttl); + + // update recency: remove and reinsert + cache.delete(key); + cache.set(key, entry); + + const finalizedValue = transform(entry.value, key, entry); + + inspect.call(this, { + ...entry, + ttl, + originalValue, + finalizedValue, + cache, + }); + + return finalizedValue; + } as Value; + + // let's obscure the fact we've memoized this method + return Object.defineProperties(fn, { + ...Object.getOwnPropertyDescriptors(method), + toString: { + value: method.toString.bind(method), + configurable: true, + }, + }); + + function createEntry< + T extends + & Omit, "age" | "createdAt" | "expiresAt"> + & Partial>, + >( + this: This | void, + entry: T, + ): T & CacheEntry { + const cache2 = cache ?? ( + typeof this === "undefined" ? undefined : getCache.call(this) + ); + const createdAt = entry.createdAt ?? Date.now(); + const expiresAt = entry.expiresAt ?? 0; + + return { + cache: cache2, + ...entry, + createdAt, + expiresAt, + get age() { + return Date.now() - (this.createdAt ??= createdAt); + }, + }; + } + + function setCacheTimer( + this: This, + entry: CacheEntry, + ttl: number, + ) { + const newExpiration = Date.now() + ttl; + entry.expiresAt = newExpiration; + if (eviction === "active") { + // Clear any existing timer before setting a new one. + entry.timer && clearTimeout(entry.timer); + // Set a timer to remove the entry after TTL expires. + entry.timer = setTimeout(() => { + onEvict.call(this, entry.value, entry.key, entry); + + // TODO(nberlette): is this line really needed? + cache ??= getCache.call(this); + cache.delete(entry.key); + + // cleanup the entry and dipose of the timer handle. + clearTimeout(entry.timer); + + entry.timer = entry.expiresAt = entry.value = undefined!; + }, ttl); + } + } + + // hoisted helper functions + function getCacheStorage(key: object) { + return storage.get(key) ?? storage.set(key, new WeakMap()).get(key)!; + } + + function getCache(this: This, p = context.name) { + // Prefer using the decorator metadata object as a key. + let key: object = context.metadata; + // Fallback to the constructor if metadata is not supported. + key ??= context.static ? this : this.constructor; + // Retrieve/initialize the outer weak map for the class constructor. + const outer = getCacheStorage(key); + if (!(caches ??= outer.get(this))) outer.set(this, caches = new Map()); + // Retrieve/initialize the actual LRU cache for this method. + if (!(cache ??= caches.get(p))) caches.set(p, cache = new LRU(maxSize)); + return cache; + } + }; +} + +export default lru; diff --git a/packages/lru/src/options.ts b/packages/lru/src/options.ts new file mode 100644 index 0000000..e4e65a1 --- /dev/null +++ b/packages/lru/src/options.ts @@ -0,0 +1,327 @@ +// deno-lint-ignore-file no-explicit-any +/** + * This module contains the types and interfaces used for configuring the + * options of the LRU cache decorator. + * + * @module options + */ + +import type { Overrides } from "./overrides.ts"; +import type { CacheEntry, CacheKey, ExtendedCacheEntry } from "./types.ts"; + +/** + * Eviction strategy for the {@linkcode lru} decorator. + * + * - `"passive"`: Expired entries are removed lazily on access. + * - `"active"`: Expired entries are removed actively using timers. + * + * @category Types + */ +export type EvictionStrategy = "passive" | "active"; + +// deno-lint-ignore ban-types +export type strings = string & {}; + +/** + * Custom function to generate a cache key from the method arguments. + * + * @category Types + */ +export type Keygen< + This extends object = any, + Args extends readonly any[] = any[], +> = (this: This, ...args: Args) => CacheKey | strings; + +/** + * Custom transformer function for post-processing cached values. This is the + * type signature for the `transform` option in the {@linkcode lru} decorator. + * + * @template I The input type of the cached value. + * @template [O=I] The output type of the transformed value. + * + * @category Types + */ +export type Transformer = ( + value: I, + key: CacheKey, + entry: CacheEntry, +) => O; + +/** + * Options for configuring the {@linkcode lru} decorator. + * + * @category Options + */ +export interface Options< + This extends object = object, + Args extends readonly unknown[] = any[], + Return = any, +> { + /** + * Controls the strategy for removing entries from the cache when they have + * expired. + * + * - `"passive"`: Expired entries are removed lazily on access. + * - `"active"`: Expired entries are removed actively using timers. + * + * @default {"passive"} + */ + eviction?: EvictionStrategy | undefined; + /** + * Custom function to generate a cache key from the method arguments. + * + * @default {defaultKey} (uses JSON.stringify) + */ + key?: Keygen | undefined; + /** + * Maximum number of entries to store in the cache. + * + * @default {128} + */ + maxSize?: number | undefined; + /** + * Custom callback to be invoked whenever an entry is evicted from the cache. + * This can be used to perform additional cleanup or logging. + * + * @example + * ```ts + * import { lru } from "@decorators/lru"; + * + * class Database { + * @lru({ ttl: 5e3, onEvict: (k, v) => console.log(`evicted ${k}: ${v}`) }) + * transaction_id(key: string): string { + * return `${key}_${Math.random().toString(36).slice(2)}`; + * } + * } + * + * const db = new Database(); + * + * db.transaction_id("user_1"); // "user_1_cdcu78j" + * db.transaction_id("user_1"); // "user_1_cdcu78j" + * // wait 5 seconds + * db.transaction_id("user_2"); // "user_2_3j8d7k9" + * // Logs: "evicted user_1_cdcu78j" + * ``` + */ + onEvict?( + this: This, + value: Return, + key: CacheKey, + entry: CacheEntry, + ): void; + + /** + * Custom callback to be invoked whenever an entry is refreshed in the cache. + * This can be used to perform additional actions or logging. + * + * @example + * ```ts + * import { lru } from "@decorators/lru"; + * + * class Database { + * @lru({ ttl: 5e3, onRefresh: (k, v) => console.log(`refreshed ${k}: ${v}`) }) + * transaction_id(key: CacheKey): string { + * return `${key}_${Math.random().toString(36).slice(2)}`; + * } + * } + * + * const db = new Database(); + * + * db.transaction_id("user_1"); // "user_1_cdcu78j" + * db.transaction_id("user_1"); // "user_1_cdcu78j" + * // wait 5 seconds + * db.transaction_id("user_2"); // "user_2_3j8d7k9" + * // Logs: "refreshed user_1_cdcu78j" + * ``` + */ + onRefresh?( + this: This, + value: Return, + key: CacheKey, + entry: CacheEntry, + ): void; + + // /** + // * Custom predicate function to determine whether a cache entry should be + // * evicted. This can be used to implement custom eviction policies. + // * + // * @example + // * ```ts + // * import { lru } from "@decorators/lru"; + // * + // * class MyService { + // * @lru({ + // * key: (url) => url.toString(), + // * shouldEvict(key, value, entry) { + // * return entry.age > 10_000 + // * }, + // * }) + // * fetch(url: string | URL): Promise { + // * return globalThis.fetch(url); + // * } + // * } + // * ``` + // */ + // shouldEvict?( + // this: This, + // entry: CacheEntry, + // ): boolean; + + // /** + // * Optional function to be called when the cache is cleared. This can be used + // * to perform additional cleanup or logging. + // * + // * @example + // * ```ts + // * import { lru } from "@decorators/lru"; + // * + // * class MyService { + // * @lru({ onClear: () => console.log("Cache cleared!") }) + // * fetch(url: string | URL): Promise { + // * return globalThis.fetch(url); + // * } + // * } + // * ``` + // */ + // onClear?(this: This): void; + + /** + * Optional function to be called on a cache hit. This can be used to + * perform additional actions or logging. + */ + onHit?( + this: This, + value: Return, + key: CacheKey, + entry: CacheEntry, + ): void; + + /** + * Optional function to be called on a cache miss. This can be used to + * perform additional actions or logging. + */ + onMiss?(this: This, key: CacheKey): void; + + /** + * Optional inspection function which is called whenever a cache entry is + * accessed, and invoked with the key, value, entry object, and the cache + * instance itself as arguments. The function and its return value have no + * effect on the cache itself, it is purely for passive inspection. + */ + inspect?( + this: This, + entry: ExtendedCacheEntry, + ): void; + + /** + * Custom API overrides for the cache implementation. This is primarily for + * dependency injection and testing purposes. + * + * **Warning**: This option is intended for advanced use-cases and testing + * purposes. It should be used with caution, and only if you really know how + * it works. Incorrect usage **will** break the caching mechanism entirely. + */ + overrides?: Overrides | undefined; + + /** + * Optional pre-processing function, which can be used to transform values + * **before** they are stored in the cache, and after they are computed by + * the original (memoized) method. + * + * This can be used to implement advanced cache behaviors which require + * the values to be transformed or otherwise processed before they are + * stored in the cache. For example, you might want to compress the value + * before storing it, or convert it to a different format. + * + * @remarks + * **Note**: It's important to note that due to the current design of the + * decorators proposal, it's not possible to transform the type of the value + * to something that is not assignable to the original type. This limitation + * was an intentional decision by the decorators proposal authors, but it has + * received some criticism and is being considered for future revisions. For + * now, however, compile-time type transformations are not supported. + * + * This means that the `prepare` function must return the same type of value + * as it receives, and must be compatible with the original method signature. + * That being said, it _is_ possible to transform these values at runtime to + * any type you want (theoretically), due to JavaScript's dynamic nature. But + * the TypeScript compiler will not be able to infer this, and will either + * raise a compiler error or simply fail to recognize the transformed type. + * @example + * ```ts + * import { lru } from "@decorators/lru"; + * + * class MyService { + * @lru({ + * prepare: async (res) => { + * const clone = (await res).clone(); + * // update the response headers + * clone.headers.set("x-lru-cache", "true"); + * return clone; + * }, + * // this option is critical when we are caching an HTTP Response. + * // if we don't clone it before we return it, it will be consumed + * // and become totally useless after the first access. + * transform: async (res) => (await res).clone(), + * }) + * fetch(url: string | URL): Promise { + * return globalThis.fetch(url); + * } + * } + * ``` + */ + prepare?: Transformer | undefined; + + /** + * Optional post-processing function, which can be used to transform values + * after they are retrieved from the cache, but before they are returned. + * + * This can be used to implement more advanced cache behaviors, such as + * caching an HTTP response, which requires cloning the cached value before + * returning it. Without a transform function, the cached value would only be + * valid for a single use, and any subsequent access would throw an error due + * to the response being consumed. + * + * @remarks + * **Note**: It's important to note that due to the current design of the + * decorators proposal, it's not possible to transform the type of the value + * to something that is not assignable to the original type. This limitation + * was an intentional decision by the decorators proposal authors, but it has + * received some criticism and is being considered for future revisions. For + * now, however, compile-time type transformations are not supported. + * + * This means that the transform function must return the same type of value + * as it receives, and must be compatible with the original method signature. + * That being said, it _is_ possible to transform these values at runtime to + * any type you want (theoretically), due to JavaScript's dynamic nature. But + * the TypeScript compiler will not be able to infer this, and will either + * raise a compiler error or simply fail to recognize the transformed type. + * @example + * ```ts + * import { lru } from "@decorators/lru"; + * + * class FetchService { + * constructor(protected init?: RequestInit) {} + * + * @lru({ + * key: (url) => url.toString(), + * transform: async (res) => (await res).clone(), + * ttl: 5_000, // cache entries expire after 5 seconds + * }) + * fetch(url: string | URL): Promise { + * return globalThis.fetch(url, this.init); + * } + * } + * ``` + */ + transform?: Transformer | undefined; + + /** + * Optional time-to-live (TTL) in milliseconds for each cache entry. If set, + * cache entries that haven't been accessed within the provided time window + * will be removed from the cache. + * + * @default {0} (entries do not expire) + */ + ttl?: number | undefined; +} diff --git a/packages/lru/src/overrides.ts b/packages/lru/src/overrides.ts new file mode 100644 index 0000000..ff69b37 --- /dev/null +++ b/packages/lru/src/overrides.ts @@ -0,0 +1,100 @@ +/** + * This module provides the types and default values for the custom overrides + * capability of the `@decorators/lru` library. It allows advanced users to + * customize the internal APIs used by the caching mechanisms, giving a much + * higher degree of flexibility and control over the caching behavior. + * + * This is especially useful for testing, where you might want to inject + * different implementations of the cache or storage mechanisms. + * + * @module overrides + */ +import { LRU } from "./lru_cache.ts"; +import type { + CacheContainer, + LruLikeConstructor, + MapLikeConstructor, + TimeProvider, +} from "./types.ts"; + +/** + * Custom overrides for the {@linkcode lru} decorator, allowing for dependency + * injection and testing of the cache implementation. + * + * - `Date` - {@linkcode TimeProvider} for time-based operations. + * - `LRU` - {@linkcode LruLikeConstructor} override for the `LRU` class + * - `Map` - {@linkcode MapLikeConstructor} override for the `Map` class + * - `WeakMap` - {@linkcode MapLikeConstructor} override for `WeakMap` + * - `storage` - {@linkcode CacheContainer} override for the storage instance. + * + * **Warning**: These options are intended for advanced use-cases and should be + * used with caution, as they can easily break the decorator if not implemented + * correctly. It's usually best to leave these as the default values. + * + * @category Options + * @tags Overrides + */ +export interface Overrides { + /** + * The LRU cache implementation to use. Defaults to the {@link LRU} class + * included with this library. + * + * @default {LRU} + */ + LRU?: LruLikeConstructor | undefined; + /** + * The Map implementation to use. Defaults to the global `Map` class. + * + * @default {globalThis.Map} + */ + Map?: MapLikeConstructor | undefined; + /** + * The WeakMap implementation to use. Defaults to the global `WeakMap` class. + * + * @default {globalThis.WeakMap} + */ + WeakMap?: MapLikeConstructor | undefined; + /** + * The `Date` constructor to use for time-based operations. + * + * @default {globalThis.Date} + */ + Date?: TimeProvider | undefined; + /** + * Custom storage implementation for the cache. This is a multi-layered map + * structure used to store the cache containers for each class constructor. + * + * The default implementation uses a WeakMap for the outer layer, a WeakMap + * for the inner layer, a Map for the property-to-LRU mapping, and (finally!) + * the actual LRU cache for the decorated method. + * + * The structure looks like this: + * ```ts ignore + * WeakMap // <- LRU cache (key: keygen result) + * >, + * >, + * > + * ``` + */ + storage?: CacheContainer | undefined; +} + +const WeakMap = globalThis.WeakMap; +const Map = globalThis.Map; +const Date = globalThis.Date; + +/** @internal */ +const storage: CacheContainer = new WeakMap(); + +export const defaults = { + storage: storage as CacheContainer, + WeakMap: WeakMap as MapLikeConstructor, + Map: Map as MapLikeConstructor, + Date: Date as TimeProvider, + LRU: LRU as LruLikeConstructor, +} as const; + +export default defaults; diff --git a/packages/lru/src/types.ts b/packages/lru/src/types.ts new file mode 100644 index 0000000..017d52c --- /dev/null +++ b/packages/lru/src/types.ts @@ -0,0 +1,180 @@ +// deno-lint-ignore-file no-explicit-any +/** + * This module contains type definitions and interfaces used by the + * `@decorators/lru` library. + * + * @module types + */ +const kCacheKey: unique symbol = Symbol("cacheKey"); +type kCacheKey = typeof kCacheKey; + +interface IsCacheKey { + readonly [kCacheKey]: never; +} + +/** + * Represents a cache key used by the internal {@linkcode LRU} cache + * implementation. This is a serialized representation of the arguments passed + * to the decorated method, and is used to uniquely identify the cached value. + * The {@linkcode Keygen} function generates values of this type. + * + * @remarks + * This is a nominal (branded) string type, which is distinct from a regular + * string. This allows us to use the `CacheKey` type in a type-safe manner, + * and ensure that we're dealing with a key that's been generated by the + * {@linkcode Keygen} function, rather than an arbitrary string. + * + * @category Types + */ +export type CacheKey = string & IsCacheKey; + +/** + * A cached entry record that holds the actual value and, optionally, + * an expiration timestamp in milliseconds as well as a timer handle for + * active eviction. + * + * @template T The type of the cached value. + * @category Types + * @internal + */ +export interface CacheEntry { + /** The key used to store the entry in the cache. */ + key: CacheKey; + + /** The cached value. */ + value: T; + + /** The entry's age, in milliseconds since it was created. */ + readonly age: number; + + /** + * The time this entry was created, in milliseconds since the epoch. + * + * This does not necessarily reflect the **_first_** time the entry was + * created, but rather the most recent time it was refreshed/recreated. + */ + createdAt: number; + + /** + * Optional expiration timestamp (in milliseconds since the epoch). + * If present, the entry expires when Date.now() exceeds this value. + */ + expiresAt?: number | undefined; + + /** Optional timer handle used for active eviction. */ + timer?: ReturnType | undefined; +} + +/** + * An extended cache entry record that holds the original value (before + * any pre-processing steps), the stored value (after pre-processing), + * and the final value (after any transformations), along with a reference + * to the LRU cache instance itself. + * + * @template T The type of the cached value. + * @category Types + */ +export interface ExtendedCacheEntry extends CacheEntry { + /** The cache instance itself. */ + cache: MapLike>; + /** The key used to store the entry in the cache. */ + key: CacheKey; + /** The value stored in the cache, after calling the `prepare` function to + * apply any transformations. */ + value: T; + /** The original value that was generated by the decorated method. */ + originalValue: T; + /** The final value that is actually being returned to the caller, after + * applying any transformations from the `transform` function. */ + finalizedValue: T; + /** The TTL (time-to-live) of the entry, in milliseconds. If no TTL is + * set, this will be `0`. */ + ttl: number; +} + +/** + * Deeply-nested cache storage structure for the {@linkcode lru} decorator. + * + * @category Types + */ +export type CacheContainer< + T extends object = object, + K extends PropertyKey = PropertyKey, + V = any, +> = MapLike>>>; + +/** + * The minimal representation of a {@linkcode Date} constructor required for + * time-based operations (e.g. TTL evictions) in the {@linkcode lru} decorator. + * + * This type only requires the presence of a `now()` method, which must return + * a number representing the current time in milliseconds. The global `Date` + * object is the default implementation for this interface. + * + * This is primarily used for testing and dependency injection purposes, and is + * the type expected by the `Date` option in the {@linkcode Overrides} object. + * + * @category Types + * @tags Overrides + */ +export interface TimeProvider { + /** Returns the current time in milliseconds since the epoch. */ + now(): number; +} + +/** + * Represents a constructor of {@linkcode LRU} instances. This is primarily + * used for testing and dependency injection purposes. It is the type expected + * by the `LRU` option in the {@linkcode Overrides} object. + * + * @category Types + * @tags Overrides + */ +export interface LruLikeConstructor { + /** Creates a new instance of the LRU cache. */ + new (maxSize: number): MapLike; +} + +/** + * Represents the minimal required API for the internal keyed collections used + * by the {@linkcode lru} decorator to store cached values. This interface is + * intentionally minimal and generic by design, for maximum flexibility. + * + * This type is implemented by the {@linkcode LRU} class as well as the global + * `Map` and `WeakMap` APIs. Together these form a multi-layer, memory-safe + * data structure that is used as the default cache storage mechanism. + * + * @remarks + * If you plan on using a custom cache implementation with the {@linkcode lru} + * decorator, or want to override the internal `Map` or `WeakMap` constructors + * for advanced testing/debugging purposes, any classes you provide for those + * overrides must implement this interface to be considered compatible. + * + * @category Types + * @tags Overrides + */ +export interface MapLike { + /** Gets the number of entries in the cache. */ + get(key: K): V | undefined; + /** Sets a value in the cache. */ + set(key: K, value: V): this; + /** Checks if a key exists in the cache. */ + has(key: K): boolean; + /** Deletes a key from the cache. */ + delete(key: K): boolean; +} + +/** + * Represents a constructor of {@linkcode MapLike} instances. This is primarily + * used for testing and dependency injection purposes. It is the type expected + * by the `Map` and `WeakMap` options in the {@linkcode Overrides} object. + * + * @category Types + * @tags Overrides + */ +export interface MapLikeConstructor { + /** Creates a new instance of the map. */ + new (): MapLike; + /** Creates a new instance of the map with the given entries. */ + new (entries?: [K, V][] | undefined): MapLike; +}