diff --git a/.changeset/config.json b/.changeset/config.json index 12b7ce1..99a5d5d 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -5,5 +5,8 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } } diff --git a/.changeset/no-release-first-publish.md b/.changeset/no-release-first-publish.md new file mode 100644 index 0000000..c8516e0 --- /dev/null +++ b/.changeset/no-release-first-publish.md @@ -0,0 +1,6 @@ +--- +--- + +Release plumbing only, deliberately no bump: the 0.1.0 CHANGELOG is written by +hand because the changeset it replaces described a fix between two states that +were never published. diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..717f715 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,31 @@ +name: "Setup Node.js and pnpm" +description: "Setup Node.js and pnpm, install dependencies" + +# CI and Release call btravstack/config's reusable workflows, which do their own +# setup. This composite is kept for parity with the sibling btravstack repos, +# for any workflow in this repo that runs its own steps and needs the same +# toolchain. + +runs: + using: "composite" + steps: + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: pnpm + + - name: Setup Turbo Cache + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..0209cb9 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,71 @@ +name: Deploy Documentation + +# Publish the VitePress site once CI is green on main. Chaining off CI rather +# than pushing directly means the site is never built from a commit that does +# not compile, and the `github-pages` environment's branch policy sees `main` +# (a `workflow_run` event runs against the default branch). +on: + workflow_run: + workflows: ["CI"] + types: + - completed + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + # A Pages deploy replaces the whole site, so only one may be in flight. + group: pages + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + build: + # A `workflow_run` fires on ANY CI conclusion (failure, cancelled); deploy + # only after a successful one. `workflow_dispatch` is unconditional. + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + # A `workflow_run` checkout defaults to the default branch's CURRENT + # tip, which a push landing after CI went green can have moved — the + # site would then build from a commit no CI run has validated, + # defeating the point of chaining off CI at all. `head_sha` is the + # exact commit the green run measured. Empty (hence `|| github.sha`) + # only for `workflow_dispatch`, where the dispatched ref is the + # intent. + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - name: Setup + uses: ./.github/actions/setup + + # The docs build runs TypeDoc (straight from packages/di/src into + # docs/api/di) and then VitePress — see docs/package.json. + - name: Build documentation + run: pnpm --filter ./docs exec turbo build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v5 + with: + path: docs/.vitepress/dist + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build + runs-on: ubuntu-latest + name: Deploy + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..288c1fb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,22 @@ +name: Release + +on: + workflow_run: + workflows: ["CI"] + types: + - completed + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + permissions: + contents: write + pull-requests: write + id-token: write + uses: btravstack/config/.github/workflows/release-reusable.yml@workflows-v1 + secrets: + RELEASE_PAT: ${{ secrets.RELEASE_PAT }} diff --git a/.gitignore b/.gitignore index bab2675..a24c05b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,12 @@ coverage/ # outlived live in git history. Ignored so they cannot grow back. docs/superpowers/ +# VitePress (`docs/.vitepress/dist/` is already covered by `**/dist/` above) +docs/.vitepress/cache/ +# The di package's API reference, generated by TypeDoc at build time +# (the hand-written docs/api/index.md overview is kept) +docs/api/di/ + # Generated Prisma client, minted by the persistence example's `generate` script # (run by its `test` / `typecheck` scripts). Ignored at the root so a checkout # predating the package cannot leave it visible and sweepable into a commit. diff --git a/CLAUDE.md b/CLAUDE.md index 0825bf6..48524f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,29 +7,33 @@ reasoning behind them. Keep it in sync with the code as the package evolves ## What this is -`@btravstack/start` — the application kernel. It boots a -[`@btravstack/di`](https://github.com/btravstack/di) module into a running -process with one runtime, drains in-flight work on SIGTERM, and closes the -application scope on every path. It owns three things — the lifecycle state -machine, the unit-of-work registry, and the `Runtime` contract — and knows -nothing about HTTP, AMQP or Temporal. +`@btravstack/start` — the application kernel. It boots a `@btravstack/di` +module into a running process with one runtime, drains in-flight work on +SIGTERM, and closes the application scope on every path. It owns three +things — the lifecycle state machine, the unit-of-work registry, and the +`Runtime` contract — and knows nothing about HTTP, AMQP or Temporal. `di` proves the wiring before the process exists. `start` owns **when** an already-proven graph is constructed and torn down, and nothing more. Nothing throws to callers: every fallible operation returns an [`unthrown`](https://github.com/btravstack/unthrown) `Result`. -pnpm workspace + turbo monorepo. `packages/` holds four published packages, -`start` (the kernel), `start-http` (the HTTP runtime), `start-temporal` (the -Temporal worker runtime) and `start-amqp` (the AMQP consumer runtime); -`examples/` holds eleven private ones — a clean-architecture application +pnpm workspace + turbo monorepo. `packages/` holds five published packages: +`di` (the module-based DI container the kernel boots — merged in from the +former `btravstack/di` repo, history included; still published as +`@btravstack/di` and still a **peer** of the other four), `start` (the +kernel), `start-http` (the HTTP runtime), `start-temporal` (the Temporal +worker runtime) and `start-amqp` (the AMQP consumer runtime). +`examples/` holds fourteen private ones — a clean-architecture application (`order-domain` → `order-application` → `order-infrastructure`) booted under four different runtimes (`order-api`, `order-worker`, `order-temporal`, `order-amqp`), with each transport's contract in a package of its own (`order-api-contract`, `order-temporal-contract`, `order-amqp-contract`) -because a client must be able to take a contract without the server. They are -consumers, not fixtures: they are part of the gate, and `examples/README.md` -is their index. +because a client must be able to take a contract without the server, plus +di's three consumer examples (`hexagonal-order-api`, `request-scope`, +`plugin-registry`). They are consumers, not fixtures: they are part of the +gate, and `examples/README.md` is their index. `docs/` is the VitePress + +TypeDoc site for `packages/di` (deployed by `deploy-docs.yml`). ## Commands @@ -468,8 +472,11 @@ namespace }` back off `Serving.info`. The Worker's lifecycle, the unit per dependencies of `start` — the dual-copy hazard is real for both (di's port identity and unthrown's `isResult` each compare across copies). `start-http` peers on both of those plus `@btravstack/start` itself, for the same reason. - `node:` builtins only otherwise. Do not add a dependency. -- `declarationMap: false` on all four published packages — the published + `node:` builtins only otherwise. Do not add a dependency. di living in this + repo changes none of that: the kernel packages reference it as + `workspace:^` in devDependencies, and the published peer range stays + `^0.1.0` — a consumer still installs `@btravstack/di` themselves. +- `declarationMap: false` on all five published packages — the published tarball has no `src/`, so maps would be dead ends. - **Relative imports carry `.js`.** `moduleResolution: NodeNext` plus `verbatimModuleSyntax`, both inherited from `@btravstack/tsconfig/base.json` — @@ -528,7 +535,8 @@ namespace }` back off `Serving.info`. The Worker's lifecycle, the unit per them, `packages/start/CLAUDE.md` too — and for a runtime package, its own: `packages/start-http/CLAUDE.md`, `packages/start-temporal/CLAUDE.md` or `packages/start-amqp/CLAUDE.md`, whichever is where that package's public - surface lives. There are **five** `CLAUDE.md` files; naming the wrong one is + surface lives. `packages/di/CLAUDE.md` plays the same role for the DI + container. There are **six** `CLAUDE.md` files; naming the wrong one is how the last drift happened. ## Test conventions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9dac7b9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,140 @@ +# Contributing + +Thanks for your interest in improving `@btravstack/start` and +`@btravstack/di`. These are small, focused libraries — the guiding principle +is **one concept = one name**, and each surface is meant to stay small enough +that the library can be "done". Contributions that sharpen the existing design +are more welcome than ones that grow it. + +## Prerequisites + +- **Node** `>=22.19` +- **pnpm** `11.7.0` (pinned via `packageManager`; run `corepack enable` to get it) + +## Getting started + +```sh +git clone https://github.com/btravstack/start.git +cd start +pnpm install +``` + +## The gate + +Every change must keep all of these green (CI runs the same set): + +```sh +pnpm format --check # oxfmt +pnpm lint # oxlint +pnpm typecheck # tsc (incl. type-level tests) +pnpm test # vitest +pnpm knip # dead code / unused deps +pnpm build # tsdown dual CJS/ESM + d.ts +``` + +Run `pnpm format` (no `--check`) to auto-fix formatting. + +### Type-level tests + +Behaviour that only shows up at the type level — the variance of `Provider`'s +and `Module`'s phantom channels, the construction family's mutual exclusivity, +`Scope` being excluded from `Needs` only by the right entry point — is pinned +in `packages/di/src/*.test-d.ts` and checked by +`tsc --noEmit -p tsconfig.test-d.json` (run as part of `pnpm typecheck`). If +you change a type-level guarantee, update or add the matching +`@ts-expect-error` assertion. + +`*.test-d.ts` files are excluded from the main `tsc` pass by +`tsconfig.json`, so that pass can keep `noUnusedLocals` strict while the +assertions declare bindings they never read. `src/type-assert.ts` exports the +shared `Equal` helper those assertions pin values against — it is a +test-only helper, not part of the published surface, and is excluded from +knip's scope the same way (`knip.jsonc`) since nothing in the runtime source +imports it. + +### Publishing settings + +`declarationMap` is off in `packages/di/tsconfig.json`: `files: ["dist"]` +excludes `src/`, so published declaration maps would be dead-ends (broken +go-to-definition). Consumers get the TSDoc'd `.d.ts` instead. + +Declaration settings reach further than they look — `tsdown` reads that +tsconfig for its `--dts` emit, so what is set there shapes the _published_ +types, while the plain `tsc` pass is `noEmit` from the shared base. + +## Design rules (binding) + +The package README documents the public behaviour and the rationale behind +it; several of the comments throughout `src/` record decisions measured +against a specific compiler version or a real failure mode (a TypeScript +diagnostic code, a variance bug, an unsoundness a review caught), not +assumed. Treat those as regression guards, not decoration — verify before +"simplifying" them away. + +- **oxlint rules are binding**, including the `unthrown/*` rules enforcing this + repo's errors-as-values convention (no throwing outside a documented defect + path). Genuine exceptions carry a targeted `oxlint-disable` with a reason. +- **One name per concept.** Resist convenience aliases. + +## Node versions + +Three numbers, and they mean different things: + +| Where | Value | Meaning | +| ----------------------------- | ---------------------- | --------------------------------------------------------- | +| `.node-version` | the pinned dev version | what contributors and the primary CI job run | +| root `package.json` `engines` | `>=22.19` | the oldest Node this repo is _developed_ on | +| `packages/di` `engines` | `>=20` | the oldest Node the _published package_ claims to support | + +CI runs the test job on `["", "22.19", "24", "26"]` — the pinned version, the +repo's own development floor, and the two current release lines. + +**The published package's floor is not covered, and this matrix cannot cover +it.** These jobs run the development toolchain, and pnpm 11 requires +`node:sqlite`, so a Node 20 row dies at `setup-node` before installing +anything: `ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite`. +That would test the toolchain, not the package — and it contradicts the root +`engines` above, which already says development needs `>=22.19`. + +`engines` on `packages/di` is a claim about **consumers**, who install the +published tarball with their own package manager and import it. Proving it +needs a consumer-side job: pack, `npm install` the tarball on the floor +version, import it. Until that exists the floor is declared, not proven — so +treat `>=20` as an intention rather than a guarantee. + +`24` overlaps `""` for as long as `.node-version` stays on 24.x. It is listed +explicitly anyway, so that bumping `.node-version` to 26 does not silently +drop 24 from the matrix. + +## Commit convention + +Commits follow [Conventional Commits](https://www.conventionalcommits.org/) and +are checked by **commitlint** via a **lefthook** `commit-msg` hook. Examples: + +``` +feat: add Provider.member for set-port contributions +fix: reject a provider registered for Scope as a wiring defect +docs: clarify the resourceful provider's Scope requirement +chore(deps): bump unthrown +``` + +## Changesets + +User-facing changes need a changeset so the release notes and version bumps are +generated correctly: + +```sh +pnpm changeset +``` + +Describe the change in one line and pick a semver bump. Purely internal changes +(tests, CI, refactors with no API/behaviour impact) don't need one. + +## Pull requests + +- Keep PRs focused — one concern each. +- Make sure the full gate passes locally before pushing. +- Reference the issue you're addressing, if any. + +By contributing, you agree that your contributions are licensed under the +project's [MIT License](./LICENSE). diff --git a/README.md b/README.md index 64cc8bf..5188f24 100644 --- a/README.md +++ b/README.md @@ -577,12 +577,15 @@ lines done well. ## Documentation See [`packages/start`](./packages/start) for the package README, -[`examples/`](./examples) for an eleven-package clean-architecture application -booted under four different runtimes, and [`CLAUDE.md`](./CLAUDE.md) for the -authoritative spec: the theses, the public surface and the conventions. The -load-bearing invariants with the test that guards each, and the internal design -notes, live in -[`packages/start/CLAUDE.md`](./packages/start/CLAUDE.md). +[`packages/di`](./packages/di) for the DI container the kernel boots (merged +into this repo from the former `btravstack/di` repository, history included — +still published separately as `@btravstack/di`, with its VitePress + TypeDoc +site in [`docs/`](./docs)), [`examples/`](./examples) for an eleven-package +clean-architecture application booted under four different runtimes plus di's +three consumer examples, and [`CLAUDE.md`](./CLAUDE.md) for the authoritative +spec: the theses, the public surface and the conventions. The load-bearing +invariants with the test that guards each, and the internal design notes, live +in [`packages/start/CLAUDE.md`](./packages/start/CLAUDE.md). ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e58b88b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,32 @@ +# Security Policy + +## Supported versions + +`@btravstack/di` is released from this repository. Security fixes land on the +**latest** published version; please upgrade to the latest release before +reporting. + +## Reporting a vulnerability + +**Please do not open a public issue for security vulnerabilities.** + +Report privately through one of: + +- **GitHub Security Advisories** — [open a private report](https://github.com/btravstack/di/security/advisories/new) + (preferred; keeps the discussion and fix coordination in one place). +- **Email** — `btravers.pro@gmail.com`. + +Please include: + +- the affected package and version, +- a description of the issue and its impact, +- and a minimal reproduction if possible. + +## What to expect + +- Acknowledgement of your report as soon as it is triaged. +- An assessment of the impact and affected versions. +- A coordinated fix and release, with credit to you in the advisory unless you + prefer to remain anonymous. + +Thank you for helping keep `@btravstack/di` and its users safe. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 0000000..2d5f549 --- /dev/null +++ b/docs/.vitepress/config.ts @@ -0,0 +1,307 @@ +import { defineConfig } from "vitepress"; + +const SITE_DESCRIPTION = + "A module-based dependency-injection container for TypeScript: ports as the vocabulary an application defines, providers bound at one edge, and Result instead of throws."; + +const BASE = "/di/"; +const SITE_URL = `https://btravstack.github.io${BASE}`; + +// The guide is structured by the four Diátaxis modes (https://diataxis.fr/): a +// learning-oriented Tutorial, task-oriented How-to guides, information-oriented +// Reference, and understanding-oriented Explanation. One shared sidebar carries +// all four so any page can reach any other. +const GUIDE_SIDEBAR = [ + { + text: "Tutorial", + items: [{ text: "Getting started", link: "/tutorial/getting-started" }], + }, + { + text: "How-to guides", + items: [ + { text: "Swap an adapter for tests", link: "/how-to/swap-an-adapter" }, + { text: "Manage a resource's lifetime", link: "/how-to/manage-a-resource" }, + { text: "Open a per-request scope", link: "/how-to/request-scope" }, + { text: "Build a plugin registry", link: "/how-to/plugin-registry" }, + { text: "Keep a port private", link: "/how-to/private-ports" }, + ], + }, + { + text: "Reference", + items: [ + { text: "Ports", link: "/reference/ports" }, + { text: "Providers", link: "/reference/providers" }, + { text: "Modules", link: "/reference/modules" }, + { text: "Entry points", link: "/reference/entry-points" }, + { text: "Wiring defects", link: "/reference/wiring-defects" }, + { text: "API reference", link: "/api/" }, + ], + }, + { + text: "Explanation", + items: [ + { text: "Why di?", link: "/explanation/why-di" }, + { text: "Compile errors, not surprises", link: "/explanation/compile-time-wiring" }, + { text: "Modules and privacy", link: "/explanation/modules-and-privacy" }, + { text: "Scopes and resource safety", link: "/explanation/scopes-and-resources" }, + { text: "Failures vs defects", link: "/explanation/failures-vs-defects" }, + { text: "Peer dependencies", link: "/explanation/peer-dependencies" }, + ], + }, +]; + +// The runnable packages under `examples/`. Unlike every fenced block in the +// guide, that code compiles and its specs run in CI. +const EXAMPLES_SECTION = { + text: "Examples", + items: [ + { text: "Overview", link: "/examples/" }, + { text: "Hexagonal order API", link: "/examples/hexagonal-order-api" }, + { text: "Request scope", link: "/examples/request-scope" }, + { text: "Plugin registry", link: "/examples/plugin-registry" }, + ], +}; + +// https://vitepress.dev/reference/site-config +export default defineConfig({ + title: "di", + description: SITE_DESCRIPTION, + base: BASE, + lang: "en-US", + cleanUrls: true, + + // `docs/superpowers/` is local design scratch — git-ignored, and not part of + // the published site. Without this VitePress renders whatever happens to be + // sitting there into a page and a sitemap entry. + srcExclude: ["superpowers/**"], + + // The API reference under /api/di/ is generated by TypeDoc and copied in at + // build time; its cross-references use relative links TypeDoc resolves itself. + ignoreDeadLinks: [/^\/api\//, /^\.\/index$/, /^\.\/[a-z-]+$/, /^\.\.\//], + + sitemap: { + hostname: SITE_URL, + }, + + // Per-page canonical URL + Open Graph / Twitter title & description, so every + // page shares a correct preview and avoids duplicate-content ambiguity. + transformPageData(pageData) { + if (!pageData.relativePath.endsWith(".md")) { + return; + } + + const normalizedPath = pageData.relativePath.replace(/^\/+/, ""); + // cleanUrls is true, so the public URL has no `.html` extension: strip + // `index.md` to the directory and any other `.md` to the bare route. + const canonicalUrl = `${SITE_URL}${normalizedPath}` + .replace(/index\.md$/, "") + .replace(/\.md$/, ""); + + pageData.frontmatter ??= {}; + pageData.frontmatter.head ??= []; + + // The /api/ pages (except the hand-written overview) are TypeDoc output copied + // in at build time — they have no source file in the repo, so "Edit this page" + // would 404. docs/api/index.md is the one committed file there. + if (pageData.relativePath.startsWith("api/") && pageData.relativePath !== "api/index.md") { + pageData.frontmatter.editLink = false; + } + + pageData.frontmatter.head.push(["link", { rel: "canonical", href: canonicalUrl }]); + + const pageTitle = pageData.title || pageData.frontmatter.title || "di"; + const pageDescription = + pageData.description || pageData.frontmatter.description || SITE_DESCRIPTION; + + pageData.frontmatter.head.push( + ["meta", { property: "og:url", content: canonicalUrl }], + ["meta", { property: "og:title", content: pageTitle }], + ["meta", { property: "og:description", content: pageDescription }], + ["meta", { name: "twitter:title", content: pageTitle }], + ["meta", { name: "twitter:description", content: pageDescription }], + ); + }, + + themeConfig: { + logo: { light: "/logo-light.svg", dark: "/logo-dark.svg" }, + + nav: [ + // The guide is organised by the four Diátaxis modes; the dropdown links + // the entry page of each. See the sidebar for the full contents. + { + text: "Guide", + items: [ + { text: "Tutorial", link: "/tutorial/getting-started" }, + { text: "How-to guides", link: "/how-to/swap-an-adapter" }, + { text: "Reference", link: "/reference/ports" }, + { text: "Explanation", link: "/explanation/why-di" }, + ], + }, + { text: "Examples", link: "/examples/" }, + { text: "API", link: "/api/" }, + { + text: "Changelog", + link: "https://github.com/btravstack/di/releases", + }, + // Back to the btravstack hub (links the docs up to the landing page). + { text: "btravstack", link: "https://btravstack.github.io/" }, + ], + + sidebar: { + // One shared sidebar across all four Diátaxis sections, so a reader can + // move between Tutorial / How-to / Reference / Explanation from any page. + ...Object.fromEntries( + ["/tutorial/", "/how-to/", "/reference/", "/explanation/"].map((prefix) => [ + prefix, + GUIDE_SIDEBAR, + ]), + ), + // The examples carry the guide sidebar too, with their own section on + // top: they are walkthroughs of the same material, so a reader landing on + // one should still reach every page of the guide. + "/examples/": [EXAMPLES_SECTION, ...GUIDE_SIDEBAR], + "/api/": [ + { + text: "API Reference", + items: [ + { text: "Overview", link: "/api/" }, + { text: "@btravstack/di", link: "/api/di/" }, + ], + }, + ], + }, + + socialLinks: [ + { icon: "github", link: "https://github.com/btravstack/di" }, + { icon: "npm", link: "https://www.npmjs.com/package/@btravstack/di" }, + ], + + footer: { + message: "Released under the MIT License.", + copyright: `Copyright © ${new Date().getFullYear()} Benoit TRAVERS`, + }, + + search: { + provider: "local", + }, + + // The reference pages are dense with `###`-level members; surfacing them in + // the right-rail outline is what makes them navigable. + outline: { level: [2, 3] }, + + editLink: { + pattern: "https://github.com/btravstack/di/edit/main/docs/:path", + text: "Edit this page on GitHub", + }, + }, + + vite: { + // @btravstack/theme's entry imports `vitepress/theme` (which pulls in `.css`) + // and its own `style.css`. VitePress externalizes node_modules deps in the SSR + // build, so Node's ESM loader would hit those `.css` files and throw + // ERR_UNKNOWN_FILE_EXTENSION. Bundling the theme through Vite handles the CSS. + ssr: { noExternal: ["@btravstack/theme"] }, + }, + + head: [ + ["link", { rel: "icon", type: "image/svg+xml", href: `${BASE}logo.svg` }], + ["meta", { name: "author", content: "Benoit TRAVERS" }], + ["meta", { name: "robots", content: "index, follow" }], + ["meta", { name: "application-name", content: "di" }], + [ + "meta", + { + name: "keywords", + content: + "typescript, dependency injection, di, ioc, inversion of control, hexagonal architecture, ports and adapters, modules, container, errors as values, result, unthrown", + }, + ], + // Open Graph — og:title/description/url are added per page in transformPageData + ["meta", { property: "og:type", content: "website" }], + ["meta", { property: "og:site_name", content: "di" }], + ["meta", { property: "og:locale", content: "en_US" }], + // The 1280x640 social card, on the same template as the other btravstack + // packages. SVG is not a valid og:image: X, Slack, LinkedIn and Discord all + // refuse to render one, which is why this is a rendered PNG rather than the + // logo itself. + ["meta", { property: "og:image", content: `${SITE_URL}og-di.png` }], + ["meta", { property: "og:image:type", content: "image/png" }], + // The card's true, measured size. + ["meta", { property: "og:image:width", content: "1280" }], + ["meta", { property: "og:image:height", content: "640" }], + [ + "meta", + { + property: "og:image:alt", + content: "di — a module-based dependency-injection container for TypeScript", + }, + ], + // Twitter Card + // summary_large_image is the wide banner; plain `summary` crops to a square + // thumbnail beside the title and wastes the card. + ["meta", { name: "twitter:card", content: "summary_large_image" }], + ["meta", { name: "twitter:image", content: `${SITE_URL}og-di.png` }], + [ + "meta", + { + name: "twitter:image:alt", + content: "di — a module-based dependency-injection container for TypeScript", + }, + ], + // JSON-LD structured data for better SEO + [ + "script", + { type: "application/ld+json" }, + JSON.stringify({ + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: "@btravstack/di", + description: SITE_DESCRIPTION, + applicationCategory: "DeveloperApplication", + operatingSystem: "Cross-platform", + offers: { + "@type": "Offer", + price: "0", + priceCurrency: "USD", + }, + url: SITE_URL, + author: { + "@type": "Person", + name: "Benoit TRAVERS", + }, + programmingLanguage: { + "@type": "ComputerLanguage", + name: "TypeScript", + url: "https://www.typescriptlang.org/", + }, + keywords: "TypeScript, dependency injection, hexagonal architecture, modules, Result", + }), + ], + // WebSite JSON-LD for proper site name display in Google search + [ + "script", + { type: "application/ld+json" }, + JSON.stringify({ + "@context": "https://schema.org", + "@type": "WebSite", + name: "di", + url: SITE_URL, + }), + ], + // Organization JSON-LD for logo display in Google search + [ + "script", + { type: "application/ld+json" }, + JSON.stringify({ + "@context": "https://schema.org", + "@type": "Organization", + name: "di", + url: SITE_URL, + logo: { + "@type": "ImageObject", + url: `${SITE_URL}logo.svg`, + }, + sameAs: ["https://github.com/btravstack/di"], + }), + ], + ], +}); diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css new file mode 100644 index 0000000..f8c19df --- /dev/null +++ b/docs/.vitepress/theme/custom.css @@ -0,0 +1,21 @@ +/* di accent — blue, the family's slot for this package (the logo's syringe + * barrel is drawn in it). Blue is plumbing: the connections behind the wall + * that everything visible runs on, which is what a wiring container is. The + * hex is the logo's own lighter blue — the barrel rim, the plunger and the + * needle hub — so chrome and artwork stay one color. + * + * Not the logo's deep #2a62b8, which this file used until the landing measured + * it: 3.22 against the dark card, where the other four package accents sit at + * 5.00–7.25. This one is 4.72 there and 7.10 as darkened text on white. It is + * --pkg-di in @btravstack/theme, so the landing panel and this site agree. + * @btravstack/theme derives the accent shades from this token. */ +:root { + --accent: #3e7fd4; +} + +/* Hero name in the package accent — the BtravStack multi-accent rule: the + * canvas stays neutral, the product glows in its own color (AA via + * --text-accent, which darkens on light). */ +:root:root { + --vp-home-hero-name-color: var(--text-accent); +} diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..d0c50ff --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,5 @@ +import Theme from "@btravstack/theme"; + +import "./custom.css"; + +export default Theme; diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..8545ca5 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,39 @@ +# API reference + +Generated from the source with [TypeDoc](https://typedoc.org/) — every exported +symbol, with its signature and TSDoc. + +- **[`@btravstack/di`](/api/di/)** — `Port`, `Provider`, `Module`, `Context`, + and the handful of type names (`AnyPort`, `ServiceOf`, `Scope`, + `ScopedOptions`, `PortClass`, `ManyPortClass`) the public surface carries. + +::: tip Looking for prose? +The generated pages document _signatures_. For what each member is **for**, +with worked examples, read the hand-written [Reference](/reference/ports); for +_why_ the surface is shaped this way, read the +[Explanation](/explanation/why-di). +::: + +## The shape of the surface + +Four values and six types — the whole of it: + +```ts +import { Module, Port, Provider, Context } from "@btravstack/di"; +import type { + AnyPort, + ManyPortClass, + PortClass, + Scope, + ScopedOptions, + ServiceOf, +} from "@btravstack/di"; +``` + +Operations hang off the values by convention — `Port.many`, `Provider.member`, +`Module.build`, `Module.scoped`, `Module.forkScope`, `Context.empty` — so the +import list stays this short. `Scope` is deliberately a **type-only** export +([why](/reference/ports#scope-type-only)), and `PortClass`/`ManyPortClass` +exist for consumers' declaration emit, not for hand-written code. Everything +else — the build pipeline, the scope machinery, the internal type helpers — is +implementation detail, not exported. diff --git a/docs/examples/hexagonal-order-api.md b/docs/examples/hexagonal-order-api.md new file mode 100644 index 0000000..1c57f5e --- /dev/null +++ b/docs/examples/hexagonal-order-api.md @@ -0,0 +1,99 @@ +--- +title: Hexagonal order API +description: A compiled hexagonal slice — application-named ports, a private connection pool behind a public repository, and one application module wired against a production adapter or an in-memory one. +--- + +# Hexagonal order API + +**Source:** +[`examples/hexagonal-order-api`](https://github.com/btravstack/di/tree/main/examples/hexagonal-order-api) + +The core story, compiled: one use case, one port for its repository, a +resourceful production adapter and a resource-free in-memory one, and a +composition seam generic enough to wire the same application against either. +It is the [tutorial](/tutorial/getting-started)'s and the +[adapter-swapping guide](/how-to/swap-an-adapter)'s material, as real code. + +## The layers + +**Ports** — named by the domain. `Pool` is the interesting one: a real +resource, and deliberately internal: + +```ts +export class Pool extends Port("Pool")<{ + readonly findById: (id: string) => Order | undefined; + readonly close: () => void; +}> {} + +export class OrderRepository extends Port("OrderRepository")<{ + readonly findById: (id: string) => AsyncResult; +}> {} +``` + +**Application** — `GetOrderInteractor` depends on `ServiceOf` +and never names an adapter. + +**Adapters** — the production module acquires the pool and exports only the +repository; the in-memory module provides the repository as a plain `value`: + +```ts +export const makePersistenceModule = () => + Module("Persistence")({ + imports: [ConfigModule], + provides: [ + Provider(Pool)([AppConfig], { acquire: openPool, release: (pool) => pool.close() }), + Provider(OrderRepository)([Pool], { sync: /* ... */ }), + ], + exports: [OrderRepository], // Pool stays internal + }); +``` + +**The seam** — generic in the adapter's channels, so both compositions reuse +it: + +```ts +export const makeAppModule = ( + persistence: Module, +) => + Module("App")({ + imports: [persistence], + provides: [ + Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor }), + ], + exports: [GetOrder], + }); +``` + +## What the spec proves + +`src/index.spec.ts` builds **both** graphs and exercises them end to end: the +production composition through `Module.scoped` — a found order comes back +`Ok`, a missing one comes back as a tagged `OrderNotFound` failure, and the +scope closes with zero teardown errors — and the in-memory composition +through `Module.build`. + +## What the type-level test pins + +Two guarantees in this example exist only at compile time, so +`src/index.test-d.ts` pins them with `@ts-expect-error`: + +- `ctx.get(Pool)` on a built application context **does not compile** — the + port class is exported (plain TypeScript `export`, so the test can name + it), but the DI module never lists it in `exports`, and that is the + boundary that counts. + ([Modules and privacy](/explanation/modules-and-privacy).) +- `Module.build(makeAppModule(makePersistenceModule()))` **does not + compile** — `Scope` is still in `Needs`, and only `Module.scoped` may + discharge it. Running that line for real would leak the very pool the test + exists to protect, which is exactly why it is a type-level test. + +The package also carries the repo's declaration-emit fixture +(`emit-guards.ts`, compiled by two TypeScript versions in `typecheck`) — a +library-maintenance concern, incidental to what the example teaches. + +## Run it + +```sh +pnpm --filter @btravstack/di-example-hexagonal-order-api test +pnpm --filter @btravstack/di-example-hexagonal-order-api typecheck +``` diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000..fbae95e --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,47 @@ +--- +title: Examples +description: Three runnable workspace packages — a hexagonal slice, per-request lifetimes, and a plugin registry — each compiled and spec-covered in CI. +--- + +# Examples + +Three small packages under +[`examples/`](https://github.com/btravstack/di/tree/main/examples), each +showing a different job `@btravstack/di` does — and, at the same time, +exercising the library end to end from a consumer's own workspace. + +| Package | Shows | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Hexagonal order API](/examples/hexagonal-order-api) | The core story: ports named by the application, a private internal beside a public surface, and one application module composed against a production adapter and an in-memory one. | +| [Request scope](/examples/request-scope) | Lifetime management: a pool acquired once under `Module.scoped`, and a `Module.forkScope`'d transaction per request over the built parent. | +| [Plugin registry](/examples/plugin-registry) | Multi-binding: a `Port.many` set port fed by contributions from two independent modules, collected and run together. | + +## Why these are tests, not just illustrations + +Unlike the fenced snippets in the guide, **this code compiles and its specs +run in CI**. Each `src/index.ts` reads as application code, not as a test +fixture — but its `src/index.spec.ts` asserts real behaviour: values returned +through a use case, the exact order resources release in, contributions +actually accumulating. + +Where a guarantee is compile-time only — an unexported port is unnameable +outside its module, a resourceful graph cannot be built with `Module.build` — +the assertion is a `@ts-expect-error` in a `*.test-d.ts` file instead. Proving +those at runtime would mean either asserting a falsehood (the built context +genuinely is [flat](/explanation/modules-and-privacy), so the "private" port +really is in it) or leaking a resource nothing would release (calling +`Module.build` for real on a graph that needs a scope). + +Nothing here is published: every package is private, depends on +`@btravstack/di` via `workspace:*`, and declares `unthrown` itself — a +[peer dependency](/explanation/peer-dependencies) is the consumer's to +install, and these packages are consumers. + +## Running them + +```sh +git clone https://github.com/btravstack/di.git && cd di +pnpm install +pnpm test # every example's specs, alongside the library's own +pnpm typecheck # includes the @ts-expect-error assertions +``` diff --git a/docs/examples/plugin-registry.md b/docs/examples/plugin-registry.md new file mode 100644 index 0000000..8f60bf6 --- /dev/null +++ b/docs/examples/plugin-registry.md @@ -0,0 +1,81 @@ +--- +title: Plugin registry +description: A compiled multi-binding example — a Port.many health-check registry fed by two independent modules, collected whole by the composition root, failures folded into the report. +--- + +# Plugin registry + +**Source:** +[`examples/plugin-registry`](https://github.com/btravstack/di/tree/main/examples/plugin-registry) + +Multi-binding, compiled: a health-check registry as a +[set port](/reference/ports#port-many-id-member), two plugin modules that +contribute to it without knowing of each other, and a composition root that +collects and runs the lot — the +[plugin-registry guide](/how-to/plugin-registry)'s material as real code. + +## The set port and its contributors + +```ts +export class HealthCheck extends Port.many("HealthCheck")<{ + readonly name: string; + readonly run: () => AsyncResult<"healthy", HealthCheckFailed>; +}> {} +``` + +Each plugin module provides its own service and contributes one member — +`DatabaseModule` a check over `Database`, `CacheModule` one over `Cache`: + +```ts +export const DatabaseModule = Module("Database")({ + provides: [ + Provider(Database)({ value: { ping: () => OkAsync("healthy") } }), + Provider.member(HealthCheck)([Database], { + sync: (db) => ({ name: "database", run: db.ping }), + }), + ], + exports: [Database, HealthCheck], +}); +``` + +The cache's `ping` is wired to fail — deliberately, because a registry that +only demonstrates the happy path would miss the design question below. + +The composition root imports both and re-exports them whole: + +```ts +export const AppModule = Module("App")({ + imports: [DatabaseModule, CacheModule], + exports: [DatabaseModule, CacheModule], +}); +``` + +## The design question the example answers + +What does "run them all" mean when one fails? That is the **composition +root's** decision, not the library's — `ctx.get(HealthCheck)` hands back +`readonly Member[]` and steps aside. This root folds: every check runs, and a +failing one becomes an `unhealthy` row in the report rather than stopping the +rest, with the `errCases`/`defect` split +[kept distinct](/explanation/failures-vs-defects) to the end — an expected +`HealthCheckFailed` carries its reason; an unexpected bug reads +`"unexpected failure"`. + +## What the spec proves + +`src/index.spec.ts` builds `AppModule` and asserts: + +- **Both contributions land on one port.** `ctx.get(HealthCheck)` yields the + cache and database members — contributed by modules that never import each + other. +- **The fold works.** The report holds `database: healthy` and + `cache: unhealthy` with the failure's reason — collected, not aborted. +- **Contribution is open.** A third module adding a `queue` check joins the + registry without either existing module changing — the list grows to + `["cache", "database", "queue"]`. + +## Run it + +```sh +pnpm --filter @btravstack/di-example-plugin-registry test +``` diff --git a/docs/examples/request-scope.md b/docs/examples/request-scope.md new file mode 100644 index 0000000..10934af --- /dev/null +++ b/docs/examples/request-scope.md @@ -0,0 +1,90 @@ +--- +title: Request scope +description: A compiled lifetime-management example — a pool acquired once under Module.scoped, a transaction per request via Module.forkScope, and the release order proven in a spec. +--- + +# Request scope + +**Source:** +[`examples/request-scope`](https://github.com/btravstack/di/tree/main/examples/request-scope) + +Lifetime management, compiled: an application-lifetime connection pool and a +per-request transaction, the pattern the +[per-request scope guide](/how-to/request-scope) teaches — with the guarantees +asserted as an event sequence rather than taken on faith. + +## The two modules + +The application module owns the pool; the request module owns the transaction +and depends on `ConnectionPool` **without providing it** — the declaration +that it expects to be forked over a parent that already has one: + +```ts +export const makeAppModule = (onEvent: (event: LifecycleEvent) => void) => + Module("App")({ + provides: [ + Provider(ConnectionPool)({ + acquire: () => { + onEvent("pool-acquired"); + return openPool(); + }, + release: () => void onEvent("pool-released"), + }), + ], + exports: [ConnectionPool], + }); + +export const makeRequestModule = (onEvent: (event: LifecycleEvent) => void) => + Module("Request")({ + provides: [ + Provider(Transaction)([ConnectionPool], { + acquire: (pool) => { + onEvent("txn-acquired"); + return beginTransaction(pool); + }, + release: () => void onEvent("txn-released"), + }), + ], + exports: [Transaction], + }); +``` + +One request is one fork: + +```ts +export const handleRequest = (appCtx, onEvent, work) => + Module.forkScope(appCtx, makeRequestModule(onEvent), (ctx) => + work(ctx.get(Transaction)), + ); +``` + +Threading lifecycle events through an `onEvent` callback — rather than +hard-coding `console.log` — is what lets the spec observe ordering without +reaching into anything private; a real caller would wire it to its logger. + +## What the spec proves + +`src/index.spec.ts` runs two requests inside one `Module.scoped` and asserts +on the recorded event stream: + +- **Each fork releases before the next request begins, and never the pool.** + After every `handleRequest` settles, the latest event is `txn-released` and + `pool-released` has not occurred. +- **The full ordering, exactly.** Once the outer scope closes, the stream + equals: pool acquired, first transaction acquired and released, second + transaction acquired and released, pool released — `pool-released` last, + [LIFO to the end](/explanation/scopes-and-resources). +- **Both forks drew on the same pool.** The transaction labels embed the pool + id, and both requests' labels share it — the parent was seeded into each + fork, not rebuilt. + +Everything this example demonstrates is observable at runtime, so it needs no +type-level test of its own; the compile-time side of forking — the parent's +channel satisfying the request module's unmet need, anything neither supplies +still gating — is pinned in the library's own `fork.test-d.ts`. + +## Run it + +```sh +pnpm --filter @btravstack/di-example-request-scope test +``` diff --git a/docs/explanation/compile-time-wiring.md b/docs/explanation/compile-time-wiring.md new file mode 100644 index 0000000..83fd9db --- /dev/null +++ b/docs/explanation/compile-time-wiring.md @@ -0,0 +1,108 @@ +--- +title: Compile errors, not surprises +description: How the Needs channel and a conditional rest parameter turn missing dependencies, leaked internals and forgotten scopes into errors at the call site — and where the compile-time line actually sits. +--- + +# Compile errors, not surprises + +The package's one-sentence promise: **every wiring mistake it can catch is a +compile error, and everything it cannot is caught before any factory runs.** +This page is about the first half — the machinery, and the exact location of +the line between the halves. + +## The ledger: `Needs` + +Every provider declares what it reads (`deps`) and, by choosing a +construction arm, whether it owes teardown (`Scope`). Every module aggregates +those into a `Needs` channel and subtracts what is available inside it — its +own provides, its imports' exports. What survives the subtraction propagates +upward, module by module, exactly like an unpaid balance: + +``` +Provider(OrderRepository)([Pool], ...) Needs: Pool +Persistence (provides Pool, exports OrderRepository) Needs: Scope ← Pool netted out; Pool's acquire owes Scope +App (imports Persistence) Needs: Scope ← still unpaid +``` + +Nothing checks anything yet — declaration is free. The check happens at the +one place a graph becomes running services. + +## The gate: an arity error + +Each [entry point](/reference/entry-points) ends in a conditional rest +parameter: + +```ts +build( + module: Module, + ..._missing: [N] extends [never] ? [] : [error: "UNSATISFIED DEPENDENCIES", missing: N] +) +``` + +When `Needs` is `never`, the tuple is empty and `Module.build(mod)` is an +ordinary call. When it is not, the call is missing two required arguments — +arguments no value can supply — and the error names both the literal +`"UNSATISFIED DEPENDENCIES"` and, in `missing`, the actual ports. The gate +differs per entry point only in what it is entitled to exclude first: +`scoped` excludes `Scope` (it opens a real scope), `forkScope` excludes +`Scope` and the parent context's channel (the parent supplies those). + +The same trick guards a related mistake at declaration time: an `exports` +entry must be provided or imported, so a module cannot claim a surface it +does not have. + +## Why the ledger cannot be cooked + +A gate is only as good as the numbers reaching it. The reason nothing between +declaration and build can drop an entry is variance — the package's one rule: + +> Capability channels are contravariant, so you may forget what you have. +> Obligation channels are covariant, so you may not forget what you owe. + +`Needs` and `E` sit in covariant (return) position. Assigning +`Module` where `Module` is expected asks the +compiler whether `Database` is assignable to `never` — it is not, and the +laundering fails. The opposite choice (contravariant, as function-parameter +position) would make that same assignment reduce to `never extends Database`, +trivially true, and an annotation as innocent as a helper's return type could +silently zero the ledger. The source pins this with type-level tests +(`*.test-d.ts`), because the guarantee lives entirely in the type system — +there is nothing to observe at runtime when it holds, only when it breaks. + +## Where the line actually is + +Honesty about the boundary matters more than the boast. Compile-time catches: + +- a dependency nothing in scope provides; +- using a private (unexported) port from outside its module — + [both naming its service and depending on it](/explanation/modules-and-privacy); +- exporting a port that is neither provided nor imported; +- a resourceful graph built without a scope, or an `onStop` hook that could + never run; +- construction arms mixed in one provider, and dependency/parameter type + mismatches. + +Beyond the line — visible only once the graph is assembled as **values** — sit +a dependency cycle, two providers for one port declared in modules that never +see each other, one id used as both set and ordinary port, and a provider +smuggled in behind a widened type. Those are the +[wiring defects](/reference/wiring-defects): checked before any factory runs, +zero side effects performed, reported on the defect channel. + +Two things bound the guarantee from below. Port identity is the **id string**, +so two port classes sharing an id are one key at runtime — a declaration bug +the types cannot see, warned about in development. And TypeScript offers +escape hatches (`as never`, `any`) that no library survives; the runtime +checks exist precisely so that even those degrade into a loud pre-construction +defect rather than silent misbehaviour. + +## Why an arity error, of all things + +The gate could have been a constraint (`N extends never`) on the module +parameter. The rest-parameter form was chosen because of what the _error_ +looks like: the constraint form reports a failure on the whole argument, +deep in a generic instantiation; the arity form reports "expected 3 +arguments, got 1" with a tuple whose labels spell `UNSATISFIED DEPENDENCIES` +and whose type names the missing ports — at the call site, in the order a +reader debugs. When a guarantee's only user interface is a compiler +diagnostic, the diagnostic is the design. diff --git a/docs/explanation/failures-vs-defects.md b/docs/explanation/failures-vs-defects.md new file mode 100644 index 0000000..74d4a13 --- /dev/null +++ b/docs/explanation/failures-vs-defects.md @@ -0,0 +1,87 @@ +--- +title: Failures vs defects +description: Nothing throws — construction failures are values on the error channel, wiring bugs are defects on their own channel, and why the two must never meet. +--- + +# Failures vs defects + +Every fallible operation in `di` returns an +[unthrown](https://github.com/btravstack/unthrown) `Result`. That sentence is +easy to read as a style preference — `Err` instead of `throw`. The design +carries more weight than that: it rests on keeping two kinds of "went wrong" +on **separate channels**, because they demand opposite responses. + +## Two kinds of wrong + +A **failure** is an outcome your program models. The database URL is unset; +the order does not exist; the connection could not be acquired. These appear +in a provider's `make`/`acquire` signature as typed errors, join the module's +`E` channel, and arrive at the entry point as the `Err` arm of its `Result` — +where the caller branches on them, because branching on them is the program: + +```ts +const outcome = await Module.scoped(App, use).match({ + ok: (value) => respond(value), + errCases: (m) => + m + .with(P.tag("ConfigError"), (e) => exitWith(`bad config: ${e.reason}`)) + .with(P.tag("OrderNotFound"), (e) => notFound(e.id)), + defect: (cause) => alertAndCrash(cause), +}); +``` + +A **defect** is a bug. A [dependency cycle](/reference/wiring-defects), two +providers for one port, an exception thrown inside a factory that promised a +`Result`. No branch of your program is the correct response to a bug — the +correct response is to surface it loudly, with its cause intact, to a human. +Defects travel on unthrown's separate defect channel and land in the one +`defect` arm. + +## Why the channels must not merge + +Merging them — the classic `catch (e)` that sees everything — forces every +caller to answer a question it cannot answer: _is this `e` an outcome or a +bug?_ Handle-everything turns wiring bugs into quietly-handled "errors" +(a cycle retried three times, then logged as a config problem); rethrow- +everything turns modeled outcomes into crashes. The type system can only help +with the failures half, and only if that half is closed: `E` is a union of +**named** errors, `errCases` matching is exhaustive, and adding a new failure +to a provider is a compile error at every unhandled match site. + +The defect channel is what keeps `E` honest. Because bugs have somewhere else +to go, nothing needs an `| unknown` escape hatch in the error union — and an +`E` without escape hatches is the difference between "the compiler checks my +error handling" and "the compiler checks the errors I remembered to list." + +## Where `di` draws the line + +The library's own sorting, concretely: + +| Event | Channel | +| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `make`/`acquire` returns `Err(...)` | Failure — the entry point's `E` | +| A factory **throws** despite promising a `Result` | Defect — a broken contract is a bug | +| Cycle, duplicate provider, provider for `Scope`, missing provider, set/ordinary conflict | Defect — [wiring bugs](/reference/wiring-defects), pre-construction | +| A `release`/`onStop` fails during close | Neither — [reported and swallowed](/explanation/scopes-and-resources), so teardown finishes and the true failure is never masked | +| Duplicate port id across two port classes | Development-time warning — the build cannot even see it | + +The last two rows are the instructive ones. Teardown failures get a +_reporting_ path rather than a channel, because propagating them would +overwrite the failure that caused the unwind — a masking bug baked into the +API. And wiring defects are raised **before any factory runs**, so a defect +never arrives with half a graph's side effects behind it. + +## What this asks of your code + +Inside providers, the contract is symmetrical: model your failures as tagged +errors and return them (`Err(new ConfigError({...}))`); let genuine bugs +escape as throws, and the boundary will file them as defects rather than +folding them into `E`. The repo enforces its half of the bargain mechanically +— unthrown's lint rules forbid stray throws outside documented defect paths — +and the same rules are available to consumers +([`@unthrown/oxlint`](https://github.com/btravstack/unthrown)). + +In tests, the channels stay distinct to the end: +[`@unthrown/vitest`](https://github.com/btravstack/unthrown)'s `toBeErr` +asserts a modeled failure, `toBeDefect` a bug — the library's own suite uses +`toBeDefect()` to pin that a cycle is a defect and never an `Err`. diff --git a/docs/explanation/modules-and-privacy.md b/docs/explanation/modules-and-privacy.md new file mode 100644 index 0000000..a5e1b95 --- /dev/null +++ b/docs/explanation/modules-and-privacy.md @@ -0,0 +1,87 @@ +--- +title: Modules and privacy +description: The built container is one flat map at runtime — module privacy is the type system withholding names, and why that is both enough and the point. +--- + +# Modules and privacy + +A module's `exports` list promises that its internals — the pool behind the +repository, the parsed config behind the client — cannot be reached from +outside. This page is about what enforces that promise, because the honest +answer is surprising: at runtime, nothing does. + +## The flat map + +Build any module tree and the result is a single map from port id to service. +`Persistence`'s private `Pool` is in it, right next to the exported +`OrderRepository` — there is nowhere else to put it; the repository's own +construction had to read it. No nested containers, no per-module resolution +scopes, no hierarchy to walk at `get` time. + +Runtime enforcement would mean wrapping that map per module boundary — +tracking, for every caller, which module's vantage point it holds. That is a +real design (nested injectors exist in other containers), and it buys real +costs: resolution walks a chain, module boundaries exist as objects with +lifetimes of their own, and the failure mode is a **runtime** "not visible +from here" — precisely the class of surprise this package exists to remove. + +## Privacy is a missing name + +`di` enforces the boundary one level earlier. The built `Context` is typed +by the module's `Exports` channel, and `ctx.get` only accepts ports in `X`: + +```ts +ctx.get(OrderRepository); // ✓ exported +ctx.get(Pool); // ✗ does not compile — Pool is not in X +``` + +The `Pool` service is present in the map; the **type that would let you ask +for it** is not in scope. The same withholding governs wiring: a provider in +`App` cannot list `Pool` in its `deps`, because what `App` can see — its own +provides, its imports' exports — does not include it, and the dependency +would surface as +["UNSATISFIED DEPENDENCIES"](/explanation/compile-time-wiring) at the entry +point. Privacy and dependency-checking are one mechanism, not two. + +This is privacy in exactly the sense TypeScript itself uses everywhere else: +`#private` fields aside, an unexported type, a module-private symbol, an +`internal` API are all names withheld rather than bytes hidden. `di` extends +the convention to wiring. + +## What it does not defend against + +A determined caller can cast — `ctx as any`, a hand-rolled object with the +right `portId` — and reach anything in the map. The boundary is not a +security perimeter, and does not try to be: the threat model is **accident**, +not adversary. What it prevents is the quiet coupling where application code +starts importing an adapter's internals because they happened to be +reachable, and a year later the adapter cannot change without breaking its +callers. + +Because the guarantee lives entirely in the types, its regression tests do +too: the [hexagonal-order-api example](/examples/hexagonal-order-api) pins +"`ctx.get(Pool)` does not compile" with a `@ts-expect-error` in a +`.test-d.ts` file. A runtime test could only prove the opposite — the flat +map genuinely holds the pool — which is true, and not the point. + +## What the flat map buys in exchange + +- **`get` is a map lookup.** No chain to walk, no vantage-point bookkeeping, + no allocation per boundary. +- **One instance per provider, ever.** A diamond — two modules importing the + same `Config` — cannot yield two configs, because de-duplication happens on + provider identity before construction and the result lands in one map. + Nested-container designs have to work to get this right; here it falls out. +- **Whole-module re-export is free.** `exports: [DatabaseModule]` forwards a + type; nothing is copied or proxied at runtime. +- **The type-level model stays honest.** `Available` (what a module can see) + and `Exports` (what it shows) are set computations over port types — + checkable, testable, and identical in shape to what the runtime actually + does with ids in a map. + +The trade, stated once: `di`'s module boundary is exactly as strong as the +type system's reach, in exchange for a runtime with nothing in it to +misbehave. Where the types end — casts, duplicate ids, widening — +[pre-construction defect checks](/reference/wiring-defects) stand behind +them; what those catch is wiring bugs, not privacy violations, because past +the types a privacy violation is indistinguishable from intent. diff --git a/docs/explanation/peer-dependencies.md b/docs/explanation/peer-dependencies.md new file mode 100644 index 0000000..8ca77ad --- /dev/null +++ b/docs/explanation/peer-dependencies.md @@ -0,0 +1,58 @@ +--- +title: Peer dependencies +description: Why unthrown is a peer dependency rather than a regular one — identity, instanceof, and a Result type that must be yours. +--- + +# Peer dependencies + +```sh +pnpm add @btravstack/di unthrown +``` + +`unthrown` is declared as a **peer** dependency, so installing `di` means +installing both. This page is the reasoning. + +## The `Result` must be _your_ `Result` + +Every fallible operation in `di` returns an unthrown `Result`, and — the +half that matters — your code _continues_ those values: a `make` you write +returns a `Result` that `di`'s build pipeline `flatMap`s; the `Result` an +entry point hands back is one your code `match`es. The values cross the +package boundary in **both directions**, constantly. + +Were `unthrown` a regular dependency, your application and `di` could resolve +**two copies** of it — different major versions declared, or a package +manager that deduplicates less aggressively than pnpm. Two copies means two +`Result` identities. TypeScript's structural typing might forgive some of it; +runtime behaviour will not: combinators from one copy receiving the other's +values, `instanceof`-based narrowing quietly false, two `TaggedError` worlds +whose pattern tags never match. The failure mode is not an error message — it +is a `match` arm that silently never fires. + +A peer dependency is the package-manager-level statement that rules this out: +**there is one `unthrown` in this application, owned by the application, and +`di` links against it.** Your lockfile pins its version once; `di`'s declared +range (`^5.0.0`) only constrains compatibility. + +## Why not zero dependencies instead + +The alternative — `di` shipping its own internal result type, or throwing +like everyone else — was rejected because the error channel is not an +implementation detail here; it is half the design. +[`E` is a typed channel](/explanation/failures-vs-defects) your `make` +functions feed and your `match` sites consume, and +[defects](/reference/wiring-defects) need somewhere to go that is not your +error union. Reinventing that inside `di` would give it a private dialect of +the discipline the rest of a btravstack application (and +[`entity`](https://btravstack.github.io/entity/), which makes the same +choice for the same reason) already speaks. Sharing the vocabulary is the +point; peering is how you share a vocabulary safely. + +## Why the examples install it themselves + +Each [example package](/examples/) declares `unthrown` in its own +`package.json` even though `di` already requires it. That is the peer +contract seen from the consumer's side: a peer is **the consumer's +dependency**, not a transitive one, and with pnpm's strict linking an +undeclared peer is simply not importable. Your application does the same — +which is why the install line at the top of every guide names both packages. diff --git a/docs/explanation/scopes-and-resources.md b/docs/explanation/scopes-and-resources.md new file mode 100644 index 0000000..1b822a5 --- /dev/null +++ b/docs/explanation/scopes-and-resources.md @@ -0,0 +1,96 @@ +--- +title: Scopes and resource safety +description: Why Scope is a phantom port rather than a runtime object you pass around — and the close-on-every-path, LIFO, never-mask-the-failure guarantees the scope actually makes. +--- + +# Scopes and resource safety + +A connection pool must be closed; a file handle must be released; a +subscription must be cancelled. Every DI container meets this requirement +somewhere. `di`'s answer has two unusual properties: forgetting teardown is a +**compile error**, and there is no scope object in your code at all. + +## `Scope` is a debt, not a thing + +Choosing the `acquire`/`release` arm (or an `onStop` hook) does not hand you +a scope to manage. It records a **debt** in the provider's `Needs` channel: +the phantom port `Scope`, a type with no service behind it — nothing +constructs one, nothing can `get` it. Like any other unmet need, +[it propagates](/explanation/compile-time-wiring) through every module that +imports the resourceful one, until it reaches an entry point. + +Two entry points can pay it. [`Module.scoped`](/reference/entry-points#module-scoped-module-use-options) +and [`Module.forkScope`](/reference/entry-points#module-forkscope-parent-module-use-options) +open a real scope, run construction and your callback inside it, and close it +before their own result settles — so they exclude `Scope` from the gate. +`Module.build` opens nothing, so it excludes nothing, and a resourceful graph +reaching it is an "UNSATISFIED DEPENDENCIES" error at the call site. The leak +is refused before it exists. + +Making `Scope` a port — rather than, say, a boolean flag on the module type — +is what lets the existing machinery do all the work: propagation is the +`Needs` union it already computes, discharge is an `Exclude`, and the gate is +the same gate. One concept, no parallel channel. + +The phantom needs one defence the types cannot give it: `Provider(Scope)(...)` +would register a service for a port that must never have one, and a widened +type could smuggle that past any compile-time guard. The value is therefore +**not exported** — `Scope` is a type-only export — and a +[runtime defect check](/reference/wiring-defects) on the port id backs even +that. + +## What the scope guarantees + +Behind the entry points sits one small machine, with four properties the test +suite pins: + +**Closed on every path.** Construction succeeded and `use` succeeded; +construction succeeded and `use` failed; construction failed halfway with +three of five resources acquired — in each case the scope closes, releasing +exactly what was acquired, before the entry point's result settles. Not +after: a caller that has its result can be certain teardown already ran. + +**LIFO.** Finalisers run in reverse acquisition order — the transaction +before the connection, the connection before the pool — because each resource +may depend on those acquired before it still being alive. Teardown is +sequential for the same reason: a finaliser is not started until the one +after it (in acquisition order) has settled. + +**A failing finaliser never masks the real failure.** If `use` failed and, on +the way down, a release also failed, the caller must see `use`'s failure — +the cause — not the release's — the symptom. So finaliser failures are +reported (to [`onTeardownError`](/reference/entry-points#scopedoptions), port-tagged) +and swallowed: close continues past them to the remaining finalisers, and the +entry point's result is never altered by one. Even a throwing _reporter_ is +swallowed; there is nowhere left to report a broken reporter to. + +**Close is idempotent.** One settle, one close; a second close is a no-op. + +## Why the callback shape + +`Module.scoped(module, use)` insists your work happen inside a callback, +rather than returning a context-plus-`close()` pair: + +```ts +const result = await Module.scoped(App, (ctx) => run(ctx)); // teardown already done +``` + +A returned `close()` is a leak with a delay — every early return, throw, or +forgotten `finally` between build and close leaves resources held. The +callback is the lexical guarantee `finally` only approximates: there is no +program text where the graph is up and teardown is not already scheduled on +every exit path. The cost is honest and visible — a `Context` must not +outlive its callback, so long-lived holds belong in long-lived scopes (a +server's `scoped` spans the server's life, with +[per-request forks](/how-to/request-scope) inside it). + +## Forks: scopes that nest without owning each other + +`Module.forkScope` layers a short-lived scope over a built parent. The +load-bearing detail is what it does **not** do: the parent's services are +seeded in, not re-constructed, so none of the parent's finalisers register on +the fork. Closing a fork releases only the fork's own acquisitions — the +transaction, never the pool — and concurrent sibling forks share the parent +without sharing anything else. Lifetime nesting (fork inside `scoped`) comes +from the call structure itself: the parent's close cannot run until its +callback — which contains every fork — has settled. diff --git a/docs/explanation/why-di.md b/docs/explanation/why-di.md new file mode 100644 index 0000000..eaaf929 --- /dev/null +++ b/docs/explanation/why-di.md @@ -0,0 +1,97 @@ +--- +title: Why di? +description: Ports as the application's own vocabulary, hexagonal architecture without decorators or reflection, and a container whose wiring mistakes are compile errors — the design, and what it refuses to do. +--- + +# Why di? + +Dependency injection in TypeScript usually arrives as machinery: decorators, +reflection metadata, string tokens, a container you query at runtime and hope. +`di` starts from a different question — **what would it take for wiring +mistakes to be compile errors?** — and lets the answer shape everything else. + +## Ports are the application's vocabulary + +The oldest idea here is hexagonal architecture's: an application defines +**ports** for what it needs, and adapters implement them at the edge. The +detail that matters is _who names things_. A port is named by the domain — +`OrderRepository`, `Clock`, `Mailer` — never by what happens to implement it. +The application never says "Postgres"; an adapter module says it once, in a +place the application cannot see. + +`di` makes that discipline structural rather than aspirational: + +- `Port(id)` is **nominal**. Two ports sharing a shape are still + different types, so a `Cache` never satisfies a `SessionStore` by + coincidence of structure. +- `ServiceOf

` types application code against the port, so a use case's + constructor never imports an adapter. +- A module's `exports` list decides what the outside may name — the adapter's + internals are not merely undocumented, they are + [untypeable outside it](/explanation/modules-and-privacy). + +## The obligations live in the types + +The design's center of gravity is two phantom channels every provider and +module carries: `E`, every way construction can fail, and `Needs`, everything +still unmet. They obey one rule, stated once in the source and enforced by +variance: + +> Capability channels are contravariant, so you may forget what you have. +> Obligation channels are covariant, so you may not forget what you owe. + +You can annotate a module as exporting less than it does. You cannot annotate +away an error case, an unmet dependency, or the `Scope` a resourceful +provider owes. That asymmetry is what lets the +["UNSATISFIED DEPENDENCIES" gate](/reference/entry-points#the-gate) at each +entry point be trustworthy: nothing between declaration and build can launder +an obligation out of view. + +What the types cannot see — a cycle, a duplicate provider registered in two +modules that never meet — is checked +[before any factory runs](/reference/wiring-defects), and arrives as a defect, +distinct from the failures your code models. +([Failures vs defects](/explanation/failures-vs-defects).) + +## What it refuses to do + +Most of the design is refusals, each buying a guarantee: + +- **No decorators, no reflection, no metadata.** Wiring is plain values and + plain types, so it survives every bundler, minifier and runtime unchanged — + and the compiler can actually check it. `emitDecoratorMetadata` never + enters the picture. +- **No runtime lookup surprises.** `ctx.get` only compiles for ports the + context's type carries, so "token not found" is not an error your users can + meet — its runtime twin exists only as a backstop behind widened types. +- **No throwing.** Every fallible operation returns an + [unthrown](https://github.com/btravstack/unthrown) `Result`. A construction + failure is a value you match on; a wiring bug is a defect on its own + channel; your process never learns about either from an uncaught exception. +- **No mutable container.** There is no `container.register(...)` to call in + test setup and forget in teardown. A module is an immutable declaration; + swapping an adapter is [building a different composition](/how-to/swap-an-adapter), + checked like any other. +- **No scope you can forget.** Owning a resource puts `Scope` in `Needs`; + only [`Module.scoped`](/reference/entry-points#module-scoped-module-use-options) + discharges it. Forgetting teardown is a compile error, not a leak found in + production. ([Scopes and resource safety](/explanation/scopes-and-resources).) + +## The cost, stated plainly + +The types work hard, and it shows at the edges: a wiring mistake surfaces as +an arity error naming "UNSATISFIED DEPENDENCIES" rather than a friendly +sentence, and hovering a large module shows real channel unions. The library +is also deliberately small — one construction family, one module algebra, +three entry points, and [one name per concept](https://github.com/btravstack/di/blob/main/CONTRIBUTING.md). +If you want conditional registration DSLs, interceptors, or property +injection, this is the wrong library on purpose. + +## Where it sits + +`di` supersedes `demesne`, an earlier layer-based design by the same author, +and pairs naturally with the other btravstack packages — +[`entity`](https://btravstack.github.io/entity/) for the domain objects the +ports traffic in, `unthrown` for the `Result` discipline both share. None of +that is required: the library has one runtime peer, `unthrown`, and no +opinion about what your services look like. diff --git a/docs/how-to/manage-a-resource.md b/docs/how-to/manage-a-resource.md new file mode 100644 index 0000000..e1aedf9 --- /dev/null +++ b/docs/how-to/manage-a-resource.md @@ -0,0 +1,114 @@ +--- +title: Manage a resource's lifetime +description: Acquire a connection once, release it on every path out, and run start/stop hooks at the right moments — with the compiler refusing any graph that could leak. +--- + +# Manage a resource's lifetime + +**Goal:** a service that must be torn down — a connection pool, a file handle, +a subscription — acquired once, released exactly once, on every path out of the +program. + +## Declare the resourceful provider + +`acquire` and `release` come as a pair — there is no `release` with nothing to +release, nor an `acquire` never torn down: + +```ts +const Persistence = Module("Persistence")({ + imports: [Config], + provides: [ + Provider(Database)([AppConfig], { + acquire: (config) => openPool(config.dbUrl), // Result | AsyncResult — may fail + release: (pool) => pool.close(), // void | Promise — may be async + }), + ], + exports: [Database], +}); +``` + +`acquire` is `make`'s fallible-construction twin: it returns a `Result` (or +`AsyncResult`), and a failed acquisition surfaces through the module's error +channel like any other construction failure. + +Choosing this arm puts the phantom `Scope` requirement into the provider's +`Needs`. That is the whole mechanism: `Needs` propagates through every module +that imports this one, and only an entry point that actually opens a scope can +discharge it. + +## Build through `Module.scoped` + +```ts +const result = await Module.scoped(App, (ctx) => runServer(ctx)); +``` + +`Module.scoped` opens a scope, builds the graph, runs your callback, and +closes the scope before its own result resolves. The close runs on **every** +path: + +- your callback succeeded — released after it settles; +- your callback failed — released, and the failure passed through untouched; +- construction itself failed halfway — everything acquired **before** the + failure is released, in reverse order. + +`Module.build` — no scope, no teardown — refuses the graph at compile time +("UNSATISFIED DEPENDENCIES", with `Scope` named as the missing piece). + +## Release order and failing finalisers + +Finalisers run **LIFO** — reverse acquisition order — so a resource is always +released before whatever it was built from: the transaction before the +connection, the connection before the pool. + +A finaliser that itself fails is **reported and swallowed**, never rethrown: +shutdown is not abandoned halfway, and a failed close never masks the failure +that triggered the unwind. Route the report where you want it with +`ScopedOptions`: + +```ts +await Module.scoped(App, use, { + onTeardownError: (portId, cause) => + logger.error({ portId, cause }, "teardown failed"), +}); +``` + +The default reporter writes to `console.error`, tagged with the port id. + +## `onStart` and `onStop` + +Every arm of the construction family — not just `acquire`/`release` — accepts +optional lifecycle hooks in the same options literal: + +```ts +Provider(Cache)([Config], { + make: (config) => connectCache(config), + onStart: (cache) => cache.warm(), // after the WHOLE graph is built + onStop: (cache) => cache.flush(), // during teardown, LIFO with releases +}); +``` + +- `onStart` fires only once the **entire** graph has finished constructing — + never while some other provider is still mid-construction — in declaration + order. +- `onStop` is teardown, so declaring one puts `Scope` in `Needs` exactly as + `release` does: only a scope can run it, and the compiler routes the module + to `Module.scoped` accordingly. + +Use `release` for undoing an acquisition; use `onStop` for shutdown work on a +service you did not acquire (flushing a cache built with `make`, stopping a +consumer built with `class`). + +## Verify the ordering, if you need to see it + +The [request-scope example](/examples/request-scope) threads a `onEvent` +callback through its providers and asserts the exact sequence — +`pool-acquired → txn-acquired → txn-released → pool-released` — in its spec, +so the guarantee above is proven in CI, not just stated here. + +## Related + +- [Open a per-request scope](/how-to/request-scope) — short-lived resources + over a long-lived parent. +- [Scopes and resource safety](/explanation/scopes-and-resources) — why `Scope` + is a phantom port, and what that buys. +- [Providers](/reference/providers) — the full construction family. diff --git a/docs/how-to/plugin-registry.md b/docs/how-to/plugin-registry.md new file mode 100644 index 0000000..6b7f847 --- /dev/null +++ b/docs/how-to/plugin-registry.md @@ -0,0 +1,129 @@ +--- +title: Build a plugin registry +description: Let independent modules contribute members to one set port with Port.many and Provider.member — health checks, event handlers, plugins — collected by the composition root. +--- + +# Build a plugin registry + +**Goal:** several modules, none knowing about the others, each contributing an +entry — a health check, an event handler, a plugin — to one list the +composition root collects whole. + +The compiled, spec-covered version of this page is the +[plugin-registry example](/examples/plugin-registry). + +## Declare a set port + +`Port.many` fixes the **member** shape — what one contribution looks like. The +port's own service, what `Context.get` actually returns, is the whole +accumulated list, `readonly Member[]`: + +```ts +class HealthCheck extends Port.many("HealthCheck")<{ + readonly name: string; + readonly run: () => AsyncResult<"healthy", HealthCheckFailed>; +}> {} +``` + +## Contribute from independent modules + +`Provider.member` contributes one member. Several providers targeting one set +port is the point, not a collision — the duplicate-provider defect that guards +ordinary ports does not apply here: + +```ts +const DatabaseModule = Module("Database")({ + provides: [ + Provider(Database)({ value: { ping: () => OkAsync("healthy") } }), + Provider.member(HealthCheck)([Database], { + sync: (db) => ({ name: "database", run: db.ping }), + }), + ], + exports: [Database, HealthCheck], +}); + +const CacheModule = Module("Cache")({ + provides: [ + Provider(Cache)({ value: cacheService }), + Provider.member(HealthCheck)([Cache], { + sync: (cache) => ({ name: "cache", run: cache.ping }), + }), + ], + exports: [Cache, HealthCheck], +}); +``` + +Neither module imports the other, and neither knows how many other contributors +exist. `Provider.member` takes the same construction family an ordinary +provider does — `value`, `sync`, `make`, `class`, `acquire`/`release` — so a +member may have dependencies, fail to construct, or own a resource, exactly +like any other service. + +## Collect at the composition root + +```ts +const AppModule = Module("App")({ + imports: [DatabaseModule, CacheModule], + exports: [DatabaseModule, CacheModule], // whole-module re-export +}); + +await Module.build(AppModule).flatMap((ctx) => { + const checks = ctx.get(HealthCheck); // readonly Member[] — BOTH contributions + return runAll(checks); +}); +``` + +`ctx.get` on a set port returns every contribution, accumulated across module +boundaries, in a stable order (declaration order within each level of the +build). A set port with no contributors is not an error — the list is empty. + +Note the whole-module re-export: `exports: [DatabaseModule, CacheModule]` +re-exports everything those imports export, which is how `HealthCheck` (and +`Database`, and `Cache`) stay nameable on the built context without `App` +listing each port again. + +## Run the contributions your way + +What "run them all" means — fail fast, fold failures into a report, race them — +is the composition root's decision, not the library's. The example folds: + +```ts +const runHealthChecks = (checks: ServiceOf) => + Promise.all( + checks.map((check) => + check.run().match({ + ok: () => ({ name: check.name, status: "healthy" as const }), + errCases: (m) => + m.with(P.tag("HealthCheckFailed"), (e) => ({ + name: check.name, + status: "unhealthy" as const, + reason: e.reason, + })), + defect: () => ({ + name: check.name, + status: "unhealthy" as const, + reason: "unexpected", + }), + }), + ), + ); +``` + +A failing check is data the caller wants, not a reason to stop asking the +others. + +## One port id, one kind + +A port id must be one thing or the other everywhere: registering the same id +through `Provider(...)` in one place and `Provider.member(...)` in another is a +[wiring defect](/reference/wiring-defects), caught before any factory runs. +The type system already steers you right — `Provider` on a set port and +`Provider.member` on an ordinary one both fail to compile — and the runtime +check backs it up against widened types. + +## Related + +- [Ports](/reference/ports) — `Port.many` beside ordinary `Port`. +- [Wiring defects](/reference/wiring-defects) — every pre-construction check. +- [Plugin registry, the example](/examples/plugin-registry) — accumulation + across modules, proven in a spec. diff --git a/docs/how-to/private-ports.md b/docs/how-to/private-ports.md new file mode 100644 index 0000000..3f61494 --- /dev/null +++ b/docs/how-to/private-ports.md @@ -0,0 +1,109 @@ +--- +title: Keep a port private +description: Use a module's exports list as the visibility boundary — internal ports stay unnameable outside the module, enforced at compile time, even though the built container is one flat map. +--- + +# Keep a port private + +**Goal:** a module with internals — a connection pool, a raw client, a parsed +config — that nothing outside the module can reach, with the boundary enforced +by the compiler rather than a naming convention. + +## Export the surface, withhold the rest + +Privacy in `di` is not a keyword; it is the `exports` list. Everything a +module provides but does not export is internal: + +```ts +const Persistence = Module("Persistence")({ + imports: [Config], + provides: [ + Provider(Pool)([AppConfig], { + acquire: openPool, + release: (pool) => pool.close(), + }), + Provider(OrderRepository)([Pool], { + sync: (pool) => makeRepository(pool), + }), + ], + exports: [OrderRepository], // Pool and AppConfig: not listed, not visible. +}); +``` + +Any module importing `Persistence` sees exactly one port: + +```ts +const App = Module("App")({ + imports: [Persistence], + provides: [ + Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor }), // ✓ + Provider(Audit)([Pool], { sync: makeAudit }), // ✗ no provider for Pool here + ], + exports: [GetOrder], +}); +``` + +The second provider does not wire: `Pool` is not among what `App` can see +(its own provides plus its imports' exports), so the dependency stays unmet — +and surfaces as the "UNSATISFIED DEPENDENCIES" compile error at the build call. + +And on a built context: + +```ts +const ctx = await Module.scoped(App, use); /* ... */ +ctx.get(Pool); // ✗ does not compile +ctx.get(GetOrder); // ✓ +``` + +## What makes this work — and what it is not + +The built container is a **single flat map at runtime**. `Pool`'s service is +genuinely in it — there is nowhere else to put it — so this is not runtime +sandboxing. What `exports` withholds is the _type_: the built `Context`'s +channel contains only the exported ports, so `ctx.get(Pool)` has no overload +that accepts it. The port class itself may even be a plain TypeScript `export` +(so tests can name it); what matters is the DI module's `exports` list. + +The [hexagonal-order-api example](/examples/hexagonal-order-api) pins exactly +this with a `@ts-expect-error` in its `index.test-d.ts` — the guarantee is +compile-time-only, so the proof is a type-level test, not a runtime assertion. +([Why that split](/explanation/modules-and-privacy).) + +## Exports are checked, not declarative + +The `exports` list cannot lie: + +```ts +Module("Persistence")({ + provides: [Provider(OrderRepository)([Pool], { sync: makeRepository })], + exports: [OrderRepository, Metrics], // ✗ Metrics: neither provided nor imported +}); +``` + +An export must be **available** — provided by this module, or exported by one +of its imports. Exporting something never imported, or re-exporting a +neighbour's internal, is a compile error at the declaration, not a silent +no-op. + +## Re-export a whole module + +Listing an imported module in `exports` re-exports its whole public surface — +useful for a facade module that groups plugins without re-listing every port: + +```ts +const AppModule = Module("App")({ + imports: [DatabaseModule, CacheModule], + exports: [DatabaseModule, CacheModule], +}); +``` + +What stays private in `DatabaseModule` stays private here too: a whole-module +re-export forwards the module's `exports`, not its internals. + +## Related + +- [Modules](/reference/modules) — `imports`/`provides`/`exports`, precisely. +- [Modules and privacy](/explanation/modules-and-privacy) — the flat map, the + withheld type, and why that is enough. +- [Swap an adapter for tests](/how-to/swap-an-adapter) — privacy is what makes + the adapters interchangeable. diff --git a/docs/how-to/request-scope.md b/docs/how-to/request-scope.md new file mode 100644 index 0000000..95e50e0 --- /dev/null +++ b/docs/how-to/request-scope.md @@ -0,0 +1,116 @@ +--- +title: Open a per-request scope +description: Layer a short-lived scope — a transaction, a request id — over a long-lived application context with Module.forkScope, releasing per-request resources without touching the parent's. +--- + +# Open a per-request scope + +**Goal:** an application-lifetime pool, plus a transaction that lives exactly +as long as one request — acquired from the pool the parent already built, +released when the request settles, never outliving it, never taking the pool +down with it. + +The compiled, spec-covered version of this page is the +[request-scope example](/examples/request-scope). + +## The two lifetimes, as two modules + +The application module owns the pool: + +```ts +const AppModule = Module("App")({ + provides: [ + Provider(ConnectionPool)({ + acquire: () => openPool(), + release: (pool) => pool.close(), + }), + ], + exports: [ConnectionPool], +}); +``` + +The request module owns the transaction — and depends on `ConnectionPool` +**without providing it**: + +```ts +const RequestModule = Module("Request")({ + provides: [ + Provider(Transaction)([ConnectionPool], { + acquire: (pool) => beginTransaction(pool), + release: (txn) => txn.rollbackIfOpen(), + }), + ], + exports: [Transaction], +}); +``` + +On its own, `RequestModule` has an unmet need: nothing in it supplies +`ConnectionPool`. That is not a bug — it is the declaration that this module +expects to be forked over a parent that already has one. + +## Fork per request + +`Module.scoped` holds the application scope open for as long as the server +runs; inside it, `Module.forkScope` opens one short-lived scope per request +over the already-built parent `Context`: + +```ts +await Module.scoped(AppModule, (appCtx) => + serve((request) => + Module.forkScope(appCtx, RequestModule, (ctx) => + handle(request, ctx.get(Transaction)), + ), + ), +); +``` + +`forkScope`'s callback sees a `Context` carrying **both** channels — the +parent's exports and the request module's — so `ctx.get(ConnectionPool)` and +`ctx.get(Transaction)` both compile inside it. + +## What the fork guarantees + +- **The parent satisfies the fork's needs.** The unmet `ConnectionPool` above + is subtracted by the parent context's channel; only a need that _neither_ + the request module _nor_ the parent satisfies is a compile error + ("UNSATISFIED DEPENDENCIES", naming exactly what is missing). +- **Closing the fork releases only what the fork acquired.** The parent's + services were never constructed by this call, so none of the parent's + finalisers are registered on the fork's scope. The transaction is released + when the request settles; the pool stays up for the next request, and for a + second, concurrent fork. +- **Forks release before the parent.** The pool's own `release` runs only + when the enclosing `Module.scoped` closes, after your server loop returns — + by which point every request scope has already settled. The + [example's spec](/examples/request-scope) asserts that exact order. + +## Per-request values that are not resources + +The forked module is an ordinary module — a request id or a deadline goes in +as a plain provider, no teardown involved: + +```ts +const makeRequestModule = (requestId: string) => + Module("Request")({ + provides: [ + Provider(RequestId)({ value: requestId }), + Provider(Transaction)([ConnectionPool], { + acquire: (pool) => beginTransaction(pool), + release: (txn) => txn.rollbackIfOpen(), + }), + ], + exports: [RequestId, Transaction], + }); +``` + +Building the module fresh per request is cheap — modules are declarations, not +containers; construction happens inside the fork. + +## Related + +- [Manage a resource's lifetime](/how-to/manage-a-resource) — the guarantees + each individual scope makes. +- [Entry points](/reference/entry-points) — `Module.forkScope`'s exact + signature and gate. +- [Request scope, the example](/examples/request-scope) — release order proven + in a spec. diff --git a/docs/how-to/swap-an-adapter.md b/docs/how-to/swap-an-adapter.md new file mode 100644 index 0000000..6e43b96 --- /dev/null +++ b/docs/how-to/swap-an-adapter.md @@ -0,0 +1,142 @@ +--- +title: Swap an adapter for tests +description: Wire one application module against a production adapter or an in-memory one, with the type system choosing the entry point each graph is allowed to use. +--- + +# Swap an adapter for tests + +**Goal:** one application module, two persistence adapters — a production one +backed by a real pool, an in-memory one for tests — swappable at the +composition root without touching the application. + +This is the seam `di` is built around. The full version, compiled and +exercised end to end, is the +[hexagonal-order-api example](/examples/hexagonal-order-api). + +## The port both adapters implement + +```ts +class OrderRepository extends Port("OrderRepository")<{ + readonly findById: (id: string) => AsyncResult; +}> {} +``` + +The application module depends on this port and nothing else — so any module +that exports it will do. + +## The production adapter + +Resourceful: the pool is acquired once and must be released, so this module's +`Needs` carries `Scope`: + +```ts +const makePersistenceModule = () => + Module("Persistence")({ + imports: [ConfigModule], + provides: [ + Provider(Pool)([AppConfig], { + acquire: openPool, + release: (pool) => pool.close(), + }), + Provider(OrderRepository)([Pool], { + sync: (pool) => ({ + findById: (id) => { + const row = pool.findById(id); + return ( + row === undefined ? Err(new OrderNotFound({ id })) : Ok(row) + ).toAsync(); + }, + }), + }), + ], + exports: [OrderRepository], // Pool stays internal. + }); +``` + +## The test adapter + +Nothing to acquire, nothing to release — `Needs` is `never`: + +```ts +const InMemoryPersistenceModule = Module("InMemoryPersistence")({ + provides: [ + Provider(OrderRepository)({ + value: { findById: (id) => Ok({ id, total: 99 }).toAsync() }, + }), + ], + exports: [OrderRepository], +}); +``` + +## The seam: an application module generic in its adapter + +Make the application module a function of the persistence module, generic in +that module's own error and requirement channels: + +```ts +const makeAppModule = (persistence: Module) => + Module("App")({ + imports: [persistence], + provides: [ + Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor }), + ], + exports: [GetOrder], + }); +``` + +The `Module` constraint says: any module whose exports +include `OrderRepository`, whatever it might fail with, whatever it still +needs. Both channels flow through into the resulting application module — which +is exactly what makes the next step work. + +## Composition roots: the types pick the entry point + +```ts +// Production: the graph needs Scope (Pool is resourceful), so only +// Module.scoped — which opens a scope and guarantees its close — accepts it. +const result = await Module.scoped( + makeAppModule(makePersistenceModule()), + (ctx) => ctx.get(GetOrder).execute("o-1"), +); + +// Tests: nothing resourceful, Needs is `never`, Module.build accepts it. +const built = await Module.build(makeAppModule(InMemoryPersistenceModule)); +``` + +The wrong pairing does not compile: + +```ts +await Module.build(makeAppModule(makePersistenceModule())); // ✗ UNSATISFIED DEPENDENCIES +``` + +`Scope` is still in `Needs`, so the call's arity gate rejects it before +anything runs. There is no convention to remember: a test that quietly wires +the production adapter into a scope-less build breaks at compile time, not in +CI at midnight. + +Passing the in-memory module to `Module.scoped` is fine, by contrast — `Scope` +is simply absent from its `Needs`, and a scope that releases nothing is +harmless. + +## In a test file + +```ts +it("returns the order", async () => { + const result = await Module.build( + makeAppModule(InMemoryPersistenceModule), + ).flatMap((ctx) => ctx.get(GetOrder).execute("o-1")); + await expect(result).toBeOk({ id: "o-1", total: 99 }); +}); +``` + +(`toBeOk` is [`@unthrown/vitest`](https://github.com/btravstack/unthrown)'s +matcher — the assertion style the library's own suite uses.) + +## Related + +- [Keep a port private](/how-to/private-ports) — why `Pool` never leaks out of + the production module. +- [Manage a resource's lifetime](/how-to/manage-a-resource) — what + `acquire`/`release` guarantee. +- [Entry points](/reference/entry-points) — `Module.build` and `Module.scoped`, + precisely. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..234ede9 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,98 @@ +--- +layout: home +title: di — a module-based dependency-injection container for TypeScript +description: Ports as the vocabulary your application defines, providers bound at one edge, and modules with imports and exports. Every wiring mistake the types can catch is a compile error. Nothing throws. + +hero: + name: "di" + text: "Wiring, checked at compile time" + tagline: Ports as the vocabulary your application defines, providers bound at one edge, and modules that declare their imports and exports — with unmet dependencies, leaked internals and resource leaks caught by the compiler, and Result instead of throws. + image: + light: /logo-light.svg + dark: /logo-dark.svg + alt: di + actions: + - theme: brand + text: Get Started + link: /tutorial/getting-started + - theme: alt + text: Why di? + link: /explanation/why-di + - theme: alt + text: GitHub + link: https://github.com/btravstack/di + +features: + - icon: 🧭 + title: Ports name what you need + details: "A port is the application's own word for a dependency — OrderRepository, never PostgresOrderRepository. Nominal by id, so two ports sharing a shape can never be swapped by accident. Application code depends on the port; adapters bind it at one edge." + - icon: 🧱 + title: Wiring mistakes are compile errors + details: "A missing dependency, an internal port leaking out of a module, a resourceful graph built without a scope — each is an error at the call site, not a runtime surprise. What the types cannot catch (a cycle, a duplicate provider) is a defect before any factory runs." + - icon: 🔐 + title: Modules keep internals private + details: "A module exports the ports outside code may see; everything else stays unnameable — even though the built container is one flat map at runtime. Swap a production adapter for an in-memory one without touching the application module." + - icon: 🪢 + title: Resources release themselves + details: "A provider with acquire/release routes its module through Module.scoped, which closes the scope on every path out — success, failure, or partial failure — releasing in reverse acquisition order. Nothing throws: every fallible operation returns an unthrown Result." +--- + +## At a glance + +```ts +import { Module, Port, Provider, type ServiceOf } from "@btravstack/di"; +import { Err, Ok, type AsyncResult } from "unthrown"; + +// 1. Ports: named by the domain, never by whatever will implement them. +class OrderRepository extends Port("OrderRepository")<{ + readonly findById: (id: string) => AsyncResult; +}> {} +class GetOrder extends Port("GetOrder")<{ + readonly execute: (id: string) => AsyncResult; +}> {} + +// 2. Application: depends on the port, never on an adapter. +class GetOrderInteractor { + private readonly orders: ServiceOf; + constructor(orders: ServiceOf) { + this.orders = orders; + } + execute(id: string): AsyncResult { + return this.orders.findById(id); + } +} + +// 3. Adapter: bound at one edge. A resourceful one puts `Scope` in `Needs`. +const Persistence = Module("Persistence")({ + provides: [ + Provider(Database)([AppConfig], { + acquire: (config) => openPool(config.dbUrl), + release: (pool) => pool.close(), + }), + Provider(OrderRepository)([Database], { + sync: (db) => ({ findById: (id) => db.query(id) }), + }), + ], + exports: [OrderRepository], // Database stays internal to this module. +}); + +// 4. Composition root: `Scope` in `Needs` forces `Module.scoped`, which opens +// a scope and guarantees it is closed — success, failure, or partial +// failure — before this call resolves. +const App = Module("App")({ + imports: [Persistence], + provides: [ + Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor }), + ], + exports: [GetOrder], +}); + +const result = await Module.scoped(App, (ctx) => + ctx.get(GetOrder).execute("o-1"), +); +``` + +Swap `Persistence` for a resource-free in-memory module and `Module.build` (no +scope, no teardown) compiles too — but passing the _resourceful_ module to +`Module.build` does not: `Needs` still contains `Scope`, so the call is rejected +before anything runs. diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..4a02a8c --- /dev/null +++ b/docs/package.json @@ -0,0 +1,22 @@ +{ + "name": "@btravstack/di-docs", + "private": true, + "description": "Documentation website for @btravstack/di", + "license": "MIT", + "author": "Benoit TRAVERS ", + "type": "module", + "scripts": { + "build": "typedoc && vitepress build .", + "dev": "typedoc && vitepress dev .", + "preview": "vitepress preview ." + }, + "devDependencies": { + "@btravstack/theme": "catalog:", + "@btravstack/typedoc": "catalog:", + "@types/node": "catalog:", + "typedoc": "catalog:", + "typedoc-plugin-markdown": "catalog:", + "typescript": "catalog:typedoc", + "vitepress": "catalog:" + } +} diff --git a/docs/public/logo-dark.svg b/docs/public/logo-dark.svg new file mode 100644 index 0000000..3fcea93 --- /dev/null +++ b/docs/public/logo-dark.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/logo-light.svg b/docs/public/logo-light.svg new file mode 100644 index 0000000..9e479a2 --- /dev/null +++ b/docs/public/logo-light.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/logo.svg b/docs/public/logo.svg new file mode 100644 index 0000000..3fcea93 --- /dev/null +++ b/docs/public/logo.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/og-di.png b/docs/public/og-di.png new file mode 100644 index 0000000..e66f97f Binary files /dev/null and b/docs/public/og-di.png differ diff --git a/docs/reference/entry-points.md b/docs/reference/entry-points.md new file mode 100644 index 0000000..e29b77c --- /dev/null +++ b/docs/reference/entry-points.md @@ -0,0 +1,139 @@ +--- +title: Entry points +description: "Module.build, Module.scoped and Module.forkScope — signatures, the UNSATISFIED DEPENDENCIES gate, ScopedOptions, and Context, precisely." +--- + +# Entry points + +Three functions turn a module declaration into running services. They differ +in one thing: what they do about scopes — and therefore which graphs the type +system lets each accept. + +## The gate + +Every entry point carries the same compile-time gate, as a conditional rest +parameter: when the module's remaining `Needs` (after the exclusions each +entry point is entitled to) is `never`, the gate is the empty tuple and the +call is ordinary; when it is not, two required parameters appear — +`error: "UNSATISFIED DEPENDENCIES", missing: N` — and the call is an arity +error naming exactly what is missing. There is no way to supply the phantom +arguments; the fix is always to satisfy the need. + +## `Module.build(module)` + +```ts +const built: AsyncResult, E> = Module.build(App); +``` + +Checks the graph, constructs every provider in dependency order, resolves to +the built [`Context`](#context). For modules with **no unmet needs at all**: +the gate excludes nothing, so `Scope` in `Needs` — any resourceful provider, +any `onStop` hook, anywhere in the tree — makes the call refuse to compile. +`build` opens no scope and runs no teardown; that is exactly why it may not +accept a graph that would need one. + +The `Context` it resolves to has no scope behind it — appropriate for +services that live as long as the process. + +## `Module.scoped(module, use, options?)` + +```ts +const result: AsyncResult = Module.scoped( + App, + (ctx) => useIt(ctx), + options, +); +``` + +The resourceful counterpart. Opens a scope, builds the graph, hands the +`Context` to `use`, and **closes the scope before its own result +settles** — on `use` succeeding, on `use` failing, and on construction +failing partway (releasing whatever was acquired before the failure). + +- The gate is computed from `Exclude`: `Scope` is the one need + this entry point discharges, by actually opening a scope. Every other unmet + need still gates. +- The error channel is `E | E2` — construction failures and `use`'s own + failures share the result. +- A non-resourceful module is fine here too; a scope with nothing registered + closes trivially. + +The `Context` must not outlive the callback — after `use` settles, acquired +resources are released. Do what needs services **inside** `use`. + +## `Module.forkScope(parent, module, use, options?)` + +```ts +const result: AsyncResult = Module.forkScope( + appCtx, + RequestModule, + (ctx) => handle(ctx.get(Transaction)), +); +``` + +A short-lived scope layered over an **already-built** parent `Context` — the +per-request pattern. Constructs only `module`'s providers, seeded with the +parent's services; `use` receives a `Context` carrying both. + +- The gate is computed from `Exclude`: the request + module may depend on anything the parent already provides — that is the + point of forking over a built parent — and `Scope` is discharged by the + fresh scope this call opens. Anything neither satisfies still gates. +- Closing the fork releases **only what the fork acquired**: the parent's + finalisers were registered on the parent's scope, not this one. The parent + stays up for sibling forks and for whatever follows. +- Forks nest: a fork's `use` may fork again over the context it received. + +## `ScopedOptions` + +Accepted by `Module.scoped` and `Module.forkScope`: + +```ts +type ScopedOptions = { + readonly onTeardownError?: (portId: string, cause: unknown) => void; +}; +``` + +Called once per finaliser (`release` or `onStop`) that fails during scope +close, tagged with the failing provider's port id. Failures are reported and +**swallowed**: teardown continues past them, and the entry point's own result +is never changed by one — a failed close must not mask the failure that +triggered the unwind. The default reporter writes to `console.error`. A +throwing reporter is itself swallowed; there is nowhere left to report a +broken reporter to. + +## `Context` + +What entry points hand back or pass to callbacks: + +```ts +const service = ctx.get(SomePort); // typed exactly as the port declared +``` + +- **`ctx.get(port)`** — returns the constructed service. Only ports in the + context's channel — the module's `Exports` (plus the parent's, in a fork) — + compile; everything else is unnameable. On a + [set port](/reference/ports#port-many-id-member), returns every accumulated + contribution. +- **`Context.empty()`** — a context with nothing in it. Useful as a typed + starting point in tests. + +A `Context` is immutable and read-only from the outside: `get` is its entire +public surface. Services construct once per build; every `get` returns the +same instance. + +## Construction order and failure + +Shared by all three entry points: + +1. The provider tree is flattened (de-duplicated by reference — a diamond + constructs once) and [checked](/reference/wiring-defects); nothing has run + yet if a check fails. +2. Providers are grouped into dependency levels. Each level constructs + **concurrently**; levels run strictly in order. +3. On a failure, siblings already in flight settle, then the build stops — + later levels never start. Within a level, the failure reported is the + first in **declaration order**, deterministically. Under a scope, + everything acquired so far is then released. +4. `onStart` hooks fire only after the whole graph is built, in declaration + order. diff --git a/docs/reference/modules.md b/docs/reference/modules.md new file mode 100644 index 0000000..353fafb --- /dev/null +++ b/docs/reference/modules.md @@ -0,0 +1,102 @@ +--- +title: Modules +description: "Module(name)({ imports, provides, exports }) — what each list means, how the Exports/E/Needs channels are computed, and the variance rule that keeps them honest." +--- + +# Modules + +A module groups providers and draws a visibility boundary. Like a provider, it +is a declaration — building happens only at an +[entry point](/reference/entry-points). + +## `Module(name)(options)` + +```ts +const Persistence = Module("Persistence")({ + imports: [Config], + provides: [ + Provider(Pool)([AppConfig], { + acquire: openPool, + release: (p) => p.close(), + }), + Provider(OrderRepository)([Pool], { sync: makeRepository }), + ], + exports: [OrderRepository], +}); +``` + +All three lists are optional and default to empty. + +### `imports` + +Modules whose **exports** become visible inside this one. A diamond — two +imports that both import a third — is fine: providers are de-duplicated by +reference at build time, so the shared module's services construct once. + +### `provides` + +This module's own providers. What a provider here may depend on is anything +**available** in this module: ports provided here, plus ports exported by the +imports. Order within the list does not matter for correctness — dependency +order is computed at build time — but it is what makes error selection +deterministic when several providers fail at once. + +### `exports` + +The ports outside code may see. Each entry must be either: + +- an **available port** — provided here, or exported by an import. Exporting + anything else (a port from nowhere, an import's internal) is a compile + error at the declaration; or +- an **imported module** — a whole-module re-export, forwarding that module's + own `exports` (never its internals). + +Everything provided but not exported is +[private to the module](/how-to/private-ports): present in the built flat map +at runtime, unnameable through the built `Context`'s type. + +## The channels + +`Module`: + +- **`Exports`** — the union of exported ports' instance types (whole-module + re-exports contributing their own `Exports`). This becomes the `Context` + channel an entry point hands back. +- **`E`** — every way construction can fail: the union of all providers' + error channels, here and in every import, transitively. +- **`Needs`** — everything still unmet: the union of all providers' needs and + all imports' needs, **minus** what is available here. A dependency satisfied + by a sibling provider or an import's export disappears from `Needs`; one + nothing supplies propagates upward until some module satisfies it — or + surfaces as the "UNSATISFIED DEPENDENCIES" compile error at the entry + point. `Scope`, once introduced by a resourceful provider, propagates the + same way and is discharged only by `Module.scoped` / `Module.forkScope`. + +The variance rule, shared with [`Provider`](/reference/providers#the-channels): + +> Capability channels (`Exports`) are contravariant, so you may forget what +> you have. Obligation channels (`E`, `Needs`) are covariant, so you may not +> forget what you owe. + +Concretely: annotating a module as exporting less than it does is fine +(forgetting a capability); annotating away an error case or an unmet need +does not compile (laundering an obligation). This is what makes the +adapter-seam pattern safe: + +```ts +const makeAppModule = (persistence: Module) => /* ... */; +``` + +Any module exporting `OrderRepository` fits, and whatever it may fail with or +still need flows through `E`/`N` into the result — invisibly to the seam, +inescapably at the entry point. + +## What a module is at runtime + +A plain object: `{ name, imports, provides, exports }`. Declaring one runs no +factories, opens nothing, allocates nothing but the object itself — building a +module fresh per call (`makeAppModule(...)`, a per-request module for +[`forkScope`](/reference/entry-points#module-forkscope-parent-module-use-options)) +is cheap and idiomatic. The channels are phantom: they exist only in the +type, which is why every wiring property they express is settled before +runtime. diff --git a/docs/reference/ports.md b/docs/reference/ports.md new file mode 100644 index 0000000..f188434 --- /dev/null +++ b/docs/reference/ports.md @@ -0,0 +1,105 @@ +--- +title: Ports +description: "Port, Port.many, ServiceOf, and the type-only Scope — the tokens the rest of the library keys on, precisely." +--- + +# Ports + +A port is a phantom class: never instantiated, it exists so the type system +can tell dependencies apart and the runtime can key services by id. Everything +else in the library — providers, modules, contexts — is expressed in terms of +ports. + +## `Port(id)` + +```ts +class OrderRepository extends Port("OrderRepository")<{ + readonly findById: (id: string) => AsyncResult; +}> {} +``` + +Declares an ordinary port. The subclass-of-a-call pattern is what fixes +`Shape` while producing a concrete class you can pass around and re-use in +type positions. + +- **Identity is nominal, by id.** Two ports with identical shapes but + different ids are unrelated types; a provider for one never satisfies a + dependency on the other. The brand is a module-private symbol, so a port + instance type cannot be forged structurally. +- **The id is also the runtime key.** The built container is a flat map keyed + by `portId`. Two distinct port classes sharing an id are distinct types but + the same key — one would shadow the other, so development builds warn: + `[di] duplicate port id "X" — one will shadow the other`. The check is + folded out of production builds by `NODE_ENV` define-replacement. +- Declaring a port has no other runtime cost or effect. + +## `Port.many(id)` + +```ts +class HealthCheck extends Port.many("HealthCheck")<{ + readonly name: string; + readonly run: () => AsyncResult<"healthy", HealthCheckFailed>; +}> {} +``` + +Declares a **set port**. `Member` fixes what one contribution looks like; the +port's own service — what lands in a `Context` and what `Context.get` +returns — is `readonly Member[]`. + +- Several providers may target it, via + [`Provider.member`](/reference/providers#provider-member-port); on an + ordinary port a second provider is a + [wiring defect](/reference/wiring-defects). +- `Context.get` returns every contribution, accumulated across module + boundaries. No contributors is not an error — the array is empty. +- One id, one kind: the same `portId` declared ordinary in one place and + set in another is a wiring defect. + +## `ServiceOf

` + +Recovers the service shape from a port — the type a provider must construct +and `Context.get` returns. Accepts the class or its instance type: + +```ts +class GetOrderInteractor { + constructor(orders: ServiceOf) { + /* ... */ + } +} +``` + +Use it to type application code against a port without importing any adapter. +For a set port, `ServiceOf` yields the accumulated `readonly Member[]` — one +contribution's shape is the port declaration's own type argument. + +## `Scope` (type only) + +The phantom requirement a resourceful provider +([`acquire`/`release`, or an `onStop` hook](/reference/providers)) adds to its +`Needs`. No service ever exists for it; its only job is to make "this graph +owns un-released resources" visible to the type system, so +[`Module.build`](/reference/entry-points#module-build-module) can refuse such +a graph and [`Module.scoped`](/reference/entry-points#module-scoped-module-use-options) +can discharge it. + +`Scope` is exported as a **type only** — useful in `Module` +annotations or `Exclude` computations. The class value is withheld: +it would enable exactly two things, providing `Scope` and widening it past the +type-level guards, and both are hazards. Attempting to provide it is caught at +runtime as a [wiring defect](/reference/wiring-defects) regardless. + +## `AnyPort` + +The structural bound every concrete port class satisfies — `portId` plus a +no-arg constructor. Use it to write helpers generic over ports: + +```ts +const describe = (port: AnyPort): string => port.portId; +``` + +## `PortClass` / `ManyPortClass` + +The return types of `Port(id)` and `Port.many(id)`. Exported so a consumer's +**declaration emit** can name them — `class X extends Port("X") {}` in a +library compiled with `declaration: true` emits a base-class type the compiler +must be able to write. You are not expected to write either by hand. diff --git a/docs/reference/providers.md b/docs/reference/providers.md new file mode 100644 index 0000000..6f28c60 --- /dev/null +++ b/docs/reference/providers.md @@ -0,0 +1,127 @@ +--- +title: Providers +description: "The construction family — value, sync, make, class, acquire/release — the onStart/onStop hooks, and Provider.member, precisely." +--- + +# Providers + +A provider binds one port to one concrete construction. It is a description, +not an instance: nothing runs until a module containing it is built. + +## `Provider(port)(deps, options)` / `Provider(port)(options)` + +```ts +Provider(OrderRepository)([Database], { + sync: (db) => ({ findById: (id) => db.query(id) }), +}); + +Provider(AppConfig)({ value: { dbUrl: "postgres://localhost/orders" } }); // no deps +``` + +- **`deps`** — an array of ports this construction reads. The resolved + services are passed to the arm's function (or constructor) **positionally**, + and their types are checked against its parameters. Omitting the array is + the zero-dependency form. +- **`options`** — exactly one construction arm, plus optional hooks. + +The dependency array is also what feeds the module's `Needs` channel: every +port listed here must be available where the module is built, or the graph is +rejected — at compile time if the type is missing, as a +[wiring defect](/reference/wiring-defects) if a widened type slipped past. + +## The construction family + +Exactly one arm per provider. The arms are mutually exclusive by +construction — an options literal supplying two arms' keys fails to compile, +not merely warns: + +| Arm | Shape | When | Puts `Scope` in `Needs`? | +| --------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------ | +| `value` | `S` | The service is already at hand — a config object, a constant. | No | +| `sync` | `(...deps) => S` | Built synchronously from its dependencies, and cannot fail. | No | +| `make` | `(...deps) => Result \| AsyncResult` | Built fallibly, possibly asynchronously — a parsed config, a validated client. | No | +| `class` | `new (...deps) => S` | Built by constructing a class, dependencies passed positionally to the constructor. | No | +| `acquire` + `release` | `acquire: (...deps) => Result \| AsyncResult`, `release: (s) => void \| Promise` | A real resource — a connection, a file handle — that must be torn down. | Yes | + +Notes per arm: + +- **`value`** cannot fail and contributes `never` to the module's error + channel. +- **`make`**'s error type is inferred from the `Result` it actually returns + and joins the module's error channel — a failing `make` stops construction + and surfaces through the build call's `Result`. +- **`class`** — the port's service type is the class's **instance** type; + the constructor's parameters are checked against `deps`. +- **`acquire`/`release`** come as a pair; neither exists without the other. + `acquire` may fail exactly as `make` may. `release` runs during scope close, + in reverse acquisition order; a failure in it is reported (see + [`ScopedOptions`](/reference/entry-points#scopedoptions)) and swallowed, + never rethrown. + +## `onStart` / `onStop` + +Optional on **every** arm, supplied inline in the same options literal: + +```ts +Provider(Cache)([Config], { + make: (config) => connectCache(config), + onStart: (cache) => cache.warm(), + onStop: (cache) => cache.flush(), +}); +``` + +- **`onStart: (service) => void | Promise`** — fires after the **whole + graph** has finished constructing, never while another provider is still + mid-construction. Hooks fire in declaration order. A rejecting `onStart` + fails the build the same way a failing construction does. +- **`onStop: (service) => void | Promise`** — fires during teardown, + LIFO alongside `release` finalisers. Declaring one puts `Scope` in `Needs` + exactly as `acquire` does: it is teardown, and only + [`Module.scoped`](/reference/entry-points#module-scoped-module-use-options) / + [`Module.forkScope`](/reference/entry-points#module-forkscope-parent-module-use-options) + ever open a scope to run it. Without that rule, a `{ value, onStop }` + provider would satisfy `Module.build` and the hook would silently never run. + +Hooks do not reopen arm exclusivity — `{ value, sync, onStart }` is still a +compile error. + +## `Provider.member(port)(deps, options)` + +The multi-binding form: contributes **one member** to a +[set port](/reference/ports#port-many-id-member). + +```ts +Provider.member(HealthCheck)([Database], { + sync: (db) => ({ name: "database", run: db.ping }), +}); +``` + +Identical to `Provider(...)` in every respect — same arms, same hooks, same +`deps` checking, same channels — except the arm constructs one `Member`, not +the port's whole `readonly Member[]`. Using `Provider` on a set port, or +`Provider.member` on an ordinary one, does not compile. + +## The channels + +`Provider` carries three phantom channels, which the containing +module aggregates: + +- **`P`** — the port it satisfies. +- **`E`** — what construction may fail with: `make`/`acquire`'s inferred + error, `never` for the other arms. +- **`N`** — what it needs: the union of `deps`' instance types, plus `Scope` + when the arm is resourceful or an `onStop` is present. + +The variance rule (shared with [`Module`](/reference/modules#the-channels)): +capability channels are contravariant — you may forget what you have; +obligation channels (`E`, `N`) are covariant — **you may not forget what you +owe**. A type annotation can widen a provider's port, but no annotation can +drop an error case or launder away `Scope`. + +## Construction semantics + +During a build, providers are grouped into dependency levels; providers in +the same level construct **concurrently** (all started before any is +awaited), levels strictly in order. Each provider constructs **once** per +build — every consumer of its port sees the same instance. Declaration order +within a level makes error selection and hook order deterministic. diff --git a/docs/reference/wiring-defects.md b/docs/reference/wiring-defects.md new file mode 100644 index 0000000..318b623 --- /dev/null +++ b/docs/reference/wiring-defects.md @@ -0,0 +1,106 @@ +--- +title: Wiring defects +description: "The pre-construction checks — cycle, duplicate provider, set/ordinary conflict, provider for Scope, missing provider — their exact messages, and the channel they arrive on." +--- + +# Wiring defects + +Some wiring bugs cannot be expressed as type errors: a dependency cycle is +only visible once the whole graph is assembled; a duplicate provider may be +declared in two modules that never see each other's types. `di` catches every +one of these **before any factory runs** — a failing check has zero side +effects to unwind — and reports it as a **defect**, not a modeled failure. + +## Where a defect arrives + +A wiring defect is a bug in the program's wiring, not an outcome caller code +should branch on — so it does not join the entry point's error channel `E`. +It arrives on unthrown's **defect channel**, the same place a thrown exception +in your own code would land: + +```ts +const outcome = await Module.build(App).match({ + ok: (ctx) => /* ... */, + errCases: (m) => /* modeled construction failures — never wiring bugs */, + defect: (cause) => { + // a WiringDefect: cycle, duplicate, missing provider, ... + console.error(cause); + return /* ... */; + }, +}); +``` + +In tests, [`@unthrown/vitest`](https://github.com/btravstack/unthrown)'s +`toBeDefect()` asserts on it directly. +([Why this split](/explanation/failures-vs-defects).) + +## The checks + +Run in this order, at every entry point, on the flattened provider tree. + +### A provider for `Scope` + +``` +[di] Scope cannot be provided; open one with Module.scoped instead +``` + +`Scope` is a phantom requirement, not a service. The type-only export already +makes this hard to write; the runtime check (keyed on the port **id**, so no +type-level widening escapes it) is defence in depth. + +### One port, two kinds + +``` +[di] port "X" is registered as both a set port and an ordinary port +``` + +The same `portId` reached by `Provider(...)` in one place and +`Provider.member(...)` in another. Left unchecked, whichever landed second +would silently win, and the eventual failure would say nothing about the +cause. + +### Two providers for one port + +``` +[di] two providers registered for port "X" +``` + +An **ordinary** port with two distinct providers anywhere in the tree — two +modules each providing the same port, both imported. One of them would +silently shadow the other, so it is a defect instead. The same provider +reached twice through a diamond is fine — de-duplication is by reference. +Set ports are exempt: accumulating members is +[their whole point](/how-to/plugin-registry). + +### A dependency nothing provides + +``` +[di] no provider for port "X", required by "Y" +``` + +A `deps` entry no provider in the tree supplies — and, for a +[fork](/reference/entry-points#module-forkscope-parent-module-use-options), +the parent context does not carry either. The compile-time `Needs` gate +catches this first in ordinary code; the runtime check is what stands when a +type was widened past it. + +### A dependency cycle + +``` +[di] dependency cycle among ports: X, Y, Z +``` + +The listed ports' providers each wait on another; no construction order +exists. The list is every provider that could not be scheduled — the cycle's +members and anything downstream of them. + +## What is _not_ a defect + +- **A failing `make`/`acquire`** — a modeled failure, on the error channel + `E`, matched in `errCases`. +- **An unmet need visible in the types** — refused at compile time by the + ["UNSATISFIED DEPENDENCIES" gate](/reference/entry-points#the-gate); the + runtime missing-provider check is its backstop, not its replacement. +- **A duplicate port id** (two port classes sharing an id) — a declaration + bug the build cannot see (the two are one key to it), warned once per id in + development: `[di] duplicate port id "X" — one will shadow the other`. diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md new file mode 100644 index 0000000..a71ca21 --- /dev/null +++ b/docs/tutorial/getting-started.md @@ -0,0 +1,271 @@ +--- +title: Getting started +description: Build a working dependency graph from nothing — declare ports, bind providers, compose modules, build the container, then add a real resource and watch the compiler route you to the right entry point. +--- + +# Getting started + +By the end of this page you will have declared two ports, written a use case +that depends on one of them without knowing its implementation, wired both into +a module, built the container, and then added a real resource — at which point +the compiler itself will tell you the entry point you were using is no longer +the right one. + +The snippets build on one another, so follow along in a `.ts` file. The lines +marked `// ✗` are meant not to compile, and that is the point of them. + +## Install + +```sh +pnpm add @btravstack/di unthrown +``` + +Both, because `unthrown` is a **peer** dependency — the package hands you back +_your_ copy of it rather than its own. +([Why](/explanation/peer-dependencies).) Every fallible operation in `di` +returns an unthrown `Result`; nothing throws. + +## 1. Declare your ports + +A port is the application's own name for something it needs — named by the +domain, never by whatever will eventually implement it: + +```ts +import { Port } from "@btravstack/di"; +import { Err, Ok, TaggedError, type AsyncResult } from "unthrown"; + +class OrderNotFound extends TaggedError("OrderNotFound")<{ + readonly id: string; +}> {} + +type Order = { + readonly id: string; + readonly total: number; +}; + +class OrderRepository extends Port("OrderRepository")<{ + readonly findById: (id: string) => AsyncResult; +}> {} + +class GetOrder extends Port("GetOrder")<{ + readonly execute: (id: string) => AsyncResult; +}> {} +``` + +`Port(id)` is a nominal token: two ports declared with the same `Shape` +but different ids are different types, so a `Database` and a `Cache` that +happen to share a service shape can never be swapped for each other by +accident. The class itself is a phantom — it is never instantiated; it exists +so the type system can tell ports apart and the runtime can key services by +`id`. + +## 2. Write the use case against the port + +`ServiceOf

` recovers the shape a service must have to satisfy a port — used +here to type the interactor's dependency without ever importing an adapter: + +```ts +import { type ServiceOf } from "@btravstack/di"; + +class GetOrderInteractor { + private readonly orders: ServiceOf; + constructor(orders: ServiceOf) { + this.orders = orders; + } + execute(id: string): AsyncResult { + return this.orders.findById(id); + } +} +``` + +Nothing in this class knows whether the eventual implementation talks to +Postgres or is an in-memory fake. That decision belongs to the composition +root, and it has not been made yet. + +## 3. Bind providers, group them in a module + +A `Provider` binds a port to a concrete construction. A `Module` groups +providers and declares what outside code may see: + +```ts +import { Module, Provider } from "@btravstack/di"; + +const InMemoryPersistence = Module("InMemoryPersistence")({ + provides: [ + Provider(OrderRepository)({ + value: { findById: (id) => Ok({ id, total: 99 }).toAsync() }, + }), + ], + exports: [OrderRepository], +}); + +const App = Module("App")({ + imports: [InMemoryPersistence], + provides: [ + Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor }), + ], + exports: [GetOrder], +}); +``` + +Two arms of the [construction family](/reference/providers) appear here: +`value` (the service is already at hand) and `class` (construct this class, +passing the resolved dependencies — the `[OrderRepository]` array — +positionally to its constructor). The dependency array is what ties +`GetOrderInteractor`'s constructor parameter to the port that will satisfy it, +and its element types are checked against the constructor's parameters. + +## 4. Build it, use it + +```ts +const result = await Module.build(App).flatMap((ctx) => + ctx.get(GetOrder).execute("o-1"), +); +``` + +`Module.build` checks the graph, constructs every provider in dependency +order, and resolves to a `Context` — the built container. `ctx.get(GetOrder)` +returns the constructed service, typed exactly as the port declared. + +Two things worth noticing before moving on: + +- `ctx.get(OrderRepository)` does **not** compile. Only `GetOrder` is in + `App`'s `exports`, so that is the only port the built `Context` lets you + name. The repository's service is genuinely in the container at runtime; the + _type_ that would let you reach it is withheld. + ([How that works](/explanation/modules-and-privacy).) +- `result` is a `Result`, not a bare value. Handle it as one: + +```ts +import { P } from "unthrown"; + +const outcome = result.match({ + ok: (order) => `total: ${order.total}`, + errCases: (m) => + m.with(P.tag("OrderNotFound"), (e) => `no such order: ${e.id}`), + defect: (cause) => { + console.error(cause); + return "bug"; + }, +}); +``` + +The `defect` branch is not decoration: a wiring bug — a dependency cycle, two +providers for one port — lands there, kept apart from the failures your code +models. ([Failures vs defects](/explanation/failures-vs-defects).) + +## 5. Let construction fail as a value + +`value` cannot fail. Real construction often can — a config read, a validated +client. The `make` arm returns a `Result`, and its error channel joins the +module's own: + +```ts +class Env extends Port("Env")> {} +class AppConfig extends Port("AppConfig")<{ readonly dbUrl: string }> {} +class ConfigError extends TaggedError("ConfigError")<{ + readonly reason: string; +}> {} + +const Config = Module("Config")({ + provides: [ + Provider(Env)({ value: process.env }), + Provider(AppConfig)([Env], { + make: (env) => + env["DATABASE_URL"] === undefined + ? Err(new ConfigError({ reason: "DATABASE_URL is unset" })) + : Ok({ dbUrl: env["DATABASE_URL"] }), + }), + ], + exports: [AppConfig], +}); +``` + +A failing `make` stops construction, and the `ConfigError` comes back through +the same `Result` you already handle — now one of the `errCases`. + +## 6. Add a real resource + +A connection pool is not a value: it must be acquired, and it must be released. +That is the `acquire`/`release` arm: + +```ts +class Database extends Port("Database")<{ + readonly query: (id: string) => AsyncResult; +}> {} + +const Persistence = Module("Persistence")({ + imports: [Config], + provides: [ + Provider(Database)([AppConfig], { + acquire: (config) => openPool(config.dbUrl), + release: (pool) => pool.close(), + }), + Provider(OrderRepository)([Database], { + sync: (db) => ({ findById: (id) => db.query(id) }), + }), + ], + exports: [OrderRepository], // Database stays internal to this module. +}); + +const ProdApp = Module("App")({ + imports: [Persistence], + provides: [ + Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor }), + ], + exports: [GetOrder], +}); +``` + +## 7. Watch the compiler change your entry point + +```ts +await Module.build(ProdApp); // ✗ does not compile — "UNSATISFIED DEPENDENCIES" +``` + +Choosing the resourceful arm put a phantom requirement — `Scope` — into the +provider's `Needs`, and it propagated through `Persistence` into `ProdApp`. +`Module.build` demands a module with no unmet needs, opens no scope and runs no +teardown, so it refuses the graph _at the call site_. Forgetting to release the +pool is not a runtime leak here; it is a type error. + +The entry point that discharges `Scope` is `Module.scoped`: + +```ts +const result = await Module.scoped(ProdApp, (ctx) => + ctx.get(GetOrder).execute("o-1"), +); +``` + +It opens a scope, builds the graph, hands the `Context` to your callback, and +closes the scope — releasing every acquired resource in reverse acquisition +order — before its own result resolves. On success, on failure, and on a +mid-graph partial failure alike. +([What the scope guarantees](/explanation/scopes-and-resources).) + +## 8. Swap the adapter, keep the application + +The application module never named an adapter, so wiring it the other way is a +one-line change at the composition root — and `Module.build` accepts the +in-memory graph, because nothing in it needs a scope: + +```ts +const built = await Module.build(App); // ✓ the in-memory graph from step 3 +``` + +One application, two adapters, and the type system — not a convention — decides +which entry point each graph is allowed to use. +([The worked version](/how-to/swap-an-adapter).) + +## Where to go next + +- [Swap an adapter for tests](/how-to/swap-an-adapter) — the seam above, made a + pattern. +- [Manage a resource's lifetime](/how-to/manage-a-resource) — `acquire`, + `release`, and the `onStart`/`onStop` hooks. +- [Open a per-request scope](/how-to/request-scope) — a transaction per request + with `Module.forkScope`. +- [Build a plugin registry](/how-to/plugin-registry) — many providers, one set + port, with `Port.many`. +- [Reference](/reference/ports) — every member, arm and entry point. +- [Why di?](/explanation/why-di) — the design, and what it refuses to do. diff --git a/docs/typedoc.json b/docs/typedoc.json new file mode 100644 index 0000000..f8cad65 --- /dev/null +++ b/docs/typedoc.json @@ -0,0 +1,25 @@ +{ + "extends": "@btravstack/typedoc/base.json", + "entryPoints": ["../packages/di/src/index.ts"], + "tsconfig": "../packages/di/tsconfig.json", + "out": "api/di", + "intentionallyNotExported": [ + "AnyModule", + "AnyPortInstance", + "AnyProvider", + "Available", + "ErrOf", + "ErrOfModule", + "ErrorOf", + "Exportable", + "MemberOf", + "NeedOf", + "NeedsOfModule", + "PortInstance", + "Qualification", + "ResolvedExports", + "ScopeOf", + "ServicesOf", + "TeardownReporter" + ] +} diff --git a/examples/README.md b/examples/README.md index 503c313..e843616 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,11 +1,13 @@ # Examples -Eleven small packages that are **one application booted four ways**: a clean -architecture split across four layers, deployed once as an oRPC API, once as a -queue worker, once as a Temporal worker and once as an AMQP consumer, with each -transport's contract in a package of its own — and, at the same time, -exercising `@btravstack/start` end to end from a consumer's own workspace, -`workspace:*` and all. +Two example families share this directory. Eleven packages are **one +application booted four ways**: a clean architecture split across four layers, +deployed once as an oRPC API, once as a queue worker, once as a Temporal +worker and once as an AMQP consumer, with each transport's contract in a +package of its own — and, at the same time, exercising `@btravstack/start` end +to end from a consumer's own workspace, `workspace:*` and all. Three more — +[the di examples](#the-di-examples) below — exercise `@btravstack/di` on its +own, one job each. | Package | Layer | Shows | | ------------------------------------------------------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -210,3 +212,23 @@ rather than executed. Nothing here is published: every package is `"private": true` and depends on the kernel via `workspace:*`. + +## The di examples + +Three packages, each showing a different job `@btravstack/di` does on its +own — no kernel, no runtime — and, at the same time, exercising the library +end to end from a consumer's own workspace. + +| Package | Shows | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`hexagonal-order-api`](./hexagonal-order-api) | The core story: ports named by the application, a private internal beside a public surface, and one application module composed against a production adapter and an in-memory one. | +| [`request-scope`](./request-scope) | Lifetime management: a pool acquired once under `Module.scoped`, and a `Module.forkScope`'d transaction per request over the built parent. | +| [`plugin-registry`](./plugin-registry) | Multi-binding: a `Port.many` set port fed by contributions from two independent modules, collected and run together. | + +The same rules as the order family: each `src/index.ts` reads as application +code, each spec asserts real behaviour (release order, set-port accumulation), +and compile-time-only guarantees live in `*.test-d.ts` — see +`hexagonal-order-api/src/index.test-d.ts`, which also runs declaration emit +under both the repo's TypeScript and a consumer's stable one. Every package +declares `unthrown` itself, because it is a peer of the library, not a +transitive. diff --git a/examples/hexagonal-order-api/README.md b/examples/hexagonal-order-api/README.md new file mode 100644 index 0000000..ccaa9a4 --- /dev/null +++ b/examples/hexagonal-order-api/README.md @@ -0,0 +1,60 @@ +# hexagonal-order-api + +The core story: an application layer that names its own ports and never +mentions an adapter, a persistence layer with a private connection pool and a +public repository, and a composition root generic enough to build the same +application against a production adapter or an in-memory one. + +```sh +pnpm --filter @btravstack/di-example-hexagonal-order-api test +pnpm --filter @btravstack/di-example-hexagonal-order-api typecheck +``` + +## What it shows + +`src/index.ts`, top to bottom: + +- **Ports named by the domain, never by an adapter.** `OrderRepository` and + `GetOrder` are declared once, by what the application needs. + `GetOrderInteractor` depends only on `ServiceOf` and never + imports an adapter module — production or in-memory. +- **A private internal beside a public surface.** `Persistence`'s `Pool` — a + real connection, acquired with `acquire`/`release` — is never listed in + that module's `exports`; `OrderRepository` is the only port it makes + visible. The built context is a single flat runtime map (there is nowhere + else to put a service), so `Pool` really is present in it — `exports` + withholds the _type_ that would let a caller name it, not the entry + itself. `src/index.test-d.ts` pins exactly that with a `@ts-expect-error`. +- **One composition seam, two adapters.** `makeAppModule` is generic in the + persistence module's own `E`/`Needs`, so one application module wires up + unchanged against `makePersistenceModule()` (resourceful, needs `Scope`) + or `InMemoryPersistenceModule` (nothing to release, `Needs` collapses to + `never`). + +## The two entry points, forced by the type system + +`Pool`'s `acquire`/`release` puts `Scope` in `Persistence`'s `Needs`, which +propagates through `makeAppModule` to anything built from it. Building that +graph with `Module.build` is a compile error, not a runtime leak — the +call's arity gate (the "UNSATISFIED DEPENDENCIES" rest parameter every unmet +requirement produces) rejects it before anything runs. `src/index.test-d.ts` +pins that with a `@ts-expect-error` of its own, right next to the privacy +one. `Module.scoped` is the one entry point that opens a scope and +discharges `Scope` — used in `src/index.spec.ts` against the production +adapter, closing the pool on every path out. + +`InMemoryPersistenceModule` has nothing resourceful, so `makeAppModule` +applied to it has `Needs = never` — `Module.build` accepts it directly, no +scope required. + +## What the spec proves + +`src/index.spec.ts` builds both graphs and calls `GetOrder.execute` through +each: the production graph resolves against the pool and releases it +cleanly (no teardown errors reported), a missing id comes back as a modeled +`OrderNotFound` — never an exception — and the in-memory graph resolves +without ever touching `Module.scoped`. `src/index.test-d.ts` is the +compile-time half; see its own header for why those two assertions live in +their own file rather than a fourth `test()` here — asserting `Pool`'s +runtime absence would assert something false, since the flat context +genuinely holds it. diff --git a/examples/hexagonal-order-api/package.json b/examples/hexagonal-order-api/package.json new file mode 100644 index 0000000..30faec0 --- /dev/null +++ b/examples/hexagonal-order-api/package.json @@ -0,0 +1,29 @@ +{ + "name": "@btravstack/di-example-hexagonal-order-api", + "private": true, + "description": "A hexagonal slice built on @btravstack/di: ports named by the application, a private connection pool, and one application module composed against a production adapter and an in-memory one", + "license": "MIT", + "author": "Benoit TRAVERS ", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "test:types": "tsc --noEmit -p tsconfig.test-d.json", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json && tsc -p tsconfig.emit.json && node ./node_modules/typescript-consumer/bin/tsc -p tsconfig.emit.json && node ./node_modules/typescript-consumer/bin/tsc --noEmit --strict --module nodenext --moduleResolution nodenext --target es2022 node_modules/.emit-check/index.d.ts node_modules/.emit-check/emit-guards.d.ts node_modules/.emit-check/index.spec.d.ts" + }, + "dependencies": { + "@btravstack/di": "workspace:*", + "unthrown": "catalog:" + }, + "devDependencies": { + "@btravstack/tsconfig": "catalog:", + "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", + "typescript": "catalog:", + "typescript-consumer": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/hexagonal-order-api/src/emit-guards.ts b/examples/hexagonal-order-api/src/emit-guards.ts new file mode 100644 index 0000000..ebf054f --- /dev/null +++ b/examples/hexagonal-order-api/src/emit-guards.ts @@ -0,0 +1,162 @@ +/** + * NOT example code. Do not copy anything out of this file. + * + * It is a compile-time test that happens to live beside an example, because + * what it tests *is* what an example is: a downstream package that uses + * `@btravstack/di` and **emits its own declarations**. Everything below is a + * consumer-side shape that `tsconfig.emit.json` has to be able to write into a + * `.d.ts` — the one thing `tsc --noEmit` on the example proper does not fully + * exercise, and the thing that was broken. + * + * What went wrong when nothing checked this: `PortInstance`'s brand keys are + * two module-private `unique symbol`s (`ID`/`SERVICE`, plus `MANY` for a set + * port), and neither `PortClass` nor `ManyPortClass` was exported from the + * package index. With no name to reach for, the emitter expanded a port class's + * heritage expression down to those symbols, and **every consumer that exported + * a port** — the pattern `packages/di/README.md` teaches on its first page — + * failed with `TS4020: 'extends' clause of exported class 'X' has or is using + * private name 'ID'`. The three example packages had been papering over it with + * `declaration: false` in their own tsconfigs, which is why the repo was green + * while no consumer could build. Those overrides are gone; this file is what + * replaced them. + * + * Three rules that are easy to destroy by tidying: + * + * 1. **An unused `@ts-expect-error` here is a failure, not noise.** The fix + * for `TS4020` is to make more of the port machinery nameable, and the + * cheap version of that — exporting `ID`/`SERVICE`/`MANY` themselves — + * makes emit work while quietly destroying the thing the brands exist for: + * with the symbols in hand a consumer hand-writes `{ [ID]: "Pool", + * [SERVICE]: Shape }` and passes it off as a `Pool`. Measured: it + * type-checks. The directives below are the assertion that the symbols are + * still out of reach, so a directive going unused is the signal that + * someone widened the export surface too far. + * + * 2. **The gate compiles this file, it does not merely check it.** Emit-time + * diagnostics like `TS4020` are raised by the *declaration emitter*, so + * `noEmit` has to be off for the pass to mean anything, and the emitted + * output is then fed back through the compiler — a dangling reference in + * the output is not an emit-time diagnostic and would otherwise ship. The + * re-check names `emit-guards.d.ts` explicitly rather than `index.d.ts` + * alone: a file is checked only if it is named or reached by an import from + * something named, and **nothing imports this one**. Never add + * `--skipLibCheck` to that step; it turns off `.d.ts` checking entirely and + * the run exits 0 on broken output. + * + * 3. **Both the plain port and the `Port.many` port are load bearing.** They + * fail through different brands — `ID`/`SERVICE` against `PortClass`, + * and additionally `MANY` against `ManyPortClass` — so a fix that names + * only one of the two class types leaves the other broken. Measured: with + * just the instance types nameable, the plain port emitted and the set + * port still reported `private name 'MANY'`. + */ +import { Module, Port, Provider, type AnyPort, type ServiceOf } from "@btravstack/di"; +import { Ok, type AsyncResult } from "unthrown"; + +import { + InMemoryPersistenceModule, + OrderRepository, + makeAppModule, + type GetOrder, + type Order, +} from "./index.js"; + +/* ── The brands stay out of reach ────────────────────────────────────────── + Nominal identity is the whole point of the symbols; declaration emit must + not have been bought with it. */ + +class Clock extends Port("Clock")<{ readonly now: () => string }> {} +class Stopwatch extends Port("Stopwatch")<{ readonly now: () => string }> {} + +declare const structurallyIdentical: Stopwatch; +// @ts-expect-error two ports with identical service shapes but different ids do not unify +const unified: Clock = structurallyIdentical; +void unified; + +declare const handWritten: { + readonly id: "Clock"; + readonly service: { readonly now: () => string }; +}; +// @ts-expect-error a port instance cannot be forged: its brand keys are module-private symbols +const forged: Clock = handWritten; +void forged; + +// @ts-expect-error nor by supplying the service shape on its own +const forgedFromService: Clock = { now: () => "" }; +void forgedFromService; + +// The brand keys themselves have no name a consumer can reach. Each of these +// resolves only if the package starts exporting the symbol, at which point the +// forgery above becomes writable — which is why the directives, not a comment, +// are what holds the export surface where it is. +// @ts-expect-error `@btravstack/di` exports no `ID` +declare const idBrand: typeof import("@btravstack/di").ID; +// @ts-expect-error `@btravstack/di` exports no `SERVICE` +declare const serviceBrand: typeof import("@btravstack/di").SERVICE; +// @ts-expect-error `@btravstack/di` exports no `MANY` +declare const manyBrand: typeof import("@btravstack/di").MANY; +void idBrand; +void serviceBrand; +void manyBrand; + +/* ── Exported ports: the shapes that tripped TS4020 ───────────────────────── */ + +/** A plain port. Fails on `ID`/`SERVICE` when `PortClass` is not nameable. */ +export class Metrics extends Port("Metrics")<{ + readonly count: (name: string) => void; +}> {} + +/** A set port. Fails additionally on `MANY` when `ManyPortClass` is not nameable. */ +export class Subscribers extends Port.many("Subscribers")<{ + readonly topic: string; + readonly handle: (order: Order) => void; +}> {} + +/** A port whose service shape reaches through another port's `ServiceOf`. */ +export class Auditor extends Port("Auditor")<{ + readonly orders: ServiceOf; + readonly record: (order: Order) => AsyncResult; +}> {} + +/** A port re-declared over a shape imported from the example proper. */ +export class OrderCache extends Port("OrderCache")<{ + readonly peek: (id: string) => Order | undefined; +}> {} + +/* ── Everything downstream of a port, also emitted ────────────────────────── */ + +export const MetricsProvider = Provider(Metrics)({ value: { count: () => {} } }); + +export const SubscriberProvider = Provider.member(Subscribers)({ + value: { topic: "orders", handle: () => {} }, +}); + +export const ObservabilityModule = Module("Observability")({ + provides: [ + MetricsProvider, + SubscriberProvider, + Provider(OrderCache)({ value: { peek: () => undefined } }), + Provider(Auditor)([OrderRepository], { + sync: (orders) => ({ orders, record: () => Ok(undefined).toAsync() }), + }), + ], + exports: [Metrics, Subscribers, OrderCache, Auditor], +}); + +/** A `Module<…>` whose inferred type names port instances in its type arguments. */ +export const AppModule = makeAppModule(InMemoryPersistenceModule); + +/** `ServiceOf` on the class and on the instance, both emitted. */ +export const subscribers: ServiceOf = []; +export const metrics: ServiceOf = { count: () => {} }; +export declare const getOrder: ServiceOf; + +/** A union of port instance types — what a `Module`'s `Exports` channel is. */ +export type Vocabulary = Metrics | Auditor | OrderCache; + +/** A helper generic over `AnyPort`: its inferred return type names the port. */ +export const identity =

(port: P): P => port; + +/** Factories whose *return* type is the class type itself, not an instance. */ +export const definePort = (id: Id) => Port(id); +export const defineSetPort = (id: Id) => Port.many(id); diff --git a/examples/hexagonal-order-api/src/index.spec.ts b/examples/hexagonal-order-api/src/index.spec.ts new file mode 100644 index 0000000..15e69a9 --- /dev/null +++ b/examples/hexagonal-order-api/src/index.spec.ts @@ -0,0 +1,56 @@ +import { Module, type ScopedOptions } from "@btravstack/di"; +// Side-effect import: brings `@unthrown/vitest`'s `toBeOkWith`/`toBeErrTagged` +// module augmentation of vitest's `Assertion` into this compilation — `tsc` +// only sees an ambient augmentation once some file in the program imports +// the module that declares it. Runtime registration is separate (this +// package's `vitest.config.ts` `setupFiles`); this import exists purely for +// `tsc --noEmit`, mirroring `@btravstack/di`'s own `provider.spec.ts`. +import "@unthrown/vitest"; +import { expect, test } from "vitest"; + +import { + GetOrder, + InMemoryPersistenceModule, + makeAppModule, + makePersistenceModule, +} from "./index.js"; + +test("the production graph resolves a use case through its ports, and releases what it acquired", async () => { + const teardownErrors: (readonly [string, unknown])[] = []; + const options: ScopedOptions = { + onTeardownError: (portId, cause) => void teardownErrors.push([portId, cause]), + }; + + const outcome = await Module.scoped( + makeAppModule(makePersistenceModule()), + (ctx) => ctx.get(GetOrder).execute("o-1"), + options, + ); + + expect(outcome).toBeOkWith({ id: "o-1", total: 4_200 }); + // No teardown failures — proof the pool's `release` actually ran cleanly, + // not just that the graph type-checked. + expect(teardownErrors).toEqual([]); +}); + +test("an id the pool does not carry comes back as a modeled error, not an exception", async () => { + const outcome = await Module.scoped(makeAppModule(makePersistenceModule()), (ctx) => + ctx.get(GetOrder).execute("does-not-exist"), + ); + + expect(outcome).toBeErrTagged("OrderNotFound", { id: "does-not-exist" }); +}); + +test("the same application module builds against an in-memory adapter, with no Scope required", async () => { + // `Module.build` — not `.scoped` — is the point: `InMemoryPersistenceModule` + // has no resourceful provider, so `makeAppModule`'s `Needs` collapses to + // `never` for this instantiation, and `Module.build`'s compile-time gate + // accepts it with no extra argument. Swapping in `makePersistenceModule()` + // here is a compile error, not a runtime surprise — see + // `src/index.test-d.ts`. + const built = await Module.build(makeAppModule(InMemoryPersistenceModule)); + + expect(built).toBeOk(); + const order = built.isOk() ? await built.value.get(GetOrder).execute("anything") : undefined; + expect(order).toBeOkWith({ id: "anything", total: 99 }); +}); diff --git a/examples/hexagonal-order-api/src/index.test-d.ts b/examples/hexagonal-order-api/src/index.test-d.ts new file mode 100644 index 0000000..0abf55e --- /dev/null +++ b/examples/hexagonal-order-api/src/index.test-d.ts @@ -0,0 +1,31 @@ +/** + * The compile-time half of this example: `Pool`'s privacy and the arity gate + * that routes a resourceful graph through `Module.scoped`. Both are + * compile-time guarantees, so — mirroring the library's own + * `example.test-d.ts` — this file's bodies are type-checked (this package's + * `test:types` script, `tsc --noEmit -p tsconfig.test-d.json`) but never + * executed: `vitest.config.ts`'s `include` only matches `*.spec.ts`, so + * `vitest run` never loads this file at all. That split matters more than + * usual here — `ctx` below stands in for a `Context` that is never actually + * built, and `Module.build` is never actually invoked on a resourceful + * module. Running either for real would either assert something false (the + * built runtime map genuinely holds `Pool`) or leak a connection nothing + * would ever release. + */ +import { Module, type Context } from "@btravstack/di"; +import { test } from "vitest"; + +import { GetOrder, Pool, makeAppModule, makePersistenceModule } from "./index.js"; + +test("an importer sees only GetOrder in the built context's type — Pool stays internal to Persistence", () => { + const ctx = null as unknown as Context; + ctx.get(GetOrder); + // @ts-expect-error Pool is provided by Persistence but never exported there, so no importer of App can name it + ctx.get(Pool); +}); + +test("a resourceful persistence graph cannot be built with Module.build — only Module.scoped discharges Scope", () => { + const resourceful = makeAppModule(makePersistenceModule()); + // @ts-expect-error UNSATISFIED DEPENDENCIES: Pool's acquire/release arm puts Scope in Needs, and only Module.scoped discharges it + void Module.build(resourceful); +}); diff --git a/examples/hexagonal-order-api/src/index.ts b/examples/hexagonal-order-api/src/index.ts new file mode 100644 index 0000000..20d782c --- /dev/null +++ b/examples/hexagonal-order-api/src/index.ts @@ -0,0 +1,160 @@ +/** + * A hexagonal slice: an application layer that names its own ports and never + * mentions an adapter, a persistence layer with a private connection pool and + * a public repository, and a composition root generic enough to wire the same + * application against a production adapter or an in-memory one. This is the + * shape `@btravstack/di` is built for — see the package README for the same + * walk-through with commentary, and `src/index.spec.ts` for both graphs built + * and exercised end to end. + */ +import { Module, Port, Provider, type ServiceOf } from "@btravstack/di"; +import { Err, Ok, TaggedError, type AsyncResult, type Result } from "unthrown"; + +export class OrderNotFound extends TaggedError("OrderNotFound")<{ readonly id: string }> {} +export class ConfigError extends TaggedError("ConfigError")<{ readonly reason: string }> {} + +export type Order = { + readonly id: string; + readonly total: number; +}; + +/* ── Ports: the application's boundary, named by the domain, never by an + adapter — ──────────────────────────────────────────────────────────── */ + +export class Env extends Port("Env")> {} +export class AppConfig extends Port("AppConfig")<{ readonly dbUrl: string }> {} + +/** + * The connection pool: a real resource, acquired once and closed on + * teardown, which is exactly what routes any module providing it through + * `Module.scoped` rather than `Module.build` (see `provider.ts`'s `ScopeOf` + * in the library itself). Exported here — the plain TypeScript `export` — so + * `src/index.test-d.ts` can name the *class* and attempt `ctx.get(Pool)` + * against it; the guarantee that assertion pins is that the *DI module* + * below never lists `Pool` in its own `exports`, so no built application + * context can name it, even though the flat runtime map genuinely holds it. + */ +export class Pool extends Port("Pool")<{ + readonly findById: (id: string) => Order | undefined; + readonly close: () => void; +}> {} + +export class OrderRepository extends Port("OrderRepository")<{ + readonly findById: (id: string) => AsyncResult; +}> {} + +export class GetOrder extends Port("GetOrder")<{ + readonly execute: (id: string) => AsyncResult; +}> {} + +/* ── Application: the use case depends on the port, never an adapter ───── */ + +export class GetOrderInteractor { + private readonly orders: ServiceOf; + // Parameter-property shorthand (`constructor(private readonly orders: …)`) + // has no type-erasure-only meaning, so this repo's tsconfig rejects it; the + // field is declared and assigned explicitly instead. + constructor(orders: ServiceOf) { + this.orders = orders; + } + execute(id: string): AsyncResult { + return this.orders.findById(id); + } +} + +/* ── Config: shared by every persistence adapter that needs one ────────── */ + +export const ConfigModule = Module("Config")({ + provides: [ + // A stand-in `Env`, not the process's real `process.env` — this is a + // composition root's own choice, not something the library asks for; a + // real one would read `process.env` and let a genuinely unset variable + // surface as the `ConfigError` below. + Provider(Env)({ value: { ORDER_API_DATABASE_URL: "postgres://localhost/orders" } }), + Provider(AppConfig)([Env], { + make: (env) => + env["ORDER_API_DATABASE_URL"] === undefined + ? Err(new ConfigError({ reason: "ORDER_API_DATABASE_URL is unset" })) + : Ok({ dbUrl: env["ORDER_API_DATABASE_URL"] }), + }), + ], + exports: [AppConfig], +}); + +/** + * Stands in for a real driver: a handful of in-process rows, closable + * exactly once. `config.dbUrl` is read but otherwise unused — a real adapter + * would pass it straight to whatever client it opens. + */ +const openPool = (config: ServiceOf): Result, never> => { + void config.dbUrl; + const seed: readonly Order[] = [ + { id: "o-1", total: 4_200 }, + { id: "o-2", total: 1_500 }, + ]; + let closed = false; + return Ok({ + findById: (id) => (closed ? undefined : seed.find((row) => row.id === id)), + close: () => { + closed = true; + }, + }); +}; + +/** + * The production adapter. `Pool` is resourceful, so this module's `Needs` + * carries `Scope` — which propagates through anything that imports it, + * routing it (and `makeAppModule` below, once applied to it) through + * `Module.scoped` at the composition root, never `Module.build`. + */ +export const makePersistenceModule = () => + Module("Persistence")({ + imports: [ConfigModule], + provides: [ + Provider(Pool)([AppConfig], { + acquire: openPool, + release: (pool) => pool.close(), + }), + Provider(OrderRepository)([Pool], { + sync: (pool) => ({ + findById: (id) => { + const row = pool.findById(id); + return (row === undefined ? Err(new OrderNotFound({ id })) : Ok(row)).toAsync(); + }, + }), + }), + ], + // `Pool` never appears here — the only port this module makes visible is + // `OrderRepository`. `Pool` is still genuinely present in the built + // context's flat runtime map; `exports` withholds the *type* that would + // let a caller name it. + exports: [OrderRepository], + }); + +/** + * The in-memory adapter: nothing to acquire, so nothing to release — this + * module's `Needs` is `never`, same as its `E` (a `value` provider cannot + * fail). `Module.build` accepts it directly. + */ +export const InMemoryPersistenceModule = Module("InMemoryPersistence")({ + provides: [ + Provider(OrderRepository)({ + value: { findById: (id) => Ok({ id, total: 99 }).toAsync() }, + }), + ], + exports: [OrderRepository], +}); + +/** + * The composition seam: generic in the persistence module's own `E`/`Needs`, + * so the same application module wires up unchanged against either adapter. + * Only the entry point used to build it — `Module.build` for the in-memory + * graph, `Module.scoped` for the resourceful one — differs, and the type + * system forces the right one at the call site (see `src/index.test-d.ts`). + */ +export const makeAppModule = (persistence: Module) => + Module("App")({ + imports: [persistence], + provides: [Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor })], + exports: [GetOrder], + }); diff --git a/examples/hexagonal-order-api/tsconfig.emit.json b/examples/hexagonal-order-api/tsconfig.emit.json new file mode 100644 index 0000000..9e53c0e --- /dev/null +++ b/examples/hexagonal-order-api/tsconfig.emit.json @@ -0,0 +1,14 @@ +// The declaration-emit gate. `TS4020` and friends are raised by the +// *declaration emitter*, so the package's ordinary `tsc --noEmit` pass cannot +// see them: `noEmit` has to come back off and the emitter has to actually run. +// `src/emit-guards.ts` is the fixture it exists for — see that file's header. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "outDir": "./node_modules/.emit-check" + } +} diff --git a/examples/hexagonal-order-api/tsconfig.json b/examples/hexagonal-order-api/tsconfig.json new file mode 100644 index 0000000..f78f9b9 --- /dev/null +++ b/examples/hexagonal-order-api/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@btravstack/tsconfig/base.json", + "compilerOptions": { + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test-d.ts"] +} diff --git a/examples/hexagonal-order-api/tsconfig.test-d.json b/examples/hexagonal-order-api/tsconfig.test-d.json new file mode 100644 index 0000000..eab5a71 --- /dev/null +++ b/examples/hexagonal-order-api/tsconfig.test-d.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src/**/*.test-d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/hexagonal-order-api/vitest.config.ts b/examples/hexagonal-order-api/vitest.config.ts new file mode 100644 index 0000000..fb76260 --- /dev/null +++ b/examples/hexagonal-order-api/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + setupFiles: ["@unthrown/vitest"], + }, +}); diff --git a/examples/order-amqp/package.json b/examples/order-amqp/package.json index f7f76f6..7e3b8b5 100644 --- a/examples/order-amqp/package.json +++ b/examples/order-amqp/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@amqp-contract/worker": "catalog:", - "@btravstack/di": "catalog:", + "@btravstack/di": "workspace:*", "@btravstack/start": "workspace:*", "@btravstack/start-amqp": "workspace:*", "@btravstack/start-example-order-amqp-contract": "workspace:*", diff --git a/examples/order-api/package.json b/examples/order-api/package.json index 2c42daf..050a469 100644 --- a/examples/order-api/package.json +++ b/examples/order-api/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "dependencies": { - "@btravstack/di": "catalog:", + "@btravstack/di": "workspace:*", "@btravstack/start": "workspace:*", "@btravstack/start-example-order-api-contract": "workspace:*", "@btravstack/start-example-order-application": "workspace:*", diff --git a/examples/order-application/package.json b/examples/order-application/package.json index b55100d..6bba6c8 100644 --- a/examples/order-application/package.json +++ b/examples/order-application/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "dependencies": { - "@btravstack/di": "catalog:", + "@btravstack/di": "workspace:*", "@btravstack/start": "workspace:*", "@btravstack/start-example-order-domain": "workspace:*", "unthrown": "catalog:" diff --git a/examples/order-infrastructure/package.json b/examples/order-infrastructure/package.json index eb47155..3de8b4a 100644 --- a/examples/order-infrastructure/package.json +++ b/examples/order-infrastructure/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@btravstack/di": "catalog:", + "@btravstack/di": "workspace:*", "@btravstack/start-example-order-application": "workspace:*", "@btravstack/start-example-order-domain": "workspace:*", "@prisma/adapter-better-sqlite3": "catalog:", diff --git a/examples/order-temporal/package.json b/examples/order-temporal/package.json index 77d8e85..70480e5 100644 --- a/examples/order-temporal/package.json +++ b/examples/order-temporal/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "dependencies": { - "@btravstack/di": "catalog:", + "@btravstack/di": "workspace:*", "@btravstack/start": "workspace:*", "@btravstack/start-example-order-application": "workspace:*", "@btravstack/start-example-order-config": "workspace:*", diff --git a/examples/order-worker/package.json b/examples/order-worker/package.json index 5f1e73f..8d70e3c 100644 --- a/examples/order-worker/package.json +++ b/examples/order-worker/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "dependencies": { - "@btravstack/di": "catalog:", + "@btravstack/di": "workspace:*", "@btravstack/start": "workspace:*", "@btravstack/start-example-order-application": "workspace:*", "@btravstack/start-example-order-config": "workspace:*", diff --git a/examples/plugin-registry/README.md b/examples/plugin-registry/README.md new file mode 100644 index 0000000..d8e4cda --- /dev/null +++ b/examples/plugin-registry/README.md @@ -0,0 +1,46 @@ +# plugin-registry + +Multi-binding: a `Port.many` health-check registry fed independently by two +modules, collected and run together at the composition root. + +```sh +pnpm --filter @btravstack/di-example-plugin-registry test +pnpm --filter @btravstack/di-example-plugin-registry typecheck +``` + +## What it shows + +`src/index.ts`, top to bottom: + +- **A set port, not an ordinary one.** `HealthCheck` is declared with + `Port.many`, so `Context.get(HealthCheck)` returns `readonly HealthCheck[]` + — every contribution — rather than a single service. Two ordinary + providers targeting the same port would be a wiring defect; two + `Provider.member` contributions to the same set port are the intended + shape. +- **Contributions from modules that do not know about each other.** + `DatabaseModule` and `CacheModule` each contribute one member with + `Provider.member(HealthCheck)(...)`. Neither imports the other, and + neither has to know how many other contributors exist — `AppModule` simply + imports both and re-exports them. +- **A composition-root concern, not the library's.** `runHealthChecks` folds + every contribution's result into a report, deliberately never + short-circuiting on the first failure — a health check that cannot reach + its dependency is data the caller wants back, not a reason to stop asking + the rest. That is ordinary application code built _on_ `@btravstack/di`, + not something the library does for you. + +## What the spec proves + +- **Contributions accumulate across module boundaries.** `AppModule` never + declares a `HealthCheck` provider itself — every member the built context + returns came from `DatabaseModule` or `CacheModule`, and the first test + asserts both are present, not just one. +- **A failure is reported, not thrown.** `CacheModule`'s check models an + unreachable cache as an `Err`; the second test asserts `runHealthChecks` + turns that into an `"unhealthy"` report alongside the database's + `"healthy"` one — the failure never aborts the run. +- **The registry keeps growing without touching what already contributes.** + The third test wires a brand-new `QueueModule` alongside the untouched + `AppModule` and gets three contributions back, proof that adding a + plugin is purely additive. diff --git a/examples/plugin-registry/package.json b/examples/plugin-registry/package.json new file mode 100644 index 0000000..c482534 --- /dev/null +++ b/examples/plugin-registry/package.json @@ -0,0 +1,27 @@ +{ + "name": "@btravstack/di-example-plugin-registry", + "private": true, + "description": "Multi-binding with @btravstack/di: a Port.many health-check registry fed independently by two modules, collected and run together at the composition root", + "license": "MIT", + "author": "Benoit TRAVERS ", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@btravstack/di": "workspace:*", + "unthrown": "catalog:" + }, + "devDependencies": { + "@btravstack/tsconfig": "catalog:", + "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/plugin-registry/src/index.spec.ts b/examples/plugin-registry/src/index.spec.ts new file mode 100644 index 0000000..03e649a --- /dev/null +++ b/examples/plugin-registry/src/index.spec.ts @@ -0,0 +1,57 @@ +import { Module, Provider } from "@btravstack/di"; +// Side-effect import for `tsc`'s benefit only — see the identical note in +// hexagonal-order-api's `index.spec.ts`. +import "@unthrown/vitest"; +import { OkAsync } from "unthrown"; +import { expect, test } from "vitest"; + +import { AppModule, HealthCheck, runHealthChecks } from "./index.js"; + +test("contributions accumulate across module boundaries", async () => { + const built = await Module.build(AppModule); + expect(built).toBeOk(); + + const names = built.isOk() + ? built.value + .get(HealthCheck) + .map((check) => check.name) + .toSorted() + : []; + // `HealthCheck` was never declared in `AppModule.provides` — every member + // came from `DatabaseModule` or `CacheModule`, and both are present. + expect(names).toEqual(["cache", "database"]); +}); + +test("the registry runs every contribution and reports a failure as data, not an exception", async () => { + const built = await Module.build(AppModule); + const checks = built.isOk() ? built.value.get(HealthCheck) : []; + + const reports = await runHealthChecks(checks); + + expect(reports.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([ + { name: "cache", status: "unhealthy", reason: "connection refused" }, + { name: "database", status: "healthy" }, + ]); +}); + +test("a third module contributes to the same set port without either existing one changing", async () => { + const QueueModule = Module("Queue")({ + provides: [ + Provider.member(HealthCheck)({ value: { name: "queue", run: () => OkAsync("healthy") } }), + ], + exports: [HealthCheck], + }); + const extended = Module("ExtendedApp")({ + imports: [AppModule, QueueModule], + exports: [AppModule, QueueModule], + }); + + const built = await Module.build(extended); + const names = built.isOk() + ? built.value + .get(HealthCheck) + .map((check) => check.name) + .toSorted() + : []; + expect(names).toEqual(["cache", "database", "queue"]); +}); diff --git a/examples/plugin-registry/src/index.ts b/examples/plugin-registry/src/index.ts new file mode 100644 index 0000000..475486a --- /dev/null +++ b/examples/plugin-registry/src/index.ts @@ -0,0 +1,110 @@ +/** + * Multi-binding: a `Port.many` set port that several modules contribute to + * independently, collected and run together by the composition root. Real + * examples: a health-check registry (this one), a list of event handlers, a + * plugin registry. `Context.get` on a set port returns every contribution, + * accumulated across module boundaries — not just the last one registered. + */ +import { Module, Port, Provider, type ServiceOf } from "@btravstack/di"; +import { ErrAsync, OkAsync, P, TaggedError, type AsyncResult } from "unthrown"; + +// `check`, not `name`: `TaggedError`'s payload reserves `name` (along with +// `message`/`stack`) for the class itself (`?: never`), so a domain field +// called `name` cannot carry a value here — it would type as `never`. +export class HealthCheckFailed extends TaggedError("HealthCheckFailed")<{ + readonly check: string; + readonly reason: string; +}> {} + +/** + * The set port. `Port.many` fixes the *member* shape — what one contribution + * looks like — while the port's own service, what `Context.get` actually + * returns, is `readonly HealthCheck[]`: the whole accumulated list. + */ +export class HealthCheck extends Port.many("HealthCheck")<{ + readonly name: string; + readonly run: () => AsyncResult<"healthy", HealthCheckFailed>; +}> {} + +export type HealthReport = { + readonly name: string; + readonly status: "healthy" | "unhealthy"; + readonly reason?: string; +}; + +/** + * The composition root's own job, not the library's: every contribution + * runs, and a failing one is folded into the report rather than stopping the + * rest — a health check that cannot reach its dependency is data the caller + * wants, not a reason to abandon asking the others. + */ +export const runHealthChecks = ( + checks: ServiceOf, +): Promise => + Promise.all( + checks.map((check): Promise => + check.run().match({ + ok: () => ({ name: check.name, status: "healthy" }), + errCases: (m) => + m.with(P.tag("HealthCheckFailed"), (e) => ({ + name: check.name, + status: "unhealthy" as const, + reason: e.reason, + })), + defect: () => ({ + name: check.name, + status: "unhealthy" as const, + reason: "unexpected failure", + }), + }), + ), + ); + +/* ── Two plugins, contributing independently ───────────────────────────── + Neither module imports the other, and neither knows how many other + contributors to HealthCheck exist — that is the whole point of a set + port. */ + +export class Database extends Port("Database")<{ + readonly ping: () => AsyncResult<"healthy", HealthCheckFailed>; +}> {} + +export const DatabaseModule = Module("Database")({ + provides: [ + Provider(Database)({ value: { ping: () => OkAsync("healthy") } }), + Provider.member(HealthCheck)([Database], { + sync: (db) => ({ name: "database", run: db.ping }), + }), + ], + exports: [Database, HealthCheck], +}); + +export class Cache extends Port("Cache")<{ + readonly ping: () => AsyncResult<"healthy", HealthCheckFailed>; +}> {} + +export const CacheModule = Module("Cache")({ + provides: [ + Provider(Cache)({ + value: { + ping: () => + ErrAsync(new HealthCheckFailed({ check: "cache", reason: "connection refused" })), + }, + }), + Provider.member(HealthCheck)([Cache], { + sync: (cache) => ({ name: "cache", run: cache.ping }), + }), + ], + exports: [Cache, HealthCheck], +}); + +/** + * The composition root: re-exports both plugin modules whole, so anything + * built from `AppModule` can still name `Database`/`Cache` individually if + * it needs to. `HealthCheck`'s contributions from both land on the one set + * port regardless of which module declared them. + */ +export const AppModule = Module("App")({ + imports: [DatabaseModule, CacheModule], + exports: [DatabaseModule, CacheModule], +}); diff --git a/examples/plugin-registry/tsconfig.json b/examples/plugin-registry/tsconfig.json new file mode 100644 index 0000000..7fa4349 --- /dev/null +++ b/examples/plugin-registry/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@btravstack/tsconfig/base.json", + "compilerOptions": { + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/examples/plugin-registry/vitest.config.ts b/examples/plugin-registry/vitest.config.ts new file mode 100644 index 0000000..fb76260 --- /dev/null +++ b/examples/plugin-registry/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + setupFiles: ["@unthrown/vitest"], + }, +}); diff --git a/examples/request-scope/README.md b/examples/request-scope/README.md new file mode 100644 index 0000000..9ba9aad --- /dev/null +++ b/examples/request-scope/README.md @@ -0,0 +1,49 @@ +# request-scope + +Lifetime management: a connection pool acquired once, under `Module.scoped`, +and a per-request transaction layered over the already-built parent with +`Module.forkScope` — once per request. + +```sh +pnpm --filter @btravstack/di-example-request-scope test +pnpm --filter @btravstack/di-example-request-scope typecheck +``` + +## What it shows + +`src/index.ts`, top to bottom: + +- **Two different lifetimes, two different modules.** `App` provides + `ConnectionPool` with the resourceful `acquire`/`release` arm — opened + once, for the life of the process. `Request` provides `Transaction`, also + resourceful, but built fresh for every request and depending on + `ConnectionPool` without providing it itself. +- **`Module.forkScope` is what makes that legal.** `Request`'s own `Needs` + still lists `ConnectionPool` — nothing in that module supplies it — but + `forkScope` resolves it from the already-_built_ parent `Context` it is + handed, rather than from `Request`'s own (empty) provider set. That is the + entire reason to fork over a built parent instead of building `Request` on + its own. +- **A fresh scope per fork.** Each call to `Module.forkScope` opens its own + scope, seeded with the parent context but registering only _this_ fork's + own finalisers on it. Closing that scope therefore releases only what this + fork acquired — the parent's pool is never touched. + +## What the spec proves + +Both facts in the brief are about _timing_, not just wiring, so +`src/index.spec.ts` proves them empirically: + +- **The per-request resource releases after each request, while the parent + stays up.** After every `handleRequest` call the test checkpoints that the + most recent lifecycle event is `"txn-released"` and that `"pool-released"` + has not appeared yet — for three separate, sequential requests over the + same parent. +- **The parent releases last.** The full event timeline asserted at the end + is `pool-acquired`, then three `txn-acquired`/`txn-released` pairs, then + `pool-released` — once, after every request, never before. + +A second test checks the other half of forking over a _built_ parent: two +sibling requests both read `ConnectionPool` off the same parent context, so +their transactions share one connection lineage rather than each fork +constructing its own copy. diff --git a/examples/request-scope/package.json b/examples/request-scope/package.json new file mode 100644 index 0000000..a5e8493 --- /dev/null +++ b/examples/request-scope/package.json @@ -0,0 +1,27 @@ +{ + "name": "@btravstack/di-example-request-scope", + "private": true, + "description": "Lifetime management with @btravstack/di: a pool acquired once under Module.scoped, and a transaction forked per request over the already-built parent", + "license": "MIT", + "author": "Benoit TRAVERS ", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@btravstack/di": "workspace:*", + "unthrown": "catalog:" + }, + "devDependencies": { + "@btravstack/tsconfig": "catalog:", + "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/request-scope/src/index.spec.ts b/examples/request-scope/src/index.spec.ts new file mode 100644 index 0000000..5f04711 --- /dev/null +++ b/examples/request-scope/src/index.spec.ts @@ -0,0 +1,76 @@ +import { Module } from "@btravstack/di"; +// Side-effect import for `tsc`'s benefit only — see the identical note in +// hexagonal-order-api's `index.spec.ts`. +import "@unthrown/vitest"; +import { Ok } from "unthrown"; +import { expect, test } from "vitest"; + +import { type LifecycleEvent, handleRequest, makeAppModule } from "./index.js"; + +test("each request's transaction releases before the next begins, and the pool survives every one", async () => { + const events: LifecycleEvent[] = []; + const onEvent = (event: LifecycleEvent) => void events.push(event); + + const outcome = await Module.scoped(makeAppModule(onEvent), (appCtx) => + handleRequest(appCtx, onEvent, (txn) => Ok(txn.run("req-1")).toAsync()) + // Checkpoint after the first request unwinds: its own transaction has + // already released, and — critically — "pool-released" has not + // appeared yet, proving the parent stayed up across the fork's own + // teardown. + .tap(() => { + expect(events.at(-1)).toBe("txn-released"); + expect(events).not.toContain("pool-released"); + }) + .flatMap(() => + handleRequest(appCtx, onEvent, (txn) => Ok(txn.run("req-2")).toAsync()) + // Same checkpoint after a second, sibling fork over the same + // parent — the parent survives more than just the first fork. + .tap(() => { + expect(events.at(-1)).toBe("txn-released"); + expect(events).not.toContain("pool-released"); + }), + ) + .flatMap(() => + handleRequest(appCtx, onEvent, (txn) => Ok(txn.run("req-3")).toAsync()).tap(() => { + expect(events.at(-1)).toBe("txn-released"); + expect(events).not.toContain("pool-released"); + }), + ), + ); + + expect(outcome).toBeOk(); + // The full timeline: the pool opens once, before anything else, and + // closes once, after every request's transaction has already released — + // last, not merely present. + expect(events).toEqual([ + "pool-acquired", + "txn-acquired", + "txn-released", + "txn-acquired", + "txn-released", + "txn-acquired", + "txn-released", + "pool-released", + ]); +}); + +test("a fork resolves its dependency from the parent, not a copy of its own", async () => { + const events: LifecycleEvent[] = []; + const onEvent = (event: LifecycleEvent) => void events.push(event); + + const outcome = await Module.scoped(makeAppModule(onEvent), (appCtx) => + handleRequest(appCtx, onEvent, (txn) => Ok(txn.run("a")).toAsync()).flatMap((first) => + handleRequest(appCtx, onEvent, (txn) => Ok(txn.run("b")).toAsync()).map( + (second) => [first, second] as const, + ), + ), + ); + + expect(outcome).toBeOk(); + const [first, second] = outcome.isOk() ? outcome.value : ["", ""]; + // Both requests' transactions ran their query through the *same* + // connection lineage — `handleRequest`'s fork read `ConnectionPool` off + // the parent `appCtx`, not off a fresh copy of its own. + const poolIdOf = (label: string) => label.split("/")[0]; + expect(poolIdOf(first)).toBe(poolIdOf(second)); +}); diff --git a/examples/request-scope/src/index.ts b/examples/request-scope/src/index.ts new file mode 100644 index 0000000..0524d5e --- /dev/null +++ b/examples/request-scope/src/index.ts @@ -0,0 +1,100 @@ +/** + * Lifetime management: a connection pool acquired once, under `Module.scoped`, + * and a per-request transaction layered over the already-built parent with + * `Module.forkScope` — once per request. The parent survives every fork, each + * fork releases only what it acquired, and it releases before the parent + * does. `src/index.spec.ts` proves the release order empirically, not just + * that the graph type-checks. + */ +import { Module, Port, Provider, type Context, type ServiceOf } from "@btravstack/di"; +import { Ok, type AsyncResult, type Result } from "unthrown"; + +/** + * The events a real system would emit as logs or metrics. Threading them + * through an `onEvent` callback — rather than hard-coding `console.log` — is + * what lets `src/index.spec.ts` observe lifecycle order without reaching + * into anything private; a real caller would wire this to its own logger. + */ +export type LifecycleEvent = "pool-acquired" | "pool-released" | "txn-acquired" | "txn-released"; + +export class ConnectionPool extends Port("ConnectionPool")<{ + readonly id: string; + readonly connect: () => string; +}> {} + +export class Transaction extends Port("Transaction")<{ + readonly id: string; + readonly run: (label: string) => string; +}> {} + +const openPool = (): Result, never> => { + const id = `pool-${crypto.randomUUID()}`; + let connections = 0; + return Ok({ + id, + connect: () => { + connections += 1; + return `${id}/conn-${connections}`; + }, + }); +}; + +const beginTransaction = ( + pool: ServiceOf, +): Result, never> => { + const id = `txn-${crypto.randomUUID()}`; + const connection = pool.connect(); + return Ok({ id, run: (label) => `${connection}/${label}` }); +}; + +/** + * Built once per application, not once per request: `Module.scoped` opens + * the scope this module's `ConnectionPool` provider registers its `release` + * on, and holds it open for as long as the caller's `use` callback runs. + */ +export const makeAppModule = (onEvent: (event: LifecycleEvent) => void) => + Module("App")({ + provides: [ + Provider(ConnectionPool)({ + acquire: () => { + onEvent("pool-acquired"); + return openPool(); + }, + release: () => void onEvent("pool-released"), + }), + ], + exports: [ConnectionPool], + }); + +/** + * Built fresh for every request: `Transaction` depends on `ConnectionPool`, + * which this module does not itself provide — `Module.forkScope` resolves it + * from the already-built parent context instead, which is the entire point + * of forking over a *built* parent rather than an empty one. + */ +export const makeRequestModule = (onEvent: (event: LifecycleEvent) => void) => + Module("Request")({ + provides: [ + Provider(Transaction)([ConnectionPool], { + acquire: (pool) => { + onEvent("txn-acquired"); + return beginTransaction(pool); + }, + release: () => void onEvent("txn-released"), + }), + ], + exports: [Transaction], + }); + +/** + * One request: forks a short-lived scope over the parent app context, runs + * `work` against the transaction it constructs, and releases the + * transaction — never the parent's pool — once `work` settles, whether it + * succeeds or fails. + */ +export const handleRequest = ( + appCtx: Context, + onEvent: (event: LifecycleEvent) => void, + work: (txn: ServiceOf) => AsyncResult, +): AsyncResult => + Module.forkScope(appCtx, makeRequestModule(onEvent), (ctx) => work(ctx.get(Transaction))); diff --git a/examples/request-scope/tsconfig.json b/examples/request-scope/tsconfig.json new file mode 100644 index 0000000..7fa4349 --- /dev/null +++ b/examples/request-scope/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@btravstack/tsconfig/base.json", + "compilerOptions": { + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/examples/request-scope/vitest.config.ts b/examples/request-scope/vitest.config.ts new file mode 100644 index 0000000..fb76260 --- /dev/null +++ b/examples/request-scope/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + setupFiles: ["@unthrown/vitest"], + }, +}); diff --git a/knip.json b/knip.json deleted file mode 100644 index e9fc074..0000000 --- a/knip.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://unpkg.com/knip@6/schema.json", - "ignoreExportsUsedInFile": true, - "ignore": ["**/*.test-d.ts"], - "ignoreDependencies": ["@btravstack/lefthook", "@btravstack/oxlint"], - "workspaces": { - "examples/order-api": { "entry": ["src/main.ts"] }, - "examples/order-worker": { "entry": ["src/main.ts"] }, - "examples/order-temporal": { "entry": ["src/main.ts"] } - } -} diff --git a/knip.jsonc b/knip.jsonc new file mode 100644 index 0000000..c787ee3 --- /dev/null +++ b/knip.jsonc @@ -0,0 +1,39 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "ignoreExportsUsedInFile": true, + + // Type-level behaviour lives in `*.test-d.ts`, checked by its own tsc pass + // and deliberately kept out of the main one, out of oxlint and out of knip. + // Nothing imports them — that is the design — so knip reads them as dead + // files. `packages/di/src/type-assert.ts` rides along in the same list, not + // just the glob: its only export, `Equal`, exists purely for `*.test-d.ts` + // files to import, and those files are the ones just excluded — with them + // gone from knip's project, nothing left in scope imports `Equal`. + "ignore": ["**/*.test-d.ts", "**/type-assert.ts"], + + // Config packages knip cannot trace, each verified referenced: + // `@btravstack/oxlint` from `.oxlintrc.json`'s `extends`, + // `@btravstack/lefthook` from `lefthook.yml`'s `extends`, and + // `@btravstack/typedoc` plus `typedoc-plugin-markdown` from + // `docs/typedoc.json`. Knip resolves none of these config formats, so it + // reads all four as unused devDependencies. + "ignoreDependencies": [ + "@btravstack/lefthook", + "@btravstack/oxlint", + "@btravstack/typedoc", + "typedoc-plugin-markdown", + ], + + "workspaces": { + "examples/hexagonal-order-api": { + // `emit-guards.ts` is deliberately imported by nothing. It exists to be + // COMPILED: it is the declaration-emit fixture that keeps TS4020 from + // coming back, and its assertions are `@ts-expect-error` directives with + // no runtime moment. Naming it an entry is what stops knip reporting the + // guard as dead code and someone helpfully deleting it. `src/index.ts` is + // not listed: it is already the package entry. + "entry": ["src/emit-guards.ts"], + "project": ["src/**/*.ts"], + }, + }, +} diff --git a/packages/di/CHANGELOG.md b/packages/di/CHANGELOG.md new file mode 100644 index 0000000..b7469ea --- /dev/null +++ b/packages/di/CHANGELOG.md @@ -0,0 +1,67 @@ +# @btravstack/di + +## 0.1.0 + +Initial release. + +A module-based dependency-injection container for TypeScript. **Ports** are the +vocabulary an application defines for what it needs, **providers** bind a port to +one concrete construction at a single edge, and **modules** group providers while +declaring what they import and what they let anyone else see. Every fallible +construction returns an `unthrown` `Result` rather than throwing. + +Wiring mistakes are compile errors — a missing dependency, an internal port +leaking out of a module, a re-export of something never imported. The two that +types cannot catch, a cycle and two providers registered for the same port, are +raised as defects before any factory runs. + +### The surface + +- **`Port(id)`** declares a port as a nominal token: + `class OrderRepository extends Port("OrderRepository") {}`. Identity is + the token, not the shape, so two ports with identical services stay distinct. + `Port.many(id)` declares a set port that several providers contribute + to — a plugin registry, a list of health checks — and reading it returns every + contribution, accumulated across module boundaries. +- **`Provider(port)(deps?, options)`** binds one port. The options literal picks + exactly one of five mutually exclusive arms — `value`, `sync`, `make`, `class`, + or `acquire` + `release` — and supplying more than one is a compile error. + Every arm also takes optional `onStart` / `onStop` hooks, fired once the whole + graph has constructed and during teardown. `Provider.member(port)` contributes + to a set port. +- **`Module(name)({ imports, provides, exports })`** groups providers. Anything + not exported is private to the module even though the built container is a + single flat map at runtime, and the privacy is enforced at compile time. + `Module.build` builds a graph that needs nothing resourceful, `Module.scoped` + opens a scope for one that does, and `Module.forkScope` layers a short-lived + scope over an already-built parent — per-request services that must not + outlive the request but may read what the parent constructed. +- **`Context`** is the built graph: `ctx.get(port)` returns the service, typed + from the port alone. +- Types: `AnyPort`, `ServiceOf`, `ScopedOptions`, and `Scope`. `PortClass` and + `ManyPortClass` are exported for declaration emit — a consumer compiling with + `declaration: true` and exporting a port needs them nameable — not because + either is meant to be written by hand. + +### What it guarantees + +- **An unmet dependency does not compile.** Every requirement a graph has not + discharged shows up in its `Needs`, and the build call's arity gate rejects it + with an `UNSATISFIED DEPENDENCIES` parameter naming what is missing. +- **A resourceful graph cannot be built without a scope.** `Scope` stays in + `Needs` for any provider with `acquire`/`release` or an `onStop`, and + `Module.scoped` is the only entry point that discharges it. Passing such a + graph to `Module.build` is a compile error, not a runtime leak. +- **Teardown is ordered and survives partial failure.** Finalisers run in + reverse acquisition order, and a graph that fails half-constructed unwinds + exactly what it managed to acquire — on success, on failure, and on the + mid-graph case — before the call resolves. +- **Errors are values.** A failing construction is an `unthrown` `Result` in the + module's own error channel. A wiring mistake is a defect on the separate + channel, because it is a bug rather than an outcome. +- **Port identity is unforgeable.** The brand symbols behind a port are never + exported, so no hand-written object can pass itself off as a port instance. + +### Peer dependency + +`unthrown` (`^5.0.0`) — install it alongside. diff --git a/packages/di/CLAUDE.md b/packages/di/CLAUDE.md new file mode 100644 index 0000000..de43aac --- /dev/null +++ b/packages/di/CLAUDE.md @@ -0,0 +1,45 @@ +# packages/di + +`@btravstack/di` — a module-based dependency-injection container for TypeScript. Ports are the vocabulary an application defines, providers bind them at one edge, modules declare `imports`/`exports`. Every wiring mistake the type system can catch is a compile error; what it can't (cycles, duplicate providers) surfaces as a defect before any factory runs. Nothing throws to callers: every fallible operation returns an [`unthrown`](https://github.com/btravstack/unthrown) `Result` (peer dependency). + +Merged into this repo from the former `btravstack/di` repository, history included; still published as `@btravstack/di`, and still a **peer** dependency of the four `start` packages that consume it. The root `CLAUDE.md` owns the repo-wide gate and conventions; this file holds only what is di's own. di's consumer examples live in `examples/hexagonal-order-api`, `examples/request-scope` and `examples/plugin-registry`, and its VitePress + TypeDoc site is the `docs/` workspace. + +To scope a run to this package, run inside it, e.g.: + +```sh +cd packages/di +pnpm vitest run src/build.spec.ts # one test file +pnpm vitest run -t "releases in reverse" # one test by name +pnpm test:types # type-level tests only (tsc -p tsconfig.test-d.json) +``` + +## Architecture + +All runtime code lives in `packages/di/src`, one concept per file: + +- **`port.ts`** — `Port("Id")` returns a phantom class; consumers write `class OrderRepository extends Port("OrderRepository") {}`. Identity is nominal via module-private `unique symbol` brands (`ID`/`SERVICE`) — deliberately unexported so port instances are unforgeable. `Port.many` creates set ports (multiple providers contribute members); its runtime discriminant is the static `many: true` field, its type-level one the `[MANY]` brand. `Scope` is a phantom port (service shape `never`) that resourceful providers add to `Needs`. +- **`provider.ts`** — `Provider(Port)([deps], arm)` with a construction family of mutually exclusive option arms: `value` / `sync` / `make` (fallible, returns `Result`) / `class` / `acquire`+`release` (resourceful — puts `Scope` in `Needs`). Exclusivity is enforced by giving each arm the other keys as optional `never`. `Provider.member` contributes one member to a set port. +- **`module.ts`** — the `Module` algebra. Three phantom channels with a deliberate variance rule: capability channels (`_exports`) are contravariant ("you may forget what you have"), obligation channels (`_error`, `_needs`) are covariant ("you may not forget what you owe"). Entry points hang off the `Module` const: `Module.build` (requires `Needs = never`), `Module.scoped` (opens a scope, excludes `Scope` from the check, guarantees close on every path), `Module.forkScope` (per-request scope seeded from a built parent `Context`). Unmet dependencies are compile errors via a conditional rest parameter — `[N] extends [never] ? [] : [error: "UNSATISFIED DEPENDENCIES", missing: N]`. +- **`build.ts`** — `flatten` (dedupe by provider reference), `plan` (levels providers for concurrent construction; detects cycles, duplicate providers, ordinary/set-port conflicts, providers for `Scope`, missing providers — all _before_ any factory runs), `run`, `runScoped`. Wiring bugs are thrown as `WiringDefect` inside a `.map` callback on purpose: unthrown converts the throw into its `Defect` channel, which is where wiring bugs (vs. modeled failures) belong. +- **`lifecycle.ts`** — constructs one level concurrently; collects `onStart` hooks, which fire only after the whole graph is built, in declaration order. +- **`context.ts`** — the built container: a flat map, `Context.get(port)`. Internal `unsafeAdd`/`unsafeAddAll`/`unsafeKeys` are package-private, not exported from `index.ts`. +- **`scope.ts`** — `createScope`: finalisers run LIFO on close; a throwing finaliser is reported (via `onTeardownError`) and swallowed, never rethrown, so teardown always completes and never masks the original failure. +- **`index.ts`** — the deliberate public surface. `Scope` is exported as a _type only_ (the class value would let consumers provide or alias it); `PortClass`/`ManyPortClass` are exported solely so declaration emit works for consumers who export ports. + +### Type-level tests + +Behaviour that exists only at the type level (phantom-channel variance, arm exclusivity, `Scope` gating) is pinned in `src/*.test-d.ts` via `@ts-expect-error`, checked by `tsc --noEmit -p tsconfig.test-d.json` (part of `pnpm typecheck`). These files are excluded from the main tsc pass, from oxlint, and from lefthook's pre-commit lint. If you change a type-level guarantee, update the matching assertion. `src/type-assert.ts` (`Equal`) is a test-only helper, excluded from knip. + +### Two TypeScript versions + +The catalog pins `typescript` 7.0.2 (what the repo builds with) and `typescript-consumer` (alias for 5.9.3 — what a consumer is realistically on). `examples/hexagonal-order-api`'s `typecheck` compiles declaration emit with _both_ and re-checks the emitted `.d.ts` under the consumer version; `src/emit-guards.ts` there is the fixture keeping the emitted declarations free of unnameable private types (the TS4020 class of bug). + +### Examples are tests + +`examples/*` exercise the library from a real consumer workspace (`workspace:*`, own `unthrown` dep since it's a peer). Their specs assert real behaviour (release order, set-port accumulation); compile-time-only guarantees live in their `*.test-d.ts`. Root `pnpm test`/`pnpm typecheck` include them. + +## Binding design rules + +- **Comments in `src/` are regression guards, not decoration.** Many record decisions measured against a specific TypeScript version or a real failure mode (a diagnostic code, a variance bug, an unsoundness). Verify before "simplifying" them away. +- **Errors as values.** The `unthrown/*` oxlint rules are binding: no throwing outside a documented defect path. The existing `WiringDefect` throws each carry a targeted `oxlint-disable` with the rationale — new exceptions need the same. +- **One name per concept.** Resist convenience aliases. The surface is meant to stay small enough that the library can be "done"; contributions that sharpen the design beat ones that grow it. diff --git a/packages/di/LICENSE b/packages/di/LICENSE new file mode 100644 index 0000000..5892fcc --- /dev/null +++ b/packages/di/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Benoit Travers + +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/di/README.md b/packages/di/README.md new file mode 100644 index 0000000..a03e959 --- /dev/null +++ b/packages/di/README.md @@ -0,0 +1,235 @@ +# @btravstack/di + +A dependency-injection container built around three ideas: + +- **Ports** are the vocabulary an application defines for what it needs — never what + an adapter happens to provide. +- **Providers** bind a port to a concrete construction — a value, a factory, a class, + or a resource with its own teardown. +- **Modules** group providers, declare what they need from elsewhere (`imports`), and + declare what they let outside modules see (`exports`) — everything else stays + private, even though the built container is a single flat map at runtime. + +Every wiring mistake this package can catch — a missing dependency, an internal port +leaking out of a module, a re-export of something never imported — is a compile +error, not a runtime surprise. What it cannot catch at compile time (a cycle, two +providers registered for the same port) is caught before any factory runs, as a +defect, not silently. + +This README is a worked example: a small hexagonal slice — one use case, one port for +its repository, a resourceful production adapter and a resource-free in-memory one — +built the same way an application depending on this package eventually will. The full +version, exercised end to end, lives in `src/example.spec.ts` / `src/example.test-d.ts`. + +## Ports: the application's boundary + +A port is declared once, named by the domain — not by whatever will eventually +implement it: + +```ts +import { Port, type ServiceOf } from "@btravstack/di"; +import type { AsyncResult } from "unthrown"; + +interface Order { + readonly id: string; + readonly total: number; +} + +class OrderRepository extends Port("OrderRepository")<{ + readonly findById: (id: string) => AsyncResult; +}> {} + +class GetOrder extends Port("GetOrder")<{ + readonly execute: (id: string) => AsyncResult; +}> {} +``` + +`Port(id)` is a nominal token: two ports declared with the same `Shape` but +different `id`s are different types, so a `Database` and a `Cache` that happen to +share a service shape can never be swapped for each other by accident. `ServiceOf

` +recovers the shape a service must have to satisfy `P` — used below to type an +application class's own dependency, without that class ever importing an adapter. + +## Application: depending on the port, never the adapter + +```ts +class GetOrderInteractor { + private readonly orders: ServiceOf; + constructor(orders: ServiceOf) { + this.orders = orders; + } + execute(id: string): AsyncResult { + return this.orders.findById(id); + } +} +``` + +`GetOrderInteractor` only ever sees `OrderRepository`'s port shape. Nothing here +knows whether the eventual implementation talks to Postgres or is an in-memory +fake — that is the adapter's problem, decided at the composition root, below. + +## Adapters: bound at one edge + +A `Provider` binds a port to a construction. Two adapters implement +`OrderRepository` here — a production one backed by a resourceful `Database` port, +and an in-memory one with nothing to release: + +```ts +import { Module, Provider } from "@btravstack/di"; +import { Err, Ok } from "unthrown"; + +const ConfigModule = Module("Config")({ + provides: [ + Provider(Env)({ value: process.env }), + Provider(AppConfig)([Env], { + make: (env) => + env["DATABASE_URL"] === undefined + ? Err(new ConfigError({ reason: "DATABASE_URL is unset" })) + : Ok({ dbUrl: env["DATABASE_URL"] }), + }), + ], + exports: [AppConfig], +}); + +const makePersistenceModule = () => + Module("Persistence")({ + imports: [ConfigModule], + provides: [ + // A real connection: acquired once, released on teardown. This is the + // resourceful arm, and it is what puts `Scope` in this module's `Needs`. + Provider(Database)([AppConfig], { + acquire: (config) => openPool(config.dbUrl), + release: (pool) => pool.close(), + }), + Provider(OrderRepository)([Database], { + sync: (db) => ({ findById: (id) => db.query(id) }), + }), + ], + exports: [OrderRepository], // Database stays internal to this module. + }); + +const InMemoryPersistenceModule = Module("InMemoryPersistence")({ + provides: [ + Provider(OrderRepository)({ + value: { findById: (id) => Ok({ id, total: 99 }).toAsync() }, + }), + ], + exports: [OrderRepository], +}); +``` + +`Database` never appears in either module's `exports`, so nothing outside +`Persistence` can name it — `OrderRepository` is the only port either adapter module +makes visible. That privacy is enforced at the type level: the built container is a +single flat map at runtime (there is nowhere else to put a service), so an internal +port really is present in it, but `exports` withholds the _type_ that would let a +caller call `ctx.get(Database)` in the first place. `src/example.test-d.ts` pins +exactly this with a `@ts-expect-error`. + +## Composition root: one application module, either adapter + +The application module is generic in the persistence module's own error and +requirement channels, so it wires up unchanged against either adapter: + +```ts +const makeAppModule = (persistence: Module) => + Module("App")({ + imports: [persistence], + provides: [ + Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor }), + ], + exports: [GetOrder], + }); +``` + +What differs is the entry point used to build it — and the type system, not a +convention, is what forces the right one: + +```ts +// The production graph needs Scope (Database is resourceful), so it must go +// through Module.scoped, which opens a scope and guarantees it is closed — +// on success, on failure, or on a mid-graph partial failure — before this +// call resolves. +const result = await Module.scoped( + makeAppModule(makePersistenceModule()), + (ctx) => ctx.get(GetOrder).execute("o-1"), +); + +// The in-memory graph has nothing resourceful, so its Needs is `never` — +// Module.build accepts it directly. Passing it to Module.scoped instead +// would also work (Scope is simply absent from Needs); passing the +// production module to Module.build is the one that does not compile. +const built = await Module.build(makeAppModule(InMemoryPersistenceModule)); +``` + +Trying to build the resourceful graph with `Module.build` is a compile error, not +a runtime leak: `Needs` still contains `Scope`, so the call's arity gate — the +"UNSATISFIED DEPENDENCIES" rest parameter every unmet requirement produces — rejects +it before anything runs. `Module.scoped` is the one entry point that opens a scope +and discharges `Scope` from `Needs`. + +## The construction family + +Every `Provider` picks exactly one of five mutually exclusive arms — supplying more +than one key at once is a compile error, not merely redundant: + +| Arm | Shape | When | Puts `Scope` in `Needs`? | +| --------------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------ | +| `value` | `S` | The service is already at hand — a config object, a constant. | No | +| `sync` | `(...deps) => S` | Built synchronously from its dependencies, and cannot fail. | No | +| `make` | `(...deps) => Result \| AsyncResult` | Built fallibly, possibly asynchronously — a parsed config, a validated client. | No | +| `class` | `new (...deps) => S` | Built by constructing a class, dependencies passed positionally to the constructor. | No | +| `acquire` + `release` | `acquire: (...deps) => Result \| AsyncResult`, `release: (s) => void \| Promise` | A real resource — a connection, a file handle — that must be torn down. | Yes | + +Every arm also accepts optional `onStart` / `onStop` hooks (`(service) => void | +Promise`), fired once the whole graph has finished constructing (`onStart`) or +during teardown (`onStop`) — supplied inline in the same options literal, e.g. +`Provider(Cache)({ value: cache, onStart: (c) => c.warm() })`. `onStop` puts `Scope` +in `Needs` for the same reason `acquire`/`release` does: it is teardown, and only +`Module.scoped` (or `Module.forkScope`) ever opens a scope to run it. + +`Port.many(id)` and `Provider.member(port)(...)` are the multi-binding +counterparts — several providers may target one set port (a plugin registry, a list +of health checks), and `Context.get` on it returns every contribution, accumulated +across module boundaries. `Module.forkScope` layers a short-lived scope over an +already-built parent `Context`, for per-request services that must not outlive the +request but may read what the parent already constructed. See `src/many.spec.ts` and +`src/fork.spec.ts` for both, worked end to end. + +## Install + +```sh +pnpm add @btravstack/di unthrown +``` + +`unthrown` is a **peer dependency** — install both. + +## Public surface + +```ts +export { Port } from "./port.js"; +export type { + AnyPort, + ManyPortClass, + PortClass, + Scope, + ServiceOf, +} from "./port.js"; +export { Context } from "./context.js"; +export { Provider } from "./provider.js"; +export { Module } from "./module.js"; +export type { ScopedOptions } from "./build.js"; +``` + +`Scope` is a **type** only. Every legitimate use of it is a type position, and +the class value is what would let you write `Provider(Scope)(…)` or widen it to +`AnyPort` — the two ways past the guard. `PortClass` and `ManyPortClass` are +exported so that a consumer compiling with `declaration: true` can export a port +of its own; without them the emitter reaches the module-private brand symbols +and fails with TS4020. The symbols stay unexported, so port instances remain +unforgeable. + +Everything else — `unsafeAdd`, `flatten`, `plan`, `run`, `runScoped`, `createScope`, +`constructLevel`, `WiringDefect`, and the handful of type-level helpers +(`ServicesOf`, `NeedsOf`, `PortInstance`, `Hooks`, …) that exist to make the four +files above typecheck — is implementation detail, not exported. diff --git a/packages/di/package.json b/packages/di/package.json new file mode 100644 index 0000000..cc690fb --- /dev/null +++ b/packages/di/package.json @@ -0,0 +1,70 @@ +{ + "name": "@btravstack/di", + "version": "0.1.0", + "description": "A module-based dependency-injection container for TypeScript: ports as the vocabulary an application defines, providers bound at one edge, and modules that declare their imports and exports", + "keywords": [ + "container", + "dependency-injection", + "di", + "hexagonal-architecture", + "inversion-of-control", + "ports-and-adapters", + "typescript", + "unthrown" + ], + "homepage": "https://github.com/btravstack/start#readme", + "bugs": { + "url": "https://github.com/btravstack/start/issues" + }, + "license": "MIT", + "author": "Benoit TRAVERS ", + "repository": { + "type": "git", + "url": "https://github.com/btravstack/start.git", + "directory": "packages/di" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsdown src/index.ts --format cjs,esm --dts --clean", + "dev": "tsdown src/index.ts --format cjs,esm --dts --watch", + "test": "vitest run", + "test:types": "tsc --noEmit -p tsconfig.test-d.json", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" + }, + "devDependencies": { + "@btravstack/tsconfig": "catalog:", + "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", + "@vitest/coverage-v8": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "unthrown": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "unthrown": "^5.0.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/di/src/build.spec.ts b/packages/di/src/build.spec.ts new file mode 100644 index 0000000..0e55e8f --- /dev/null +++ b/packages/di/src/build.spec.ts @@ -0,0 +1,195 @@ +import { Err, Ok, TaggedError, fromSafePromise } from "unthrown"; +import { expect, test, vi } from "vitest"; + +import { Module, Port, Provider } from "./index.js"; + +class AError extends TaggedError("AError")<{ readonly why: string }> {} +class BError extends TaggedError("BError")<{ readonly why: string }> {} + +class A extends Port("BA")<{ readonly v: string }> {} +class B extends Port("BB")<{ readonly v: string }> {} +class C extends Port("BC")<{ readonly v: string }> {} + +test("providers construct in dependency order, not declaration order", async () => { + const order: string[] = []; + const mod = Module("Ordered")({ + provides: [ + Provider(C)([B], { + sync: (b) => { + order.push("C"); + return { v: `${b.v}C` }; + }, + }), + Provider(B)([A], { + sync: (a) => { + order.push("B"); + return { v: `${a.v}B` }; + }, + }), + Provider(A)({ + sync: () => { + order.push("A"); + return { v: "A" }; + }, + }), + ], + exports: [C], + }); + + const built = await Module.build(mod); + expect(order).toEqual(["A", "B", "C"]); + expect(built).toBeOk(); +}); + +test("a port shared by two branches constructs exactly once", async () => { + const made = vi.fn(() => ({ v: "A" })); + const shared = Module("Shared")({ + provides: [Provider(A)({ sync: made })], + exports: [A], + }); + const left = Module("Left")({ + imports: [shared], + provides: [Provider(B)([A], { sync: (a) => ({ v: a.v }) })], + exports: [B], + }); + const right = Module("Right")({ + imports: [shared], + provides: [Provider(C)([A], { sync: (a) => ({ v: a.v }) })], + exports: [C], + }); + const app = Module("App")({ imports: [left, right], exports: [left, right] }); + + await Module.build(app); + expect(made).toHaveBeenCalledTimes(1); +}); + +test("a cycle within one module is a defect, reported before any factory runs", async () => { + const ran = vi.fn(); + const cyclic = Module("Cyclic")({ + provides: [Provider(A)([B], { sync: ran as never }), Provider(B)([A], { sync: ran as never })], + exports: [A], + }); + const built = await Module.build(cyclic); + expect(built).toBeDefect(); + expect(ran).not.toHaveBeenCalled(); +}); + +test("two distinct providers for one port are a defect, before any factory runs", async () => { + const ran = vi.fn(() => ({ v: "A" })); + const dup = Module("Dup")({ + provides: [Provider(A)({ sync: ran }), Provider(A)({ sync: ran })], + exports: [A], + }); + const built = await Module.build(dup); + expect(built).toBeDefect(); + expect(ran).not.toHaveBeenCalled(); +}); + +test("the error from a parallel level is the first in declaration order", async () => { + const slowFailure = Module("Slow")({ + provides: [ + Provider(A)({ + make: () => + Ok(undefined) + .toAsync() + .flatMap(() => Err(new AError({ why: "a" }))), + }), + Provider(B)({ make: () => Err(new BError({ why: "b" })) }), + ], + exports: [A, B], + }); + const built = await Module.build(slowFailure); + // B lands first in wall-clock terms; A wins because it is declared first. + expect(built).toBeErrTagged("AError"); +}); + +test( + "two providers at the same level construct concurrently, not one after another", + { timeout: 5000 }, + async () => { + // The one guarantee in the design doc's "Independent providers construct + // in parallel" section with no repo coverage until now. Written as a + // deadlock rather than with timers, so it cannot pass or fail on timing: + // each provider announces itself and then blocks on a barrier that only + // the *second* arrival opens. Under sequential construction the first + // provider's promise never settles, so the second is never started, so + // the barrier is never opened — `Module.build` never resolves and the + // test fails on its timeout. Under concurrent construction both arrive, + // the barrier opens, and both resolve. There is no schedule under which a + // sequential implementation passes. + const starts: string[] = []; + // The executor runs synchronously, so `openBarrier` is definitely assigned + // before `arrive` can be called. (`Promise.withResolvers` would say this + // more directly but is newer than this project's `lib` target.) + let openBarrier!: () => void; + const barrier = new Promise((resolve) => { + openBarrier = resolve; + }); + const arrive = (name: string): Promise => { + starts.push(name); + if (starts.length === 2) openBarrier(); + return barrier; + }; + + const concurrent = Module("Concurrent")({ + provides: [ + Provider(A)({ + make: () => fromSafePromise(() => arrive("A")).map(() => ({ v: "A" })), + }), + Provider(B)({ + make: () => fromSafePromise(() => arrive("B")).map(() => ({ v: "B" })), + }), + ], + exports: [A, B], + }); + + const built = await Module.build(concurrent); + expect(built).toBeOk(); + // Both genuinely ran; asserted as a set, since *which* order two + // concurrent factories announce themselves in is not what this test is + // about — "the error from a parallel level is the first in declaration + // order" above is the test that pins ordering. + expect(starts.toSorted()).toEqual(["A", "B"]); + }, +); + +test("a dependency no provider supplies is a defect, before any factory runs", async () => { + const sibling = vi.fn(() => ({ v: "A" })); + const dependent = vi.fn(() => ({ v: "C" })); + const orphan = Module("Orphan")({ + provides: [ + Provider(A)({ sync: sibling }), + // `B` is provided by nobody, here or in any import. + Provider(C)([A, B], { sync: dependent }), + ], + exports: [A, C], + }); + + // `Module.build`'s type-level gate catches this first — `orphan`'s `Needs` + // is `B`, so the honest call is an arity error. Cast past it to reach the + // runtime path a JavaScript consumer, or a `Needs` laundered through a + // widening annotation, actually takes. + const built = await Module.build(orphan as never); + + expect(built).toBeDefect(); + // The point of moving the check into `plan`: `A` is perfectly constructible + // and sits in an earlier level than `C`, so before the fix it had already + // been built by the time the missing `B` surfaced — the failure was not + // pre-construction at all, and arrived as `context.ts`'s "[di] no service + // registered", which reads like a bug in this package rather than a missing + // provider in the caller's graph. + expect(sibling).not.toHaveBeenCalled(); + expect(dependent).not.toHaveBeenCalled(); + const cause = built.isDefect() ? built.cause : undefined; + expect(String(cause)).toContain(`[di] no provider for port "BB"`); + expect(String(cause)).toContain(`required by "BC"`); +}); + +test("a built context resolves an exported port", async () => { + const mod = Module("Exported")({ + provides: [Provider(A)({ value: { v: "A" } })], + exports: [A], + }); + const built = await Module.build(mod); + expect(built.isOk() && built.value.get(A).v).toBe("A"); +}); diff --git a/packages/di/src/build.test-d.ts b/packages/di/src/build.test-d.ts new file mode 100644 index 0000000..bff1b8e --- /dev/null +++ b/packages/di/src/build.test-d.ts @@ -0,0 +1,107 @@ +import { Err, Ok, TaggedError, type AsyncResult } from "unthrown"; +import { describe, test } from "vitest"; + +import { Module, Port, Provider, type Context } from "./index.js"; +import { type Equal } from "./type-assert.js"; + +class CfgError extends TaggedError("BDCfgError")<{ readonly reason: string }> {} + +class Cfg extends Port("BDCfg")<{ readonly url: string }> {} +class Repo extends Port("BDRepo")<{ readonly find: () => string }> {} +class Env extends Port("BDEnv")> {} + +/** + * Recovers `Module.build`'s return type positionally, the same `ChannelsOf` + * trick `module.test-d.ts`/`provider.test-d.ts` use for `Module`/`Provider` + * themselves. A plain `const typed: AsyncResult, E> = built` + * assignment would only prove the declared type is assignable *into* + * whatever `built` actually carries — which stays green even if `X`/`E` + * silently widened to `unknown` — so this instead reads the literal type + * arguments `built`'s declared type was built from. + */ +type BuiltChannels = T extends AsyncResult ? readonly [C, E] : never; +/** Same trick one level in: recovers `Context`'s own `R` from the built context type. */ +type ExportsOf = T extends Context ? R : never; + +describe("Module.build", () => { + test("a complete module's build type carries the real exports and a never error", () => { + const mod = Module("Complete")({ + provides: [ + Provider(Cfg)({ value: { url: "u" } }), + Provider(Repo)([Cfg], { sync: (c) => ({ find: () => c.url }) }), + ], + exports: [Repo], + }); + const built = Module.build(mod); + + type Channels = BuiltChannels; + const exportsIsRepo: Equal, Repo> = true; + const exportsIsNotCfg: Equal, Cfg> = false; + const errorIsNever: Equal = true; + const errorIsNotUnknown: Equal = false; + void built; + void exportsIsRepo; + void exportsIsNotCfg; + void errorIsNever; + void errorIsNotUnknown; + }); + + test("a module with a fallible provider carries that real error, not a widened one", () => { + const mod = Module("Fallible")({ + provides: [ + Provider(Env)({ value: {} }), + Provider(Cfg)([Env], { + make: (env) => + env["URL"] === undefined + ? Err(new CfgError({ reason: "unset" })) + : Ok({ url: env["URL"] }), + }), + ], + exports: [Cfg], + }); + const built = Module.build(mod); + + type Channels = BuiltChannels; + const exportsIsCfg: Equal, Cfg> = true; + const errorIsCfgError: Equal = true; + const errorIsNotUnknown: Equal = false; + const errorIsNotNever: Equal = false; + void built; + void exportsIsCfg; + void errorIsCfgError; + void errorIsNotUnknown; + void errorIsNotNever; + }); + + test("a module with unmet needs does not compile", () => { + const mod = Module("Incomplete")({ + provides: [Provider(Repo)([Cfg], { sync: (c) => ({ find: () => c.url }) })], + exports: [Repo], + }); + // @ts-expect-error unsatisfied dependency: Cfg — the rest parameter + // becomes a required two-element tuple when Needs is not `never`, so + // calling with just `mod` is an arity error at the call site. The + // "complete module" test above is the negative control: it calls + // `Module.build(mod)` with exactly one argument and no + // `@ts-expect-error`, so this failure is specific to the unmet + // dependency, not the gate misfiring on every call. + Module.build(mod); + }); + + test("a module whose only teardown is an onStop does not compile under build", () => { + const mod = Module("OnStopOnly")({ + // No `acquire`/`release` here — a plain `value` arm with just an + // `onStop`. `Module.build` never opens or closes a scope + // (`build.ts`'s `run` takes a bare `ClosableFinalisers`; only + // `Module.scoped`/`forkScope` in `module.ts` call `createScope`), so + // a registered `onStop` under `build` would silently never run — + // `ScopeOf` (`provider.ts`) must put `Scope` in `Needs` exactly as it + // already does for `acquire`/`release`. + provides: [Provider(Cfg)({ value: { url: "u" }, onStop: () => {} })], + exports: [Cfg], + }); + // @ts-expect-error unsatisfied dependency: Scope — same gate `acquire` + // triggers, now also triggered by a bare `onStop`. + Module.build(mod); + }); +}); diff --git a/packages/di/src/build.ts b/packages/di/src/build.ts new file mode 100644 index 0000000..c1bfead --- /dev/null +++ b/packages/di/src/build.ts @@ -0,0 +1,294 @@ +import { Ok, fromSafePromise, type AsyncResult } from "unthrown"; + +import { Context, unsafeAddAll, unsafeKeys } from "./context.js"; +import { constructLevel, runStartHooks, type AnyProvider } from "./lifecycle.js"; +import { Scope } from "./port.js"; +import { createScope, type ClosableFinalisers, type TeardownReporter } from "./scope.js"; + +type AnyModule = { + readonly imports: readonly AnyModule[]; + readonly provides: readonly AnyProvider[]; +}; + +/** + * Every provider in the tree, de-duplicated by reference so a diamond yields one entry. + * Not part of the public surface — `run` (this file) is the only caller; `index.ts` + * deliberately does not re-export it (see `unsafeAdd`'s own note in `context.ts` for + * the same "internal, package-private" rationale). + */ +const flatten = (module: AnyModule): readonly AnyProvider[] => { + const out = new Set(); + const walk = (m: AnyModule): void => { + for (const imported of m.imports) walk(imported); + for (const provider of m.provides) out.add(provider); + }; + walk(module); + return [...out]; +}; + +/** + * A wiring bug: a dependency cycle, two distinct providers registered for the + * same port, a port registered as both a set port and an ordinary one, a + * provider for `Scope`, or a dependency nothing provides. `plan` throws this + * rather than returning it, on purpose — + * see the note on `run` below for why that is the correct channel for it. + * Internal only: a caller observes a `WiringDefect` through unthrown's + * `Defect` channel (`toBeDefect()` in the test suite), never by importing + * this class, so it is not part of `index.ts`'s public surface. + */ +class WiringDefect extends Error {} + +/** + * Groups providers into levels that may construct concurrently. Runs before any + * factory, so a cycle or a duplicate is reported with no side effects performed. + * Declaration order is preserved within a level, which is what makes the error + * chosen on failure deterministic (see `run`). + * + * A set port (`port.many === true`) is exempt from the duplicate check — + * several providers targeting it is `Provider.member`'s whole point, not a + * collision. That exemption ripples into leveling below: the brief's sketch + * kept `placed`/`remaining` keyed by bare `portId`, assuming one provider per + * port, which under several members sharing one portId would both drop + * not-yet-placed siblings out of `remaining` the moment the *first* member + * landed, and make a consumer of the set port "ready" after that first member + * too — silently losing whatever was still pending in a later level. `placed` + * is therefore a `Set` (provider identity, not portId), and + * readiness for a many-port dependency compares its placed count against its + * total member count, not mere presence. + * Internal only, same as `flatten` above — `run` is the only caller. + */ +const plan = ( + providers: readonly AnyProvider[], + seedKeys: ReadonlySet, +): readonly (readonly AnyProvider[])[] => { + const byPort = new Map(); + const totalByPort = new Map(); + const manyByPort = new Map(); + for (const provider of providers) { + const id = provider.port.portId; + // A provider for `Scope` is a wiring bug, not merely an unmet-dependency + // gap (see `Scope`'s own doc comment in `port.ts` for why this is a + // runtime `portId` check, not a type-level guard). `Scope` is never a set + // port, so this runs — unaffected — before the many-port exemption below. + if (id === Scope.portId) { + // oxlint-disable-next-line unthrown/no-throw -- see the rationale on the duplicate-provider throw just below + throw new WiringDefect(`[di] Scope cannot be provided; open one with Module.scoped instead`); + } + const isMany = provider.port.many === true; + const seenMany = manyByPort.get(id); + if (seenMany !== undefined && seenMany !== isMany) { + // Same class of wiring bug as the duplicate-provider throw below, and + // thrown for the same reason: a portId declared as an ordinary port by + // one provider and a set port by another is a declaration bug, not + // something `unsafeAddAll` (`context.ts`) should have to cope with — + // left unchecked, whichever provider lands second silently `continue`s + // past (if it is the set-port one) or overwrites (if it is the + // ordinary one) `byPort`'s entry, and the failure that eventually + // surfaces is `unsafeAddAll` spreading a non-array single service, a + // `TypeError` defect whose message says nothing about the real cause. + // oxlint-disable-next-line unthrown/no-throw + throw new WiringDefect( + `[di] port ${JSON.stringify(id)} is registered as both a set port and an ordinary port`, + ); + } + manyByPort.set(id, isMany); + totalByPort.set(id, (totalByPort.get(id) ?? 0) + 1); + // Members accumulate; several providers for one set port are not a + // collision. Keyed on `many === true` — the static field `Port.many` + // attaches — so an ordinary port is never accidentally exempted. + if (isMany) { + byPort.set(id, provider); + continue; + } + const existing = byPort.get(id); + if (existing !== undefined && existing !== provider) { + // Deliberate: a duplicate-provider registration is a wiring bug, not a + // modeled failure the caller branches on. Thrown here, on purpose, so + // the `.map` callback in `run` that calls `plan` converts it to a + // `Defect` via unthrown's throw-to-defect net. + // oxlint-disable-next-line unthrown/no-throw + throw new WiringDefect(`[di] two providers registered for port ${JSON.stringify(id)}`); + } + byPort.set(id, provider); + } + + // A dependency that no provider in the tree supplies, and that the seed + // context does not already carry, is a wiring bug — and belongs in the same + // pre-construction channel as a cycle or a duplicate, which is where every + // other wiring bug already lands. Run as its own pass after the loop above, + // not inside it, because a dependency may legitimately be registered by a + // provider declared later in the array. + // + // The seed is what makes this safe to raise at all: `isSatisfied` below + // stays deliberately permissive, because a port the seed supplies genuinely + // has no provider here — that is exactly the shape `Module.forkScope` + // (`module.ts`) builds, where a request module's providers depend on ports + // the already-built parent context carries. Permissiveness is right for + // *scheduling*; it is only wrong as a substitute for *checking*, which is + // the split this pass introduces. + // + // Left unchecked, such a dependency was silently treated as ready, survived + // levelling, and surfaced only when `constructLevel` looked it up — as + // `context.ts`'s `[di] no service registered for port …`, a message whose + // own comment calls it unreachable and a bug in this package. It is neither: + // it is the caller's graph missing a provider, and it now says so, before + // any factory has run. + for (const provider of providers) { + for (const dep of provider.deps) { + if (byPort.has(dep.portId) || seedKeys.has(dep.portId)) continue; + // Deliberate: same rationale and same channel as the duplicate-provider + // and cycle throws above — `run`'s `.map` callback converts it to a + // `Defect`. + // oxlint-disable-next-line unthrown/no-throw + throw new WiringDefect( + `[di] no provider for port ${JSON.stringify(dep.portId)}, required by ${JSON.stringify(provider.port.portId)}`, + ); + } + } + + const levels: AnyProvider[][] = []; + const placed = new Set(); + const placedCountByPort = new Map(); + let remaining = providers; + + // A dependency is satisfied once every provider registered for its port has + // been placed — one, for an ordinary port; every member, for a set port. A + // port nothing provides at all is treated as externally satisfied, same as + // before: an unmet requirement is `Module`'s `Needs` channel's problem. + const isSatisfied = (portId: string): boolean => + !byPort.has(portId) || placedCountByPort.get(portId) === totalByPort.get(portId); + + while (remaining.length > 0) { + const ready = remaining.filter((p) => p.deps.every((d) => isSatisfied(d.portId))); + if (ready.length === 0) { + const stuck = remaining.map((p) => p.port.portId).join(", "); + // Deliberate: same rationale as the duplicate-provider throw above — a + // cycle is a wiring bug, meant to surface as a `Defect` via `run`'s + // `.map` callback, not as a modeled `Err`. + // oxlint-disable-next-line unthrown/no-throw + throw new WiringDefect(`[di] dependency cycle among ports: ${stuck}`); + } + levels.push(ready); + for (const p of ready) { + placed.add(p); + const id = p.port.portId; + placedCountByPort.set(id, (placedCountByPort.get(id) ?? 0) + 1); + } + remaining = remaining.filter((p) => !placed.has(p)); + } + return levels; +}; + +/** + * `run`'s running fold: the `Context` built so far, plus every + * `onStart`-bearing pair collected across levels, in level order (each + * level's own entries already positional — see `lifecycle.ts`'s + * `ConstructedLevel`, which `constructLevel` returns). + */ +type BuildAcc = { + readonly ctx: Context; + readonly started: readonly (readonly [AnyProvider, unknown])[]; +}; + +/** + * Sorts, checks and constructs a module tree. `plan` runs inside a `.map` + * callback rather than being called directly: a `WiringDefect` it throws is a + * wiring bug, not a modeled failure the caller is expected to branch on, and + * unthrown's combinators catch a thrown callback and turn it into a `Defect` + * (verified in `build.spec.ts` via `toBeDefect()`, not merely assumed) — + * exactly the channel a wiring bug belongs on. Nothing before this map runs + * a factory, so a cycle or a duplicate is reported with zero side effects. + * + * `onStart` hooks fire only after the whole `levels.reduce` has finished — + * not folded into `constructLevel`, not run per level — via one more + * `.flatMap` appended after the fold, so the graph is fully built by the + * time any of them runs. + */ +// The error is genuinely unknown at this boundary: it is whichever provider +// in the tree fails first (see the field comment on `AnyProvider.construct`), +// or a `WiringDefect` — which never reaches `E` at all, since it is thrown +// and so lands in the `Defect` channel instead. +export const run = ( + module: AnyModule, + scope: ClosableFinalisers, + seed: Context = Context.empty(), + // oxlint-disable-next-line unthrown/no-ambiguous-error-type +): AsyncResult, unknown> => + Ok() + .toAsync() + .map(() => plan(flatten(module), unsafeKeys(seed))) + .flatMap((levels) => + levels + // Same rationale as `run`'s own return type just above. + // oxlint-disable-next-line unthrown/no-ambiguous-error-type + .reduce>( + (acc, level) => + acc.flatMap(({ ctx, started }) => + constructLevel(level, ctx, scope).map((result) => ({ + ctx: unsafeAddAll(ctx, result.built), + started: [...started, ...result.started], + })), + ), + Ok({ ctx: seed, started: [] }).toAsync(), + ) + .flatMap(({ ctx, started }) => runStartHooks(started).map(() => ctx)), + ); + +export type ScopedOptions = { + readonly onTeardownError?: TeardownReporter; +}; + +/** + * Runs a module, hands the resulting `Context` to `use`, and closes the scope + * on every path — construction failure, `use` failure, `use` success — before + * this function's own `AsyncResult` ever settles. + * + * The brief's sketch chains this with `.tap`/`.tapFailure` on `run(...).flatMap(use)`, + * firing `scope.close()` as a side effect. That does not hold up: `tap`'s callback + * must be synchronous (see `NotThenable` in unthrown's types — it exists precisely + * to keep async work out of `tap`), so `void scope.close()` inside one is + * fire-and-forget. `close` awaits each finaliser in turn, so a fire-and-forget call + * lets this function's result resolve before teardown — in particular before the + * *last* finaliser has necessarily run — which breaks exactly the ordering the + * unwind tests assert on (`released` must already hold every entry once + * `Module.scoped` resolves). Built explicitly around `await` instead: the settling + * `Promise` is lifted back into an `AsyncResult` with `fromSafePromise` (it cannot + * reject — `run`/`flatMap`/`scope.close` all resolve to a value, never a rejected + * promise) and then flattened, since it resolves to a `Result` rather than a bare + * value. + */ +export const runScoped = ( + module: AnyModule, + use: (ctx: Context) => AsyncResult, + options: ScopedOptions = {}, + seed: Context = Context.empty(), + // oxlint-disable-next-line unthrown/no-ambiguous-error-type -- same rationale as `run`'s return type above +): AsyncResult => { + const scope = createScope(options.onTeardownError); + // Deliberately a bare `async` function, not further unthrown combinators: + // it needs to `await` the scope's own close before deciding what settled, + // which is exactly the ordering `tap`/`tapFailure` cannot express (see the + // doc comment above). Its inferred return type is a plain + // `Promise>` — left inferred, not spelled out, since an + // explicit `Result`-in-a-`Promise` annotation is the shape + // `unthrown/prefer-async-result` warns against in general; here it is the + // unavoidable seam between this function's `await`-based body and + // `fromSafePromise`, which is what turns it back into a real `AsyncResult` + // immediately below. + const settle = async () => { + // oxlint-disable-next-line unicorn/no-array-callback-reference -- `use` is this function's own parameter, not an array method's element callback + const result = await run(module, scope, seed).flatMap(use); + // Never conditioned on `result`: teardown must happen whether construction + // failed, `use` failed, or `use` succeeded, and it must never change what + // this function returns (`close` swallows and reports its own failures — + // see `scope.ts` — so it cannot mask `result` even on a rejecting release). + await scope.close(); + return result; + }; + // `fromSafePromise` lifts the settled `Result` into an `AsyncResult, never>`; flattening it one level is `Result`/`AsyncResult` + // composition, not the `Array#flatMap(x => x)` anti-pattern + // `unicorn/prefer-array-flat` is built for — there is no array here. + // oxlint-disable-next-line unicorn/prefer-array-flat + return fromSafePromise(settle()).flatMap((result) => result); +}; diff --git a/packages/di/src/context.spec.ts b/packages/di/src/context.spec.ts new file mode 100644 index 0000000..2ba2d85 --- /dev/null +++ b/packages/di/src/context.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from "vitest"; + +import { unsafeAdd } from "./context.js"; +import { Context, Port } from "./index.js"; + +class Logger extends Port("CtxLogger")<{ readonly log: () => string }> {} + +test("a service added to a context is readable from it", () => { + const ctx = unsafeAdd(Context.empty(), Logger, { log: () => "hi" }); + expect((ctx as Context).get(Logger).log()).toBe("hi"); +}); + +test("adding does not mutate the context it was derived from", () => { + const empty = Context.empty(); + unsafeAdd(empty, Logger, { log: () => "hi" }); + expect(() => (empty as unknown as Context).get(Logger)).toThrow(/no service/u); +}); diff --git a/packages/di/src/context.test-d.ts b/packages/di/src/context.test-d.ts new file mode 100644 index 0000000..8b2b1c8 --- /dev/null +++ b/packages/di/src/context.test-d.ts @@ -0,0 +1,33 @@ +import { describe, test } from "vitest"; + +import { type Context, Port } from "./index.js"; + +class Logger extends Port("Logger")<{ readonly log: (msg: string) => void }> {} +class Clock extends Port("Clock")<{ readonly now: () => string }> {} + +describe("Context", () => { + test("get returns the service shape", () => { + const ctx = null as unknown as Context; + const log: (msg: string) => void = ctx.get(Logger).log; + void log; + }); + + test("reading an absent port is a compile error", () => { + const ctx = null as unknown as Context; + // @ts-expect-error Clock is not in R + ctx.get(Clock); + }); + + test("a richer context satisfies a consumer asking for less", () => { + const rich = null as unknown as Context; + const narrow: Context = rich; + void narrow; + }); + + test("a narrower context does not satisfy a consumer asking for more", () => { + const narrow = null as unknown as Context; + // @ts-expect-error Context is contravariant in R + const rich: Context = narrow; + void rich; + }); +}); diff --git a/packages/di/src/context.ts b/packages/di/src/context.ts new file mode 100644 index 0000000..697ff90 --- /dev/null +++ b/packages/di/src/context.ts @@ -0,0 +1,157 @@ +import type { PortInstance, ServiceOf } from "./port.js"; + +// `AnyPort` requires a *generic* construct signature (`new (): ...`), which +// a concrete port class such as `class Logger extends Port("Logger") {}` does +// not have once `Shape` has been fixed by the heritage clause — its constructor is +// concrete, not generic, so it is not structurally assignable to `AnyPort`. The only +// thing this module ever does with a port value at runtime is read `portId`, so the +// internal plumbing is typed against this minimal shape instead of `AnyPort` — kept +// as a constructor intersection (rather than plain `{ readonly portId: string }`) so +// it still overlaps with the public `get` signature's `abstract new () => S` param. +// `many?: true` (optional) is the same runtime discriminant `port.ts`'s +// `AnyPort` carries, added here too so `unsafeAddAll` below can tell a set +// port's members from an ordinary port's single service without importing +// `AnyPort` itself (which would reintroduce the generic-construct-signature +// mismatch this alias exists to avoid). +type PortLike = { + readonly portId: string; + readonly many?: true; +} & (abstract new () => unknown); + +/** + * `_R` is load-bearing, not decoration. `Context` is declared `in R`, but the + * `in` modifier only *asserts* contravariance — TypeScript still checks the + * declaration's own structure against it, and `get`'s signature cannot carry + * that check on its own: `get` is a *generic method* whose `R` appears solely + * as the bound of its own type parameter (``), a position that is + * not independently contravariant in `R`. With `_R` removed — or made + * optional, which has the same effect on the variance measurement — `Context` + * measures as bivariant in `R`, and `Context` starts flowing where a + * `Context` is required with no error at the call site. + * The phantom field is what puts `R` in a genuine parameter position and makes + * the `in` annotation something the compiler can actually enforce. Do not + * remove it, and do not make it optional; there is no signal at the use site + * if you do. (Ledger note carried over from Task 2.) + */ +export type Context = { + readonly _R: (r: R) => void; + // `S extends R` with the port typed as `abstract new () => S` — rather than a + // `P extends AnyPort` inferred through `InstanceType

extends R ? P : never` — + // is what the pinned TypeScript 7 compiler can actually solve: S is inferred + // directly from the naked constructor parameter, then checked against R by + // ordinary constraint checking. The rejected form left `P` unresolved (it + // defaulted to the `AnyPort` constraint before the conditional was evaluated), + // which made every `get` call — valid or not — fail to compile. + readonly get: (port: abstract new () => S) => ServiceOf; +}; + +// Declared before `make` so it is initialised by the time `make` first runs. The +// backing map is kept off the Context object itself so nothing but this module can +// reach it — `get` is the only public way in. +const entries = new WeakMap>(); + +const make = (services: ReadonlyMap): Context => { + const ctx = { + _R: () => {}, + get: (port: PortLike) => { + const service = services.get(port.portId); + if (service === undefined) { + // Unreachable through the public API: `get` only accepts a port in R, and + // the build pipeline fills every port it claims to provide. Reaching here + // is a bug in this package, so it is a throw — which unthrown turns into a + // defect at the nearest combinator — not a modeled error. + // oxlint-disable-next-line unthrown/no-throw + throw new Error(`[di] no service registered for port ${port.portId}`); + } + return service; + }, + // `Context` is an opaque public interface backed by a differently-shaped + // concrete object — `unknown` first is the correct escape hatch here, not a + // sign the shapes are actually wrong; see the `PortLike` note above for why + // they cannot be made to overlap directly at the type level. + } as unknown as Context; + entries.set(ctx, services); + return ctx; +}; + +export const Context = { + empty: (): Context => make(new Map()), +}; + +/** Internal: used only by the build pipeline. Not exported from the package index. */ +export const unsafeAdd = ( + ctx: Context, + port: PortLike, + service: unknown, +): Context> => { + const next = new Map([ + ...(entries.get(ctx) ?? new Map()), + [port.portId, service], + ]); + return make(next) as never; +}; + +/** + * Internal: the `portId`s a context already carries. Used only by `build.ts`'s + * `run`, to tell a dependency that is supplied from *outside* the module tree + * (the seed context a `forkScope` builds over) from one that nothing supplies + * at all — the first is legitimate, the second is a wiring bug `plan` raises + * before any factory runs. Not exported from the package index: `get` remains + * the only public way to read a context, and this deliberately exposes no + * services, only which keys exist. + */ +export const unsafeKeys = (ctx: Context): ReadonlySet => + new Set(entries.get(ctx)?.keys() ?? []); + +// Unlike `get`/`unsafeAdd`'s callers, which treat a missing key as this +// package's own bug, a set port genuinely has nothing registered yet before +// its first level of members lands — a missing key here is the ordinary +// case, not a defect, hence `orElse` rather than a throw. +const getOrElse = (ctx: Context, port: PortLike, orElse: () => unknown): unknown => { + const services = entries.get(ctx); + return services !== undefined && services.has(port.portId) ? services.get(port.portId) : orElse(); +}; + +/** + * Internal: used only by `build.ts`'s `run`, folding one dependency-ordered + * level's constructed results into `ctx`. An ordinary port's single service + * is added directly; a set port's members (`port.many === true`) are + * gathered into one array first, since `Context.get` on a set port must + * yield every contribution, not just the last one added. + * + * A set port's members can land across more than one level — a + * dependency-free member is ready earlier than a sibling that depends on + * something else — so each group is appended to whatever array an *earlier* + * level already registered for that port (`getOrElse`, `[]` the first time), + * never overwritten. That is also what lets a later level's consumer, which + * `build.ts`'s `plan` schedules only once every member of a port it depends + * on has been placed, see every contribution built so far. + */ +export const unsafeAddAll = ( + ctx: Context, + built: readonly (readonly [PortLike, unknown])[], +): Context => { + const singles = built.filter(([port]) => port.many !== true); + const members = built.filter(([port]) => port.many === true); + + const withSingles = singles.reduce>( + (c, [port, service]) => unsafeAdd(c, port, service) as Context, + ctx, + ); + + const grouped = new Map(); + for (const [port, service] of members) { + const existing = grouped.get(port.portId); + if (existing === undefined) { + const already = getOrElse(withSingles, port, () => []) as unknown[]; + grouped.set(port.portId, [port, [...already, service]]); + continue; + } + existing[1].push(service); + } + + return [...grouped.values()].reduce>( + (c, [port, services]) => unsafeAdd(c, port, services) as Context, + withSingles, + ); +}; diff --git a/packages/di/src/example.spec.ts b/packages/di/src/example.spec.ts new file mode 100644 index 0000000..7d5d487 --- /dev/null +++ b/packages/di/src/example.spec.ts @@ -0,0 +1,159 @@ +import { Err, Ok, TaggedError, type AsyncResult } from "unthrown"; +import { expect, test } from "vitest"; + +import { Module, Port, Provider, type ScopedOptions, type ServiceOf } from "./index.js"; + +/** + * A worked hexagonal example: ports named by the application (`OrderRepository`, + * `GetOrder`), two adapters bound at one edge (a resourceful "production" + * persistence module and a resource-free in-memory one), and an application + * module generic in the persistence module's own `E`/`Needs` so it can be built + * against either without change. See the package README for the same walk-through + * with commentary. + */ + +class OrderNotFound extends TaggedError("XOrderNotFound")<{ readonly id: string }> {} +class ConfigError extends TaggedError("XConfigError")<{ readonly reason: string }> {} + +type Order = { + readonly id: string; + readonly total: number; +}; + +// --- Ports: the application's boundary, named by the domain, not by any adapter. --- + +class Env extends Port("XEnv")> {} +class AppConfig extends Port("XAppConfig")<{ readonly dbUrl: string }> {} +class Database extends Port("XDatabase")<{ readonly rows: readonly Order[] }> {} +class OrderRepository extends Port("XOrderRepository")<{ + readonly findById: (id: string) => AsyncResult; +}> {} +class GetOrder extends Port("XGetOrder")<{ + readonly execute: (id: string) => AsyncResult; +}> {} + +// --- Application: the use case, depending only on the port, never an adapter. --- + +class GetOrderInteractor { + private readonly orders: ServiceOf; + // `erasableSyntaxOnly` (this repo's tsconfig) rejects TypeScript's parameter-property + // shorthand — `constructor(private readonly orders: ...)` — since it has no + // type-erasure-only meaning; the field is declared and assigned explicitly instead. + constructor(orders: ServiceOf) { + this.orders = orders; + } + execute(id: string): AsyncResult { + return this.orders.findById(id); + } +} + +const ConfigModule = Module("Config")({ + provides: [ + Provider(Env)({ value: { XDATABASE_URL: "postgres://localhost/app" } }), + Provider(AppConfig)([Env], { + make: (env) => + env["XDATABASE_URL"] === undefined + ? Err(new ConfigError({ reason: "XDATABASE_URL is unset" })) + : Ok({ dbUrl: env["XDATABASE_URL"] }), + }), + ], + exports: [AppConfig], +}); + +/** + * The production adapter. `Database` opens a real connection and must close + * it again, so its provider is the resourceful `acquire`/`release` arm — which + * puts `Scope` in this module's `Needs` (see `provider.ts`'s `ScopeOf`) and so + * routes the whole graph through `Module.scoped`, not `Module.build`, at the + * composition root below. `released` is the test's own hook into that + * teardown, not part of the shape a real adapter would have. + */ +const makePersistenceModule = (released: string[]) => + Module("Persistence")({ + imports: [ConfigModule], + provides: [ + Provider(Database)([AppConfig], { + acquire: () => Ok({ rows: [{ id: "o-1", total: 10 }] }), + release: () => void released.push("database"), + }), + Provider(OrderRepository)([Database], { + sync: (db) => ({ + findById: (id) => { + const row = db.rows.find((r) => r.id === id); + return (row === undefined ? Err(new OrderNotFound({ id })) : Ok(row)).toAsync(); + }, + }), + }), + ], + exports: [OrderRepository], + }); + +/** + * The in-memory adapter: no connection, so no resource, so no `Scope` — its + * `Needs` is `never`, same as its `E` (a `value` provider cannot fail). + */ +const InMemoryPersistenceModule = Module("InMemoryPersistence")({ + provides: [ + Provider(OrderRepository)({ + value: { findById: (id) => Ok({ id, total: 99 }).toAsync() }, + }), + ], + exports: [OrderRepository], +}); + +/** + * The composition seam: generic in the persistence module's own `E`/`Needs`, + * so the same application module wires up unchanged against either adapter — + * only the entry point used to build it (`Module.build` vs `Module.scoped`) + * differs, and that difference is forced by the type system, not a choice. + */ +const makeAppModule = (persistence: Module) => + Module("App")({ + imports: [persistence], + provides: [Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor })], + exports: [GetOrder], + }); + +test("the production graph resolves a use case through its ports, and releases what it acquired", async () => { + const released: string[] = []; + const teardownErrors: (readonly [string, unknown])[] = []; + const options: ScopedOptions = { + onTeardownError: (portId, cause) => void teardownErrors.push([portId, cause]), + }; + + const outcome = await Module.scoped( + makeAppModule(makePersistenceModule(released)), + (ctx) => ctx.get(GetOrder).execute("o-1"), + options, + ); + + expect(outcome).toBeOkWith({ id: "o-1", total: 10 }); + // The `Database` connection opened by `acquire` was closed by `release` + // once `use` settled — proof the resourceful arm's teardown actually ran, + // not just that it type-checked. + expect(released).toEqual(["database"]); + expect(teardownErrors).toEqual([]); +}); + +test("the same app module builds against an in-memory adapter, with no Scope required", async () => { + // `Module.build` — not `.scoped` — is the point: `InMemoryPersistenceModule` + // has no resourceful provider, so `makeAppModule`'s `Needs` collapses to + // `never` for this instantiation, and `Module.build`'s compile-time gate + // (Task 5) accepts it with no extra argument. Swapping in + // `makePersistenceModule` here — the resourceful adapter — is a compile + // error, not a runtime surprise: its `Needs` is `Scope`, which only + // `Module.scoped` discharges. + const built = await Module.build(makeAppModule(InMemoryPersistenceModule)); + expect(built).toBeOk(); + const order = built.isOk() ? await built.value.get(GetOrder).execute("anything") : undefined; + expect(order).toBeOkWith({ id: "anything", total: 99 }); +}); + +// The third guarantee this example proves — an importer sees only the exported +// surface in the built context's *type* — is compile-time only (see +// `example.test-d.ts`): the context is flat, so `Database` really is in the +// runtime map, and asserting its absence at runtime would assert something +// false. `vitest`'s `include`/`typecheck.include` split (`vitest.config.ts`) +// is exactly what keeps that assertion from ever executing — `.test-d.ts` +// files are type-checked, never run — which is why it lives in its own file +// rather than as a fourth `test()` here. diff --git a/packages/di/src/example.test-d.ts b/packages/di/src/example.test-d.ts new file mode 100644 index 0000000..be9230c --- /dev/null +++ b/packages/di/src/example.test-d.ts @@ -0,0 +1,53 @@ +import { test } from "vitest"; + +import { Module, Port, Provider, type Context } from "./index.js"; + +/** + * The type-level half of the hexagonal example in `example.spec.ts`: an + * importer of a module can only name what that module exports, even though + * the built `Context` is flat and genuinely holds every internal port's + * service too. Kept in its own `.test-d.ts` file, not a fourth `test()` in + * `example.spec.ts`, because privacy is a *compile-time* guarantee — this + * file's bodies are type-checked (`vitest.config.ts`'s `typecheck.include`) + * but never executed, so `ctx` can be a `null` stand-in used only for its + * type. A runtime `test()` executing `ctx.get(...)` against that same + * `null` would throw, and asserting the port is runtime-absent would assert + * something false — `exports` withholds the *type* that names a port, not + * the entry in the map. + */ + +class Database extends Port("YDatabase")<{ readonly rows: readonly unknown[] }> {} +class OrderRepository extends Port("YOrderRepository")<{ + readonly findById: () => string; +}> {} +class GetOrder extends Port("YGetOrder")<{ readonly execute: () => string }> {} + +const Persistence = Module("YPersistence")({ + provides: [ + Provider(Database)({ value: { rows: [] } }), + Provider(OrderRepository)([Database], { sync: () => ({ findById: () => "o-1" }) }), + ], + exports: [OrderRepository], +}); + +const makeAppModule = (persistence: Module) => + Module("YApp")({ + imports: [persistence], + provides: [ + Provider(GetOrder)([OrderRepository], { + sync: (orders) => ({ execute: () => orders.findById() }), + }), + ], + exports: [GetOrder], + }); + +test("an importer sees only the exported surface in the built context's type", () => { + const app = makeAppModule(Persistence); + const ctx = null as unknown as Context< + ReturnType extends Module ? X : never + >; + ctx.get(GetOrder); + // @ts-expect-error Database is internal to Persistence + ctx.get(Database); + void app; +}); diff --git a/packages/di/src/fork.spec.ts b/packages/di/src/fork.spec.ts new file mode 100644 index 0000000..5edffe0 --- /dev/null +++ b/packages/di/src/fork.spec.ts @@ -0,0 +1,48 @@ +import { Ok } from "unthrown"; +import { expect, test } from "vitest"; + +import { Module, Port, Provider } from "./index.js"; + +class Pool extends Port("FPool")<{ readonly id: string }> {} +class Txn extends Port("FTxn")<{ readonly id: string }> {} + +test("a fork releases only its own resources and leaves the parent up", async () => { + const released: string[] = []; + const app = Module("App")({ + provides: [ + Provider(Pool)({ + acquire: () => Ok({ id: "pool" }), + release: () => void released.push("pool"), + }), + ], + exports: [Pool], + }); + const request = Module("Request")({ + provides: [ + Provider(Txn)([Pool], { + acquire: (pool) => Ok({ id: `txn-on-${pool.id}` }), + release: () => void released.push("txn"), + }), + ], + exports: [Txn], + }); + + const outcome = await Module.scoped(app, (appCtx) => + Module.forkScope(appCtx, request, (ctx) => Ok(ctx.get(Txn).id).toAsync()) + // Checkpoint after the first fork unwinds: only its own "txn" release + // has run — the parent's "pool" is still absent, proving the parent + // stayed up across the fork's own teardown. + .tap(() => void expect(released).toEqual(["txn"])) + .flatMap((first) => + Module.forkScope(appCtx, request, (ctx) => Ok(ctx.get(Txn).id).toAsync()) + // Checkpoint after the second, sibling fork unwinds: a second + // "txn" release, still no "pool" — the parent survives a second + // fork over the same context too, not just the first. + .tap(() => void expect(released).toEqual(["txn", "txn"])) + .map((second) => [first, second] as const), + ), + ); + + expect(outcome).toBeOk(); + expect(released).toEqual(["txn", "txn", "pool"]); +}); diff --git a/packages/di/src/fork.test-d.ts b/packages/di/src/fork.test-d.ts new file mode 100644 index 0000000..2d58c09 --- /dev/null +++ b/packages/di/src/fork.test-d.ts @@ -0,0 +1,80 @@ +import { Ok, type AsyncResult } from "unthrown"; +import { describe, test } from "vitest"; + +import { Module, Port, Provider, type Context } from "./index.js"; +import { type Equal } from "./type-assert.js"; + +class Db extends Port("FDb")<{ readonly q: () => string }> {} +class RequestId extends Port("FRequestId")<{ readonly value: string }> {} +class Missing extends Port("FMissing")<{ readonly nope: true }> {} + +const RequestModule = Module("Request")({ + provides: [Provider(RequestId)([Db], { sync: (db) => ({ value: db.q() }) })], + exports: [RequestId], +}); + +const NeedsMissing = Module("NeedsMissing")({ + provides: [Provider(RequestId)([Missing], { sync: () => ({ value: "x" }) })], + exports: [RequestId], +}); + +/** + * Same positional-inference trick `scoped.test-d.ts`/`build.test-d.ts` use: + * a plain `const typed: AsyncResult = forked` assignment only proves + * the declared type is assignable *into* whatever `forked` actually + * carries — it would stay green even if `A`/`E` silently widened to + * `unknown`. Reading the literal type arguments back out pins the value. + */ +type ForkChannels = T extends AsyncResult ? readonly [A, E] : never; + +describe("forkScope", () => { + test("a request module whose needs the parent covers is accepted", () => { + const parent = null as unknown as Context; + const forked = Module.forkScope(parent, RequestModule, (ctx) => + Ok(ctx.get(RequestId)).toAsync(), + ); + + type Channels = ForkChannels; + const valueIsRequestIdService: Equal = true; + // Negative control: pins the resolved value to the real service shape, + // not a widened `unknown` that would pass regardless of what + // `forkScope` actually resolves `use`'s callback result to. + const valueIsNotUnknown: Equal = false; + const errorIsNever: Equal = true; + void forked; + void valueIsRequestIdService; + void valueIsNotUnknown; + void errorIsNever; + }); + + test("the fork's context exposes both the parent's services and the module's own exports", () => { + const parent = null as unknown as Context; + const forked = Module.forkScope(parent, RequestModule, (ctx) => { + // `Db` comes from the parent context; `RequestId` from the forked + // module's own exports. Both compiling with no `@ts-expect-error` + // is what proves `ctx`'s type is really `Context`, not + // just `Context` (which would still let the "accepted" test above + // pass, since that test only reads `RequestId`). + const db = ctx.get(Db); + const requestId = ctx.get(RequestId); + return Ok({ db, requestId }).toAsync(); + }); + + type Channels = ForkChannels; + const valueShape: Equal< + Channels[0], + { + db: { readonly q: () => string }; + requestId: { readonly value: string }; + } + > = true; + void forked; + void valueShape; + }); + + test("a request module needing a port the parent lacks does not compile", () => { + const parent = null as unknown as Context; + // @ts-expect-error unsatisfied dependency: Missing + Module.forkScope(parent, NeedsMissing, (ctx) => Ok(ctx.get(RequestId)).toAsync()); + }); +}); diff --git a/packages/di/src/index.ts b/packages/di/src/index.ts new file mode 100644 index 0000000..06c67e9 --- /dev/null +++ b/packages/di/src/index.ts @@ -0,0 +1,42 @@ +export { Port } from "./port.js"; +// `Scope` is exported as a *type* only. Every legitimate consumer use is a +// type position — `Module`, `Exclude`, pinning a +// provider's `Needs` — and nothing outside this package has a reason for the +// class value. The two things the value enables are both the hazard: +// `Provider(Scope)(…)` registers a provider for the phantom, and `const +// widened: AnyPort = Scope` is the alias that defeats any type-level guard. +// Withholding the value removes the ordinary way in; `plan()`'s runtime +// `portId` check (`build.ts`) stays as defence in depth for the paths a +// type-only export cannot close (a hand-rolled port with the same id, or a +// consumer reaching past the index). Internal modules import the class from +// `./port.js` directly, as do the two tests that exist to prove the runtime +// check still fires (`scoped.spec.ts`). +// +// `PortClass`/`ManyPortClass` are exported for declaration emit, not because a +// consumer is expected to write either by hand. `class OrderRepository extends +// Port("OrderRepository") {}` — the pattern the README teaches — emits as +// `declare const OrderRepository_base: `, +// and the emitter can only write that type using names the consumer can reach. +// With these two unexported it had none: it expanded the heritage expression +// down to `PortInstance`'s `[ID]`/`[SERVICE]` keys, which are module-private +// `unique symbol`s, and every consumer that *exported* a port failed with +// TS4020 ("has or is using private name 'ID'"). Naming the class types is the +// fix that costs least: the emitter stops at `PortClass<"OrderRepository">` +// (measured: 2,683 bytes of consumer declarations across the reproduction, +// against 3,545 when only the instance types are nameable and the emitter has +// to write the construct signature out). +// +// The symbols themselves stay unexported deliberately. They are what makes port +// identity nominal, and a consumer who can name `ID`/`SERVICE` can hand-write +// `{ [ID]: "Logger", [SERVICE]: Shape }` and pass it off as a `Logger` — +// measured, it type-checks. Exporting the class *types* grants no such thing: +// the brand keys stay unnameable, so `PortInstance` values remain unforgeable +// and `MemberOf`'s `[MANY]` discriminant stays unspoofable. `PortInstance` and +// the `[MANY]` intersection are never named here either — nothing in the emitted +// output needs them once the class types are reachable, and `emit-guards.ts` in +// `examples/hexagonal-order-api` is the fixture that keeps that true. +export type { AnyPort, ManyPortClass, PortClass, Scope, ServiceOf } from "./port.js"; +export { Context } from "./context.js"; +export { Provider } from "./provider.js"; +export { Module } from "./module.js"; +export type { ScopedOptions } from "./build.js"; diff --git a/packages/di/src/lifecycle.spec.ts b/packages/di/src/lifecycle.spec.ts new file mode 100644 index 0000000..3123f52 --- /dev/null +++ b/packages/di/src/lifecycle.spec.ts @@ -0,0 +1,185 @@ +import { Ok, fromSafePromise } from "unthrown"; +import { expect, test, vi } from "vitest"; + +import { Module, Port, Provider } from "./index.js"; + +class Server extends Port("LServer")<{ readonly port: number }> {} +class Worker extends Port("LWorker")<{ readonly name: string }> {} + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +test("onStart runs after the whole graph is built, in declaration order", async () => { + const events: string[] = []; + const mod = Module("Lifecycle")({ + provides: [ + Provider(Server)({ + value: { port: 8080 }, + onStart: (s) => void events.push(`start-server-${s.port}`), + }), + Provider(Worker)([Server], { + sync: () => ({ name: "w" }), + onStart: (w) => void events.push(`start-${w.name}`), + }), + ], + exports: [Server, Worker], + }); + + await Module.scoped(mod, () => Ok("ran").toAsync()); + expect(events).toEqual(["start-server-8080", "start-w"]); +}); + +test("onStop runs in reverse declaration order during teardown", async () => { + const events: string[] = []; + const mod = Module("Lifecycle")({ + provides: [ + Provider(Server)({ + value: { port: 1 }, + onStop: () => void events.push("stop-server"), + }), + Provider(Worker)([Server], { + sync: () => ({ name: "w" }), + onStop: () => void events.push("stop-worker"), + }), + ], + exports: [Server, Worker], + }); + + await Module.scoped(mod, () => Ok("ran").toAsync()); + expect(events).toEqual(["stop-worker", "stop-server"]); +}); + +test("Module.scoped runs an onStop registered with no acquire/release at all", async () => { + let stopped = false; + const mod = Module("OnStopOnly")({ + provides: [ + Provider(Server)({ + value: { port: 1 }, + onStop: () => { + stopped = true; + }, + }), + ], + exports: [Server], + }); + + await Module.scoped(mod, () => Ok("ran").toAsync()); + expect(stopped).toBe(true); +}); + +test("release and onStop interleave in one combined LIFO unwind, not two separate passes", async () => { + const events: string[] = []; + const mod = Module("Lifecycle")({ + provides: [ + Provider(Server)({ + value: { port: 1 }, + onStop: () => void events.push("server-onStop"), + }), + Provider(Worker)([Server], { + acquire: () => Ok({ name: "w" }), + release: () => void events.push("worker-release"), + onStop: () => void events.push("worker-onStop"), + }), + ], + exports: [Server, Worker], + }); + + await Module.scoped(mod, () => Ok("ran").toAsync()); + // Worker (built second) unwinds before Server (built first); within + // Worker, its own `onStop` and `release` are adjacent — `onStop` first, + // since it is registered on the scope right after `release` and the scope + // unwinds LIFO — not grouped by kind across providers (which would give + // the wrong ["worker-onStop", "server-onStop", "worker-release"] or + // similar "two passes" order). + expect(events).toEqual(["worker-onStop", "worker-release", "server-onStop"]); +}); + +test("an async onStart is genuinely awaited before use runs", async () => { + const events: string[] = []; + const mod = Module("Lifecycle")({ + provides: [ + Provider(Server)({ + value: { port: 1 }, + onStart: async () => { + await delay(10); + events.push("start"); + }, + }), + ], + exports: [Server], + }); + + await Module.scoped(mod, () => { + events.push("use"); + return Ok("ran").toAsync(); + }); + // If `onStart`'s promise were discarded (`void provider.onStart!(service)` + // on a value that happens to be a `Promise`) rather than awaited, `use` + // would run on the very next microtask — before the 10ms delay resolves — + // and this would observe `["use", "start"]` instead. + expect(events).toEqual(["start", "use"]); +}); + +test("a throwing onStart surfaces as a Defect, use is skipped, and teardown still runs", async () => { + const events: string[] = []; + const useRan = vi.fn(); + const mod = Module("Lifecycle")({ + provides: [ + Provider(Server)({ + acquire: () => Ok({ port: 1 }), + release: () => void events.push("release"), + // `async` on purpose, not a plain synchronous throw: `.map`'s + // callback already catches a *synchronous* throw regardless of + // whether its result is awaited, so a sync throw here would not + // distinguish this implementation from one that discards onStart's + // return value — an async rejection only becomes a Defect if it is + // genuinely awaited. + onStart: async () => { + // Deliberate: this test exists specifically to prove a rejecting + // onStart becomes a Defect rather than an unhandled rejection. + // oxlint-disable-next-line unthrown/no-throw + throw new Error("onStart boom"); + }, + }), + ], + exports: [Server], + }); + + const result = await Module.scoped(mod, () => { + useRan(); + return Ok("ran").toAsync(); + }); + + expect(result).toBeDefect(); + expect(useRan).not.toHaveBeenCalled(); + // `runStartHooks` fires only after the whole graph is built, so `release` + // was already registered on the scope by the time `onStart` threw — + // teardown still unwinds it. + expect(events).toEqual(["release"]); +}); + +test("declaration order holds even when an earlier same-level provider resolves later", async () => { + const events: string[] = []; + const mod = Module("Lifecycle")({ + provides: [ + // Declared first, but its construct resolves *last* — the exact + // shape the brief's `.tap`-order sketch got wrong: a push inside + // `constructLevel`'s `.tap` fires per-provider as each one settles, + // so Worker (which resolves first) would have landed in `started` + // before Server there, running its `onStart` first despite being + // declared second. + Provider(Server)({ + make: () => fromSafePromise(delay(20)).flatMap(() => Ok({ port: 1 })), + onStart: () => void events.push("server"), + }), + // Declared second, resolves first. + Provider(Worker)({ + make: () => fromSafePromise(delay(1)).flatMap(() => Ok({ name: "w" })), + onStart: () => void events.push("worker"), + }), + ], + exports: [Server, Worker], + }); + + await Module.scoped(mod, () => Ok("ran").toAsync()); + expect(events).toEqual(["server", "worker"]); +}); diff --git a/packages/di/src/lifecycle.ts b/packages/di/src/lifecycle.ts new file mode 100644 index 0000000..a021b96 --- /dev/null +++ b/packages/di/src/lifecycle.ts @@ -0,0 +1,160 @@ +import { Ok, allAsync, fromSafePromise, type AsyncResult } from "unthrown"; + +import type { Context } from "./context.js"; +import type { AnyPort } from "./port.js"; +import type { ClosableFinalisers } from "./scope.js"; + +/** + * `Module.provides`'s own public type (`module.ts`'s `AnyProvider`) is + * deliberately structural down to `port`/`deps` only — it erases + * `construct`, `release`, `onStart` and `onStop` so the module algebra never + * has to reason about how a port gets built or torn down. The build + * pipeline (this file plus `build.ts`) is exactly the code that *does* need + * those fields, so it gets its own, richer view of the same runtime objects + * rather than reusing `module.ts`'s. + */ +export type AnyProvider = { + readonly port: AnyPort; + readonly deps: readonly AnyPort[]; + // This is the package's own construction boundary, not application code — + // same rationale as `provider.ts`'s identical field, which this type mirrors. + // oxlint-disable-next-line unthrown/no-ambiguous-error-type + readonly construct: (services: readonly unknown[]) => AsyncResult; + readonly release: ((service: unknown) => void | Promise) | undefined; + readonly onStart: ((service: unknown) => void | Promise) | undefined; + readonly onStop: ((service: unknown) => void | Promise) | undefined; +}; + +/** + * Reads a service that some earlier level has already placed into `ctx`. Not + * `Context`'s public `get` — that method's `S extends R` bound makes it + * uncallable against a port typed only as the structural `AnyPort` this + * module works with (nothing but `never` extends the `Context` this + * pipeline threads through `build.ts`'s `run`). This mirrors `context.ts`'s + * own internal `PortLike` escape hatch for the same reason. + */ +const unsafeGet = (ctx: Context, port: AnyPort): unknown => + (ctx as unknown as { readonly get: (p: AnyPort) => unknown }).get(port); + +/** + * One level's outcome: the `[port, service]` entries to fold into the + * running `Context`, plus the `[provider, service]` pairs whose `onStart` is + * defined. `started` is read off `values` *positionally* (by index into + * `level`), not pushed inside `constructLevel`'s `tap` as each provider's + * promise happens to settle — same-level providers construct concurrently + * (see the doc comment below), so a `tap`-based push would land in + * whichever order they resolved in, not declaration order. Reading it off + * the already-settled, positionally-ordered `values` keeps `started` in + * declaration order regardless of which one finished first. + */ +export type ConstructedLevel = { + readonly built: readonly (readonly [AnyPort, unknown])[]; + readonly started: readonly (readonly [AnyProvider, unknown])[]; +}; + +/** + * Constructs one level. Every provider in the level is started — its + * `construct` called — in the same synchronous pass, *before* any of them is + * awaited, so they genuinely run concurrently rather than one-after-another. + * `allAsync` (unthrown's `Promise.all`-backed aggregate) then awaits every one + * of them to settle before folding: its fold walks the resolved array in + * *positional* order and keeps the first `Err` it meets, so a provider + * declared earlier wins even if a later one's promise happens to land first — + * exactly the "declaration order, not arrival order" contract this pipeline + * needs. See `build.spec.ts`'s "the error from a parallel level is the first + * in declaration order" for the behavioural proof. + * + * That contract holds *within* a channel, not across them. `allAsync`'s fold + * records the first `Err` but does not stop at it; it stops at the first + * `Defect`, and returns the defect in preference to any `Err` it had already + * recorded. So a *later* provider that throws outranks an *earlier* provider + * that returned `Err` — "first in declaration order" is the tiebreak among + * equals, and a defect is not one. Still fully deterministic: the outcome + * depends only on declaration order and on which channel each provider failed + * on, never on which promise settled first. It is also the right precedence, + * since a wiring bug in one provider should not be reported to the caller as + * another provider's modeled, branchable error. + */ +export const constructLevel = ( + level: readonly AnyProvider[], + ctx: Context, + scope: ClosableFinalisers, + // The error is genuinely unknown here: it is whichever level provider's + // `construct` fails first, and `construct` itself is typed this way for the + // same reason (see the field comment on `AnyProvider.construct`). + // oxlint-disable-next-line unthrown/no-ambiguous-error-type +): AsyncResult => { + const settling = level.map((provider) => { + const services = provider.deps.map((dep) => unsafeGet(ctx, dep)); + // Registered the moment *this provider's* construction succeeds, not + // after the whole level (or the whole graph) settles — a sibling in the + // same level, or a later level, may still fail, and the partial-failure + // unwind (see `build.ts`'s `runScoped`) needs every already-acquired + // resource on the scope by the time that happens, not just the ones + // that happened to be declared first. A provider with no `release` + // (every arm but `acquire`) never registers anything, so it never gets + // torn down. + return provider.construct(services).tap((service) => { + if (provider.release !== undefined) { + scope.onStop(provider.port.portId, () => provider.release!(service)); + } + // Registered right beside `release`, in the same `tap` call, so the + // two land adjacently in the scope's finaliser list — unwound + // together in one LIFO pass on close, not as two separate passes over + // the providers. + if (provider.onStop !== undefined) { + scope.onStop(provider.port.portId, () => provider.onStop!(service)); + } + }); + }); + return allAsync(settling).map((values) => ({ + built: values.map((value, i) => [level[i]!.port, value] as const), + started: values + .map((value, i) => [level[i]!, value] as const) + .filter(([provider]) => provider.onStart !== undefined), + })); +}; + +/** + * Fires every collected `onStart` hook once the whole graph has finished + * constructing — never per level, never inline with `construct` — one after + * another in the order `entries` lists them. `build.ts`'s `run` is what + * guarantees that order is declaration order: each level's hooks are + * collected *after* `allAsync` resolves, positionally (see `constructLevel` + * above), so same-level concurrent construction cannot reorder them, and + * levels themselves are appended level-by-level in the sequence `plan` + * produced. + * + * Threaded through `AsyncResult.flatMap` rather than `Promise.all`-ed: hooks + * run sequentially, and a throwing or rejecting hook must stop the rest and + * surface as a `Defect` (the same channel a failed `construct` uses), not be + * silently raced against its siblings. `fromSafePromise` is what gives a + * thrown or rejected hook that `Defect` outcome — passed a thunk, not an + * already-invoked call, so a *synchronous* throw is caught too, not just a + * rejected promise (its `Promise.resolve().then(promise)` internals invoke + * the thunk inside the `.then`, where a throw becomes a rejection like any + * other). + * + * Asymmetric with teardown, deliberately: a throwing/rejecting `onStart` + * short-circuits this `reduce` (`acc.flatMap` never calls the next hook once + * `acc` is an `Err`/`Defect`), so every `onStart` declared after the failing + * one is skipped — but `scope.close()` (`scope.ts`) still runs *every* + * registered `onStop`, including for providers whose own `onStart` never got + * to run. The two channels aren't symmetric: `onStart` is forward + * initialization work with no obligation to run at all if a peer's already + * failed, while `onStop`/`release` are undoing work that already happened — + * a resource a provider actually acquired must always be released, whether + * or not that provider (or a later one) ever reached its `onStart`. + */ +export const runStartHooks = ( + entries: readonly (readonly [AnyProvider, unknown])[], + // The failing hook's cause is genuinely unknown here — same rationale as + // `AnyProvider.construct` above. + // oxlint-disable-next-line unthrown/no-ambiguous-error-type +): AsyncResult => + // oxlint-disable-next-line unthrown/no-ambiguous-error-type -- same rationale as the return type above + entries.reduce>( + (acc, [provider, service]) => + acc.flatMap(() => fromSafePromise(() => Promise.resolve(provider.onStart!(service)))), + Ok().toAsync(), + ); diff --git a/packages/di/src/many.spec.ts b/packages/di/src/many.spec.ts new file mode 100644 index 0000000..b501b39 --- /dev/null +++ b/packages/di/src/many.spec.ts @@ -0,0 +1,123 @@ +import { expect, test, vi } from "vitest"; + +import { Module, Port, Provider } from "./index.js"; + +class Db extends Port("MyDb")<{ readonly name: string }> {} +class HealthChecks extends Port.many("MyHealthChecks")<{ + readonly check: () => string; +}> {} + +test("contributions accumulate across module boundaries", async () => { + const dbModule = Module("Db")({ + provides: [ + Provider(Db)({ value: { name: "pg" } }), + Provider.member(HealthChecks)([Db], { sync: (db) => ({ check: () => db.name }) }), + ], + exports: [Db, HealthChecks], + }); + const cacheModule = Module("Cache")({ + provides: [Provider.member(HealthChecks)({ value: { check: () => "redis" } })], + exports: [HealthChecks], + }); + const app = Module("App")({ + imports: [dbModule, cacheModule], + exports: [dbModule, cacheModule], + }); + + const built = await Module.build(app); + expect( + built.isOk() && + built.value + .get(HealthChecks) + .map((h) => h.check()) + .toSorted(), + ).toEqual(["pg", "redis"]); +}); + +test("several members of one set port are not a collision", async () => { + const mod = Module("Many")({ + provides: [ + Provider.member(HealthChecks)({ value: { check: () => "a" } }), + Provider.member(HealthChecks)({ value: { check: () => "b" } }), + ], + exports: [HealthChecks], + }); + await expect(Module.build(mod)).resolves.toBeOk(); +}); + +// The brief's two tests above only prove the happy path for a set port in +// isolation. Neither shows the exemption is actually *keyed* on `many === +// true` rather than on, say, shape or naming convention — the two tests +// below guard that: one port pair shares a service shape but differs only +// in "many", and a plain ordinary port still rejects a second provider +// after the exemption landed. +class SingleCheck extends Port("MySingleCheck")<{ readonly check: () => string }> {} +class ManyCheck extends Port.many("MyManyCheck")<{ readonly check: () => string }> {} + +test("a set port and an ordinary port of the same service shape do not interfere", async () => { + const mod = Module("Mixed")({ + provides: [ + Provider(SingleCheck)({ value: { check: () => "single" } }), + Provider.member(ManyCheck)({ value: { check: () => "many-a" } }), + Provider.member(ManyCheck)({ value: { check: () => "many-b" } }), + ], + exports: [SingleCheck, ManyCheck], + }); + const built = await Module.build(mod); + expect(built.isOk() && built.value.get(SingleCheck).check()).toBe("single"); + expect( + built.isOk() && + built.value + .get(ManyCheck) + .map((h) => h.check()) + .toSorted(), + ).toEqual(["many-a", "many-b"]); +}); + +test("two providers for an ordinary port are still a defect after the many-port exemption", async () => { + const dup = Module("StillDup")({ + provides: [ + Provider(SingleCheck)({ value: { check: () => "a" } }), + Provider(SingleCheck)({ value: { check: () => "b" } }), + ], + exports: [SingleCheck], + }); + const built = await Module.build(dup); + expect(built).toBeDefect(); +}); + +// Regression test for a review finding: nothing stopped one portId from being +// declared both an ordinary port (by one class) and a set port (by another), +// and left unchecked the ordinary provider's later-arriving `continue` (if +// declared after the set-port one) or silent overwrite (if declared before +// it) meant the mismatch was never reported as the wiring bug it is — +// `unsafeAddAll` (`context.ts`) would instead try to spread a single, +// non-iterable service as though it were a set port's member array, and the +// resulting `TypeError` defect said nothing about the real cause. `plan` +// (`build.ts`) now checks every provider's `many`-ness against whatever it +// has already seen for that portId and throws a clear `WiringDefect` the +// moment they disagree — before any factory runs, same as every other +// wiring check. +test("a portId used as both a set port and an ordinary port is a clear wiring defect", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + class MixedOrdinary extends Port("MDMixedId")<{ readonly check: () => string }> {} + class MixedMany extends Port.many("MDMixedId")<{ readonly check: () => string }> {} + + const mod = Module("MixedId")({ + provides: [ + Provider(MixedOrdinary)({ value: { check: () => "ordinary" } }), + Provider.member(MixedMany)({ value: { check: () => "member" } }), + ], + exports: [MixedOrdinary], + }); + const built = await Module.build(mod); + expect(built).toBeDefect(); + // Not merely "some defect" — the *clear* WiringDefect, not the misleading + // spread-a-non-iterable TypeError the old, unchecked code path produced. + expect(built.isDefect() && built.cause).toBeInstanceOf(Error); + expect(built.isDefect() && (built.cause as Error).message).toBe( + '[di] port "MDMixedId" is registered as both a set port and an ordinary port', + ); + + warn.mockRestore(); +}); diff --git a/packages/di/src/many.test-d.ts b/packages/di/src/many.test-d.ts new file mode 100644 index 0000000..ca991eb --- /dev/null +++ b/packages/di/src/many.test-d.ts @@ -0,0 +1,115 @@ +import { describe, test } from "vitest"; + +import { Port, Provider, type Context } from "./index.js"; +import { type Equal } from "./type-assert.js"; + +class Handlers extends Port.many("MyHandlers")<{ readonly run: () => void }> {} +class Db extends Port("MDDb")<{ readonly name: string }> {} + +/** + * Recovers `Provider`'s three type arguments by direct positional inference, + * the same `ChannelsOf` trick `provider.test-d.ts`/`module.test-d.ts` use — + * `Provider`'s channels sit in contravariant field positions, so a plain + * `const typed: Provider = p` assignment alone would stay green + * even if a channel silently widened. + */ +type ChannelsOf = T extends Provider ? readonly [P, E, N] : never; + +describe("Port.many", () => { + test("get yields an array of the member service shape, not the member itself", () => { + const ctx = null as unknown as Context; + const all = ctx.get(Handlers); + + // A plain `const all: readonly {...}[] = ctx.get(Handlers)` (the brief's + // form, kept as its own test below) only proves the actual return type + // is assignable *into* that declared array type — a widening to `any` + // would pass that check regardless. Reading `typeof all` back out and + // comparing with `Equal` pins the literal inferred type instead. + type All = typeof all; + const isMemberArray: Equal void }[]> = true; + const isNotSingleMember: Equal void }> = false; + const isNotUnknownArray: Equal = false; + void isMemberArray; + void isNotSingleMember; + void isNotUnknownArray; + }); + + test("get does not yield a single member", () => { + const ctx = null as unknown as Context; + // @ts-expect-error a set port resolves to an array + const one: { readonly run: () => void } = ctx.get(Handlers); + void one; + }); +}); + +describe("Provider.member", () => { + test("a value member is qualified against the member shape, not the array", () => { + const p = Provider.member(Handlers)({ value: { run: () => {} } }); + + type Channels = ChannelsOf; + const portIsHandlers: Equal = true; + const errorIsNever: Equal = true; + const needsIsNever: Equal = true; + void portIsHandlers; + void errorIsNever; + void needsIsNever; + }); + + test("a value member cannot supply the array shape", () => { + // @ts-expect-error the member arm wants one Handler, not an array of them + Provider.member(Handlers)({ value: [{ run: () => {} }] }); + }); + + test("deps are typed into the member factory parameters, positionally", () => { + const p = Provider.member(Handlers)([Db], { + sync: (db) => ({ run: () => void db.name }), + }); + + type Channels = ChannelsOf; + const portIsHandlers: Equal = true; + const needsIsDb: Equal = true; + // Negative control: `Needs` must be pinned to exactly `Db`, not merely + // "not never" — a regression that widened it to `unknown` would + // otherwise slip past the positive assertion above. + const needsIsNotNever: Equal = false; + void portIsHandlers; + void needsIsDb; + void needsIsNotNever; + }); + + test("a member factory returning the array shape is rejected", () => { + Provider.member(Handlers)([Db], { + // @ts-expect-error sync must return one Handler, not an array of them + sync: (db) => [{ run: () => void db.name }], + }); + }); + + test("an ordinary, non-array-shaped port has no member shape to construct", () => { + class NotMany extends Port("MDNotMany")<{ readonly name: string }> {} + // @ts-expect-error NotMany's service is not an array — MemberOf resolves + // to never, so no value can satisfy the `value` arm + Provider.member(NotMany)({ value: { name: "x" } }); + }); + + /** + * Regression test for an unsoundness a review caught: an earlier `MemberOf` + * keyed off "does `ServiceOf` look like an array" rather than the `[MANY]` + * brand. `Tags` here is an *ordinary* port whose service happens to be + * array-shaped — `Tags.many` is `undefined` at runtime, so + * `build.ts`/`context.ts` treat any provider for it as a single service, + * not a member. Under the old shape-keyed `MemberOf`, `Provider.member(Tags)` + * type-checked as if `Tags`' member shape were `string`, so + * `Provider.member(Tags)({ value: "a" })` compiled clean while landing "a" + * (not `["a"]`) at runtime — a type-level lie about `ctx.get(Tags)`'s real, + * `readonly string[]`-typed value. `MemberOf` keyed off `[MANY]` (module- + * private, unforgeable outside `port.ts`) makes this the same `never` + * rejection as any other ordinary port. + */ + test("an ordinary port whose service happens to be array-shaped is not a set port", () => { + class Tags extends Port("MDTags") {} + // @ts-expect-error Tags is an ordinary port (many is undefined at + // runtime) despite its array-shaped service — MemberOf must resolve to + // never here, not `string` + Provider.member(Tags)({ value: "a" }); + }); +}); diff --git a/packages/di/src/module.test-d.ts b/packages/di/src/module.test-d.ts new file mode 100644 index 0000000..affcc90 --- /dev/null +++ b/packages/di/src/module.test-d.ts @@ -0,0 +1,197 @@ +import { Err, Ok, TaggedError } from "unthrown"; +import { describe, test } from "vitest"; + +import { Module, Port, Provider } from "./index.js"; +import { type Equal } from "./type-assert.js"; + +class ConfigError extends TaggedError("MConfigError")<{ readonly reason: string }> {} +class PoolError extends TaggedError("MPoolError")<{ readonly url: string }> {} + +class Env extends Port("MEnv")> {} +class AppConfig extends Port("MAppConfig")<{ readonly dbUrl: string }> {} +class Database extends Port("MDatabase")<{ readonly query: () => readonly unknown[] }> {} +class OrderRepository extends Port("MOrderRepository")<{ readonly find: () => string }> {} + +const EnvProvider = Provider(Env)({ value: {} }); +const AppConfigProvider = Provider(AppConfig)([Env], { + make: (env) => + env["DATABASE_URL"] === undefined + ? Err(new ConfigError({ reason: "unset" })) + : Ok({ dbUrl: env["DATABASE_URL"] }), +}); +const DatabaseProvider = Provider(Database)([AppConfig], { + make: (cfg) => + cfg.dbUrl === "" ? Err(new PoolError({ url: cfg.dbUrl })) : Ok({ query: () => [] }), +}); +const OrderRepositoryProvider = Provider(OrderRepository)([Database], { + sync: (db) => ({ find: () => String(db.query().length) }), +}); + +const ConfigModule = Module("Config")({ + provides: [EnvProvider, AppConfigProvider], + exports: [AppConfig], +}); + +/** + * Recovers `Module`'s three type arguments by direct positional inference + * against the same generic interface, rather than by assignability. `Exports`, + * `E`, and `Needs` all sit in contravariant field positions, so a plain + * `const typed: Module = m` assignment only proves `X`/`E`/`N` are + * assignable *into* whatever the value actually carries — see + * `provider.test-d.ts`'s `ChannelsOf` for the same pattern. + */ +type ChannelsOf = T extends Module ? readonly [X, E, N] : never; + +describe("Module algebra", () => { + test("Needs is empty when every requirement is provided internally", () => { + const typed: Module = ConfigModule; + void typed; + + type Channels = ChannelsOf; + const exportsIsAppConfig: Equal = true; + const errorIsConfigError: Equal = true; + const errorIsNotUnknown: Equal = false; + const needsIsNever: Equal = true; + void exportsIsAppConfig; + void errorIsConfigError; + void errorIsNotUnknown; + void needsIsNever; + }); + + test("E is the union of provider errors", () => { + const persistence = Module("Persistence")({ + imports: [ConfigModule], + provides: [DatabaseProvider, OrderRepositoryProvider], + exports: [OrderRepository], + }); + const typed: Module = persistence; + void typed; + + type Channels = ChannelsOf; + const exportsIsOrderRepository: Equal = true; + const errorIsUnion: Equal = true; + const errorIsNotUnknown: Equal = false; + const needsIsNever: Equal = true; + void exportsIsOrderRepository; + void errorIsUnion; + void errorIsNotUnknown; + void needsIsNever; + }); + + test("E is not narrowable to one arm", () => { + const persistence = Module("Persistence")({ + imports: [ConfigModule], + provides: [DatabaseProvider, OrderRepositoryProvider], + exports: [OrderRepository], + }); + // @ts-expect-error ConfigError is still in the union + const typed: Module = persistence; + void typed; + }); + + test("an unmet requirement stays in Needs", () => { + const orphan = Module("Orphan")({ + provides: [OrderRepositoryProvider], + exports: [OrderRepository], + }); + const typed: Module = orphan; + void typed; + + type Channels = ChannelsOf; + const exportsIsOrderRepository: Equal = true; + const errorIsNever: Equal = true; + const needsIsDatabase: Equal = true; + const needsIsNotNever: Equal = false; + void exportsIsOrderRepository; + void errorIsNever; + void needsIsDatabase; + void needsIsNotNever; + }); + + test("an unmet requirement cannot be laundered to no requirement", () => { + const orphan = Module("Orphan")({ + provides: [OrderRepositoryProvider], + exports: [OrderRepository], + }); + // `orphan` genuinely needs `Database` (unmet — nothing provides or + // imports it). `_needs` must be covariant so this widening lie is + // rejected: if it were contravariant (as it read before this fix), + // `Module` would be assignable to + // `Module` — laundering a real, unmet + // dependency past a caller that asks for `Needs = never`, which is + // exactly what Task 5's build gate uses to decide a module is + // runnable. Pins the direction, not just the value, so a future + // variance regression on `_needs` fails this test immediately instead + // of surviving to a runtime "dependency missing" failure. + // @ts-expect-error Database is still an unmet requirement + const typed: Module = orphan; + void typed; + }); + + test("a wider error union cannot be narrowed away", () => { + const persistence = Module("Persistence")({ + imports: [ConfigModule], + provides: [DatabaseProvider, OrderRepositoryProvider], + exports: [OrderRepository], + }); + // Mirror control for the `_needs` test above, on the `_error` channel: + // `persistence` can genuinely fail with `ConfigError | PoolError`, so a + // caller declaring it as `Module<_, PoolError, _>` (dropping + // `ConfigError`) must be rejected. This is the same assignment the + // brief's "E is not narrowable to one arm" test already makes, kept + // here as its own named test so the `_error`/`_needs` symmetry is + // explicit and each variance choice has a test that fails immediately + // if that field's direction ever regresses. + // @ts-expect-error ConfigError is still a possible failure, not just PoolError + const typed: Module = persistence; + void typed; + }); + + test("exporting a port the module neither provides nor imports is rejected", () => { + Module("Bad")({ + provides: [EnvProvider], + // @ts-expect-error AppConfig is neither provided nor imported here + exports: [AppConfig], + }); + }); + + test("re-exporting a module that is not imported is rejected", () => { + Module("Bad")({ + provides: [EnvProvider], + // @ts-expect-error ConfigModule is not in imports + exports: [ConfigModule], + }); + }); + + test("re-exporting an imported module widens Exports to its exports", () => { + const facade = Module("Facade")({ + imports: [ConfigModule], + exports: [ConfigModule], + }); + const typed: Module = facade; + void typed; + + type Channels = ChannelsOf; + const exportsIsAppConfig: Equal = true; + const errorIsConfigError: Equal = true; + const needsIsNever: Equal = true; + void exportsIsAppConfig; + void errorIsConfigError; + void needsIsNever; + }); + + test("an internal port is not in Exports", () => { + const persistence = Module("Persistence")({ + imports: [ConfigModule], + provides: [DatabaseProvider, OrderRepositoryProvider], + exports: [OrderRepository], + }); + // @ts-expect-error Database is internal to Persistence + const typed: Module = persistence; + void typed; + + type Channels = ChannelsOf; + const exportsIsNotUnionWithDatabase: Equal = false; + void exportsIsNotUnionWithDatabase; + }); +}); diff --git a/packages/di/src/module.ts b/packages/di/src/module.ts new file mode 100644 index 0000000..3795eeb --- /dev/null +++ b/packages/di/src/module.ts @@ -0,0 +1,259 @@ +import type { AsyncResult } from "unthrown"; + +import { run, runScoped, type ScopedOptions } from "./build.js"; +import { type Context } from "./context.js"; +import type { AnyPort, Scope } from "./port.js"; +import type { Provider } from "./provider.js"; +import { createScope } from "./scope.js"; + +/** + * Structural bounds, not `Provider` / `Module` + * as the brief has it. `Provider`/`Module`'s three phantom fields sit in + * *contravariant* (function-parameter) position by design — see + * `type-assert.ts` — and under this TypeScript version `any` is no longer + * universally assignable into a contravariant slot whose peer channel is + * `never` (e.g. a value provider's `E`/`N`, or a module with no unmet + * needs): `Provider` fails to satisfy `Provider` with "Type 'any' is not assignable to type 'never'". A minimal + * `tsc --strict` repro: `interface Foo { readonly f: (e: E) => void }; + * declare const x: Foo; const y: Foo = x` is itself rejected. + * These bounds only need to describe the shape every provider/module has in + * common regardless of its channels, so they list the concrete + * (non-phantom) fields structurally instead — no channel comparison, so + * nothing to trip on. + */ +type AnyProvider = { readonly port: AnyPort; readonly deps: readonly AnyPort[] }; + +type AnyModule = { + readonly name: string; + readonly imports: readonly AnyModule[]; + readonly provides: readonly AnyProvider[]; + readonly exports: readonly (AnyPort | AnyModule)[]; +}; + +/** + * Recovers `Provider`'s/`Module`'s channels by inferring all three + * positions at once and reading the result positionally, the same + * `ChannelsOf` trick `provider.test-d.ts` uses for tests. The brief's + * version inferred one position while fixing the other two to `unknown` + * (e.g. `P extends Provider ? E : never`) — that + * also fails under this TypeScript version: matching a contravariant field + * whose real parameter type is concrete (e.g. `ConfigError`) against a + * fixed `unknown` in the pattern requires `unknown` to be assignable to + * `ConfigError`, which is false, so the whole `extends` check fails and the + * conditional silently falls through to `never` for every real provider or + * module. Inferring every position sidesteps the comparison entirely: each + * inferred type variable matches its own field exactly, regardless of + * variance. + */ +type ProviderChannels = + T extends Provider ? readonly [P, E, N] : never; +type PortOf = ProviderChannels[0]; +type ErrOf = ProviderChannels[1]; +type NeedOf = ProviderChannels[2]; + +type ModuleChannels = T extends Module ? readonly [X, E, N] : never; +type ExportsOfModule = ModuleChannels[0]; +type ErrOfModule = ModuleChannels[1]; +type NeedsOfModule = ModuleChannels[2]; + +/** + * The variance rule, stated once and shared with `Provider` (see the identical + * note above `Provider`'s own phantom fields in `provider.ts`): + * + * > Capability channels (`_port`, `_exports`) are contravariant, so you may + * > forget what you have. Obligation channels (`_error`, `_needs`) are + * > covariant, so you may not forget what you owe. + * + * Each field's own comment below says why that direction is the right one for + * that channel; this is the one-line rule they are instances of. It is stated + * here and in `provider.ts` because its absence is exactly how `Provider` came + * to drift from `Module` after Task 4 fixed only the latter. + */ +export type Module = { + readonly _exports: (x: Exports) => void; + // Covariant (return position), not contravariant like `_exports`/`_needs` + // and unlike `Provider`'s `_error`. The brief's own test "E is not + // narrowable to one arm" assigns a module whose real error channel is + // `ConfigError | PoolError` into a variable declared as `Module<_, + // PoolError, _>` and expects rejection via `@ts-expect-error`. With a + // contravariant `(e: E) => void` field that assignment *succeeds*: + // checking function-parameter contravariance reduces to "is `PoolError` + // assignable to `ConfigError | PoolError`", which is true regardless of + // the dropped member, so the narrowing silently passes (confirmed with a + // minimal `tsc --strict` repro). A covariant `() => E` field instead + // reduces the same assignment to "is `ConfigError | PoolError` assignable + // to `PoolError`", which correctly fails. `_exports` keeps the + // contravariant shape because its brief test goes the other way + // ("internal port is not in Exports" declares a *wider* Exports than + // actual and expects rejection, which contravariance does catch). + readonly _error: () => E; + // Covariant (return position), not contravariant. `Needs` is a + // *requirements* channel (compare Effect's `out R`): a module that still + // needs a `Database` must not be substitutable where a module needing + // nothing (`never`) is expected — that would launder an unmet dependency + // past Task 5's build gate, defeating the whole point of catching missing + // dependencies at compile time. With `(n: Needs) => void` (contravariant), + // `Module` *is* assignable to `Module`: + // checking function-parameter contravariance reduces to "is `never` + // assignable to `Database`", which is trivially true, so the laundering + // silently succeeds. `() => Needs` (covariant) instead reduces the same + // check to "is `Database` assignable to `never`", which correctly fails. + // See "an unmet requirement cannot be laundered to no requirement" below. + readonly _needs: () => Needs; + readonly name: string; + readonly imports: readonly AnyModule[]; + readonly provides: readonly AnyProvider[]; + readonly exports: readonly (AnyPort | AnyModule)[]; +}; + +/** Everything visible inside the module: what it provides plus what its imports export. */ +type Available = + | ExportsOfModule + | PortOf; + +/** + * An export entry is legal only if it is an available port or an imported + * module (whole-module re-export). + * + * The intersection `AnyPort & (new () => Available)` checks a + * candidate port class's *constructor return type* — its instance type — + * against the `Available` union. Return-type position is covariant, so it + * is unaffected by the contravariant-field quirk above: `AppConfig`'s + * instance type is `PortInstance<"MAppConfig", Shape>`, branded by the + * literal id, so it is only assignable into `Available` when some + * available port shares that exact id. That is a genuine check, not a + * vacuous one — a port that is not available fails it. + * + * This must stay a plain union member, not a generic helper invoked with + * `AnyPort` as its argument: instantiating a per-element conditional with + * the whole `AnyPort` union (rather than letting each array element be + * checked against the intersection directly) tests whether *every* possible + * port is available, which is never true and rejects legal exports too. + * Checked directly like this, TypeScript validates each array element + * against the intersection individually when the `exports` literal is + * checked against `readonly Exportable[]`. + */ +type Exportable = + | (AnyPort & (new () => Available)) + | I[number]; + +type ResolvedExports = + | (X[number] extends infer E ? (E extends AnyPort ? InstanceType : never) : never) + | ExportsOfModule>; + +/** + * Renamed from the brief's plain exported `Module` function: the "operations + * namespaced on the constructor" convention (`Module.build`, below) needs a + * value distinct from this one to `Object.assign` the namespace onto — + * assigning a property onto the function TypeScript infers for a generic + * `export function Module(...)` does not let the result keep that generic + * call signature. Kept unexported; `Module` (the merged const below) is the + * only public entry point. + */ +function ModuleDeclaration(name: Name) { + return < + const I extends readonly AnyModule[] = [], + const P extends readonly AnyProvider[] = [], + const X extends readonly Exportable[] = [], + >(options: { + readonly imports?: I; + readonly provides?: P; + readonly exports?: X; + }): Module< + ResolvedExports, + ErrOf | ErrOfModule, + Exclude | NeedsOfModule, Available> + > => + ({ + name, + imports: options.imports ?? [], + provides: options.provides ?? [], + exports: options.exports ?? [], + }) as never; +} + +/** + * `Module.build` sorts the tree into dependency-ordered levels, checks for + * wiring bugs (cycles, duplicate providers) before any factory runs, then + * constructs level by level. See `build.ts` for the implementation; this is + * just the typed entry point, namespaced on `Module` per the package's + * convention of hanging operations off the type's constructor. + * + * The rest parameter is the compile-time gate for unmet dependencies: when + * `N` (the module's remaining `Needs`) is `never`, `..._missing` is typed as + * the empty tuple `[]`, so `Module.build(mod)` is a normal one-argument call. + * When `N` is not `never`, the tuple has two *required* elements, so calling + * with just `mod` is an arity error — the module's unmet dependency becomes a + * compile error at the call site, not a runtime surprise. + */ +export const Module = Object.assign(ModuleDeclaration, { + build: ( + module: Module, + ..._missing: [N] extends [never] ? [] : [error: "UNSATISFIED DEPENDENCIES", missing: N] + ): AsyncResult, E> => run(module as never, createScope()) as never, + + /** + * `Module.build`'s resourceful counterpart: opens a scope, runs the module, + * hands the built `Context` to `use`, and closes the scope — releasing + * every already-acquired resource, LIFO — on every path out, whether + * construction failed, `use` failed, or `use` succeeded. See + * `build.ts`'s `runScoped` for the unwind itself. + * + * The gate mirrors `build`'s — a rest parameter that is the empty tuple + * only when there is nothing left unmet — except it excludes `Scope` + * first: `Scope` is not a real dependency the caller must supply, it is + * the phantom marker that routed the module here in the first place, and + * this is the one entry point that discharges it (by actually opening a + * `createScope`, unlike `build`, which never sees a resourceful module at + * all — `Scope` in `Needs` makes that a compile error). Any *other* + * unmet requirement in `N` still has to surface, so `Exclude`, + * not a blanket bypass, is what the rest parameter is computed from. + */ + scoped: ( + module: Module, + use: (ctx: Context) => AsyncResult, + options?: ScopedOptions, + ..._missing: [Exclude] extends [never] + ? [] + : [error: "UNSATISFIED DEPENDENCIES", missing: Exclude] + ): AsyncResult => runScoped(module as never, use as never, options) as never, + + /** + * A short-lived scope layered over an already-built parent `Context`, + * for per-request services (a transaction, a request id) that must not + * outlive the request but do need to read services the parent already + * constructed (a pool, config). Built from the exact same `runScoped` + * `Module.scoped` uses above — Task 6 gave `runScoped` a `seed` parameter + * precisely so this did not need any new machinery — just seeded with + * `parent` instead of `Context.empty()` and handed a *fresh* `createScope` + * (that happens inside `runScoped` itself). + * + * That fresh scope is exactly what makes the two load-bearing guarantees + * hold: the parent's own services were never passed through `run` here + * (only `module`'s providers are `flatten`ed and constructed against this + * call), so none of the parent's finalisers are registered on this + * scope — closing it therefore releases only what *this* fork acquired, + * and the parent stays up for a second, sibling fork or for whatever the + * enclosing `Module.scoped` does after this call returns. + * + * The gate is `scoped`'s, with one more exclusion: `Exclude` rather than `Exclude`. `PParent` — the parent + * `Context`'s own channel — is subtracted because a request module is + * allowed to depend on anything the parent already provides (that is the + * entire point of forking over a *built* parent instead of an empty one); + * only a need that neither the request module itself nor the parent + * satisfies must surface as the "UNSATISFIED DEPENDENCIES" arity error, + * exactly as `NeedsMissing` does in `fork.test-d.ts`. + */ + forkScope: ( + parent: Context, + module: Module, + use: (ctx: Context) => AsyncResult, + options?: ScopedOptions, + ..._missing: [Exclude] extends [never] + ? [] + : [error: "UNSATISFIED DEPENDENCIES", missing: Exclude] + ): AsyncResult => + runScoped(module as never, use as never, options, parent as never) as never, +}); diff --git a/packages/di/src/port.spec.ts b/packages/di/src/port.spec.ts new file mode 100644 index 0000000..f32e2a9 --- /dev/null +++ b/packages/di/src/port.spec.ts @@ -0,0 +1,20 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import { Port } from "./index.js"; + +beforeEach(() => void vi.spyOn(console, "warn").mockImplementation(() => {})); +afterEach(() => void vi.restoreAllMocks()); + +test("a port exposes its id as the runtime key", () => { + class Logger extends Port("Logger")<{ readonly log: () => void }> {} + expect(Logger.portId).toBe("Logger"); +}); + +test("a duplicate id warns exactly once", () => { + class First extends Port("Duplicated")<{ readonly a: 1 }> {} + class Second extends Port("Duplicated")<{ readonly a: 1 }> {} + void First; + void Second; + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Duplicated")); +}); diff --git a/packages/di/src/port.test-d.ts b/packages/di/src/port.test-d.ts new file mode 100644 index 0000000..8e74e58 --- /dev/null +++ b/packages/di/src/port.test-d.ts @@ -0,0 +1,39 @@ +import { describe, test } from "vitest"; + +import { Port, type ServiceOf } from "./index.js"; + +interface Clock { + readonly now: () => string; +} + +class SystemClock extends Port("SystemClock") {} +class TestClock extends Port("TestClock") {} +class Logger extends Port("Logger")<{ readonly log: (msg: string) => void }> {} + +describe("Port identity is nominal", () => { + test("two ports with the same shape but different ids do not unify", () => { + const system = null as unknown as SystemClock; + // @ts-expect-error a SystemClock is not a TestClock, despite an identical service shape + const wrong: TestClock = system; + void wrong; + }); + + test("a port is assignable to itself", () => { + const system = null as unknown as SystemClock; + const same: SystemClock = system; + void same; + }); + + test("ServiceOf recovers the shape from the instance type and from the class", () => { + const fromInstance: ServiceOf = { log: () => {} }; + const fromClass: ServiceOf = { log: () => {} }; + void fromInstance; + void fromClass; + }); + + test("ServiceOf rejects a shape that does not match", () => { + // @ts-expect-error a Logger service has `log`, not `write` + const wrong: ServiceOf = { write: () => {} }; + void wrong; + }); +}); diff --git a/packages/di/src/port.ts b/packages/di/src/port.ts new file mode 100644 index 0000000..25901ef --- /dev/null +++ b/packages/di/src/port.ts @@ -0,0 +1,197 @@ +declare const ID: unique symbol; +declare const SERVICE: unique symbol; +declare const MANY: unique symbol; + +/** + * The type that appears in a Needs / Exports union. Identity is the literal `Id`: + * two ports declared with different ids have different instance types even when + * their service shapes are identical. + */ +export type PortInstance = { + readonly [ID]: Id; + readonly [SERVICE]: Service; +}; + +export type PortClass = { + new (): PortInstance; + readonly portId: Id; +}; + +/** + * A set port: several providers may target it, and `Context.get` yields every + * contribution rather than one service. `Port.many("Id")` fixes the + * *member* shape via the same generic-heritage-instantiation trick + * `PortClass` uses (`class Handlers extends Port.many("Id") {}`), but + * the port's own `Service` — what actually lands in a `Context` and what + * `Context.get` returns — is `readonly Member[]`, not `Member`. The `[MANY]` + * brand on the instance type exists purely at the type level (no + * `ManyPortClass` is ever constructed at runtime; ports are phantom tokens, + * same as `PortClass`) so a member's *own* shape can be recovered from a + * concrete set-port class via `MemberOf` below, the same way `ServiceOf` + * recovers an ordinary port's shape from `PortInstance`. + * + * The `many: true` *static* field is the actual runtime discriminant — + * `build.ts`'s `plan`/`constructLevel` read `port.many` off the concrete + * class object at runtime (inherited from whatever `Port.many` returns, the + * same way a concrete port's `portId` is inherited), since the `[MANY]` + * symbol lives only in the (never-instantiated) instance type and cannot be + * read back at runtime. + */ +export type ManyPortClass = { + new (): PortInstance & { readonly [MANY]: true }; + readonly portId: Id; + readonly many: true; +}; + +// `PortClass` has a *generic* construct signature (`new (): ...`). +// A concrete port class — `class Logger extends Port("Logger") {}` — has a +// concrete constructor once `Shape` is fixed by the heritage clause, so `typeof +// Logger` is not structurally assignable to `PortClass`: it would need to +// accept an explicit `Service` type argument at every call, which a fixed-shape +// constructor cannot do. `AnyPort` instead demands only what a concrete port class +// actually has — a `portId` and a (possibly abstract) no-arg constructor returning +// some `PortInstance` — which every port, generic or concrete, satisfies. `abstract +// new` rather than `new` is what makes a concrete class's constructor assignable +// here at all: a plain `new` signature is invariant in "can this be called with +// `new`", so a concrete class needs the weaker `abstract new` target. +// `any` is the only bound that accepts every concrete port as a constraint; a +// narrower one would reject ports whose service shape is itself generic. Kept +// as its own alias so the disable comment survives reformatting — inlined into +// `AnyPort` below, oxfmt wraps the type across lines and moves `any` off the +// line the comment targets. +// oxlint-disable-next-line typescript/no-explicit-any +type AnyPortInstance = PortInstance; + +// `many?: true` is optional, not required, so it stays structurally +// unaffected for every ordinary `PortClass`-derived port (which never +// declares it) while still letting `build.ts` read `provider.port.many` off +// a value typed only as `AnyPort` — the same reasoning that keeps `Scope`, +// an ordinary port, exempt from the many-port codepath: `Scope.many` is +// `undefined`, not `true`. +export type AnyPort = { + readonly portId: string; + readonly many?: true; +} & (abstract new () => AnyPortInstance); + +/** Recovers a service shape from either the instance type or the class. */ +export type ServiceOf = + T extends PortInstance + ? S + : T extends abstract new () => PortInstance + ? S + : never; + +/** + * Recovers a set port's *member* shape — the type a `Provider.member` factory + * actually produces — from `[MANY]`, not from `ServiceOf`'s shape. An + * earlier version keyed this off "does the service look like an array" + * (`ServiceOf extends readonly (infer M)[] ? M : never`), which is + * unsound: an *ordinary* port whose declared service happens to be an array + * — `class Tags extends Port("Tags") {}` — has `many` + * `undefined` at runtime (`build.ts`'s `plan`/`context.ts`'s `unsafeAddAll` + * both discriminate on that static field, never on shape), so + * `Provider.member(Tags)({ value: "a" } )` type-checked under the old + * definition while landing as a single service at runtime — `ctx.get(Tags)` + * would return `"a"`, contradicting its own `readonly string[]` type. `[MANY]` + * is the same brand `ManyPortClass`'s instance type carries and is + * module-private (declared, not exported, above), so nothing outside this + * file can forge it onto an ordinary port's instance type; keying `MemberOf` + * off its presence makes the type-level check agree with the runtime one. + */ +export type MemberOf = T extends { readonly [MANY]: true } & PortInstance + ? S extends readonly (infer M)[] + ? M + : never + : T extends abstract new () => { readonly [MANY]: true } & PortInstance + ? S extends readonly (infer M)[] + ? M + : never + : never; + +const seen = new Set(); + +/** + * Two distinct port classes sharing an id are distinct types but the same runtime + * key, so one would silently read the other's service. That is a declaration bug, + * not a modeled failure, so it warns once per id in development and is folded out + * of production builds by bundler define-replacement. + */ +function warnOnDuplicateId(id: string): void { + if (process.env["NODE_ENV"] === "production") return; + if (seen.has(id)) { + console.warn(`[di] duplicate port id ${JSON.stringify(id)} — one will shadow the other`); + return; + } + seen.add(id); +} + +/** + * A phantom requirement, not a real service — its shape is `never` because + * nothing ever constructs one or reads it out of a `Context`. A resourceful + * provider (the `acquire`/`release` qualification arm, `provider.ts`) adds + * `Scope` to its `Needs`, so `Module.build` — which demands `Needs` be + * `never` — refuses a graph that still owns an un-discharged resource. Only + * `Module.scoped` strips `Scope` back out of `Needs` before checking for + * unmet dependencies, because it is the one entry point that actually opens + * a `createScope` and guarantees its `close`. Forgetting to route a + * resourceful module through `Module.scoped` is a compile error, not a + * runtime leak. + * + * `Scope` being *providable* — `Provider(Scope)({ value: ... })` — is a + * separate hazard from being unmet, and is deliberately **not** blocked by + * the type system: a generic type-level guard keyed on `P`'s id (tried + * first) turned out to be simultaneously bypassable (any `const widened: + * AnyPort = Scope` before the call slips past a conditional that only ever + * sees the widened structural type) and a false positive on ordinary + * port-generic helpers (`function wrap

(port: P) { + * return Provider(port) }` couldn't typecheck, since the conditional can't + * reduce for an unresolved `P`). `build.ts`'s `plan()` instead rejects a + * provider registered for `Scope`'s `portId` as a `WiringDefect`, the same + * class of pre-construction wiring bug a dependency cycle or a duplicate + * provider already is — sound against any type-level alias or widening, + * because it checks the runtime `portId` string, not a static type. + */ +export class Scope extends PortDeclaration("@di/Scope") {} + +/** + * Renamed from the brief's plain exported `Port` function: the "operations + * namespaced on the constructor" convention (`Port.many`, below) needs a + * value distinct from this one to `Object.assign` the namespace onto — + * same rationale, and the same fix, as `ModuleDeclaration`/`ProviderDeclaration` + * elsewhere in this package. Kept as a `function` declaration (not a `const`) + * specifically so it hoists: `Scope`, above, calls it at module-evaluation + * time, before the `export const Port = Object.assign(...)` line below has + * run — a `const` there would still be in its temporal dead zone. + */ +function PortDeclaration(id: Id): PortClass { + warnOnDuplicateId(id); + // A class, not a plain object, is required here: `extends Port("X")` + // needs a construct signature, and only a class expression provides one. + // Two classes in this file are deliberate, not an organisation smell: `Scope` + // above is a concrete port that must live here (see its own doc comment), + // and this one is the factory `Port()` itself returns. + // oxlint-disable-next-line typescript/no-extraneous-class max-classes-per-file + return class { + static readonly portId = id; + } as unknown as PortClass; +} + +/** + * `Port.many` mirrors `PortDeclaration` exactly, except the returned class + * also carries a `many: true` static field — the runtime discriminant + * `build.ts`'s `plan`/`constructLevel` read to decide a port accumulates + * contributions instead of colliding on the second provider. See + * `ManyPortClass`'s own doc comment above for why this is a *static* field + * (readable at runtime off the class) rather than the `[MANY]` brand (a + * type-level-only marker on the never-instantiated instance type). + */ +export const Port = Object.assign(PortDeclaration, { + many: (id: Id): ManyPortClass => { + warnOnDuplicateId(id); + // oxlint-disable-next-line typescript/no-extraneous-class max-classes-per-file + return class { + static readonly portId = id; + static readonly many = true; + } as unknown as ManyPortClass; + }, +}); diff --git a/packages/di/src/provider.spec.ts b/packages/di/src/provider.spec.ts new file mode 100644 index 0000000..40982c0 --- /dev/null +++ b/packages/di/src/provider.spec.ts @@ -0,0 +1,63 @@ +import { Err, Ok, TaggedError } from "unthrown"; +import { expect, test } from "vitest"; +// Registers the `toBeOkWith` / `toBeErrTagged` / `toBeDefect` matchers at +// runtime (already wired via `setupFiles` in vitest.config.ts) and, just as +// importantly for `tsc --noEmit`, pulls its `declare module "vitest"` type +// augmentation into this file's compilation — the augmentation is only +// picked up by the type checker where the module is actually imported. +// oxlint-disable-next-line import/no-unassigned-import +import "@unthrown/vitest"; + +import { Port, Provider } from "./index.js"; + +class BoomError extends TaggedError("BoomError")<{ readonly why: string }> {} +class Value extends Port("PValue")<{ readonly n: number }> {} +class Seed extends Port("PSeed") {} + +test("a value provider yields its service and declares no deps", async () => { + const p = Provider(Value)({ value: { n: 1 } }); + expect(p.deps).toEqual([]); + await expect(p.construct([])).resolves.toBeOkWith({ n: 1 }); +}); + +test("a sync provider receives its dependencies positionally", async () => { + const p = Provider(Value)({ sync: () => ({ n: 2 }) }); + await expect(p.construct([])).resolves.toBeOkWith({ n: 2 }); +}); + +test("a make provider propagates the Err it returns", async () => { + const p = Provider(Value)({ make: () => Err(new BoomError({ why: "nope" })) }); + await expect(p.construct([])).resolves.toBeErrTagged("BoomError"); +}); + +test("a throw inside a factory becomes a defect, not an error", async () => { + const p = Provider(Value)({ + sync: () => { + // Deliberate: this is exactly the case under test — an unmodeled throw + // from a factory must land as a Defect, not propagate or become an Err. + // oxlint-disable-next-line unthrown/no-throw + throw new Error("kaboom"); + }, + }); + await expect(p.construct([])).resolves.toBeDefect(); +}); + +test("a class provider constructs with the resolved dependencies", async () => { + class Impl { + private readonly seed: number; + constructor(seed: number) { + this.seed = seed; + } + get n(): number { + return this.seed + 1; + } + } + const p = Provider(Value)([Seed], { class: Impl as never }); + const built = await p.construct([41]); + expect(built.isOk() && (built.value as Impl).n).toBe(42); +}); + +test("an Ok result from make is passed through unchanged", async () => { + const p = Provider(Value)({ make: () => Ok({ n: 3 }) }); + await expect(p.construct([])).resolves.toBeOkWith({ n: 3 }); +}); diff --git a/packages/di/src/provider.test-d.ts b/packages/di/src/provider.test-d.ts new file mode 100644 index 0000000..838bf45 --- /dev/null +++ b/packages/di/src/provider.test-d.ts @@ -0,0 +1,267 @@ +import { Err, Ok, TaggedError } from "unthrown"; +import { describe, test } from "vitest"; + +import { Port, Provider, type Scope, type ServiceOf } from "./index.js"; +import { type Equal } from "./type-assert.js"; + +class ConfigError extends TaggedError("ConfigError")<{ readonly reason: string }> {} +class PoolError extends TaggedError("ProvPoolError")<{ readonly url: string }> {} + +class Env extends Port("Env")> {} +class AppConfig extends Port("AppConfig")<{ readonly dbUrl: string }> {} +class Logger extends Port("ProvLogger")<{ readonly log: (m: string) => void }> {} +class Repo extends Port("ProvRepo")<{ readonly find: () => string }> {} +class Pool extends Port("ProvPool")<{ readonly close: () => void }> {} + +/** + * Recovers `Provider`'s three type arguments by direct positional inference + * against the same generic interface, rather than by assignability. `P` sits + * in a contravariant field position (`_port`), so a plain `const typed: + * Provider = p` assignment only proves `X` is assignable *into* + * whatever the value actually carries — a narrower or unrelated declared + * type, or a widened actual type like `unknown`, can pass that check + * regardless. (`_error`/`_needs` are covariant since the variance fix — see + * `Provider`'s own doc comment — so assignment *does* now catch a narrowing + * lie on those two, which the three "cannot be laundered" tests below rely + * on. Pinning with `Equal` is still the stronger check, and stays the + * default here.) Matching `T` against `Provider` + * reads the literal type arguments `p`'s declared type was built from, + * sidestepping field variance entirely — combine with `Equal` to pin them + * exactly. + */ +type ChannelsOf = T extends Provider ? readonly [P, E, N] : never; + +class RepoImpl { + private readonly cfg: ServiceOf; + constructor(cfg: ServiceOf) { + this.cfg = cfg; + } + find(): string { + return this.cfg.dbUrl; + } +} + +describe("Provider", () => { + test("a value provider needs nothing and cannot fail", () => { + const p = Provider(Logger)({ value: { log: () => {} } }); + const typed: Provider = p; + void typed; + + // The assignability check above is close to vacuous here: `never` is + // assignable into a contravariant parameter position regardless of what + // the actual type is, so it would pass even if `E`/`N` weren't really + // `never`. Pin the three channels exactly. + type Channels = ChannelsOf; + const portIsLogger: Equal = true; + const errorIsNever: Equal = true; + const needsIsNever: Equal = true; + void portIsLogger; + void errorIsNever; + void needsIsNever; + }); + + test("deps are typed into the factory parameters, positionally", () => { + Provider(AppConfig)([Env], { + sync: (env) => ({ dbUrl: env["DATABASE_URL"] ?? "" }), + }); + }); + + test("a factory parameter has the dependency's service shape, not the port", () => { + Provider(AppConfig)([Env], { + // @ts-expect-error the parameter is the env record, which has no `portId` + sync: (env) => ({ dbUrl: env.portId }), + }); + }); + + test("make infers E from the Err it returns", () => { + const p = Provider(AppConfig)([Env], { + make: (env) => { + const url = env["DATABASE_URL"]; + return url === undefined + ? Err(new ConfigError({ reason: "DATABASE_URL is unset" })) + : Ok({ dbUrl: url }); + }, + }); + const typed: Provider = p; + void typed; + + // The assignability check above only proves `ConfigError` is assignable + // *into* `p`'s actual error channel — it stays green even if `ErrorOf` + // regressed to widening `E` to `unknown` (a wider type is always + // assignable into a contravariant parameter). Pin it exactly, and prove + // the hole is closed with a negative control against `unknown`. + type Channels = ChannelsOf; + const portIsAppConfig: Equal = true; + const errorIsConfigError: Equal = true; + const errorIsNotUnknown: Equal = false; + const needsIsEnv: Equal = true; + void portIsAppConfig; + void errorIsConfigError; + void errorIsNotUnknown; + void needsIsEnv; + }); + + test("class checks the constructor against the declared deps", () => { + Provider(Repo)([AppConfig], { class: RepoImpl }); + }); + + test("a class whose constructor does not match the deps is rejected", () => { + // @ts-expect-error RepoImpl takes an AppConfig service, not a Logger service + Provider(Repo)([Logger], { class: RepoImpl }); + }); + + test("two qualifications at once are rejected", () => { + // @ts-expect-error `value` and `sync` are mutually exclusive + Provider(Logger)({ value: { log: () => {} }, sync: () => ({ log: () => {} }) }); + }); + + test("onStart is optional on every arm, without reopening arm exclusivity", () => { + const p = Provider(Logger)({ + value: { log: () => {} }, + onStart: (s) => void s.log, + }); + + // The assignability check style used elsewhere in this file only proves + // `never` is assignable *into* a contravariant slot, which passes + // regardless of whether hooks disturbed anything — pin the channels + // exactly instead, same as every other test here. + type Channels = ChannelsOf; + const portIsLogger: Equal = true; + const errorIsNever: Equal = true; + // A bare `onStart` (no `acquire`, no `onStop`) needs no `Scope` — only + // teardown (`release`/`onStop`) does; see "onStop needs a Scope..." below. + const needsIsNever: Equal = true; + // Negative control: a regression that let `Hooks`'s intersection leak + // into `ErrorOf`/`ScopeOf` (e.g. by making `O extends { acquire: ... }` + // spuriously true) would widen `Needs` away from `never` — pin against + // that, not just check it's `never`. + const needsIsNotUnknown: Equal = false; + void portIsLogger; + void errorIsNever; + void needsIsNever; + void needsIsNotUnknown; + + // Hooks riding along does not make two real qualification arms + // compatible — the union's own `?: never` siblings still fire. + // @ts-expect-error `value` and `sync` are mutually exclusive even with hooks present + Provider(Logger)({ + value: { log: () => {} }, + sync: () => ({ log: () => {} }), + onStart: () => {}, + }); + }); + + test("onStop needs a Scope even without acquire/release — a value arm's onStop is still teardown", () => { + const p = Provider(Logger)({ + value: { log: () => {} }, + onStop: (s) => void s.log, + }); + + // `onStop` is registered on the scope exactly like `release` is + // (`lifecycle.ts`'s `constructLevel`) — only `Module.scoped`/`forkScope` + // ever open and close one, so a provider whose only teardown is an + // `onStop` must gate `Module.build` the same way `acquire`/`release` + // already does, or the hook silently never runs (`Module.build` never + // closes the throwaway scope it builds against). + type Channels = ChannelsOf; + const needsIsScope: Equal = true; + // Negative control: pins `Scope` exactly, not merely "not never" — a + // regression that widened `Needs` to `unknown` instead of `Scope` would + // otherwise slip past a bare "is it never" check. + const needsIsNotNever: Equal = false; + void needsIsScope; + void needsIsNotNever; + }); + + /** + * The three tests below are `Provider`'s analogues of `module.test-d.ts`'s + * "an unmet requirement cannot be laundered to no requirement" and "a wider + * error union cannot be narrowed away", plus one more for the `Scope` case. + * Task 4 made `Module`'s `_error`/`_needs` covariant and wrote those two + * tests; `Provider` was left contravariant and untested, which is precisely + * why the drift survived ten tasks. Each is written in the shape the defect + * actually takes in real code — an ordinary return-type annotation on a + * factory function, no cast and no `any` — because that is the form that + * launders the channel silently. + */ + test("an unmet requirement cannot be laundered to no requirement", () => { + const p = Provider(Repo)([AppConfig], { sync: (cfg) => ({ find: () => cfg.dbUrl }) }); + // @ts-expect-error AppConfig is still an unmet requirement + const typed: Provider = p; + void typed; + + // The same lie in the form it is actually written: a factory whose + // declared return type quietly drops the dependency. With `_needs` + // contravariant this compiled, and the resulting `Provider` sailed through `Module.build`'s `[N] extends [never]` gate with + // nothing registered for `AppConfig` at all. + const makeRepoProvider = (): Provider => + // @ts-expect-error AppConfig is still an unmet requirement + Provider(Repo)([AppConfig], { sync: (cfg) => ({ find: () => cfg.dbUrl }) }); + void makeRepoProvider; + }); + + test("a wider error union cannot be narrowed away", () => { + const p = Provider(AppConfig)([Env], { + make: (env) => { + const url = env["DATABASE_URL"]; + if (url === undefined) return Err(new ConfigError({ reason: "unset" })); + if (url === "") return Err(new PoolError({ url })); + return Ok({ dbUrl: url }); + }, + }); + + type Channels = ChannelsOf; + const errorIsUnion: Equal = true; + void errorIsUnion; + + // @ts-expect-error ConfigError is still a possible failure, not just PoolError + const typed: Provider = p; + void typed; + + // And the annotation form: a provider that genuinely fails cannot be + // declared infallible. Contravariance made this reduce to "is `never` + // assignable to `ConfigError`" — trivially true — so the error vanished + // from the module's `E` and from `Module.build`'s result type. + const infallible = (): Provider => + // @ts-expect-error ConfigError is a real failure this provider can return + Provider(AppConfig)({ make: () => Err(new ConfigError({ reason: "unset" })) }); + void infallible; + }); + + test("a resourceful provider cannot be laundered into needing no Scope", () => { + // The variance leak with a silent *runtime* consequence, which is why it + // gets its own test rather than riding along with the `_needs` case above. + // `ScopeOf` puts `Scope` in this provider's `Needs` exactly so the graph + // is forced through `Module.scoped`, the only entry point that closes the + // scope it opens. Laundered to `never`, it routes to `Module.build` + // instead — which creates a `createScope()` it never closes (`module.ts`), + // so the `release` registered in `lifecycle.ts`'s `constructLevel` is + // dropped on the floor and the pool is never closed. No type error, no + // runtime error, just a leak: the same hole Task 6 closed from the + // scope-unwind side, reopened from the declaration side. + const leaky = (): Provider => + // @ts-expect-error Scope is still required — this provider has a release + Provider(Pool)({ + acquire: () => Ok({ close: () => {} }), + release: (pool) => pool.close(), + }); + void leaky; + + // Positive control: the honest annotation, which must keep compiling. + const honest = (): Provider => + Provider(Pool)({ + acquire: () => Ok({ close: () => {} }), + release: (pool) => pool.close(), + }); + void honest; + }); + + test("a hook's parameter is the constructed service, not the port", () => { + Provider(Logger)({ + value: { log: () => {} }, + // @ts-expect-error the hook parameter is the service, which has no `portId` + onStart: (s) => void s.portId, + }); + }); +}); diff --git a/packages/di/src/provider.ts b/packages/di/src/provider.ts new file mode 100644 index 0000000..d4fa6c4 --- /dev/null +++ b/packages/di/src/provider.ts @@ -0,0 +1,293 @@ +import { Ok, type AsyncResult, type Result } from "unthrown"; + +import type { AnyPort, MemberOf, Scope, ServiceOf } from "./port.js"; + +/** Internal: the resolved service tuple a `deps` array's factory must accept. */ +type ServicesOf = { + readonly [K in keyof D]: ServiceOf; +}; + +/** Internal: the union of instance types a `deps` array requires. */ +type NeedsOf = InstanceType; + +type ErrorOfResult = + R extends Result ? E : R extends AsyncResult ? E : never; + +/** + * The construction family, as mutually exclusive option shapes rather than + * four method names. Each arm qualifies construction differently — ready, + * sync, fallible, class — and a value can satisfy only one. + * + * A plain union of object types does not reject excess properties in every + * position (only fresh-literal checks do, and only when the literal matches no + * arm at all). Giving every arm the other keys as optional `never` makes them + * genuinely exclusive: a literal supplying two real keys fails *both* arms it + * might otherwise match, since the arm owning the first key requires the + * second to be absent, and vice versa. + */ +type ValueArm = { + readonly value: S; + readonly sync?: never; + readonly make?: never; + readonly class?: never; + readonly acquire?: never; + readonly release?: never; +}; + +type SyncArm = { + readonly value?: never; + readonly sync: (...args: Args) => S; + readonly make?: never; + readonly class?: never; + readonly acquire?: never; + readonly release?: never; +}; + +type MakeArm = { + readonly value?: never; + readonly sync?: never; + // The error is bounded by `unknown`, not `never`: this arm serves both as a + // constraint (is the option object *some* valid make arm) and, via `ErrorOf` + // below, as the inference source for the real error type. A `never` bound + // would make the constraint check reject any function whose `Err` branch + // carries a real error — every useful `make` — before `ErrorOf` could read + // it. `unknown` accepts any concrete error there while leaving the inferred + // option type `O` holding the function's precise return type. + // oxlint-disable-next-line unthrown/no-ambiguous-error-type + readonly make: (...args: Args) => Result | AsyncResult; + readonly class?: never; + readonly acquire?: never; + readonly release?: never; +}; + +type ClassArm = { + readonly value?: never; + readonly sync?: never; + readonly make?: never; + readonly class: new (...args: Args) => S; + readonly acquire?: never; + readonly release?: never; +}; + +/** + * The resourceful arm: `acquire` is `make`'s fallible-construction twin and + * `release` the finaliser undoing it. Both are required together — there is no + * `release` with nothing to release, nor an `acquire` never torn down — which + * is what makes the pair its own arm rather than an optional `release` bolted + * onto `MakeArm`. `ScopeOf` below turns "this arm was chosen" into the `Scope` + * phantom landing in `Needs`. + */ +type AcquireArm = { + readonly value?: never; + readonly sync?: never; + readonly make?: never; + readonly class?: never; + // Same `unknown` bound and the same reason as `MakeArm.make` above. + // oxlint-disable-next-line unthrown/no-ambiguous-error-type + readonly acquire: (...args: Args) => Result | AsyncResult; + readonly release: (service: S) => void | Promise; +}; + +/** + * Optional on every arm via the intersection below, rather than duplicated + * into all five. Contributing only *optional* fields the arms don't otherwise + * mention cannot reopen their mutual exclusivity: the `?: never` siblings that + * make `value`/`sync`/`make`/`class`/`acquire` reject each other are + * untouched, and a fresh literal's excess-property check still runs per + * branch — `{ value, sync }` is rejected exactly as before (see + * `provider.test-d.ts`, "hooks do not reopen arm exclusivity"). Not on + * `index.ts`'s public surface: hooks are always supplied inline. + */ +type Hooks = { + readonly onStart?: (service: S) => void | Promise; + readonly onStop?: (service: S) => void | Promise; +}; + +type Qualification = ( + | ValueArm + | SyncArm + | MakeArm + | ClassArm + | AcquireArm +) & + Hooks; + +/** + * Recovers the error type from a `make` arm's *actual* supplied function, not + * the widened `unknown` bound `Qualification` checks it against. `O` is the + * inferred argument type — untouched by the constraint check — so `infer R` + * captures the real `Result`/`AsyncResult` returned. Arms without a `make` key + * have `make?: never` (optional), not assignable to the required `make` this + * pattern demands, so the conditional falls through to `never` for `value`, + * `sync`, and `class`. Internal: only `build`'s overloads need it. + */ +type ErrorOf = O extends { readonly make: (...args: never) => infer R } + ? ErrorOfResult + : O extends { readonly acquire: (...args: never) => infer R } + ? ErrorOfResult + : never; + +/** + * A resourceful provider requires a `Scope` on top of its declared deps. + * Checked structurally (does `O` have an `acquire` or an `onStop` key) rather + * than by matching a whole arm, for the same reason `ErrorOf` infers off a + * bare `(...args: never) => infer R`: `O`'s shape, not its assignability to + * some wider constraint, decides whether `Scope` joins `Needs`. + * + * `onStop` gates on `Scope` for the same reason `release` does: both are + * teardown registered on the scope that only `Module.scoped`/`forkScope` ever + * open and close (`lifecycle.ts`'s `constructLevel`, `module.ts`), never + * `Module.build`. Without this arm `Provider(P)({ value, onStop })` would + * type-check under `Module.build` with `Needs = never`, and the hook would + * silently never run. Internal, same as `ErrorOf` above. + */ +type ScopeOf = O extends { readonly acquire: unknown } + ? Scope + : O extends { readonly onStop: unknown } + ? Scope + : never; + +/** + * The package's variance rule, stated here and on `Module` (`module.ts`): + * + * > Capability channels (`_port`, `_exports`) are contravariant, so you may + * > forget what you have. Obligation channels (`_error`, `_needs`) are + * > covariant, so you may not forget what you owe. + * + * With `_error`/`_needs` contravariant — as they were until this fix, Task 4 + * having corrected only `Module` — an ordinary return-type annotation + * laundered both, and with them the `Scope` that `ScopeOf` puts in `Needs`, + * routing a resourceful provider to `Module.build` (which never closes the + * scope it opens) and silently dropping its `release`. See the three "cannot + * be laundered" tests in `provider.test-d.ts`. + */ +export type Provider = { + readonly _port: (p: P) => void; + readonly _error: () => E; + readonly _needs: () => N; + readonly port: AnyPort; + readonly deps: readonly AnyPort[]; + // The package's own construction boundary, not application code: a + // `Provider` is built once per port from whatever qualification the caller + // supplies, so its error and service types are genuinely unknown here — the + // build pipeline narrows them back to `E`/`P` per port. + // oxlint-disable-next-line unthrown/no-ambiguous-error-type + readonly construct: (services: readonly unknown[]) => AsyncResult; + readonly release: ((service: unknown) => void | Promise) | undefined; + // `onStart`/`onStop` mirror `release`'s "present only when supplied" shape; + // `build.ts`'s pipeline fires them, this record just carries them through. + readonly onStart: ((service: unknown) => void | Promise) | undefined; + readonly onStop: ((service: unknown) => void | Promise) | undefined; +}; + +/** + * `Provider` is the *bottom* under the variance rule + * above — contravariant `_port` takes the widest argument, covariant + * `_error`/`_needs` return the narrowest — so it is assignable to every + * `Provider`, as an implementation signature shared by both `build` + * overloads must be. + */ +const descriptor = ( + port: AnyPort, + deps: readonly AnyPort[], + options: Record, +): Provider => { + // oxlint-disable-next-line unthrown/no-ambiguous-error-type -- see the field comment on `Provider.construct` + const construct = (services: readonly unknown[]): AsyncResult => { + if ("value" in options) return Ok(options["value"]).toAsync(); + if ("sync" in options) { + const f = options["sync"] as (...a: readonly unknown[]) => unknown; + return Ok() + .toAsync() + .map(() => f(...services)); + } + if ("class" in options) { + const C = options["class"] as new (...a: readonly unknown[]) => unknown; + return Ok() + .toAsync() + .map(() => new C(...services)); + } + // Whichever of `make`/`acquire` was supplied — `Qualification`'s + // exclusivity guarantees at most one — is the fallible path. `acquire` is + // `make` with a finaliser attached; construction works identically either + // way. + const f = (options["acquire"] ?? options["make"]) as ( + ...a: readonly unknown[] + // oxlint-disable-next-line unthrown/no-ambiguous-error-type -- see the field comment on `Provider.construct` + ) => Result | AsyncResult; + // Lifting through an Ok keeps a sync Result and an AsyncResult on one path, + // with no runtime type-sniffing — the same trick demesne (this library's retired predecessor) used in Layer.make. + return Ok() + .toAsync() + .flatMap(() => f(...services)); + }; + return { + port, + deps, + construct, + // Only the `acquire` arm sets this; every other arm carries `undefined`, + // which is how `constructLevel` (`lifecycle.ts`) decides whether a + // constructed service has anything to register with the scope. + release: options["release"] as ((service: unknown) => void | Promise) | undefined, + onStart: options["onStart"] as ((service: unknown) => void | Promise) | undefined, + onStop: options["onStop"] as ((service: unknown) => void | Promise) | undefined, + } as unknown as Provider; +}; + +/** + * Renamed from the brief's plain exported `Provider` function: the + * "operations namespaced on the constructor" convention (`Provider.member`, + * below) needs a value distinct from this one to `Object.assign` the + * namespace onto, as was done for `Module` in Task 5. + * `S` — the service shape a `Qualification` must construct — is a second, + * defaulted type parameter rather than hard-coded to `ServiceOf

`, so + * `Provider.member` can instantiate it as `MemberOf

` (one contribution's + * shape, not the `readonly Member[]` the set port resolves to) while every + * ordinary `Provider(port)` call site keeps the default. + */ +function ProviderDeclaration

>(port: P) { + function build, S>>( + deps: D, + options: O, + ): Provider, ErrorOf, NeedsOf | ScopeOf>; + function build>( + options: O, + ): Provider, ErrorOf, ScopeOf>; + function build( + depsOrOptions: readonly AnyPort[] | Record, + maybeOptions?: Record, + ): Provider { + // `Array.isArray`'s predicate is `arg is any[]` — a *mutable* array type, + // which a `readonly AnyPort[]` in the union is not assignable to, so the + // false branch does not narrow away the array member on its own. The cast + // is a true narrowing (the runtime check already ruled the array case + // out), not a workaround for something unsound. + return Array.isArray(depsOrOptions) + ? descriptor(port, depsOrOptions, maybeOptions ?? {}) + : descriptor(port, [], depsOrOptions as Record); + } + return build; +} + +/** + * `Provider.member`'s port parameter is bound by the structural `AnyPort`, + * not the brief's `ManyPortClass`: a concrete set-port class such as + * `class HealthChecks extends Port.many("Id") {}` has a *concrete* + * constructor once `Member` is fixed by the heritage clause, so (exactly as a + * concrete `PortClass` never satisfies `PortClass` — see + * `port.ts`'s note on `AnyPort`) `typeof HealthChecks` is not assignable to + * `ManyPortClass`, and binding it that way would reject every real + * call site. + * + * The brief's one-liner (`ProviderDeclaration(port as never) as ReturnType< + * typeof ProviderDeclaration>`) also just forwards to the ordinary factory, + * qualifying against `ServiceOf

` — the set port's whole `readonly + * Member[]` — rather than one member's shape. Instantiating the second type + * parameter with `MemberOf

` is what changes the shape an arm is qualified + * against; the runtime body is identical, since `context.ts`'s `unsafeAddAll` + * (via `build.ts`'s `run`), never this factory, is what turns one member into + * an entry of the port's array. + */ +export const Provider = Object.assign(ProviderDeclaration, { + member:

(port: P) => ProviderDeclaration>(port), +}); diff --git a/packages/di/src/scope.ts b/packages/di/src/scope.ts new file mode 100644 index 0000000..21894a5 --- /dev/null +++ b/packages/di/src/scope.ts @@ -0,0 +1,59 @@ +/** Internal: only `createScope` (this file) and `ClosableFinalisers` below need the shape. */ +type Finaliser = () => void | Promise; + +/** Reports a finaliser failure during close: which port's release rejected, and why. */ +export type TeardownReporter = (portId: string, cause: unknown) => void; + +/** Internal: `ClosableFinalisers` below is the shape callers actually see. */ +type Finalisers = { + readonly onStop: (portId: string, f: Finaliser) => void; +}; + +export type ClosableFinalisers = { + readonly close: () => Promise; +} & Finalisers; + +/** + * Finalisers run in reverse acquisition order, so a resource is always released + * before whatever it was built from. A throwing finaliser is reported — via + * `onTeardownError`, which portId-tags it — and swallowed rather than rethrown: + * shutdown must not be abandoned half-way, and a failed close must not shadow + * whatever failure triggered the unwind in the first place. + */ +export function createScope( + onTeardownError: TeardownReporter = (portId, cause) => + console.error(`[di] finaliser for ${portId} failed during close`, cause), +): ClosableFinalisers { + const finalisers: (readonly [string, Finaliser])[] = []; + let closed = false; + + return { + onStop: (portId, f) => void finalisers.push([portId, f]), + close: async () => { + if (closed) return; + closed = true; + for (const [portId, f] of finalisers.toReversed()) { + try { + // Teardown is ordered, not parallel: a finaliser may depend on one + // registered before it still being up. + // oxlint-disable-next-line no-await-in-loop + await f(); + } catch (cause) { + // Reported, never rethrown: shutdown must not be abandoned half-way, and + // a failed close must not shadow the failure that caused the unwind. + // The reporter itself is untrusted user code — if it throws, that must + // be swallowed too (there is nowhere left to report a broken reporter + // to), or a throwing `onTeardownError` would propagate out of this + // loop exactly like an unguarded finaliser would: abandoning every + // remaining release and turning `close()` into a rejected promise + // that masks whatever failure triggered the unwind in the first place. + try { + onTeardownError(portId, cause); + } catch { + // Nowhere left to report a reporter failure — swallowed on purpose. + } + } + } + }, + }; +} diff --git a/packages/di/src/scoped.spec.ts b/packages/di/src/scoped.spec.ts new file mode 100644 index 0000000..9d77f79 --- /dev/null +++ b/packages/di/src/scoped.spec.ts @@ -0,0 +1,178 @@ +import { Err, Ok, TaggedError } from "unthrown"; +import { expect, test, vi } from "vitest"; + +import { Module, Port, Provider, type AnyPort } from "./index.js"; +// Deliberately not from `./index.js`: the package deliberately exports `Scope` +// as a type only, so the class *value* these two defect tests need is not on +// the public surface at all. Importing it straight from the module that +// declares it is what lets them keep proving `plan()`'s runtime `portId` +// check fires — the defence in depth behind the type-only export. +import { Scope } from "./port.js"; + +class OpenError extends TaggedError("OpenError")<{ readonly which: string }> {} + +class First extends Port("SFirst")<{ readonly n: 1 }> {} +class Second extends Port("SSecond")<{ readonly n: 2 }> {} + +test("resources release in reverse acquisition order after use", async () => { + const released: string[] = []; + const mod = Module("Two")({ + provides: [ + Provider(First)({ + acquire: () => Ok({ n: 1 as const }), + release: () => void released.push("first"), + }), + Provider(Second)([First], { + acquire: () => Ok({ n: 2 as const }), + release: () => void released.push("second"), + }), + ], + exports: [First, Second], + }); + + await Module.scoped(mod, () => Ok("done").toAsync()); + expect(released).toEqual(["second", "first"]); +}); + +test("a mid-graph failure releases everything already acquired", async () => { + const released: string[] = []; + const mod = Module("Failing")({ + provides: [ + Provider(First)({ + acquire: () => Ok({ n: 1 as const }), + release: () => void released.push("first"), + }), + Provider(Second)([First], { + // `acquire` (not `make`) on purpose: `Second` has a `release`, so this + // exercises the real guarantee — a failed *acquire* never registers its + // own release (the `.tap` in `constructLevel` only fires on `Ok`) — not + // just "a provider with no `release` field has nothing to release". + acquire: () => Err(new OpenError({ which: "second" })), + release: () => void released.push("second"), + }), + ], + exports: [First, Second], + }); + + const result = await Module.scoped(mod, () => Ok("unreachable").toAsync()); + expect(result).toBeErrTagged("OpenError"); + expect(released).toEqual(["first"]); +}); + +test("a rejecting release neither masks the failure nor stops the unwind", async () => { + const released: string[] = []; + const onTeardownError = vi.fn(); + const mod = Module("BadRelease")({ + provides: [ + Provider(First)({ + acquire: () => Ok({ n: 1 as const }), + release: () => void released.push("first"), + }), + Provider(Second)([First], { + acquire: () => Ok({ n: 2 as const }), + release: () => Promise.reject(new Error("close failed")), + }), + ], + exports: [First, Second], + }); + + const result = await Module.scoped(mod, () => Err(new OpenError({ which: "use" })).toAsync(), { + onTeardownError, + }); + + expect(result).toBeErrTagged("OpenError"); + expect(released).toEqual(["first"]); + expect(onTeardownError).toHaveBeenCalledWith("SSecond", expect.any(Error)); +}); + +test("a throwing onTeardownError does not abandon the unwind or mask the original failure", async () => { + const released: string[] = []; + // A reporter that itself throws — the failure mode a rejecting release + // already covers is "the thing being reported fails"; this covers "the + // reporting itself fails," which must not propagate: there is nowhere + // left to report a broken reporter to, and the `for` loop in `close()` + // must still reach `First`'s release after `Second`'s throws here. + const onTeardownError = vi.fn(() => { + // Deliberate: this test exists specifically to prove `createScope` + // survives a throwing reporter, so the reporter has to actually throw. + // oxlint-disable-next-line unthrown/no-throw + throw new Error("reporter itself is broken"); + }); + const openError = new OpenError({ which: "use" }); + const mod = Module("BadReporter")({ + provides: [ + Provider(First)({ + acquire: () => Ok({ n: 1 as const }), + release: () => void released.push("first"), + }), + Provider(Second)([First], { + acquire: () => Ok({ n: 2 as const }), + release: () => Promise.reject(new Error("close failed")), + }), + ], + exports: [First, Second], + }); + + const result = await Module.scoped(mod, () => Err(openError).toAsync(), { + onTeardownError, + }); + + // Not just `toBeErrTagged`: identity, not merely shape, proves the + // reporter's own throw never got laundered into the result (e.g. as a + // `Defect` replacing the original `Err`). + expect(result.isErr() && result.error).toBe(openError); + // `First` still released — the reporter's throw on `Second`'s failed + // release did not abandon the rest of the reverse-order loop. + expect(released).toEqual(["first"]); + expect(onTeardownError).toHaveBeenCalledWith("SSecond", expect.any(Error)); +}); + +test("Scope is not on the package's runtime export surface", async () => { + // The first line of defence behind the two defect tests below: consumers + // need `Scope` only in type positions, so the class value is withheld from + // `index.ts` (`export type { Scope }`). Asserted on the real module + // namespace rather than by a type-level check, because a type-only export + // is precisely one that leaves no trace in the type of the import — the + // erasure *is* the property under test, and only the runtime surface can + // observe it. If someone re-adds `Scope` to the value exports, this fails. + const index: Record = await import("./index.js"); + expect(Object.keys(index)).not.toContain("Scope"); + // Control: the value exports that are supposed to be there still are, so + // this cannot pass by the import silently resolving to nothing. + expect(Object.keys(index)).toEqual( + expect.arrayContaining(["Port", "Context", "Provider", "Module"]), + ); +}); + +test("providing Scope directly is a wiring defect, not a satisfied dependency", async () => { + const ran = vi.fn(); + const mod = Module("ProvidesScopeDirect")({ + // `Scope`'s own service shape is `never`, so nothing can genuinely + // construct one — this factory would never legitimately run; the test + // is about `plan()` rejecting the registration itself, before any + // factory (including this one) is called at all. + provides: [Provider(Scope)({ sync: ran as never })], + }); + + const built = await Module.build(mod); + expect(built).toBeDefect(); + expect(ran).not.toHaveBeenCalled(); +}); + +test("providing Scope through a widened AnyPort alias is still a wiring defect", async () => { + // The bypass a type-level guard on `Provider`'s own port parameter could + // not catch: one widening annotation erases which concrete port class + // `widened` statically is, so any conditional keyed on that static type + // sees only the structural `AnyPort` shape and cannot single `Scope` back + // out. `plan()`'s check is sound against exactly this, because it reads + // the *runtime* `portId` off the registered provider, not a static type. + const widened: AnyPort = Scope; + const ran = vi.fn(); + const mod = Module("ProvidesScopeWidened")({ + provides: [Provider(widened)({ sync: ran as never })], + }); + + const built = await Module.build(mod); + expect(built).toBeDefect(); + expect(ran).not.toHaveBeenCalled(); +}); diff --git a/packages/di/src/scoped.test-d.ts b/packages/di/src/scoped.test-d.ts new file mode 100644 index 0000000..ec582b3 --- /dev/null +++ b/packages/di/src/scoped.test-d.ts @@ -0,0 +1,122 @@ +import { Ok, type AsyncResult } from "unthrown"; +import { describe, test } from "vitest"; + +import { Module, Port, Provider, type AnyPort, type Scope } from "./index.js"; +import { type Equal } from "./type-assert.js"; + +class Pool extends Port("SPool")<{ readonly close: () => Promise }> {} + +const PoolProvider = Provider(Pool)({ + acquire: () => Ok({ close: async () => {} }), + release: (pool) => pool.close(), +}); + +const Resourceful = Module("Resourceful")({ provides: [PoolProvider], exports: [Pool] }); + +/** + * Same positional-inference trick `module.test-d.ts`/`build.test-d.ts` use: + * `Module`'s `_exports`/`_needs` are contravariant/covariant phantom fields, + * so a plain `const typed: Module = m` assignment only proves the + * declared type is assignable *into* whatever `m` actually carries — it + * would stay green even if `Needs` silently dropped `Scope` (or widened to + * `unknown`). Reading the literal type arguments back out pins the value, + * not just its assignability. + */ +type ChannelsOf = T extends Module ? readonly [X, E, N] : never; + +/** Same trick one level in, for what `Module.scoped` itself returns. */ +type ScopedChannels = T extends AsyncResult ? readonly [A, E] : never; + +/** + * `Scope` is deliberately *not* rejected by a type-level guard on + * `Provider`'s port parameter — that was tried and reverted (see `Scope`'s + * own doc comment in `port.ts`, and `build.ts`'s `plan()`, which now carries + * the real, runtime check). A guard keyed on `P`'s static type would make + * this ordinary, port-generic helper fail to typecheck too, since a + * conditional on `P` can't reduce while `P` is still an unresolved type + * parameter — this function compiling at all, with no `@ts-expect-error` + * below, is the regression test for that false positive. + */ +function wrap

(port: P) { + return Provider(port); +} + +describe("resources", () => { + test("acquire puts Scope in Needs", () => { + const typed: Module = Resourceful; + void typed; + + type Channels = ChannelsOf; + const exportsIsPool: Equal = true; + const errorIsNever: Equal = true; + const needsIsScope: Equal = true; + // Negative control: Needs must be pinned to exactly `Scope`, not merely + // "not never" — a regression that widened it to `unknown` or dropped it + // to `never` would otherwise slip past the positive assertions above. + const needsIsNotNever: Equal = false; + void exportsIsPool; + void errorIsNever; + void needsIsScope; + void needsIsNotNever; + }); + + test("a resourceful graph is rejected by build", () => { + // @ts-expect-error unsatisfied dependency: Scope + Module.build(Resourceful); + }); + + test("a resourceful graph is accepted by scoped", () => { + const used = Module.scoped(Resourceful, (ctx) => Ok(ctx.get(Pool)).toAsync()); + + type Channels = ScopedChannels; + const valueIsPoolService: Equal Promise }> = true; + const errorIsNever: Equal = true; + // Negative control: pins `use`'s callback result as the real return + // value, not a widened `unknown` that would pass regardless of what + // `Module.scoped` actually resolves to. + const errorIsNotUnknown: Equal = false; + void used; + void valueIsPoolService; + void errorIsNever; + void errorIsNotUnknown; + }); + + test("scoped still gates on a genuinely unmet dependency, not just Scope", () => { + class Other extends Port("SOther")<{ readonly n: number }> {} + const needsOther = Module("NeedsOther")({ + provides: [ + Provider(Pool)([Other], { + acquire: () => Ok({ close: async () => {} }), + release: (pool) => pool.close(), + }), + ], + exports: [Pool], + }); + // @ts-expect-error unsatisfied dependency: Other (Scope alone is excused) + Module.scoped(needsOther, (ctx) => Ok(ctx.get(Pool)).toAsync()); + }); + + test("a port-generic helper still typechecks", () => { + void wrap; + }); + + test("hooks on the resourceful arm do not disturb Needs", () => { + const hooked = Provider(Pool)({ + acquire: () => Ok({ close: async () => {} }), + release: (pool) => pool.close(), + onStart: (pool) => void pool.close(), + onStop: (pool) => void pool.close(), + }); + const mod = Module("HookedResourceful")({ provides: [hooked], exports: [Pool] }); + + // Pins that `Hooks`'s intersection still lets `ScopeOf` see the + // `acquire` key through it — `Needs` must stay exactly `Scope`, not + // widen (or, worse, silently drop back to `never` if the hooks somehow + // shadowed `acquire` in the structural check). + type Channels = ChannelsOf; + const needsIsScope: Equal = true; + const needsIsNotNever: Equal = false; + void needsIsScope; + void needsIsNotNever; + }); +}); diff --git a/packages/di/src/type-assert.ts b/packages/di/src/type-assert.ts new file mode 100644 index 0000000..5fc5c5b --- /dev/null +++ b/packages/di/src/type-assert.ts @@ -0,0 +1,23 @@ +/** + * Exact type equality — the standard two-conditional trick. Distinguishes + * "assignable both ways" from "literally the same type," which is what an + * inference-pinning test needs and an ordinary assignability assertion + * cannot give: assigning a value into a variable of a declared type checks + * each field's type according to its own variance, so a phantom field + * declared in contravariant position — e.g. `Provider`'s + * `readonly _error: (e: E) => void` — only proves the declared type is + * assignable *into* whatever the value actually carries. A narrower or + * unrelated declared type (`never`, or a type that happens to be a subtype + * of the real one) can pass that check even when the real inferred type has + * silently widened, because a "smaller" target is always assignable into a + * contravariant parameter position. `Equal` sidesteps this by comparing how + * `A` and `B` each distribute a fresh type variable through a conditional — + * the two only produce the same (non-generic) result when `A` and `B` are + * the same type, regardless of where either type appears structurally. + * + * Shared across this package's `*.test-d.ts` files rather than duplicated + * per file. Not exported from the package index — this is a test-only + * helper, imported directly where needed. + */ +export type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; diff --git a/packages/di/tsconfig.json b/packages/di/tsconfig.json new file mode 100644 index 0000000..3c0ba5c --- /dev/null +++ b/packages/di/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@btravstack/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declarationMap": false, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test-d.ts"] +} diff --git a/packages/di/tsconfig.test-d.json b/packages/di/tsconfig.test-d.json new file mode 100644 index 0000000..eab5a71 --- /dev/null +++ b/packages/di/tsconfig.test-d.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src/**/*.test-d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/di/vitest.config.ts b/packages/di/vitest.config.ts new file mode 100644 index 0000000..fb76260 --- /dev/null +++ b/packages/di/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + setupFiles: ["@unthrown/vitest"], + }, +}); diff --git a/packages/start-amqp/package.json b/packages/start-amqp/package.json index 71eb488..0f0ae59 100644 --- a/packages/start-amqp/package.json +++ b/packages/start-amqp/package.json @@ -54,7 +54,7 @@ "@amqp-contract/contract": "catalog:", "@amqp-contract/testing": "catalog:", "@amqp-contract/worker": "catalog:", - "@btravstack/di": "catalog:", + "@btravstack/di": "workspace:^", "@btravstack/start": "workspace:*", "@btravstack/tsconfig": "catalog:", "@opentelemetry/api": "catalog:", diff --git a/packages/start-http/package.json b/packages/start-http/package.json index 413fc2a..37058fe 100644 --- a/packages/start-http/package.json +++ b/packages/start-http/package.json @@ -49,7 +49,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@btravstack/di": "catalog:", + "@btravstack/di": "workspace:^", "@btravstack/start": "workspace:*", "@btravstack/tsconfig": "catalog:", "@types/node": "catalog:", diff --git a/packages/start-temporal/package.json b/packages/start-temporal/package.json index 0afe402..53941d9 100644 --- a/packages/start-temporal/package.json +++ b/packages/start-temporal/package.json @@ -50,7 +50,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@btravstack/di": "catalog:", + "@btravstack/di": "workspace:^", "@btravstack/start": "workspace:*", "@btravstack/tsconfig": "catalog:", "@temporal-contract/contract": "catalog:", diff --git a/packages/start/package.json b/packages/start/package.json index e26f2db..18b91a5 100644 --- a/packages/start/package.json +++ b/packages/start/package.json @@ -62,7 +62,7 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "devDependencies": { - "@btravstack/di": "catalog:", + "@btravstack/di": "workspace:^", "@btravstack/tsconfig": "catalog:", "@types/node": "catalog:", "@unthrown/vitest": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd6f60c..3a6a9a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,9 +18,6 @@ catalogs: '@btravstack/commitlint': specifier: 0.1.0 version: 0.1.0 - '@btravstack/di': - specifier: 0.1.0 - version: 0.1.0 '@btravstack/entity': specifier: 0.7.0 version: 0.7.0 @@ -30,9 +27,15 @@ catalogs: '@btravstack/oxlint': specifier: 0.2.1 version: 0.2.1 + '@btravstack/theme': + specifier: 2.0.0 + version: 2.0.0 '@btravstack/tsconfig': specifier: 0.2.0 version: 0.2.0 + '@btravstack/typedoc': + specifier: 0.1.0 + version: 0.1.0 '@changesets/cli': specifier: 2.31.1 version: 2.31.1 @@ -129,18 +132,37 @@ catalogs: turbo: specifier: 2.10.8 version: 2.10.8 + typedoc: + specifier: 0.28.20 + version: 0.28.20 + typedoc-plugin-markdown: + specifier: 4.12.0 + version: 4.12.0 typescript: specifier: 7.0.2 version: 7.0.2 + typescript-consumer: + specifier: npm:typescript@5.9.3 + version: 5.9.3 unthrown: specifier: 5.5.0 version: 5.5.0 + vitepress: + specifier: 1.6.4 + version: 1.6.4 vitest: specifier: 4.1.10 version: 4.1.10 zod: specifier: 4.4.3 version: 4.4.3 + typedoc: + typescript: + specifier: 6.0.3 + version: 6.0.3 + +overrides: + vite@<6.4.3: 6.4.3 importers: @@ -180,14 +202,66 @@ importers: specifier: 'catalog:' version: 2.10.8 + docs: + devDependencies: + '@btravstack/theme': + specifier: 'catalog:' + version: 2.0.0(vitepress@1.6.4(@algolia/client-search@5.56.0)(@types/node@26.1.2)(jiti@2.7.0)(postcss@8.5.26)(terser@5.50.0)(typescript@6.0.3)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + '@btravstack/typedoc': + specifier: 'catalog:' + version: 0.1.0(typedoc-plugin-markdown@4.12.0(typedoc@0.28.20(typescript@6.0.3)))(typedoc@0.28.20(typescript@6.0.3)) + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + typedoc: + specifier: 'catalog:' + version: 0.28.20(typescript@6.0.3) + typedoc-plugin-markdown: + specifier: 'catalog:' + version: 4.12.0(typedoc@0.28.20(typescript@6.0.3)) + typescript: + specifier: catalog:typedoc + version: 6.0.3 + vitepress: + specifier: 'catalog:' + version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@26.1.2)(jiti@2.7.0)(postcss@8.5.26)(terser@5.50.0)(typescript@6.0.3)(yaml@2.9.0) + + examples/hexagonal-order-api: + dependencies: + '@btravstack/di': + specifier: workspace:* + version: link:../../packages/di + unthrown: + specifier: 'catalog:' + version: 5.5.0 + devDependencies: + '@btravstack/tsconfig': + specifier: 'catalog:' + version: 0.2.0 + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 5.5.0(unthrown@5.5.0)(vitest@4.1.10) + typescript: + specifier: 'catalog:' + version: 7.0.2 + typescript-consumer: + specifier: 'catalog:' + version: typescript@5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + examples/order-amqp: dependencies: '@amqp-contract/worker': specifier: 'catalog:' version: 3.0.0-beta.6(@opentelemetry/api@1.9.1)(unthrown@5.5.0) '@btravstack/di': - specifier: 'catalog:' - version: 0.1.0(unthrown@5.5.0) + specifier: workspace:* + version: link:../../packages/di '@btravstack/start': specifier: workspace:* version: link:../../packages/start @@ -275,8 +349,8 @@ importers: examples/order-api: dependencies: '@btravstack/di': - specifier: 'catalog:' - version: 0.1.0(unthrown@5.5.0) + specifier: workspace:* + version: link:../../packages/di '@btravstack/start': specifier: workspace:* version: link:../../packages/start @@ -367,8 +441,8 @@ importers: examples/order-application: dependencies: '@btravstack/di': - specifier: 'catalog:' - version: 0.1.0(unthrown@5.5.0) + specifier: workspace:* + version: link:../../packages/di '@btravstack/start': specifier: workspace:* version: link:../../packages/start @@ -457,8 +531,8 @@ importers: examples/order-infrastructure: dependencies: '@btravstack/di': - specifier: 'catalog:' - version: 0.1.0(unthrown@5.5.0) + specifier: workspace:* + version: link:../../packages/di '@btravstack/start-example-order-application': specifier: workspace:* version: link:../order-application @@ -500,8 +574,8 @@ importers: examples/order-temporal: dependencies: '@btravstack/di': - specifier: 'catalog:' - version: 0.1.0(unthrown@5.5.0) + specifier: workspace:* + version: link:../../packages/di '@btravstack/start': specifier: workspace:* version: link:../../packages/start @@ -604,8 +678,8 @@ importers: examples/order-worker: dependencies: '@btravstack/di': - specifier: 'catalog:' - version: 0.1.0(unthrown@5.5.0) + specifier: workspace:* + version: link:../../packages/di '@btravstack/start': specifier: workspace:* version: link:../../packages/start @@ -647,11 +721,88 @@ importers: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) - packages/start: + examples/plugin-registry: + dependencies: + '@btravstack/di': + specifier: workspace:* + version: link:../../packages/di + unthrown: + specifier: 'catalog:' + version: 5.5.0 devDependencies: + '@btravstack/tsconfig': + specifier: 'catalog:' + version: 0.2.0 + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 5.5.0(unthrown@5.5.0)(vitest@4.1.10) + typescript: + specifier: 'catalog:' + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + + examples/request-scope: + dependencies: '@btravstack/di': + specifier: workspace:* + version: link:../../packages/di + unthrown: + specifier: 'catalog:' + version: 5.5.0 + devDependencies: + '@btravstack/tsconfig': + specifier: 'catalog:' + version: 0.2.0 + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 5.5.0(unthrown@5.5.0)(vitest@4.1.10) + typescript: + specifier: 'catalog:' + version: 7.0.2 + vitest: specifier: 'catalog:' - version: 0.1.0(unthrown@5.5.0) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + + packages/di: + devDependencies: + '@btravstack/tsconfig': + specifier: 'catalog:' + version: 0.2.0 + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 5.5.0(unthrown@5.5.0)(vitest@4.1.10) + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.10(vitest@4.1.10) + tsdown: + specifier: 'catalog:' + version: 0.22.14(oxc-resolver@11.24.2)(typescript@7.0.2) + typescript: + specifier: 'catalog:' + version: 7.0.2 + unthrown: + specifier: 'catalog:' + version: 5.5.0 + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + + packages/start: + devDependencies: + '@btravstack/di': + specifier: workspace:^ + version: link:../di '@btravstack/tsconfig': specifier: 'catalog:' version: 0.2.0 @@ -689,8 +840,8 @@ importers: specifier: 'catalog:' version: 3.0.0-beta.6(@opentelemetry/api@1.9.1)(unthrown@5.5.0) '@btravstack/di': - specifier: 'catalog:' - version: 0.1.0(unthrown@5.5.0) + specifier: workspace:^ + version: link:../di '@btravstack/start': specifier: workspace:* version: link:../start @@ -728,8 +879,8 @@ importers: packages/start-http: devDependencies: '@btravstack/di': - specifier: 'catalog:' - version: 0.1.0(unthrown@5.5.0) + specifier: workspace:^ + version: link:../di '@btravstack/start': specifier: workspace:* version: link:../start @@ -761,8 +912,8 @@ importers: packages/start-temporal: devDependencies: '@btravstack/di': - specifier: 'catalog:' - version: 0.1.0(unthrown@5.5.0) + specifier: workspace:^ + version: link:../di '@btravstack/start': specifier: workspace:* version: link:../start @@ -820,6 +971,82 @@ importers: packages: + '@algolia/abtesting@1.22.0': + resolution: {integrity: sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.56.0': + resolution: {integrity: sha512-7r4Z3NC7yU1oAQVWJNA2HX7tX481F3pJvCGyLIXiTdBcthz4Q/o21jwcMYDFkuI92UWTNBQQmHYgwHo1zS5dzg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.56.0': + resolution: {integrity: sha512-avmjXQSq+jadFO8Xl2em05/uQdQnEmHsJyOAdVbZkmVgpMfxL12aJwVVfGNwYr9nulcpuJN1X0lTaQ5wxuNGcA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.56.0': + resolution: {integrity: sha512-v2TPStUhY//ripPjIVclZ8AWc7DEGooXULZGFlFu37zNatgHjw34oZZ+OSbbc/YHO+xZwPl62I1k8xH1m4S2eg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.56.0': + resolution: {integrity: sha512-P0ehROpM4Sem3Sqo5x2cKPgj67D3G3jy0rh1Amwkcvsfr6tkvIcdCmerieanqTF7NxUMPNFLkpIFeMO8Rpa50w==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.56.0': + resolution: {integrity: sha512-SXK3Vn3WVxyzbm31oePZBJkp1wpOyuWdd4B/Pv7n0aXDxmeSWhC1R1FC1517mMrFAIaPH4Rt0x6RUe7ZNjz8FA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.56.0': + resolution: {integrity: sha512-5+ZdX8garFnmycnZgKhtXHePEaLj5zqDxI/0lkhhluzCcvTn0/PvvTirTg8hHYetQHvn7GDyeAiqTAieMvMW4A==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.56.0': + resolution: {integrity: sha512-+mKUdYvqOi0BcvpAEyCEw49vSBptufIcfibtHz2bdr1pI789M46Yt0uQEk/sxtK3teh71OQvVFHaTDzShUWewQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.56.0': + resolution: {integrity: sha512-9g/zj+AZx5moFcdFIrYQoVrueXivjUcc3MQHtCYT8WhIuk1lUh1AyEhvJCS0XBZld09cLvd1AZ3BvDBpVpX2UA==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.56.0': + resolution: {integrity: sha512-Qf3Sr6f9A9uxCZUf3MXS0d2b877uYzEB5yxqpVGXAhcJnBCQjrRRon0KvefpGkxy+BshrIJs96OUoMtGqXTFDA==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.56.0': + resolution: {integrity: sha512-GXWG1rWc5wu8hY4N33Y3b6ernY6sAdAvmKWN/zHAiACOx40WnpG0TVX5YazCAr/9gOYGInSiM2A0y2jy2xbiDA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.56.0': + resolution: {integrity: sha512-7t24cBxaInS3mZb7ddEaZT/tp6q+/aR4YttsQVyP1/i+LmwPR34atO35KjaLFCcRVrlP7sYOAqkCfg6lIRB+ew==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.56.0': + resolution: {integrity: sha512-R7ePHgVYmDFjZpvrsVAfbDz/d4RxKAYZ5/vgLfIsCVRZRryjWl/3INOxpOICzitehQ5FjNtNjcLQTrmHPTcHBQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.56.0': + resolution: {integrity: sha512-PIOUXlSnrqM0S+WOgDRb4RzotydJH7ZoT6tOyL7tAO7qJOfvX5wsEW8Pe+PMKMwvuI4/gIyK9cg2H7lJXqnc4Q==} + engines: {node: '>= 14.0.0'} + '@amqp-contract/contract@3.0.0-beta.6': resolution: {integrity: sha512-9eDFKrdUo2MfM0tB4vJTmvK2wgEXmJYi2iCX0MsMG1yLESL4w4iXp9cHg+RypicHsHlhOg8wjcRHGkLEtj7KoA==} engines: {node: '>=22.19'} @@ -884,12 +1111,6 @@ packages: peerDependencies: '@commitlint/cli': '>=21' - '@btravstack/di@0.1.0': - resolution: {integrity: sha512-xIVlSr4Yjpw7mJ1nGGBOhCWmvcNGSi1rqK8XUpeJr/6KdcXIkYSECXplY/H9cpS4tIu2cT4QTAW8wtFt/CWsLg==} - engines: {node: '>=20'} - peerDependencies: - unthrown: ^5.0.0 - '@btravstack/entity@0.7.0': resolution: {integrity: sha512-nJfynjtlZ68Z1HGWp7lbq5fQG+J/W3olccRRB6KjYP01wfZqf4L6pzeUyn864zG7sQXp1WwTpLAuv5jbePPwzA==} engines: {node: '>=20'} @@ -910,10 +1131,26 @@ packages: peerDependencies: oxlint: '>=1' + '@btravstack/theme@2.0.0': + resolution: {integrity: sha512-tEhaBntwON0PpDWoSsp1cwxmFAXlBk4sLld2U3/MRKWFxAl8/E0ZHtUyy/Bd5rShi7caI7dwq4PzC0mqh7psJA==} + peerDependencies: + vitepress: ^1.6.0 + vue: ^3.3.0 + peerDependenciesMeta: + vue: + optional: true + '@btravstack/tsconfig@0.2.0': resolution: {integrity: sha512-0VZt4DNJlY+XgAeCS4HybgsG0kdWDGjRwBiDdQZ6pYPUo1ayjM4AIH1oPatL5+YQvICTqrSaXTHSa8DnP+GXbg==} engines: {node: '>=20'} + '@btravstack/typedoc@0.1.0': + resolution: {integrity: sha512-cAhj15iqJwPQ+Z+I/Kc1Bafu9HqfS2D/uaU7czPecaCgZpH5F8LUfNRmTZppkvnTMpDX0La+UrdIUyNHHLqtIg==} + engines: {node: '>=20'} + peerDependencies: + typedoc: '>=0.28' + typedoc-plugin-markdown: '>=4' + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -1054,6 +1291,29 @@ packages: resolution: {integrity: sha512-GsCw/qu92GI0EX6s7fxUi/SG1lmFjG9XZivxxEDZXqztuQKCn5o5wKdz4v005zci0Md7EZzgAwmQLoIEDZoaow==} engines: {node: '>=22'} + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + '@electric-sql/pglite-socket@0.1.3': resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} hasBin: true @@ -1077,6 +1337,165 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@gerrit0/mini-shiki@3.23.0': + resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + '@grpc/grpc-js@1.14.4': resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} engines: {node: '>=12.10.0'} @@ -1091,6 +1510,12 @@ packages: engines: {node: '>=6'} hasBin: true + '@iconify-json/simple-icons@1.2.93': + resolution: {integrity: sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -1252,6 +1677,13 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/wasm-runtime@1.2.3': resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -2045,6 +2477,180 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@simple-libs/child-process-utils@2.0.0': resolution: {integrity: sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==} engines: {node: '>=22'} @@ -2327,12 +2933,27 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash@4.17.25': resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -2357,6 +2978,12 @@ packages: '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -2477,6 +3104,9 @@ packages: cpu: [x64] os: [win32] + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + '@unthrown/orpc@0.1.2': resolution: {integrity: sha512-xAZ+E3OtjtfhGF9MHLbradU1fTBW5ZaeyZrFrzwxiB5S8GIhPDfUeF54tMjmTHg6pYbKfIZcpsh07k3g8PLlWg==} engines: {node: '>=20'} @@ -2545,6 +3175,13 @@ packages: '@visx/vendor@4.0.0-alpha.0': resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: 6.4.3 + vue: ^3.2.25 + '@vitest/coverage-v8@4.1.10': resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} peerDependencies: @@ -2561,7 +3198,7 @@ packages: resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + vite: 6.4.3 peerDependenciesMeta: msw: optional: true @@ -2583,6 +3220,92 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vue/compiler-core@3.5.41': + resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} + + '@vue/compiler-dom@3.5.41': + resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} + + '@vue/compiler-sfc@3.5.41': + resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} + + '@vue/compiler-ssr@3.5.41': + resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/reactivity@3.5.41': + resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} + + '@vue/runtime-core@3.5.41': + resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==} + + '@vue/runtime-dom@3.5.41': + resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==} + + '@vue/server-renderer@3.5.41': + resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==} + + '@vue/shared@3.5.41': + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -2789,6 +3512,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + algoliasearch@5.56.0: + resolution: {integrity: sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q==} + engines: {node: '>= 14.0.0'} + amqp-connection-manager@5.0.0: resolution: {integrity: sha512-88yQzqa5RSBgnLl504XjvCQJ7d+osskdwvg35Lwm1LRbfLjNU9p7SQUMSP82BB7mseiq9tIUPJ3HE3eXQbpjEw==} engines: {node: '>=10.0.0', npm: '>5.0.0'} @@ -2876,6 +3603,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + bare-events@2.9.1: resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} peerDependencies: @@ -2938,12 +3669,19 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} brace-expansion@2.1.4: resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2993,10 +3731,19 @@ packages: caniuse-lite@1.0.30001809: resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} @@ -3029,6 +3776,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -3059,6 +3809,10 @@ packages: resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} engines: {node: '>=22'} + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3178,6 +3932,10 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -3189,6 +3947,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3230,6 +3991,9 @@ packages: elkjs@0.11.1: resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3258,6 +4022,14 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3275,6 +4047,11 @@ packages: es-toolkit@1.50.0: resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -3300,6 +4077,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -3381,6 +4161,9 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -3477,16 +4260,28 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + heap-js@2.7.1: resolution: {integrity: sha512-EQfezRg0NCZGNlhlDR3Evrw1FVL2G3LhU7EgPoxufQKruNBSYA8MiRPHeWbU+36o+Fhel0wMwM+sLEiBAlNLJA==} engines: {node: '>=10.0.0'} + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + human-id@4.2.0: resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} hasBin: true @@ -3566,6 +4361,10 @@ packages: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -3689,83 +4488,12 @@ packages: resolution: {integrity: sha512-K7mM4WoqMwqfXYK11EHy+lSH1uW8XHni3Yn/bSqyerPkUPygGdf3xn18JoV5HyA06xuQL3ofGAOjG01QX9oJ4w==} hasBin: true - lightningcss-android-arm64@1.33.0: - resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.33.0: - resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.33.0: - resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.33.0: - resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.33.0: - resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.33.0: - resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.33.0: - resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.33.0: - resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.33.0: - resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.33.0: - resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.33.0: - resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.33.0: - resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} - engines: {node: '>= 12.0.0'} - lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -3789,6 +4517,9 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3799,6 +4530,19 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} + hasBin: true + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + memfs@4.68.1: resolution: {integrity: sha512-OD+IDRUvIxu3QHL+nFm9gdyugInD27FDJ+sl4B5QgomPHXMlbw+GP918P8VNKu2FkNlVeqBkpzkwROpamVifRw==} @@ -3809,6 +4553,21 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3821,6 +4580,10 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimatch@5.1.9: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} @@ -3879,6 +4642,12 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -3946,6 +4715,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -4035,6 +4807,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -4064,6 +4839,14 @@ packages: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} + preact@10.29.8: + resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -4105,6 +4888,9 @@ packages: resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==} engines: {node: '>=18'} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + proto3-json-serializer@2.0.2: resolution: {integrity: sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==} engines: {node: '>=14.0.0'} @@ -4116,6 +4902,10 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -4161,6 +4951,15 @@ packages: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + remeda@2.33.4: resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} @@ -4195,6 +4994,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -4222,6 +5024,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -4261,6 +5068,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -4306,9 +5116,16 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + split-ca@1.0.1: resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} @@ -4360,6 +5177,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -4380,6 +5200,10 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -4394,6 +5218,9 @@ packages: '@swc/core': ^1.2.147 webpack: '>=2' + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -4476,6 +5303,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + tsdown@0.22.14: resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==} engines: {node: ^22.18.0 || >=24.11.0} @@ -4527,11 +5357,37 @@ packages: resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} engines: {node: '>=20'} + typedoc-plugin-markdown@4.12.0: + resolution: {integrity: sha512-eJDEMAfxCmede22c/Jw7d0FA13ggAQv+KkwQYKYCdqI02cin6Rc9QRwbG/7XvvHWinuFejySnZVUWDtvGk3Vbg==} + engines: {node: '>= 18'} + peerDependencies: + typedoc: 0.28.x + + typedoc@0.28.20: + resolution: {integrity: sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + unbash@4.0.10: resolution: {integrity: sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg==} engines: {node: '>=14'} @@ -4552,6 +5408,21 @@ packages: unionfs@4.6.0: resolution: {integrity: sha512-fJAy3gTHjFi5S3TP5EGdjs/OUMFFvI/ady3T8qVuZfkv8Qi8prV/Q8BuFEgODJslhZTT2z2qdD2lGdee9qjEnA==} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -4585,34 +5456,37 @@ packages: resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} engines: {node: '>=18.12.0'} - vite@8.2.1: - resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} - engines: {node: ^20.19.0 || >=22.12.0} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.4.0 - esbuild: ^0.27.0 || ^0.28.0 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true jiti: optional: true less: optional: true + lightningcss: + optional: true sass: optional: true sass-embedded: @@ -4628,6 +5502,18 @@ packages: yaml: optional: true + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + vitest@4.1.10: resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4668,6 +5554,14 @@ packages: jsdom: optional: true + vue@3.5.41: + resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -4759,8 +5653,122 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: + '@algolia/abtesting@1.22.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + '@algolia/client-search': 5.56.0 + algoliasearch: 5.56.0 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)': + dependencies: + '@algolia/client-search': 5.56.0 + algoliasearch: 5.56.0 + + '@algolia/client-abtesting@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-analytics@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-common@5.56.0': {} + + '@algolia/client-insights@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-personalization@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-query-suggestions@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-search@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/ingestion@1.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/monitoring@1.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/recommend@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/requester-browser-xhr@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + + '@algolia/requester-fetch@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + + '@algolia/requester-node-http@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@amqp-contract/contract@3.0.0-beta.6': dependencies: '@standard-schema/spec': 1.1.0 @@ -4825,10 +5833,6 @@ snapshots: '@commitlint/cli': 21.2.1(@types/node@26.1.2)(conventional-commits-parser@7.1.2)(typescript@7.0.2) '@commitlint/config-conventional': 21.2.0 - '@btravstack/di@0.1.0(unthrown@5.5.0)': - dependencies: - unthrown: 5.5.0 - '@btravstack/entity@0.7.0(@unthrown/standard-schema@5.5.0)(unthrown@5.5.0)(zod@4.4.3)': dependencies: '@unthrown/standard-schema': 5.5.0 @@ -4843,8 +5847,19 @@ snapshots: dependencies: oxlint: 1.77.0 + '@btravstack/theme@2.0.0(vitepress@1.6.4(@algolia/client-search@5.56.0)(@types/node@26.1.2)(jiti@2.7.0)(postcss@8.5.26)(terser@5.50.0)(typescript@6.0.3)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))': + dependencies: + vitepress: 1.6.4(@algolia/client-search@5.56.0)(@types/node@26.1.2)(jiti@2.7.0)(postcss@8.5.26)(terser@5.50.0)(typescript@6.0.3)(yaml@2.9.0) + optionalDependencies: + vue: 3.5.41(typescript@6.0.3) + '@btravstack/tsconfig@0.2.0': {} + '@btravstack/typedoc@0.1.0(typedoc-plugin-markdown@4.12.0(typedoc@0.28.20(typescript@6.0.3)))(typedoc@0.28.20(typescript@6.0.3))': + dependencies: + typedoc: 0.28.20(typescript@6.0.3) + typedoc-plugin-markdown: 4.12.0(typedoc@0.28.20(typescript@6.0.3)) + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -5107,6 +6122,29 @@ snapshots: '@conventional-changelog/template@1.3.0': {} + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.56.0)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.56.0) + preact: 10.29.8 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - preact-render-to-string + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.56.0)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + '@docsearch/css': 3.8.2 + algoliasearch: 5.56.0 + transitivePeerDependencies: + - '@algolia/client-search' + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': dependencies: '@electric-sql/pglite': 0.4.3 @@ -5133,6 +6171,92 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@gerrit0/mini-shiki@3.23.0': + dependencies: + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@grpc/grpc-js@1.14.4': dependencies: '@grpc/proto-loader': 0.8.1 @@ -5152,6 +6276,12 @@ snapshots: protobufjs: 7.6.5 yargs: 17.7.3 + '@iconify-json/simple-icons@1.2.93': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + '@inquirer/external-editor@1.0.3(@types/node@26.1.2)': dependencies: chardet: 2.2.0 @@ -5339,6 +6469,9 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: '@emnapi/core': 1.11.2 @@ -5803,59 +6936,192 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.18)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18) - optionalDependencies: - '@types/react': 19.2.18 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.18)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.18) + optionalDependencies: + '@types/react': 19.2.18 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.18)': + optionalDependencies: + '@types/react': 19.2.18 + + '@rolldown/binding-android-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-x64@1.2.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.4': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.18)': - optionalDependencies: - '@types/react': 19.2.18 + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true - '@rolldown/binding-android-arm64@1.2.4': + '@rollup/rollup-linux-loong64-musl@4.62.4': optional: true - '@rolldown/binding-darwin-arm64@1.2.4': + '@rollup/rollup-linux-ppc64-gnu@4.62.4': optional: true - '@rolldown/binding-darwin-x64@1.2.4': + '@rollup/rollup-linux-ppc64-musl@4.62.4': optional: true - '@rolldown/binding-freebsd-x64@1.2.4': + '@rollup/rollup-linux-riscv64-gnu@4.62.4': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + '@rollup/rollup-linux-riscv64-musl@4.62.4': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.4': + '@rollup/rollup-linux-s390x-gnu@4.62.4': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.4': + '@rollup/rollup-linux-x64-gnu@4.62.4': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.4': + '@rollup/rollup-linux-x64-musl@4.62.4': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.4': + '@rollup/rollup-openbsd-x64@4.62.4': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.4': + '@rollup/rollup-openharmony-arm64@4.62.4': optional: true - '@rolldown/binding-linux-x64-musl@1.2.4': + '@rollup/rollup-win32-arm64-msvc@4.62.4': optional: true - '@rolldown/binding-openharmony-arm64@1.2.4': + '@rollup/rollup-win32-ia32-msvc@4.62.4': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.4': + '@rollup/rollup-win32-x64-gnu@4.62.4': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.4': + '@rollup/rollup-win32-x64-msvc@4.62.4': optional: true - '@rolldown/pluginutils@1.0.1': {} + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} '@simple-libs/child-process-utils@2.0.0': dependencies: @@ -6166,10 +7432,27 @@ snapshots: '@types/geojson@7946.0.16': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} + '@types/linkify-it@5.0.0': {} + '@types/lodash@4.17.25': {} + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + '@types/node@12.20.55': {} '@types/node@18.19.130': @@ -6201,6 +7484,10 @@ snapshots: dependencies: '@types/node': 18.19.130 + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -6261,6 +7548,8 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@ungap/structured-clone@1.3.3': {} + '@unthrown/orpc@0.1.2(@orpc/client@2.0.0-beta.23(@opentelemetry/api@1.9.1))(@orpc/server@2.0.0-beta.23(@opentelemetry/api@1.9.1))': dependencies: '@orpc/client': 2.0.0-beta.23(@opentelemetry/api@1.9.1) @@ -6361,6 +7650,11 @@ snapshots: d3-time-format: 4.1.0 internmap: 2.0.3 + '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))': + dependencies: + vite: 6.4.3(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + vue: 3.5.41(typescript@6.0.3) + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -6384,13 +7678,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.1(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + vite: 6.4.3(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -6416,6 +7710,105 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + '@vue/compiler-core@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.41': + dependencies: + '@vue/compiler-core': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/compiler-sfc@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.41 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-ssr': 3.5.41 + '@vue/shared': 3.5.41 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.41': + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.41': + dependencies: + '@vue/shared': 3.5.41 + + '@vue/runtime-core@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/runtime-dom@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/runtime-core': 3.5.41 + '@vue/shared': 3.5.41 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.41': + dependencies: + '@vue/compiler-ssr': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/shared@3.5.41': {} + + '@vueuse/core@12.8.2(typescript@6.0.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.41(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@6.0.3)': + dependencies: + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.41(typescript@6.0.3) + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2(typescript@6.0.3)': + dependencies: + vue: 3.5.41(typescript@6.0.3) + transitivePeerDependencies: + - typescript + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -6592,6 +7985,23 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + algoliasearch@5.56.0: + dependencies: + '@algolia/abtesting': 1.22.0 + '@algolia/client-abtesting': 5.56.0 + '@algolia/client-analytics': 5.56.0 + '@algolia/client-common': 5.56.0 + '@algolia/client-insights': 5.56.0 + '@algolia/client-personalization': 5.56.0 + '@algolia/client-query-suggestions': 5.56.0 + '@algolia/client-search': 5.56.0 + '@algolia/ingestion': 1.56.0 + '@algolia/monitoring': 1.56.0 + '@algolia/recommend': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + amqp-connection-manager@5.0.0(amqplib@2.0.1): dependencies: amqplib: 2.0.1 @@ -6669,6 +8079,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + bare-events@2.9.1: {} bare-fs@4.8.0: @@ -6721,6 +8133,8 @@ snapshots: dependencies: file-uri-to-path: 1.0.0 + birpc@2.9.0: {} + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -6731,6 +8145,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -6785,8 +8203,14 @@ snapshots: caniuse-lite@1.0.30001809: {} + ccount@2.0.1: {} + chai@6.2.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + chardet@2.2.0: {} chokidar@5.0.0: @@ -6817,6 +8241,8 @@ snapshots: color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} + commander@2.20.3: {} compress-commons@6.0.2: @@ -6846,6 +8272,10 @@ snapshots: cookie@2.0.1: {} + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + core-util-is@1.0.3: {} cosmiconfig-typescript-loader@6.3.0(@types/node@26.1.2)(cosmiconfig@9.0.2(typescript@7.0.2))(typescript@7.0.2): @@ -6951,12 +8381,18 @@ snapshots: denque@2.1.0: {} + dequal@2.0.3: {} + destr@2.0.5: {} detect-indent@6.1.0: {} detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -7002,6 +8438,8 @@ snapshots: elkjs@0.11.1: {} + emoji-regex-xs@1.0.0: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -7026,6 +8464,10 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@4.5.0: {} + + entities@7.0.1: {} + env-paths@2.2.1: {} env-paths@3.0.0: {} @@ -7038,6 +8480,35 @@ snapshots: es-toolkit@1.50.0: {} + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + escalade@3.2.0: {} eslint-scope@5.1.1: @@ -7055,6 +8526,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -7130,6 +8603,10 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + focus-trap@7.8.0: + dependencies: + tabbable: 6.5.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -7220,12 +8697,34 @@ snapshots: has-flag@4.0.0: {} + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + heap-js@2.7.1: {} + hookable@5.5.3: {} + hookable@6.1.1: {} html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} + human-id@4.2.0: {} hyperdyperid@1.2.0: {} @@ -7279,6 +8778,8 @@ snapshots: dependencies: better-path-resolve: 1.0.0 + is-what@5.5.0: {} + is-windows@1.0.2: {} isarray@1.0.0: {} @@ -7306,7 +8807,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 26.1.2 + '@types/node': 26.2.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -7398,56 +8899,11 @@ snapshots: lefthook-windows-arm64: 2.1.10 lefthook-windows-x64: 2.1.10 - lightningcss-android-arm64@1.33.0: - optional: true - - lightningcss-darwin-arm64@1.33.0: - optional: true - - lightningcss-darwin-x64@1.33.0: - optional: true - - lightningcss-freebsd-x64@1.33.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.33.0: - optional: true - - lightningcss-linux-arm64-gnu@1.33.0: - optional: true - - lightningcss-linux-arm64-musl@1.33.0: - optional: true - - lightningcss-linux-x64-gnu@1.33.0: - optional: true - - lightningcss-linux-x64-musl@1.33.0: - optional: true - - lightningcss-win32-arm64-msvc@1.33.0: - optional: true - - lightningcss-win32-x64-msvc@1.33.0: - optional: true + lines-and-columns@1.2.4: {} - lightningcss@1.33.0: + linkify-it@5.0.2: dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.33.0 - lightningcss-darwin-arm64: 1.33.0 - lightningcss-darwin-x64: 1.33.0 - lightningcss-freebsd-x64: 1.33.0 - lightningcss-linux-arm-gnueabihf: 1.33.0 - lightningcss-linux-arm64-gnu: 1.33.0 - lightningcss-linux-arm64-musl: 1.33.0 - lightningcss-linux-x64-gnu: 1.33.0 - lightningcss-linux-x64-musl: 1.33.0 - lightningcss-win32-arm64-msvc: 1.33.0 - lightningcss-win32-x64-msvc: 1.33.0 - - lines-and-columns@1.2.4: {} + uc.micro: 2.1.0 locate-path@5.0.0: dependencies: @@ -7465,6 +8921,8 @@ snapshots: lru.min@1.1.4: {} + lunr@2.3.9: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7479,6 +8937,31 @@ snapshots: dependencies: semver: 7.8.5 + mark.js@8.11.1: {} + + markdown-it@14.3.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.3 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdurl@2.1.0: {} + memfs@4.68.1: dependencies: '@jsonjoy.com/fs-core': 4.68.1(tslib@2.8.1) @@ -7500,6 +8983,23 @@ snapshots: merge2@1.4.1: {} + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -7509,6 +9009,10 @@ snapshots: mimic-response@3.1.0: {} + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimatch@5.1.9: dependencies: brace-expansion: 2.1.4 @@ -7531,6 +9035,10 @@ snapshots: minipass@7.1.3: {} + minisearch@7.2.0: {} + + mitt@3.0.1: {} + mkdirp-classic@0.5.3: {} mkdirp@3.0.1: {} @@ -7584,6 +9092,12 @@ snapshots: dependencies: wrappy: 1.0.2 + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + outdent@0.5.0: {} oxc-parser@0.142.0: @@ -7725,6 +9239,8 @@ snapshots: pathe@2.0.3: {} + perfect-debounce@1.0.0: {} + perfect-debounce@2.1.0: {} picocolors@1.1.1: {} @@ -7749,6 +9265,8 @@ snapshots: postgres@3.4.7: {} + preact@10.29.8: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -7803,6 +9321,8 @@ snapshots: transitivePeerDependencies: - supports-color + property-information@7.2.0: {} + proto3-json-serializer@2.0.2: dependencies: protobufjs: 7.6.5 @@ -7818,7 +9338,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.2 - '@types/node': 26.1.2 + '@types/node': 26.2.0 long: 5.3.2 pump@3.0.4: @@ -7826,6 +9346,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode.js@2.3.1: {} + pure-rand@6.1.0: {} quansync@0.2.11: {} @@ -7885,6 +9407,16 @@ snapshots: readdirp@5.1.1: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + remeda@2.33.4: {} require-directory@2.1.1: {} @@ -7903,6 +9435,8 @@ snapshots: reusify@1.1.0: {} + rfdc@1.4.1: {} + robust-predicates@3.0.3: {} rolldown-plugin-dts@0.27.14(oxc-resolver@11.24.2)(rolldown@1.2.4)(typescript@7.0.2): @@ -7939,6 +9473,38 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.4 '@rolldown/binding-win32-x64-msvc': 1.2.4 + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -7974,6 +9540,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -8009,11 +9586,15 @@ snapshots: source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} + spawndamnit@3.0.1: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 + speakingurl@14.0.1: {} + split-ca@1.0.1: {} sprintf-js@1.0.3: {} @@ -8079,6 +9660,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -8093,6 +9679,10 @@ snapshots: strip-json-comments@5.0.3: {} + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -8107,6 +9697,8 @@ snapshots: '@swc/counter': 0.1.3 webpack: 5.109.2(@swc/core@1.15.47) + tabbable@6.5.0: {} + tagged-tag@1.0.0: {} tapable@2.3.3: {} @@ -8223,6 +9815,8 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + tsdown@0.22.14(oxc-resolver@11.24.2)(typescript@7.0.2): dependencies: ansis: 4.3.1 @@ -8269,6 +9863,23 @@ snapshots: dependencies: tagged-tag: 1.0.0 + typedoc-plugin-markdown@4.12.0(typedoc@0.28.20(typescript@6.0.3)): + dependencies: + typedoc: 0.28.20(typescript@6.0.3) + + typedoc@0.28.20(typescript@6.0.3): + dependencies: + '@gerrit0/mini-shiki': 3.23.0 + lunr: 2.3.9 + markdown-it: 14.3.0 + minimatch: 10.2.6 + typescript: 6.0.3 + yaml: 2.9.0 + + typescript@5.9.3: {} + + typescript@6.0.3: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -8292,6 +9903,8 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + uc.micro@2.1.0: {} + unbash@4.0.10: {} unconfig-core@7.5.0: @@ -8309,6 +9922,29 @@ snapshots: dependencies: fs-monkey: 1.1.0 + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@0.1.2: {} unthrown@5.5.0: {} @@ -8329,12 +9965,23 @@ snapshots: verkit@0.3.2: {} - vite@8.2.1(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0): + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0): dependencies: - lightningcss: 1.33.0 + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 postcss: 8.5.26 - rolldown: 1.2.4 + rollup: 4.62.4 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.1.2 @@ -8343,10 +9990,63 @@ snapshots: terser: 5.50.0 yaml: 2.9.0 + vitepress@1.6.4(@algolia/client-search@5.56.0)(@types/node@26.1.2)(jiti@2.7.0)(postcss@8.5.26)(terser@5.50.0)(typescript@6.0.3)(yaml@2.9.0): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.56.0) + '@iconify-json/simple-icons': 1.2.93 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + '@vue/devtools-api': 7.7.10 + '@vue/shared': 3.5.41 + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@6.0.3) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 6.4.3(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + vue: 3.5.41(typescript@6.0.3) + optionalDependencies: + postcss: 8.5.26 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jiti + - jwt-decode + - less + - lightningcss + - nprogress + - preact-render-to-string + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - tsx + - typescript + - universal-cookie + - yaml + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@6.4.3(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -8363,17 +10063,16 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.1(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + vite: 6.4.3(@types/node@26.1.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 26.1.2 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - - '@vitejs/devtools' - - esbuild - jiti - less + - lightningcss - msw - sass - sass-embedded @@ -8383,6 +10082,16 @@ snapshots: - tsx - yaml + vue@3.5.41(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-sfc': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/server-renderer': 3.5.41 + '@vue/shared': 3.5.41 + optionalDependencies: + typescript: 6.0.3 + walk-up-path@4.0.0: {} watchpack@2.5.2: @@ -8534,3 +10243,5 @@ snapshots: readable-stream: 4.7.0 zod@4.4.3: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ce05156..04e0c7d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ strictPeerDependencies: true packages: - packages/* - examples/* + - docs catalog: # `@amqp-contract/{contract,testing,worker}` v3 are still in beta and their @@ -17,11 +18,12 @@ catalog: "@amqp-contract/testing": 3.0.0-beta.6 "@amqp-contract/worker": 3.0.0-beta.6 "@btravstack/commitlint": 0.1.0 - "@btravstack/di": 0.1.0 "@btravstack/entity": 0.7.0 "@btravstack/lefthook": 0.1.1 "@btravstack/oxlint": 0.2.1 + "@btravstack/theme": 2.0.0 "@btravstack/tsconfig": 0.2.0 + "@btravstack/typedoc": 0.1.0 "@changesets/cli": 2.31.1 "@commitlint/cli": 21.2.1 # `@amqp-contract/core`'s telemetry module (pulled in by @@ -67,12 +69,48 @@ catalog: prisma: 7.9.1 tsdown: 0.22.14 turbo: 2.10.8 + typedoc: 0.28.20 + typedoc-plugin-markdown: 4.12.0 typescript: 7.0.2 + # The TypeScript a *consumer* is realistically on, for the second + # declaration-emit pass in `examples/hexagonal-order-api` + # (`tsconfig.emit.json`, compiled once by each version). 7.0.2 is the native + # port and the one this repo builds with; a published package has to be + # readable by the stable line too, and the two emitters do not agree on + # everything. Aliased because one `package.json` cannot name `typescript` + # twice — the same arrangement, and the same alias, as + # `@btravstack/entity`'s `examples/billing-domain`. + typescript-consumer: "npm:typescript@5.9.3" unthrown: 5.5.0 "@vitest/coverage-v8": 4.1.10 + vitepress: 1.6.4 vitest: 4.1.10 zod: 4.4.3 +catalogs: + # TypeDoc's own toolchain, deliberately NOT the default catalog's TypeScript. + # `typescript: 7.0.2` above is the native port: it ships `lib/tsc.js` (a thin + # launcher) and no `typescript.js`, so the compiler API TypeDoc is written + # against does not exist there — measured in `@btravstack/entity`, whose docs + # this setup follows, and the reason `typedoc` runs from the `docs` workspace + # rather than from `packages/di`. 6.0.3 is the last release carrying the JS + # API, and is what `typedoc@0.28`'s peer range allows. + typedoc: + typescript: 6.0.3 + +# Force patched versions of vulnerable transitive dependencies. Every advisory +# below reaches us only through dev/build tooling (never a published package's +# runtime deps). Each selector is version-scoped so only the affected line +# moves and unrelated lines are untouched. +overrides: + # GHSA-fx2h-pf6j-xcff (High, `server.fs.deny` bypass on Windows alternate data + # streams) plus three moderates on the same line. All four are dev-server-only; + # the docs ship as static HTML. They reach us via docs > vitepress > vite: + # vitepress 1.6.x pins `vite ^5.4.14`, and the 5.x line has no patched release, + # so lift any pre-6.4.3 vite to 6.4.3 (the first fixed version). Same override, + # same rationale and verification, as `@btravstack/entity`'s. + "vite@<6.4.3": "6.4.3" + allowBuilds: # Prisma 7 is engine-less (WASM query compiler); nothing to build or download. "@prisma/engines": false @@ -94,14 +132,16 @@ peerDependencyRules: - react - react-dom - "@types/react" + # Optional analytics peer of VitePress's bundled Algolia DocSearch. + - search-insights minimumReleaseAgeExclude: - "@amqp-contract/contract@3.0.0-beta.6" - "@amqp-contract/core@3.0.0-beta.6" - "@amqp-contract/testing@3.0.0-beta.6" - "@amqp-contract/worker@3.0.0-beta.6" - - "@btravstack/di@0.1.0" - "@btravstack/entity@0.7.0" + - "@btravstack/theme@2.0.0" - "@unthrown/oxlint@5.2.0" - "@unthrown/prisma@0.3.2" - "@unthrown/standard-schema@5.5.0" diff --git a/turbo.json b/turbo.json index 280f69f..a3e6c80 100644 --- a/turbo.json +++ b/turbo.json @@ -8,6 +8,10 @@ "test:types": { "dependsOn": ["^build", "generate", "^generate"] }, "test": { "dependsOn": ["^build", "generate", "^generate"], "cache": false }, "dev": { "dependsOn": ["^build"], "cache": false, "persistent": true }, - "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] } + "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, + "@btravstack/di-docs#build": { + "inputs": ["$TURBO_DEFAULT$", "../packages/di/src/**"], + "outputs": [".vitepress/dist/**", "api/di/**"] + } } }