diff --git a/.claude/skills/vitnode-upgrade-npm-packages/SKILL.md b/.claude/skills/vitnode-upgrade-npm-packages/SKILL.md index 3780a5ddab..eae1361924 100644 --- a/.claude/skills/vitnode-upgrade-npm-packages/SKILL.md +++ b/.claude/skills/vitnode-upgrade-npm-packages/SKILL.md @@ -11,10 +11,22 @@ description: A skill that helps users upgrade their npm packages to the latest v 1. Open `packages/create-vitnode-app/src/create/package-versions.ts` file and read it. 2. Run `pnpm outdated` to check for outdated packages in the project - do it for all projects in the turborepo (monorepo) including with root. -3. For each outdated package, run `pnpm up @` to upgrade it. -4. For each package, check if there are any breaking changes in the new version. If there are, ask the user if they want to proceed with the upgrade or skip it. -5. After upgrading all packages, update the `package-versions.ts` file with the new versions of the packages. -6. Run `pnpm install` to ensure all dependencies are correctly installed. +3. Check pre-release dependencies separately - `pnpm outdated` compares against the `latest` dist-tag only, so it never reports a newer rc/beta/alpha/canary (e.g. `drizzle-orm@1.0.0-rc.4`, `drizzle-kit`, `nitro@3.0.x-beta`). + - List them across every workspace `package.json` (root, `apps/*`, `packages/*`, `plugins/*`), including `npm:` aliases, plus the `nitro`/`drizzle*` entries in `package-versions.ts`: + + ```bash + for f in package.json apps/*/package.json packages/*/package.json plugins/*/package.json; do + jq -r --arg f "$f" '[.dependencies,.devDependencies,.peerDependencies,.optionalDependencies] | map(select(.)) | add // {} | to_entries[] | select(.value|test("-[a-z]")) | select(.value|test("workspace")|not) | "\(.key) \(.value) \($f)"' "$f" + done + ``` + + - For each, run `npm view dist-tags --json` and `npm view versions --json`. Upgrade to the newest version on the same channel (the `rc`/`beta`/`latest` tag the current version belongs to, or a newer clean release such as `1.0.0-rc.5` / `1.0.0`). Ignore commit-hash snapshot builds like `1.0.0-rc.5-5935859` and feature-branch tags. + - Keep the pin style the file already uses (exact `1.0.0-rc.4` vs `^1.0.0-rc.4`) and bump every workspace that uses the package to the same version. + - A channel jump (rc → stable, beta → rc) counts as a possible breaking change - read the release notes and ask the user (step 5). +4. For each outdated package, run `pnpm up -r @` to upgrade it. +5. For each package, check if there are any breaking changes in the new version. If there are, ask the user if they want to proceed with the upgrade or skip it. +6. After upgrading all packages, update the `package-versions.ts` file with the new versions of the packages, including pre-release pins (`drizzleKit`, `drizzleOrm`, `nitro`). +7. Run `pnpm install` to ensure all dependencies are correctly installed. ## Examples diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index f02fedb43b..0000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,33 +0,0 @@ -version: 2 - -updates: - - package-ecosystem: npm - directory: / - schedule: - interval: weekly - day: monday - cooldown: - default-days: 3 - open-pull-requests-limit: 10 - commit-message: - prefix: chore(deps) - groups: - npm-minor-patch: - update-types: - - minor - - patch - - - package-ecosystem: github-actions - directories: - - / - - /.github/actions/setup - schedule: - interval: weekly - day: monday - commit-message: - prefix: ci(deps) - groups: - github-actions: - update-types: - - minor - - patch diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index e294e972c3..4aefc80cb1 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -70,26 +70,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 - # Real Postgres for the integration tests that need row locks and - # `ON CONFLICT` (notifications). Each test file creates its own database. - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_USER: root - POSTGRES_PASSWORD: root - POSTGRES_DB: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U root" - --health-interval 5s - --health-timeout 5s - --health-retries 10 - - env: - VITNODE_TEST_POSTGRES_URL: postgresql://root:root@localhost:5432/postgres - steps: - name: Checkout uses: actions/checkout@v7 diff --git a/AGENTS.md b/AGENTS.md index fc796d0a66..0f25f6a425 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,7 @@ npm i x - Do not test database models or schema definitions (Drizzle tables, column types, indexes, foreign keys). - Do not test static config, constants, color presets, or plugin configuration (`config.api`, content type definitions). - Do not test third-party libraries or trivial helper/wrapper code. +- Always run "pnpm lint:fix" to fix formatting and linting issues before committing. Never commit a file with lint errors. ### Mocking diff --git a/README.md b/README.md index badb7919f9..1633ca1e80 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ installable plugins instead of becoming permanent residents of one giant app. ## Start here -You need Node.js 22+ and Postgres (or Docker). Create an app with the package -manager you use every day: +You need Node.js 22.18+ (or 24.11+, or 26+) and Postgres (or Docker). Create an +app with the package manager you use every day: ### Bun diff --git a/apps/api/package.json b/apps/api/package.json index c2ede591c5..4aceaace5f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,8 +20,8 @@ "i18n:update:ai": "vitnode i18n update-ai" }, "dependencies": { - "@ai-sdk/anthropic": "^4.0.75", - "@ai-sdk/google": "^4.0.90", + "@ai-sdk/anthropic": "^4.0.77", + "@ai-sdk/google": "^4.0.92", "@hono/zod-openapi": "^1.6.3", "@hono/zod-validator": "^0.9.1", "@vitnode/core": "workspace:*", @@ -37,7 +37,7 @@ "zod": "^4.6.5" }, "devDependencies": { - "@hono/node-server": "^2.1.3", + "@hono/node-server": "^2.1.4", "@react-email/ui": "^6.11.1", "@types/node": "^26.6.4", "@types/react": "^19.3.0", diff --git a/apps/web/content/docs/dev/cli/build.mdx b/apps/web/content/docs/dev/cli/build.mdx index ebea86aed6..2bdb5b52b5 100644 --- a/apps/web/content/docs/dev/cli/build.mdx +++ b/apps/web/content/docs/dev/cli/build.mdx @@ -78,11 +78,24 @@ Nothing is static until you ask for it. [Prerender static pages](/docs/dev/deplo The same command builds other project types: -| Project | What `vitnode build` does | -| -------------- | --------------------------------------------------------------------------- | -| API app (Node) | Compiles with `tsc` and `tsc-alias` into `dist`, then lists the output | -| API app (Bun) | Nothing: Bun runs `src/index.ts` directly | -| Plugin package | Emits type declarations, compiles with `swc` and rewrites aliases in `dist` | +| Project | What `vitnode build` does | +| -------------- | --------------------------------------------------------------------------------- | +| API app (Node) | Compiles with `tsc` and `tsc-alias` into `dist`, then lists the output | +| API app (Bun) | Nothing: Bun runs `src/index.ts` directly | +| Plugin package | Compiles `src` into `dist/src` with tsdown and checks types with `tsc --noEmit` | + +```txt title="vitnode build in a plugin package" +◆ VitNode + Building @acme/blog + + ✓ API registry generated + ✓ Compiling sources 8.0s + ✓ Checking types 7.3s + + ✓ Built in 8.0s +``` + +Compiling and type checking run at the same time, and both must pass. [Build and watch a plugin](/docs/dev/plugins/build) explains what ends up in `dist`. ## When a build fails diff --git a/apps/web/content/docs/dev/cli/dev.mdx b/apps/web/content/docs/dev/cli/dev.mdx index de32298dff..d3910c807c 100644 --- a/apps/web/content/docs/dev/cli/dev.mdx +++ b/apps/web/content/docs/dev/cli/dev.mdx @@ -4,7 +4,7 @@ description: Start a VitNode app, API app or plugin package in development with icon: Play --- -`vitnode dev` starts whatever the current folder is: an app gets a Vite dev server, an API app gets a watching Node or Bun process, and a plugin package gets its compilers in watch mode. Run it from the project's folder. +`vitnode dev` starts whatever the current folder is: an app gets a Vite dev server, an API app gets a watching Node or Bun process, and a plugin package gets tsdown and the type checker in watch mode. Run it from the project's folder. ```bash vitnode dev @@ -91,7 +91,7 @@ Otherwise it runs on Node with `tsx watch`. ## Develop a plugin package -In a plugin package, `vitnode dev` regenerates the plugin's API registry and runs `tsc`, `swc` and `tsc-alias` in watch mode. Apps that use the plugin import its `dist` folder, so they reload as you save. +In a plugin package, `vitnode dev` regenerates the plugin's API registry, then watches `src` with tsdown and runs `tsc` in watch mode to report type errors. Apps that use the plugin import its `dist` folder, so they reload as you save. ```txt ◆ VitNode @@ -99,9 +99,14 @@ In a plugin package, `vitnode dev` regenerates the plugin's API registry and run ✓ API registry generated ● Watching sources - apps using this plugin reload as dist/ changes + +tsdown js Rebuilt in 71ms. +tsdown types Rebuilt in 4493ms. ``` -If one compiler crashes, the CLI stops the others and exits with code `1`. +The JavaScript (`tsdown js`) is rebuilt within milliseconds of a save. Declarations (`tsdown types`) follow once TypeScript catches up, so your editor sees new types a few seconds later. A syntax error is printed and the next save rebuilds. If a watcher fails to start, for example because tsdown is not installed, the CLI stops the others and exits with code `1`. + +[Build and watch a plugin](/docs/dev/plugins/build) covers added and deleted files, assets and what your app sees while a rebuild is written. ## Next steps diff --git a/apps/web/content/docs/dev/cli/index.mdx b/apps/web/content/docs/dev/cli/index.mdx index cb90075ec0..7cca5a2fb4 100644 --- a/apps/web/content/docs/dev/cli/index.mdx +++ b/apps/web/content/docs/dev/cli/index.mdx @@ -49,11 +49,11 @@ Run `vitnode` alone (or `vitnode --help`) for a short overview, `vitnode + + + + + + + + + + + + + + + + + + + + + + + + + + +Every `.ts` and `.tsx` file under `src` is compiled, not only what an `index.ts` imports, because the plugin's `"./*"` export makes each module public. Test files (`*.test.ts`, `*.test.tsx`, `*.test-d.ts`), `src/tests/`, `__tests__/` and `__fixtures__/` folders stay out of `dist`. + +The output is ES modules that Node and Vite load as they are: + +- **Imports of packages stay imports.** React, Hono, Drizzle, `@vitnode/core` and every other package are left for the app to install, exactly as written. +- **Relative imports get their `.js` extension**, and dynamic imports such as `lazy(() => import("./pages/home-page"))` keep working. +- **`@/` aliases become relative paths**, in the JavaScript and in the declarations. The aliases come from `paths` in `tsconfig.build.json`. +- **JSX** uses React's automatic runtime (`react/jsx-runtime`). +- **File names are kept**, so `auth.server.ts` stays `auth.server.js`. Modules keep their side effects; nothing is tree-shaken away. + +A production build is minified and ships declaration maps, but no JavaScript source maps. `vitnode dev` writes readable JavaScript with source maps instead. + +## Assets and aliases + +Every file under `src` that is not code (locale JSON, CSS, images for emails) is copied to the same place under `dist/src`. Imports of those files stay imports, with their import attributes: + +```ts title="src/locales/index.ts" +export default { + en: async () => await import("./en.json", { with: { type: "json" } }), +}; +``` + +`dist/src/locales/index.js` imports `./en.json` next to it, so the compiled module finds its JSON the way the source did. An app loads your translations through the `"./locales/*.json"` export instead, which points at `src/locales`. + +## Types and declarations + +Two TypeScript jobs run side by side, and both must pass: + +| Job | What it does | +| -------------- | ------------------------------------------------------------------------------ | +| tsdown | Writes the `.d.ts` files and their maps with the TypeScript compiler | +| `tsc --noEmit` | Type checks the whole package with `tsconfig.build.json` and fails on an error | + +Declarations are written from every file `tsconfig.build.json` includes. That matters for files nothing imports, such as `global.d.ts` and the generated `types/api-registry.gen.d.ts`: they are what lets `fetcher({ plugin: "@acme/site-notes", ... })` infer its response, and that inferred type lands in your `.d.ts` files. + +You don't need `isolatedDeclarations` or explicit return types. Inferred types come out the same as `tsc` would write them. + +## What a plugin package needs + +| File | Why | +| --------------------- | --------------------------------------------------------------------------------------- | +| `package.json` | `"type": "module"`, `exports` pointing into `./dist/src/`, and `tsdown` and `typescript` in `devDependencies` | +| `tsconfig.json` | Compiler options for your editor, with `"paths": { "@/*": ["./src/*"] }` | +| `tsconfig.build.json` | The same, without tests: what `vitnode build` compiles and type checks | + +The CLI treats a folder as a plugin package when it has `tsconfig.build.json` and its `exports` point into `./dist/src/`. `create-vitnode-app` generates all three files. A `tsdown.config.ts` in a plugin is not read. + +## How `vitnode dev` watches a plugin + +`vitnode dev` runs three watchers: + +- **tsdown js** rebuilds the JavaScript and copies changed assets, usually within 100 ms for a plugin. +- **tsdown types** rebuilds the declarations once TypeScript has caught up, a few seconds later. +- **`tsc --watch --noEmit`** prints type errors. + +The two tsdown watchers run as separate processes, so the TypeScript compiler never holds back the JavaScript your app reloads. + +Adding, deleting or renaming a file restarts both tsdown watchers with the new list of modules, after a short pause so a branch switch counts as one change. Output for a deleted module is removed from `dist/src`. A file that fails to compile is reported, the last good output stays in place, and the next save rebuilds. + +Ctrl+C stops every watcher. If one of them exits on its own, the CLI stops the rest and exits with an error. + +## How dist stays consistent + +A build only writes to `dist/src`, and only files whose content changed. An app watching the plugin reloads for the module you edited, not for every file in the package. After a successful build, files under `dist/src` that no source produces anymore are deleted. Nothing outside `dist/src` is touched, so don't keep hand-written files in `dist/src`. + +Writes are not atomic. A changed file is overwritten in place, so for a moment an app's dev server can see the old or a partly written file, and may reload once more when the write finishes. Since only changed files are written, this window covers the files you edited. + +## Plugin, CLI and app builds + +VitNode uses different builds for different things. This page is about the first one. + +| Build | Who runs it | Tool | +| -------------- | ---------------------------------- | ----------------------------------------------------------- | +| Plugin package | `vitnode build` in a plugin | tsdown in unbundle mode, plus `tsc --noEmit` | +| VitNode CLI | `pnpm build:scripts` in `@vitnode/core` | tsdown with `packages/vitnode/tsdown.config.ts`, into `dist/scripts` | +| App | `vitnode build` in an app | Vite and TanStack Start, unchanged | + +`@vitnode/core` itself is built as a plugin package into `dist/src`. Its CLI build writes `dist/scripts` and only cleans that folder, so the two never delete each other's output. + +## Limitations + +- tsdown needs Node.js `^22.18.0`, `^24.11.0` or `26` and later, which is also the range VitNode supports. Node.js 23 and 25 are not on the list. +- Declarations are generated by the TypeScript compiler, which is the slow part of a build. In watch mode they arrive a few seconds after the JavaScript. For a package as large as `@vitnode/core`, adding or deleting a file rebuilds all of its declarations. +- Each JavaScript rebuild renders every module of the package again and writes only the changed ones. That takes under 100 ms for a typical plugin and about 2 seconds for the roughly 1,600 modules of `@vitnode/core`. +- A re-export-only `index.ts` may get no `.d.ts.map`, so "Go to definition" stops at its `.d.ts` first. + +## Next steps + + + + + diff --git a/apps/web/content/docs/dev/plugins/meta.json b/apps/web/content/docs/dev/plugins/meta.json index 74e5df8f27..bb71ded73a 100644 --- a/apps/web/content/docs/dev/plugins/meta.json +++ b/apps/web/content/docs/dev/plugins/meta.json @@ -5,9 +5,11 @@ "defaultOpen": true, "pages": [ "create", + "build", "api", "admin", "navigation", + "migrate-to-tsdown", "..." ] } diff --git a/apps/web/content/docs/dev/plugins/migrate-to-tsdown.mdx b/apps/web/content/docs/dev/plugins/migrate-to-tsdown.mdx new file mode 100644 index 0000000000..9d2154b164 --- /dev/null +++ b/apps/web/content/docs/dev/plugins/migrate-to-tsdown.mdx @@ -0,0 +1,118 @@ +--- +title: Migrate a plugin from SWC to tsdown +description: Move an existing VitNode plugin or adapter package from SWC and tsc-alias to the tsdown build vitnode build now uses. +icon: ArrowRightLeft +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Plugins generated before the switch to tsdown compiled with `tsc`, SWC and `tsc-alias`. `vitnode build` and `vitnode dev` no longer run those tools, so an older plugin needs `tsdown` instead. Your sources, exports and import paths stay the same, and the migration takes about five minutes. + + + + +### Swap the build dependencies + +In the plugin's folder, remove SWC and `tsc-alias`, then add tsdown: + + + +```bash tab="bun" +bun remove @swc/cli @swc/core tsc-alias +bun add -D tsdown +``` + +```bash tab="pnpm" +pnpm remove @swc/cli @swc/core tsc-alias +pnpm add -D tsdown +``` + +```bash tab="npm" +npm uninstall @swc/cli @swc/core tsc-alias +npm i -D tsdown +``` + + + +Keep `typescript`: the build still uses it for declarations and type checking. If a standalone API app in your workspace builds with `tsc-alias`, keep it there. Only plugin packages switched. + + + + +### Delete `.swcrc` + +Delete `.swcrc` from the plugin, and the `/.swcrc` line from its `.npmignore`. VitNode owns the build configuration, so there is nothing to replace it with. + + + + +### Check `package.json` + +The scripts should call the CLI, and `exports` should point into `./dist/src/`, which is how the CLI recognizes a plugin package now that `.swcrc` is gone: + +```json title="package.json" +{ + "type": "module", + "scripts": { + "build:plugins": "vitnode build", + "dev": "vitnode dev" + }, + "exports": { + "./locales/*.json": "./src/locales/*.json", + "./*": { + "import": "./dist/src/*.js", + "types": "./dist/src/*.d.ts", + "default": "./dist/src/*.js" + } + } +} +``` + +If your scripts still run `tsc`, `swc` and `tsc-alias` directly, replace them with these two. + + + + +### Build the plugin + + + +```bash tab="bun" +bun run build:plugins +``` + +```bash tab="pnpm" +pnpm build:plugins +``` + +```bash tab="npm" +npm run build:plugins +``` + + + +The first build removes leftovers from the old build that no source produces anymore, such as a stray `dist/vitest.config.d.ts`. Files outside `dist/src` are not touched. + + + + +## Check the result + +Validate the plugin from the app that uses it, through the same loaders the app runs: + +```bash +vitnode plugin validate +``` + +`dist/src` holds one `.js`, `.d.ts` and `.d.ts.map` per module, plus copies of your JSON and CSS. Then start `vitnode dev` in the plugin and save a file: the app reloads with the change. + +## Troubleshooting + +| Message | Fix | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `... is not a VitNode app, API or plugin package.` | Add `tsconfig.build.json`, or point `exports` into `./dist/src/` | +| `Could not find "tsdown" from ...` | Install `tsdown` in the plugin as shown in the first step | +| `Build failed: tsc exited with code 2` | A type error. The build prints it; the old build failed on it too | +| A file you put in `dist/src` by hand disappears | Move it to `src`. Non-code files there are copied to `dist/src` on every build | + +[Build and watch a plugin](/docs/dev/plugins/build) explains what the new build does with each file. diff --git a/apps/web/content/docs/dev/setup.mdx b/apps/web/content/docs/dev/setup.mdx index c774c7a2f1..213328cf81 100644 --- a/apps/web/content/docs/dev/setup.mdx +++ b/apps/web/content/docs/dev/setup.mdx @@ -12,7 +12,8 @@ import { } from 'lucide-react' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -You need Node.js 22+, Postgres (or Docker), and your favorite package manager. +You need Node.js 22.18+ (or 24.11+, or 26+), Postgres (or Docker), and your +favorite package manager. Choose a workspace with Turborepo if you will create plugins; plugins are the home for product work, not an optional side quest. diff --git a/apps/web/package.json b/apps/web/package.json index 9802f5f0de..4615f62685 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,7 +38,7 @@ "fumadocs-mdx": "^15.4.6", "fumadocs-ui": "npm:@fumadocs/base-ui@16.17.1", "hono": "^4.13.13", - "lucide-react": "^1.52.0", + "lucide-react": "^1.53.0", "motion": "^14.0.0", "nitro": "3.0.260903-beta", "react": "^19.3.0", @@ -68,7 +68,7 @@ "oxlint-tsgolint": "^7.0.2003", "tw-animate-css": "^1.4.0", "typescript": "^6.0.2", - "vite": "^8.3.3" + "vite": "^8.3.4" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/apps/web/src/docs/page-content.tsx b/apps/web/src/docs/page-content.tsx index 4bddc8e595..efdcfdbd63 100644 --- a/apps/web/src/docs/page-content.tsx +++ b/apps/web/src/docs/page-content.tsx @@ -1,3 +1,4 @@ +import { usePathname } from 'fumadocs-core/framework' import { DocsBody, DocsPage } from 'fumadocs-ui/layouts/spacious/page' import { use } from 'react' @@ -13,6 +14,7 @@ export const DocsPageContent = ({ markdownUrl, path, }: DocsPageData) => { + const pathname = usePathname() const page = docs.getPage(path) if (!page) throw new Error(`Unknown docs page: ${path}`) @@ -23,6 +25,7 @@ export const DocsPageContent = ({ return ( { + const slugs = file + .replace(/\.mdx$/, '') + .split(/[\\/]/) + .filter((segment) => !/^\(.+\)$/.test(segment)) + + if (slugs.at(-1) === 'index') slugs.pop() + + return [DOCS_ROUTE, ...slugs].join('/') +} + +export const docsPrerenderPaths = (): string[] => + globSync('**/*.mdx', { cwd: DOCS_CONTENT_DIRECTORY }) + .map(docsPathFromFile) + .filter((path) => path !== DOCS_ROUTE) diff --git a/apps/web/src/site/home/sections/developers.tsx b/apps/web/src/site/home/sections/developers.tsx index f7c0a7888f..7c118ed5b1 100644 --- a/apps/web/src/site/home/sections/developers.tsx +++ b/apps/web/src/site/home/sections/developers.tsx @@ -74,7 +74,7 @@ export const DevelopersSection = () => ( ))}

- Node.js 22+, Postgres or Docker, and whichever package manager you + Node.js 22.18+, Postgres or Docker, and whichever package manager you already argue about. bun, pnpm and npm are all welcome.

diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a685db6830..9a1c429992 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,6 +7,7 @@ import { fumadocsMdx } from 'fumadocs-mdx/vite' import { nitro } from 'nitro/vite' import { defineConfig } from 'vite' +import { docsPrerenderPaths } from './src/docs/prerender.ts' import { PRERENDERED_PATHS } from './src/site/prerender.ts' import { vitNodeConfig } from './src/vitnode.config.ts' @@ -24,7 +25,7 @@ const config = defineConfig({ prerender: { routes: prerenderRoutes({ i18n: vitNodeConfig.i18n, - paths: PRERENDERED_PATHS, + paths: [...PRERENDERED_PATHS, ...docsPrerenderPaths()], }), }, }), diff --git a/package.json b/package.json index 8ac41d1b2f..a94db3dfbe 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "zod": "^4.6.5" }, "engines": { - "node": ">=22" + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" }, "packageManager": "pnpm@11.9.0", "workspaces": [ diff --git a/packages/config/package.json b/packages/config/package.json index 2856b9a9c8..6b16ff04c3 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -52,7 +52,7 @@ "typescript": "^6.0.3" }, "dependencies": { - "@eslint-react/eslint-plugin": "^5.24.8", + "@eslint-react/eslint-plugin": "^5.24.9", "eslint-plugin-perfectionist": "^5.12.1", "eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2" } diff --git a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/.swcrc b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/.swcrc deleted file mode 100644 index 91fc9a4b03..0000000000 --- a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/.swcrc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://swc.rs/schema.json", - "exclude": ["\\.test\\.tsx?$", "\\.test-d\\.ts$", "^src/tests/"], - "minify": true, - "jsc": { - "baseUrl": "./", - "target": "esnext", - "paths": { - "@/*": ["./src/*"] - }, - "parser": { - "syntax": "typescript", - "tsx": true - }, - "transform": { - "react": { - "runtime": "automatic" - } - } - }, - "module": { - "type": "nodenext", - "strict": true, - "resolveFully": true - } -} diff --git a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/npmignore.template b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/npmignore.template index ed9d3ee90d..ec7fb96ac5 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/npmignore.template +++ b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/npmignore.template @@ -7,12 +7,12 @@ /node_modules /.turbo /tsconfig.json -/.swcrc /components.json /global.d.ts /types -/tsup.config.ts +/tsdown.config.ts /vitest.config.ts /tsconfig.json /scripts /config +/dist/*.tsbuildinfo diff --git a/packages/create-vitnode-app/package.json b/packages/create-vitnode-app/package.json index 137b9e6505..848860524c 100644 --- a/packages/create-vitnode-app/package.json +++ b/packages/create-vitnode-app/package.json @@ -11,6 +11,9 @@ "directory": "packages/create-vitnode-app" }, "type": "module", + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + }, "bin": { "create-vitnode-app": "dist/src/index.js" }, diff --git a/packages/create-vitnode-app/src/create/create-package-json.ts b/packages/create-vitnode-app/src/create/create-package-json.ts index 0aa6cfa186..414a296afe 100644 --- a/packages/create-vitnode-app/src/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/create/create-package-json.ts @@ -6,11 +6,14 @@ import type { CreateCliReturn } from "../questions.js"; import { getAvailablePackageManagers } from "../helpers/get-available-package-managers.js"; import { getVitnodePackageVersion } from "../helpers/get-vitnode-package-version.js"; +import { supportedNodeRange } from "../helpers/node-version.js"; import { withIf } from "../helpers/with-If.js"; import { versionsPackageJson } from "./package-versions.js"; type Mode = CreateCliReturn["mode"]; +const engines = { node: supportedNodeRange }; + const writeJson = async (path: string, data: unknown) => writeFile(path, JSON.stringify(data, null, 2)); @@ -330,6 +333,7 @@ export const createPackageJSON = async ({ const rootPkg: PackageJSON = { name: appName, private: true, + engines, scripts: rootScripts( oxlint, !!docker, @@ -354,6 +358,7 @@ export const createPackageJSON = async ({ version: "0.1.0", private: true, type: "module", + engines, scripts: apiScripts( packageManager, oxlint, @@ -380,6 +385,7 @@ export const createPackageJSON = async ({ version: "0.1.0", private: true, type: "module", + engines, scripts: singleAppScripts(oxlint, !!docker, appName), dependencies: { ...singleAppDeps, @@ -404,6 +410,7 @@ export const createPackageJSON = async ({ version: "0.1.0", private: true, type: "module", + engines, scripts: webScripts(oxlint), dependencies: { ...webDeps, diff --git a/packages/create-vitnode-app/src/create/package-versions.ts b/packages/create-vitnode-app/src/create/package-versions.ts index bf7e4fca86..9c2188b7ae 100644 --- a/packages/create-vitnode-app/src/create/package-versions.ts +++ b/packages/create-vitnode-app/src/create/package-versions.ts @@ -18,7 +18,7 @@ export const versionsPackageJson = { react: "^19.3", reactDom: "^19.3", useIntl: "^4.14.9", - lucide: "^1.52", + lucide: "^1.53", sonner: "^2.0.8", dotenv: "^18.0", @@ -47,8 +47,7 @@ export const versionsPackageJson = { tailwindVite: "^4.2", nitro: "3.0.260903-beta", tslib: "^2.8.1", - swcCli: "^0.8.1", - swcCore: "^1.16", + tsdown: "^0.23.0", vitest: "^5.0", shadcn: "^4.21", }; diff --git a/packages/create-vitnode-app/src/helpers/node-version.test.ts b/packages/create-vitnode-app/src/helpers/node-version.test.ts new file mode 100644 index 0000000000..659b09cfa2 --- /dev/null +++ b/packages/create-vitnode-app/src/helpers/node-version.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { isSupportedNodeVersion } from "./node-version.js"; + +describe("isSupportedNodeVersion", () => { + it.each(["22.18.0", "22.20.1", "24.11.0", "24.15.0", "26.0.0", "27.1.0"])( + "accepts %s, a release tsdown runs on", + version => { + expect(isSupportedNodeVersion(version)).toBe(true); + }, + ); + + it.each([ + "20.19.0", + "22.0.0", + "22.12.0", + "22.17.1", + "23.11.0", + "24.10.0", + "25.2.0", + ])("refuses %s, a release tsdown does not support", version => { + expect(isSupportedNodeVersion(version)).toBe(false); + }); + + it("reads a version with the v prefix of process.version", () => { + expect(isSupportedNodeVersion("v24.11.0")).toBe(true); + }); +}); diff --git a/packages/create-vitnode-app/src/helpers/node-version.ts b/packages/create-vitnode-app/src/helpers/node-version.ts new file mode 100644 index 0000000000..584532fa4c --- /dev/null +++ b/packages/create-vitnode-app/src/helpers/node-version.ts @@ -0,0 +1,18 @@ +export const supportedNodeRange = "^22.18.0 || ^24.11.0 || >=26.0.0"; + +export const supportedNodeRangeLabel = "22.18+, 24.11+ or 26+"; + +const minimumMinorByMajor: Record = { 22: 18, 24: 11 }; + +export const isSupportedNodeVersion = (version: string) => { + const [major = 0, minor = 0] = version + .replace(/^v/, "") + .split(".") + .map(Number); + + if (major >= 26) return true; + + const minimumMinor = minimumMinorByMajor[major]; + + return minimumMinor !== undefined && minor >= minimumMinor; +}; diff --git a/packages/create-vitnode-app/src/helpers/packages-json.ts b/packages/create-vitnode-app/src/helpers/packages-json.ts index 2e2e498556..42eb943416 100644 --- a/packages/create-vitnode-app/src/helpers/packages-json.ts +++ b/packages/create-vitnode-app/src/helpers/packages-json.ts @@ -1,6 +1,7 @@ export interface PackageJSON { dependencies?: Record; devDependencies?: Record; + engines?: Record; exports?: Record | string>; name: string; diff --git a/packages/create-vitnode-app/src/index.ts b/packages/create-vitnode-app/src/index.ts index 55d61bf408..668447aa46 100644 --- a/packages/create-vitnode-app/src/index.ts +++ b/packages/create-vitnode-app/src/index.ts @@ -6,16 +6,19 @@ import color from "picocolors"; import { createVitNode } from "./create/create-vitnode.js"; import { packageJson } from "./helpers/get-package-json.js"; +import { + isSupportedNodeVersion, + supportedNodeRangeLabel, +} from "./helpers/node-version.js"; import { validateNpmName } from "./helpers/validate-pkg.js"; import { createPlugin } from "./plugin/index.js"; import { createQuestionsCli } from "./questions.js"; import { validationProject } from "./validation.js"; -const [major] = process.versions.node.split(".").map(Number); -if (major < 20) { +if (!isSupportedNodeVersion(process.versions.node)) { console.error( color.red( - `\nError: VitNode requires Node.js version 20 or higher.\nYou are currently using Node.js ${process.versions.node}\n`, + `\nError: VitNode requires Node.js ${supportedNodeRangeLabel}.\nYou are currently using Node.js ${process.versions.node}\n`, ), ); process.exit(1); diff --git a/packages/create-vitnode-app/src/plugin/create/create-package-json.ts b/packages/create-vitnode-app/src/plugin/create/create-package-json.ts index 439a9a5179..4950e1e07a 100644 --- a/packages/create-vitnode-app/src/plugin/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/plugin/create/create-package-json.ts @@ -4,6 +4,7 @@ import type { PackageJSON } from "../../helpers/packages-json.js"; import { versionsPackageJson } from "../../create/package-versions.js"; import { getVitnodePackageVersion } from "../../helpers/get-vitnode-package-version.js"; +import { supportedNodeRange } from "../../helpers/node-version.js"; import { withIf } from "../../helpers/with-If.js"; import { pluginPackageExports } from "./route-templates.js"; @@ -37,6 +38,7 @@ export const createPluginPackageJSON = async ({ version: "0.1.0", private: true, type: "module", + engines: { node: supportedNodeRange }, scripts: pluginScripts(oxlint), exports: pluginPackageExports(), dependencies: { @@ -56,8 +58,6 @@ export const createPluginPackageJSON = async ({ }, devDependencies: { "@react-email/ui": versionsPackageJson.reactEmailUi, - "@swc/cli": versionsPackageJson.swcCli, - "@swc/core": versionsPackageJson.swcCore, "@types/react": versionsPackageJson.typesReact, "@types/react-dom": versionsPackageJson.typesReactDom, "@vitnode/config": vitnodeVersionRange, @@ -66,7 +66,7 @@ export const createPluginPackageJSON = async ({ oxlint: versionsPackageJson.oxlint, "oxlint-tsgolint": versionsPackageJson.oxlintTsgolint, }), - "tsc-alias": versionsPackageJson.tscAlias, + tsdown: versionsPackageJson.tsdown, typescript: versionsPackageJson.typescript, vitest: versionsPackageJson.vitest, }, diff --git a/packages/elasticsearch/.npmignore b/packages/elasticsearch/.npmignore index 83f4308b4c..29b2e259eb 100644 --- a/packages/elasticsearch/.npmignore +++ b/packages/elasticsearch/.npmignore @@ -2,5 +2,4 @@ /src /node_modules /tsconfig.json -/.swcrc /oxlint.config.ts diff --git a/packages/elasticsearch/.swcrc b/packages/elasticsearch/.swcrc deleted file mode 100644 index 8f099dc7ab..0000000000 --- a/packages/elasticsearch/.swcrc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://swc.rs/schema.json", - "exclude": ["\\.test\\.tsx?$"], - "minify": true, - "jsc": { - "baseUrl": "./", - "target": "esnext", - "paths": { - "@/*": ["./src/*"] - }, - "parser": { - "syntax": "typescript", - "tsx": true - }, - "transform": { - "react": { - "runtime": "automatic" - } - } - }, - "module": { - "type": "nodenext", - "strict": true, - "resolveFully": true - } -} diff --git a/packages/elasticsearch/package.json b/packages/elasticsearch/package.json index ac57420fb9..75cb199ac2 100644 --- a/packages/elasticsearch/package.json +++ b/packages/elasticsearch/package.json @@ -33,13 +33,11 @@ "@elastic/elasticsearch": "^9.5.1" }, "devDependencies": { - "@swc/cli": "^0.8.1", - "@swc/core": "^1.16.13", "@vitnode/config": "workspace:*", "@vitnode/core": "workspace:*", "oxlint": "^1.87.0", "oxlint-tsgolint": "^7.0.2003", - "tsc-alias": "^1.9.7", + "tsdown": "^0.23.0", "typescript": "^6.0.3" } } diff --git a/packages/node-cron/.npmignore b/packages/node-cron/.npmignore index 1ec76b9bc1..4dd465a89f 100644 --- a/packages/node-cron/.npmignore +++ b/packages/node-cron/.npmignore @@ -2,5 +2,4 @@ /src /node_modules /tsconfig.json -/.swcrc /oxlint.config.ts \ No newline at end of file diff --git a/packages/node-cron/.swcrc b/packages/node-cron/.swcrc deleted file mode 100644 index 8f099dc7ab..0000000000 --- a/packages/node-cron/.swcrc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://swc.rs/schema.json", - "exclude": ["\\.test\\.tsx?$"], - "minify": true, - "jsc": { - "baseUrl": "./", - "target": "esnext", - "paths": { - "@/*": ["./src/*"] - }, - "parser": { - "syntax": "typescript", - "tsx": true - }, - "transform": { - "react": { - "runtime": "automatic" - } - } - }, - "module": { - "type": "nodenext", - "strict": true, - "resolveFully": true - } -} diff --git a/packages/node-cron/package.json b/packages/node-cron/package.json index 59800775f1..df153879c5 100644 --- a/packages/node-cron/package.json +++ b/packages/node-cron/package.json @@ -34,13 +34,11 @@ "node-cron": "^4.6.0" }, "devDependencies": { - "@swc/cli": "^0.8.1", - "@swc/core": "^1.16.13", "@vitnode/config": "workspace:*", "@vitnode/core": "workspace:*", "oxlint": "^1.87.0", "oxlint-tsgolint": "^7.0.2003", - "tsc-alias": "^1.9.7", + "tsdown": "^0.23.0", "typescript": "^6.0.3" } } diff --git a/packages/nodemailer/.npmignore b/packages/nodemailer/.npmignore index 1ec76b9bc1..4dd465a89f 100644 --- a/packages/nodemailer/.npmignore +++ b/packages/nodemailer/.npmignore @@ -2,5 +2,4 @@ /src /node_modules /tsconfig.json -/.swcrc /oxlint.config.ts \ No newline at end of file diff --git a/packages/nodemailer/.swcrc b/packages/nodemailer/.swcrc deleted file mode 100644 index 8f099dc7ab..0000000000 --- a/packages/nodemailer/.swcrc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://swc.rs/schema.json", - "exclude": ["\\.test\\.tsx?$"], - "minify": true, - "jsc": { - "baseUrl": "./", - "target": "esnext", - "paths": { - "@/*": ["./src/*"] - }, - "parser": { - "syntax": "typescript", - "tsx": true - }, - "transform": { - "react": { - "runtime": "automatic" - } - } - }, - "module": { - "type": "nodenext", - "strict": true, - "resolveFully": true - } -} diff --git a/packages/nodemailer/package.json b/packages/nodemailer/package.json index 5a25785159..60329d578b 100644 --- a/packages/nodemailer/package.json +++ b/packages/nodemailer/package.json @@ -33,14 +33,12 @@ "nodemailer": "^10.0.16" }, "devDependencies": { - "@swc/cli": "^0.8.1", - "@swc/core": "^1.16.13", "@types/nodemailer": "^8.0.2", "@vitnode/config": "workspace:*", "@vitnode/core": "workspace:*", "oxlint": "^1.87.0", "oxlint-tsgolint": "^7.0.2003", - "tsc-alias": "^1.9.7", + "tsdown": "^0.23.0", "typescript": "^6.0.3" } } diff --git a/packages/resend/.npmignore b/packages/resend/.npmignore index 1ec76b9bc1..4dd465a89f 100644 --- a/packages/resend/.npmignore +++ b/packages/resend/.npmignore @@ -2,5 +2,4 @@ /src /node_modules /tsconfig.json -/.swcrc /oxlint.config.ts \ No newline at end of file diff --git a/packages/resend/.swcrc b/packages/resend/.swcrc deleted file mode 100644 index 8f099dc7ab..0000000000 --- a/packages/resend/.swcrc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://swc.rs/schema.json", - "exclude": ["\\.test\\.tsx?$"], - "minify": true, - "jsc": { - "baseUrl": "./", - "target": "esnext", - "paths": { - "@/*": ["./src/*"] - }, - "parser": { - "syntax": "typescript", - "tsx": true - }, - "transform": { - "react": { - "runtime": "automatic" - } - } - }, - "module": { - "type": "nodenext", - "strict": true, - "resolveFully": true - } -} diff --git a/packages/resend/package.json b/packages/resend/package.json index 45f00cab1e..6fae498ce5 100644 --- a/packages/resend/package.json +++ b/packages/resend/package.json @@ -32,13 +32,11 @@ "resend": "^6.32.1" }, "devDependencies": { - "@swc/cli": "^0.8.1", - "@swc/core": "^1.16.13", "@vitnode/config": "workspace:*", "@vitnode/core": "workspace:*", "oxlint": "^1.87.0", "oxlint-tsgolint": "^7.0.2003", - "tsc-alias": "^1.9.7", + "tsdown": "^0.23.0", "typescript": "^6.0.3" } } diff --git a/packages/s3/.npmignore b/packages/s3/.npmignore index 83f4308b4c..29b2e259eb 100644 --- a/packages/s3/.npmignore +++ b/packages/s3/.npmignore @@ -2,5 +2,4 @@ /src /node_modules /tsconfig.json -/.swcrc /oxlint.config.ts diff --git a/packages/s3/.swcrc b/packages/s3/.swcrc deleted file mode 100644 index 8f099dc7ab..0000000000 --- a/packages/s3/.swcrc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://swc.rs/schema.json", - "exclude": ["\\.test\\.tsx?$"], - "minify": true, - "jsc": { - "baseUrl": "./", - "target": "esnext", - "paths": { - "@/*": ["./src/*"] - }, - "parser": { - "syntax": "typescript", - "tsx": true - }, - "transform": { - "react": { - "runtime": "automatic" - } - } - }, - "module": { - "type": "nodenext", - "strict": true, - "resolveFully": true - } -} diff --git a/packages/s3/package.json b/packages/s3/package.json index 9de184d764..81ac5f3497 100644 --- a/packages/s3/package.json +++ b/packages/s3/package.json @@ -31,16 +31,14 @@ "lint:fix": "oxlint --type-aware --fix" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1147.0" + "@aws-sdk/client-s3": "^3.1148.0" }, "devDependencies": { - "@swc/cli": "^0.8.1", - "@swc/core": "^1.16.13", "@vitnode/config": "workspace:*", "@vitnode/core": "workspace:*", "oxlint": "^1.87.0", "oxlint-tsgolint": "^7.0.2003", - "tsc-alias": "^1.9.7", + "tsdown": "^0.23.0", "typescript": "^6.0.3" } } diff --git a/packages/supabase-storage/.npmignore b/packages/supabase-storage/.npmignore index 83f4308b4c..29b2e259eb 100644 --- a/packages/supabase-storage/.npmignore +++ b/packages/supabase-storage/.npmignore @@ -2,5 +2,4 @@ /src /node_modules /tsconfig.json -/.swcrc /oxlint.config.ts diff --git a/packages/supabase-storage/.swcrc b/packages/supabase-storage/.swcrc deleted file mode 100644 index 8f099dc7ab..0000000000 --- a/packages/supabase-storage/.swcrc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://swc.rs/schema.json", - "exclude": ["\\.test\\.tsx?$"], - "minify": true, - "jsc": { - "baseUrl": "./", - "target": "esnext", - "paths": { - "@/*": ["./src/*"] - }, - "parser": { - "syntax": "typescript", - "tsx": true - }, - "transform": { - "react": { - "runtime": "automatic" - } - } - }, - "module": { - "type": "nodenext", - "strict": true, - "resolveFully": true - } -} diff --git a/packages/supabase-storage/package.json b/packages/supabase-storage/package.json index e1818f3cb6..49bdc8a7ab 100644 --- a/packages/supabase-storage/package.json +++ b/packages/supabase-storage/package.json @@ -33,13 +33,11 @@ "@supabase/storage-js": "^2.117.3" }, "devDependencies": { - "@swc/cli": "^0.8.1", - "@swc/core": "^1.16.13", "@vitnode/config": "workspace:*", "@vitnode/core": "workspace:*", "oxlint": "^1.87.0", "oxlint-tsgolint": "^7.0.2003", - "tsc-alias": "^1.9.7", + "tsdown": "^0.23.0", "typescript": "^6.0.3" } } diff --git a/packages/vitnode/.npmignore b/packages/vitnode/.npmignore index e22ba95285..163292806d 100644 --- a/packages/vitnode/.npmignore +++ b/packages/vitnode/.npmignore @@ -10,10 +10,10 @@ /node_modules /.turbo /tsconfig.json -/.swcrc /components.json /global.d.ts -/tsup.config.ts +/tsdown.config.ts /vitest.config.ts /tsconfig.json -/scripts \ No newline at end of file +/scripts +/dist/*.tsbuildinfo diff --git a/packages/vitnode/.swcrc b/packages/vitnode/.swcrc deleted file mode 100644 index 91fc9a4b03..0000000000 --- a/packages/vitnode/.swcrc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://swc.rs/schema.json", - "exclude": ["\\.test\\.tsx?$", "\\.test-d\\.ts$", "^src/tests/"], - "minify": true, - "jsc": { - "baseUrl": "./", - "target": "esnext", - "paths": { - "@/*": ["./src/*"] - }, - "parser": { - "syntax": "typescript", - "tsx": true - }, - "transform": { - "react": { - "runtime": "automatic" - } - } - }, - "module": { - "type": "nodenext", - "strict": true, - "resolveFully": true - } -} diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index 242dbb2822..4fbb59b229 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -29,8 +29,6 @@ ], "peerDependencies": { "@hono/zod-openapi": "^1.0.x", - "@swc/cli": "^0.7.0 || ^0.8.0", - "@swc/core": "^1.15.0", "@tanstack/react-form": "^1.33.x", "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.x", @@ -43,6 +41,7 @@ "motion": "^14.x.x", "react": "^19.2.x", "react-dom": "^19.2.x", + "tsdown": "^0.23.0", "typescript": "^6.x.x", "vite": "^8.x", "zod": "^4.x.x" @@ -51,8 +50,6 @@ "@hono/zod-openapi": "^1.6.3", "@hono/zod-validator": "^0.9.1", "@react-email/ui": "^6.11.1", - "@swc/cli": "^0.8.1", - "@swc/core": "^1.16.13", "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.104.1", "@tanstack/react-router": "^1.170.41", @@ -72,7 +69,7 @@ "hono": "^4.13.13", "jiti": "^2.7.0", "jsdom": "^30.1.2", - "lucide-react": "^1.52.0", + "lucide-react": "^1.53.0", "oxlint": "^1.87.0", "oxlint-tsgolint": "^7.0.2003", "react": "^19.3.0", @@ -80,12 +77,11 @@ "react-email": "^6.11.1", "sonner": "^2.0.8", "tailwindcss": "^4.3.3", - "tsc-alias": "^1.9.7", - "tsup": "^8.5.1", + "tsdown": "^0.23.0", "tsx": "^4.23.15", "tw-animate-css": "^1.4.0", "typescript": "^6.0.3", - "vite": "^8.3.3", + "vite": "^8.3.4", "vitest": "^5.0.3", "zod": "^4.6.5" }, @@ -208,9 +204,9 @@ "./styles/view-transitions.css": "./src/view-transitions.css" }, "scripts": { - "build:scripts": "tsup", + "build:scripts": "tsdown", "start:scripts": "node dist/scripts/scripts.js db:prepare", - "build:plugins": "tsc -p tsconfig.build.json && swc src -d dist --config-file .swcrc --copy-files && tsc-alias -p tsconfig.build.json", + "build:plugins": "node dist/scripts/scripts.js build", "dev": "node dist/scripts/scripts.js dev", "dev:email": "email dev --dir src/emails", "test": "vitest run", @@ -221,6 +217,9 @@ "lint:fix": "oxlint --type-aware --fix" }, "type": "module", + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + }, "dependencies": { "@base-ui/react": "^1.8.0", "@dnd-kit/core": "^6.3.1", @@ -247,7 +246,7 @@ "@tiptap/react": "^3.31.4", "@tiptap/starter-kit": "^3.31.4", "@tiptap/suggestion": "^3.31.4", - "ai": "^7.0.131", + "ai": "^7.0.134", "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", "cn": "^0.4.0", @@ -265,7 +264,7 @@ "rate-limiter-flexible": "^11.2.1", "react-colorful": "^5.8.1", "react-day-picker": "^10.0.2", - "react-resizable-panels": "^4.14.2", + "react-resizable-panels": "^4.14.3", "react-scan": "^0.5.7", "redis": "^6.3.0", "sanitize-html": "^2.18.0", @@ -284,6 +283,9 @@ "@tanstack/react-start": { "optional": true }, + "tsdown": { + "optional": true + }, "vite": { "optional": true } diff --git a/packages/vitnode/scripts/cli/builder/compiler-build.ts b/packages/vitnode/scripts/cli/builder/compiler-build.ts index afa677265e..837e13e3b9 100644 --- a/packages/vitnode/scripts/cli/builder/compiler-build.ts +++ b/packages/vitnode/scripts/cli/builder/compiler-build.ts @@ -2,14 +2,21 @@ import { readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import type { Project } from "../project/project"; -import type { Ui } from "../ui/ui"; +import type { TaskHandle, Ui } from "../ui/ui"; import type { MeasuredFile } from "./output-files"; +import type { TsdownApi } from "./package-build"; import { writePluginApiRegistry } from "../../write-plugin-api-registry"; -import { RuntimeError } from "../errors"; -import { resolveBin } from "../project/packages"; +import { errorMessage, RuntimeError } from "../errors"; +import { importFromProject, resolveBin } from "../project/packages"; import { runProcess } from "../project/processes"; -import { toDisplayPath } from "../ui/format"; +import { captureProcessOutput } from "../ui/capture-output"; +import { formatDuration, toDisplayPath } from "../ui/format"; +import { + createPackageLogger, + PACKAGE_TSCONFIG, + packageTsdownConfig, +} from "./package-build"; export interface CompilerStep { args: string[]; @@ -19,29 +26,22 @@ export interface CompilerStep { } /** - * A plugin package's build: the three compilers it has always used, in order. + * A plugin package's type check: the full `tsc` program, without output. * - * Types first (`tsc` emits declarations only), then the JavaScript (`swc`, - * which also copies locale JSON into `dist`), then `tsc-alias` rewriting the - * `@/` imports both of them left behind. + * tsdown writes the JavaScript and the declarations, but its declaration step + * emits each file without reporting type errors - so this is what still fails + * the build on one, as `tsc` always did. */ -export const packageBuildSteps = (): CompilerStep[] => [ - { - args: ["-p", "tsconfig.build.json"], - bin: { name: "tsc", package: "typescript" }, - label: "Emitting type declarations", - }, - { - args: ["src", "-d", "dist", "--config-file", ".swcrc", "--copy-files"], - bin: { name: "swc", package: "@swc/cli" }, - label: "Compiling sources", - }, - { - args: ["-p", "tsconfig.build.json"], - bin: { name: "tsc-alias", package: "tsc-alias" }, - label: "Rewriting path aliases", - }, -]; +export const typeCheckStep = (watch = false): CompilerStep => ({ + args: [ + "--noEmit", + "-p", + PACKAGE_TSCONFIG, + ...(watch ? ["-w", "--preserveWatchOutput"] : []), + ], + bin: { name: "tsc", package: "typescript" }, + label: "Checking types", +}); /** A standalone API app's build: `tsc`, then `tsc-alias`. */ export const apiBuildSteps = (): CompilerStep[] => [ @@ -73,6 +73,28 @@ export const runStepWithNode: StepRunner = async (step, project) => cwd: project.root, }); +/** Ends a step's task, failing the build with the tool's output when it failed. */ +const finishStep = ( + ui: Ui, + task: TaskHandle, + step: CompilerStep, + { code, output }: { code: number; output: string }, + detail?: string, +) => { + if (ui.verbose && output.trim() !== "") ui.line(output.trimEnd()); + + if (code !== 0) { + task.fail(); + throw new RuntimeError( + `Build failed: ${step.bin.name} exited with code ${String(code)}`, + { + output: ui.verbose ? undefined : output, + }, + ); + } + task.succeed(undefined, detail); +}; + export const runCompilerSteps = async ({ project, runStep = runStepWithNode, @@ -86,24 +108,91 @@ export const runCompilerSteps = async ({ }): Promise => { for (const step of steps) { const task = ui.task(step.label); - const { code, output } = await runStep(step, project); - - if (ui.verbose && output.trim() !== "") ui.line(output.trimEnd()); - - if (code !== 0) { - task.fail(); - throw new RuntimeError( - `Build failed: ${step.bin.name} exited with code ${String(code)}`, - { - output: ui.verbose ? undefined : output, - }, - ); - } - task.succeed(); + finishStep(ui, task, step, await runStep(step, project)); + } +}; + +export type LoadTsdown = (root: string) => Promise; + +export const loadProjectTsdown: LoadTsdown = async root => + importFromProject(root, "tsdown"); + +/** + * A plugin package's build: tsdown compiles `src` into `dist/src` - the + * JavaScript and its declarations - while `tsc` checks the types alongside it. + * + * Both have to pass. They run at the same time because each is a full + * TypeScript program over the same files, and neither needs the other's + * output; the type check is awaited even when compiling fails, so no `tsc` is + * ever left running. + */ +export const runPackageBuild = async ({ + loadTsdown = loadProjectTsdown, + project, + runStep = runStepWithNode, + ui, +}: { + loadTsdown?: LoadTsdown; + project: Project; + runStep?: StepRunner; + ui: Ui; +}): Promise => { + const tsdown = await loadTsdown(project.root); + const check = typeCheckStep(); + const startedAt = Date.now(); + const typeCheck = runStep(check, project).then(result => ({ + ...result, + duration: Date.now() - startedAt, + })); + const warnings: string[] = []; + const logger = createPackageLogger((type, message) => { + if (type !== "info") warnings.push(message); + }); + + const compile = ui.task("Compiling sources"); + // Anything tsdown or a plugin prints straight to the console is held back, + // and shown with the error if the build fails. + const capture = ui.verbose ? null : captureProcessOutput(); + try { + await tsdown.build( + packageTsdownConfig({ logger, mode: "build", root: project.root }), + ); + } catch (error) { + const captured = capture?.restore() ?? ""; + compile.fail(); + await typeCheck.catch(() => undefined); + throw new RuntimeError( + "Build failed: tsdown could not compile the package", + { + cause: error, + output: [captured.trimEnd(), ...warnings, errorMessage(error)] + .filter(text => text !== "") + .join("\n"), + }, + ); + } + const captured = capture?.restore() ?? ""; + compile.succeed(); + + const checked = await typeCheck; + finishStep( + ui, + ui.task(check.label), + check, + checked, + formatDuration(checked.duration), + ); + + if (warnings.length > 0) { + ui.line(); + warnings.forEach(warning => { + ui.warning(warning); + }); } + if (ui.verbose && captured.trim() !== "") ui.line(captured.trimEnd()); }; -/** Writes `types/api-registry.gen.d.ts` before `tsc` needs it. */ +/** Writes `types/api-registry.gen.d.ts` before tsdown and `tsc` need it. */ export const prepareRegistryStep = (project: Project, ui: Ui) => { if (writePluginApiRegistry(project.root)) { ui.success("API registry generated"); diff --git a/packages/vitnode/scripts/cli/builder/package-build.test.ts b/packages/vitnode/scripts/cli/builder/package-build.test.ts new file mode 100644 index 0000000000..552eb00a9d --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/package-build.test.ts @@ -0,0 +1,311 @@ +// @vitest-environment node +import { execFileSync } from "node:child_process"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { runBuildCommand } from "../commands/build"; +import { RuntimeError } from "../errors"; +import { createTestContext } from "../testing"; + +const packageRoot = join(import.meta.dirname, "..", "..", ".."); +const template = join( + packageRoot, + "..", + "create-vitnode-app", + "copy-of-vitnode-plugin", + "root", +); + +let root: string; + +const write = (path: string, content: string) => { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), content); +}; + +const read = (path: string) => readFileSync(join(root, path), "utf8"); + +const exists = (path: string) => existsSync(join(root, path)); + +/** + * A plugin package laid out the way `create-vitnode-app` generates one - the + * template's own tsconfig files - with one module for each thing a package + * build has to get right. + */ +const fixture = () => { + cpSync(join(template, "tsconfig.json"), join(root, "tsconfig.json")); + cpSync( + join(template, "tsconfig.build.json"), + join(root, "tsconfig.build.json"), + ); + write( + "package.json", + JSON.stringify({ + exports: { + "./*": { + default: "./dist/src/*.js", + import: "./dist/src/*.js", + types: "./dist/src/*.d.ts", + }, + "./locales/*.json": "./src/locales/*.json", + }, + name: "@acme/fixture", + type: "module", + version: "0.1.0", + }), + ); + write( + "types/fixture.d.ts", + 'declare global {\n interface FixtureRegistry {\n greeting: "hello";\n }\n}\n\nexport {};\n', + ); + write( + "src/components/greeting.tsx", + "export const Greeting = ({ name }: { name: string }) =>

Hello {name}

;\n", + ); + write( + "src/lib/math.ts", + "export interface Shape {\n height: number;\n width: number;\n}\n\nexport const double = (value: number) => value * 2;\n", + ); + write( + "src/uses-alias.ts", + 'import type { Shape } from "@/lib/math";\n\nimport { double } from "@/lib/math";\n\nexport const quadruple = (value: number) => double(double(value));\n\nexport const area = (shape: Shape) => shape.width * shape.height;\n', + ); + write( + "src/standalone.ts", + 'export const standalone = "only reachable through the wildcard export";\n', + ); + write( + "src/lazy.ts", + 'export const loadPage = async () => await import("./pages/page");\n', + ); + write("src/pages/page.ts", 'export default "page";\n'); + write( + "src/api/hello.module.ts", + 'import { helloRoute } from "./hello.route";\n\nexport const helloModule = { routes: [helloRoute] };\n', + ); + write("src/api/hello.route.ts", 'export const helloRoute = "/hello";\n'); + write("src/auth.server.ts", 'export const secret = () => "server only";\n'); + write( + "src/side-effect.ts", + "(globalThis as Record).fixtureLoaded = true;\n", + ); + write("src/register.ts", 'import "./side-effect";\n'); + write( + "src/registry.ts", + 'const registry = { greeting: "hello" } as unknown as FixtureRegistry;\n\nexport const readGreeting = () => registry.greeting;\n', + ); + write("src/locales/en.json", '{ "fixture": { "hello": "Hi" } }\n'); + write( + "src/locales/index.ts", + 'export default {\n en: async () => await import("./en.json", { with: { type: "json" } }),\n};\n', + ); + write("src/lib/math.test.ts", "export const notShipped = true;\n"); + write("src/tests/setup.ts", "export const notShippedEither = true;\n"); + write("src/__fixtures__/sample.json", "{}\n"); +}; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-package-build-")); + // The package's own node_modules, as an install would leave it: tsdown, + // TypeScript and React's types resolve from there. The sources themselves + // must not live under a node_modules folder - an import resolving into one + // is a dependency, and stays external. + symlinkSync( + join(packageRoot, "node_modules"), + join(root, "node_modules"), + "junction", + ); + fixture(); +}); + +afterEach(() => { + rmSync(root, { force: true, recursive: true }); +}); + +const build = async () => { + const { context, runtime } = createTestContext({ cwd: root }); + const code = await runBuildCommand(context, {}, { now: () => 0 }); + + return { code, output: runtime.output() }; +}; + +const RELATIVE_IMPORT = + /(?:\bfrom|\bimport\(?)\s*["'`](\.{1,2}\/[^"'`]+)["'`]/g; + +/** `file -> specifier` for each relative import under `dir` with no file behind it. */ +const unresolvedRelativeImports = (dir: string): string[] => + readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && /\.(?:js|d\.ts)$/.test(entry.name)) + .flatMap(entry => { + const file = join(entry.parentPath, entry.name); + + return [...readFileSync(file, "utf8").matchAll(RELATIVE_IMPORT)] + .map(match => match[1]) + .filter(specifier => { + const target = join(dirname(file), specifier); + + return ( + !existsSync(target) && !existsSync(target.replace(/\.js$/, ".d.ts")) + ); + }) + .map(specifier => `${relative(dir, file)} -> ${specifier}`); + }); + +/** Imports a built module with Node itself, as an app's server would. */ +const importWithNode = (expression: string) => + execFileSync( + process.execPath, + ["--input-type=module", "--eval", expression], + { cwd: root, encoding: "utf8" }, + ).trim(); + +describe("vitnode build in a plugin package", () => { + it("compiles every module under src into dist/src with tsdown and checks types", async () => { + const { code, output } = await build(); + + expect(code).toBe(0); + expect(output).toContain("✓ Compiling sources"); + expect(output).toContain("✓ Checking types"); + + // A module nothing imports is still public through `"./*"`. + expect(exists("dist/src/standalone.js")).toBe(true); + expect(exists("dist/src/standalone.d.ts")).toBe(true); + // Server-only filenames are kept as they are. + expect(exists("dist/src/auth.server.js")).toBe(true); + // Tests, their setup and their fixtures stay out. + expect(exists("dist/src/lib/math.test.js")).toBe(false); + expect(exists("dist/src/tests")).toBe(false); + expect(exists("dist/src/__fixtures__")).toBe(false); + + // Every relative import - JavaScript and declarations - names a file + // that was written, as Node's ESM resolution requires. + expect(unresolvedRelativeImports(join(root, "dist", "src"))).toEqual([]); + }, 60_000); + + it("emits ESM that Node runs, with aliases, JSX, dynamic imports and side effects intact", async () => { + await build(); + + expect(read("dist/src/components/greeting.js")).toContain( + "react/jsx-runtime", + ); + expect(read("dist/src/uses-alias.js")).toMatch( + /from\s*["'`]\.\/lib\/math\.js["'`]/, + ); + expect(read("dist/src/uses-alias.js")).not.toContain("@/"); + expect(read("dist/src/lazy.js")).toMatch( + /import\(\s*["'`]\.\/pages\/page\.js["'`]\s*\)/, + ); + expect(read("dist/src/register.js")).toMatch( + /import\s*["'`]\.\/side-effect\.js["'`]/, + ); + // A dotted file name is a module name, not an extension. + expect(read("dist/src/api/hello.module.js")).toMatch( + /from\s*["'`]\.\/hello\.route\.js["'`]/, + ); + + expect( + importWithNode( + 'const { quadruple } = await import("./dist/src/uses-alias.js"); const { loadPage } = await import("./dist/src/lazy.js"); await import("./dist/src/register.js"); const { helloModule } = await import("./dist/src/api/hello.module.js"); console.log(quadruple(2), (await loadPage()).default, globalThis.fixtureLoaded, helloModule.routes[0]);', + ), + ).toBe("8 page true /hello"); + }, 60_000); + + it("copies JSON next to the module that imports it, with the import attribute kept", async () => { + await build(); + + expect(read("dist/src/locales/en.json")).toBe(read("src/locales/en.json")); + expect(read("dist/src/locales/index.js")).toMatch( + /import\(\s*["'`]\.\/en\.json["'`]\s*,\s*\{\s*with:\s*\{\s*type:\s*["'`]json["'`]/, + ); + expect( + importWithNode( + 'const { default: messages } = await import("./dist/src/locales/index.js"); console.log((await messages.en()).default.fixture.hello);', + ), + ).toBe("Hi"); + }, 60_000); + + it("writes declarations with relative imports, declaration maps and the ambient types the package declares", async () => { + await build(); + + const declaration = read("dist/src/uses-alias.d.ts"); + expect(declaration).toMatch(/from\s*["']\.\/lib\/math\.js["']/); + expect(declaration).not.toContain("@/"); + expect(exists("dist/src/lib/math.d.ts")).toBe(true); + + const map = JSON.parse(read("dist/src/uses-alias.d.ts.map")) as { + sources: string[]; + }; + expect(map.sources).toEqual(["../../src/uses-alias.ts"]); + + // Inferred from `types/fixture.d.ts`, which no module imports - as a + // plugin's fetcher types come from its generated API registry. + expect(read("dist/src/registry.d.ts")).toMatch( + /readGreeting: \(\) => "hello"/, + ); + }, 60_000); + + it("ships no JavaScript source maps from a production build", async () => { + await build(); + + expect(exists("dist/src/standalone.js.map")).toBe(false); + expect(read("dist/src/standalone.js")).not.toContain("sourceMappingURL"); + }, 60_000); + + it("fails on a type error, as tsc always made it", async () => { + write("src/broken.ts", 'export const value: number = "text";\n'); + + const failure = await build().catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(RuntimeError); + expect((failure as RuntimeError).message).toBe( + "Build failed: tsc exited with code 2", + ); + expect((failure as RuntimeError).output).toContain("TS2322"); + }, 60_000); + + it("fails on code tsdown cannot compile, with its error", async () => { + write("src/broken.ts", "export const = ;\n"); + + const failure = await build().catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(RuntimeError); + expect((failure as RuntimeError).message).toBe( + "Build failed: tsdown could not compile the package", + ); + expect((failure as RuntimeError).output).toContain("src/broken.ts"); + }, 60_000); + + it("removes the output of deleted sources, rewrites only what changed and never touches anything outside dist/src", async () => { + write("dist/scripts/scripts.js", "// another build's output\n"); + await build(); + const before = statSync(join(root, "dist/src/uses-alias.js")).mtimeMs; + + rmSync(join(root, "src/standalone.ts")); + rmSync(join(root, "src/locales"), { recursive: true }); + write( + "src/register.ts", + 'import "./side-effect";\n\nexport const registered = true;\n', + ); + await build(); + + expect(exists("dist/src/standalone.js")).toBe(false); + expect(exists("dist/src/standalone.d.ts")).toBe(false); + expect(exists("dist/src/locales")).toBe(false); + expect(read("dist/src/register.js")).toContain("registered"); + expect(statSync(join(root, "dist/src/uses-alias.js")).mtimeMs).toBe(before); + expect(read("dist/scripts/scripts.js")).toBe("// another build's output\n"); + }, 120_000); +}); diff --git a/packages/vitnode/scripts/cli/builder/package-build.ts b/packages/vitnode/scripts/cli/builder/package-build.ts new file mode 100644 index 0000000000..99711709f1 --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/package-build.ts @@ -0,0 +1,441 @@ +import type { FSWatcher } from "node:fs"; +import type { InlineConfig, Logger, Rolldown, TsdownHandle } from "tsdown"; + +import { + existsSync, + readdirSync, + readFileSync, + rmdirSync, + rmSync, + statSync, + watch, +} from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { format } from "node:util"; + +import { toDisplayPath } from "../ui/format"; + +/** + * How VitNode builds a plugin or adapter package with tsdown. + * + * Every package is built the same way, so there is no `tsdown.config.ts` to + * write: each TypeScript module under `src` is compiled on its own (unbundle + * mode) into `dist/src`, next to its `.d.ts`, because a package's wildcard + * exports (`"./*": "./dist/src/*.js"`) make every module public - not only the + * ones an `index.ts` happens to import. + */ + +export type PackageBuildMode = "build" | "watch"; + +/** + * Which part of `dist/src` one tsdown build writes. + * + * A production build writes `all` in one pass. Watch mode runs two builds side + * by side: `javascript`, rebuilt in milliseconds on every save - what an app + * reloads - and `declarations`, which waits on the TypeScript compiler and so + * must not hold the JavaScript back. + */ +export type PackageOutput = "all" | "declarations" | "javascript"; + +/** The tsdown API the CLI uses, loaded from the package being built. */ +export interface TsdownApi { + build: (config: InlineConfig) => Promise; +} + +export const PACKAGE_OUT_DIR = "dist"; +export const PACKAGE_TSCONFIG = "tsconfig.build.json"; +/** The only folder a package build writes to, and the only one it prunes. */ +const OUTPUT_ROOT = "src"; + +/** Tests, type tests and their fixtures never reach `dist`. */ +const isTestPath = (path: string): boolean => + /\.test(?:-d)?\.[cm]?[jt]sx?$/.test(path) || + path.startsWith("src/tests/") || + /(?:^|\/)__(?:tests|fixtures)__\//.test(path); + +/** Compiled into `dist`; everything else under `src` is copied as it is. */ +const SCRIPT = /\.[cm]?[jt]sx?$/; +const DECLARATION = /\.d\.[cm]?ts$/; +const DECLARATION_OUTPUT = /\.d\.[cm]?ts(?:\.map)?$/; + +const ownsFile = (output: PackageOutput, fileName: string): boolean => + output === "all" || + (output === "declarations") === DECLARATION_OUTPUT.test(fileName); + +/** Every file under `dir`, skipping dotfiles and dot-folders. */ +const walk = (dir: string, visit: (path: string) => void) => { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const entry of entries) { + if (entry.startsWith(".")) continue; + const path = join(dir, entry); + if (statSync(path).isDirectory()) walk(path, visit); + else visit(path); + } +}; + +const listSourceFiles = (root: string) => { + const files: string[] = []; + walk(join(root, OUTPUT_ROOT), path => { + const file = toDisplayPath(relative(root, path)); + if (!isTestPath(file)) files.push(file); + }); + + return files.sort(); +}; + +/** + * Every module a package build compiles, e.g. `src/routes.ts`: each `.ts` and + * `.tsx` file under `src` except declarations and tests. + * + * All of them, not only what an `index.ts` imports: the package's wildcard + * exports make each one public. + */ +export const listPackageEntries = (root: string): string[] => + listSourceFiles(root).filter( + file => /\.tsx?$/.test(file) && !DECLARATION.test(file), + ); + +/** + * The files under `src` a package ships as they are - locale JSON, CSS, email + * images - as paths relative to the package, e.g. `src/locales/en.json`. + * + * Compiled modules import them relative to themselves + * (`import("./en.json", { with: { type: "json" } })`), so each one is copied to + * the same place under `dist`. + */ +export const listPackageAssets = (root: string): string[] => + listSourceFiles(root).filter(file => !SCRIPT.test(file)); + +const isRelative = (specifier: string) => + specifier.startsWith("./") || specifier.startsWith("../"); + +const contentOf = (output: Rolldown.OutputAsset | Rolldown.OutputChunk) => + output.type === "chunk" ? output.code : output.source; + +const sameAsOnDisk = (path: string, content: string | Uint8Array) => { + if (!existsSync(path)) return false; + const current = readFileSync(path); + const next = typeof content === "string" ? Buffer.from(content) : content; + + return current.length === next.length && current.equals(next); +}; + +/** + * Deletes every file under `dir` this build owns but did not produce, then + * empty folders. A file the other watch build owns is never touched. + */ +const prune = ( + outDir: string, + dir: string, + keep: ReadonlySet, + owns: (fileName: string) => boolean, +) => { + walk(join(outDir, dir), path => { + const fileName = toDisplayPath(relative(outDir, path)); + if (owns(fileName) && !keep.has(fileName)) rmSync(path); + }); + + const removeEmpty = (current: string): boolean => { + let empty = true; + for (const entry of readdirSync(current)) { + const path = join(current, entry); + if (statSync(path).isDirectory() && removeEmpty(path)) continue; + empty = false; + } + if (empty && current !== join(outDir, dir)) rmdirSync(current); + + return empty; + }; + + if (existsSync(join(outDir, dir))) removeEmpty(join(outDir, dir)); +}; + +const SOURCE_MAP_COMMENT = /\n?\/\/# sourceMappingURL=\S+\s*$/; + +/** + * What VitNode adds on top of tsdown's own output: + * + * - Asset imports (`./en.json`, `./styles.css`) stay imports, and the files + * themselves are copied next to the compiled module. + * - Only files whose content changed are written, so an app watching `dist` + * reloads for the module that changed - not for every file in the package. + * - Files under `dist/src` the build no longer produces are deleted after a + * successful build, which handles deleted and renamed sources without ever + * emptying `dist` first. Nothing outside `dist/src` is touched, and a build + * writing one part of the output never deletes the other part. + * - Production builds ship declaration maps, but not JavaScript source maps: + * tsdown emits both together, and the JavaScript ones are dropped here. + */ +export const packageOutputPlugin = ({ + output = "all", + root, + sourcemaps, +}: { + output?: PackageOutput; + root: string; + sourcemaps: boolean; +}): Rolldown.Plugin => ({ + buildStart() { + if (output === "declarations") return; + for (const asset of listPackageAssets(root)) { + this.addWatchFile(join(root, asset)); + } + }, + generateBundle: { + handler(options, bundle) { + const outDir = options.dir ?? join(root, PACKAGE_OUT_DIR); + + if (output !== "declarations") { + for (const asset of listPackageAssets(root)) { + this.emitFile({ + fileName: asset, + source: readFileSync(join(root, asset)), + type: "asset", + }); + } + } + + if (!sourcemaps) { + for (const file of Object.values(bundle)) { + if (file.type !== "chunk" || DECLARATION.test(file.fileName)) + continue; + file.code = file.code.replace(SOURCE_MAP_COMMENT, "\n"); + delete bundle[`${file.fileName}.map`]; + } + } + + const produced = new Set(Object.keys(bundle)); + + for (const [fileName, file] of Object.entries(bundle)) { + if (sameAsOnDisk(join(outDir, fileName), contentOf(file))) { + delete bundle[fileName]; + } + } + + prune(outDir, OUTPUT_ROOT, produced, fileName => + ownsFile(output, fileName), + ); + }, + order: "post", + }, + name: "vitnode:package-output", + resolveId(source, importer) { + if (importer === undefined || !isRelative(source)) return null; + // Only a file that exists under exactly that name: `./hello.module` names + // `hello.module.ts`, a module, even though it reads like an extension. + const path = resolve(dirname(importer), source.split("?")[0]); + if (SCRIPT.test(path) || !existsSync(path) || !statSync(path).isFile()) + return null; + + return { external: true, id: source }; + }, +}); + +/** + * A tsdown logger that keeps only what a developer acts on. + * + * tsdown's own lists every entry it compiles - over a thousand for core - and + * clears the screen on each rebuild, which would wipe the type checker's output + * running in the same terminal. + */ +export const createPackageLogger = ( + write: (type: "error" | "info" | "warn", message: string) => void, +): Logger => { + // tsdown passes its config's name label first, `undefined` when unnamed. + const text = (messages: unknown[]) => + messages + .filter(message => message !== undefined) + .map(message => + message instanceof Error ? message.message : format(message), + ) + .join(" "); + const warned = new Set(); + + return { + clearScreen: () => undefined, + error: (...messages: unknown[]) => { + write("error", text(messages)); + }, + info: (...messages: unknown[]) => { + const message = text(messages); + if (/^(?:entry|target|tsconfig): /.test(message)) return; + write("info", message); + }, + level: "info", + success: (...messages: unknown[]) => { + write("info", text(messages)); + }, + warn: (...messages: unknown[]) => { + write("warn", text(messages)); + }, + warnOnce: (...messages: unknown[]) => { + const message = text(messages); + if (warned.has(message)) return; + warned.add(message); + write("warn", message); + }, + }; +}; + +/** + * The one tsdown configuration every VitNode package is built with. + * + * - `unbundle` with `root: "."`: one output file per source module, keeping + * the `dist/src/` prefix the package's exports point at. + * - Every package import stays external, exactly as written - React, Hono, + * Drizzle, `@vitnode/core`, workspace packages and dev dependencies alike. A + * package's dependencies belong to the app that installs it. + * - `@/` aliases are resolved through `tsconfig.build.json`, in the JavaScript + * and in the declarations, so no alias-rewriting pass runs afterwards. + * - Declarations come from the TypeScript compiler (no `isolatedDeclarations`), + * with every file the tsconfig includes - `global.d.ts` and the generated + * `types/api-registry.gen.d.ts` among them - so inferred types such as a + * fetcher's response come out the same as `tsc` would emit them. + * - No tree shaking, no `import.meta.glob` expansion and an `esnext` target: + * modules keep their side effects and syntax, and the app's bundler does the + * rest, as it did with SWC. + */ +export const packageTsdownConfig = ({ + logger, + mode, + output = "all", + root, +}: { + logger: Logger; + mode: PackageBuildMode; + output?: PackageOutput; + root: string; +}): InlineConfig => ({ + checks: { bundlerTimings: false }, + clean: false, + config: false, + customLogger: logger, + cwd: root, + deps: { neverBundle: true }, + dts: + output === "javascript" + ? false + : { eager: true, emitDtsOnly: output === "declarations" }, + entry: listPackageEntries(root), + fixedExtension: false, + format: "esm", + globImport: false, + inputOptions: options => ({ + ...options, + transform: { ...options.transform, jsx: "react-jsx" }, + }), + logLevel: "warn", + minify: mode === "build", + outDir: PACKAGE_OUT_DIR, + // The helper rolldown emits for `import * as ns` stays inside `dist/src`. + outputOptions: options => ({ + ...options, + virtualDirname: `${OUTPUT_ROOT}/_virtual`, + }), + platform: "neutral", + plugins: [ + packageOutputPlugin({ output, root, sourcemaps: mode === "watch" }), + ], + report: false, + root: ".", + sourcemap: mode === "watch", + target: "esnext", + treeshake: false, + tsconfig: PACKAGE_TSCONFIG, + unbundle: true, + watch: mode === "watch", +}); + +/** How long `watchPackage` waits for a burst of file changes - a branch switch - to settle. */ +const SETTLE_MS = 200; + +/** + * One part of a package's output in watch mode, kept running across changes + * to the set of files under `src`. + * + * tsdown rebuilds on every edit by itself. A file added or deleted is + * different: the entries are fixed when a build starts, so the build is + * replaced with one over the new list. tsdown can restart itself on a glob + * entry, but it re-reads the glob while its new watcher is not yet listening, + * and a file deleted in between leaves it failing on a missing entry until the + * next change - this watches `src` on its own instead, without gaps, and + * checks the list again after every start. + */ +export const watchPackage = async ({ + logger, + output, + root, + tsdown, +}: { + logger: Logger; + output: PackageOutput; + root: string; + tsdown: TsdownApi; +}): Promise<{ close: () => Promise }> => { + const sources = () => + JSON.stringify([listPackageEntries(root), listPackageAssets(root)]); + // A deleted module fails the running build on its missing entry a moment + // before the restart below replaces it - an error nobody has to act on. + const quiet: Logger = { + ...logger, + error: (...messages: unknown[]) => { + const text = messages + .map(message => (message instanceof Error ? message.message : message)) + .join(" "); + if (!text.includes("UNRESOLVED_ENTRY")) logger.error(...messages); + }, + }; + let current: null | TsdownHandle = null; + let built = ""; + let closed = false; + + const sync = async () => { + while (!closed && sources() !== built) { + built = sources(); + await current?.watch.close(); + current = null; + if (closed) return; + current = await tsdown.build( + packageTsdownConfig({ logger: quiet, mode: "watch", output, root }), + ); + } + }; + + // Every restart runs after the previous one, so two never overlap. + let pending: Promise = Promise.resolve(); + let timer: NodeJS.Timeout | undefined; + const watcher: FSWatcher = watch( + join(root, OUTPUT_ROOT), + { recursive: true }, + () => { + clearTimeout(timer); + timer = setTimeout(() => { + pending = pending.then(sync).catch((error: unknown) => { + quiet.error(error); + }); + }, SETTLE_MS); + }, + ); + + try { + await sync(); + } catch (error) { + watcher.close(); + throw error; + } + + return { + close: async () => { + closed = true; + clearTimeout(timer); + watcher.close(); + await pending; + await current?.watch.close(); + }, + }; +}; diff --git a/packages/vitnode/scripts/cli/builder/scripts-build.test.ts b/packages/vitnode/scripts/cli/builder/scripts-build.test.ts new file mode 100644 index 0000000000..a5b5bc0f2e --- /dev/null +++ b/packages/vitnode/scripts/cli/builder/scripts-build.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment node +import { execFileSync, spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { build } from "tsdown"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import config from "../../../tsdown.config"; + +const packageRoot = join(import.meta.dirname, "..", "..", ".."); +const manifest = JSON.parse( + readFileSync(join(packageRoot, "package.json"), "utf8"), +) as { version: string }; + +let root: string; + +const write = (path: string, content: string) => { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), content); +}; + +afterEach(() => { + rmSync(root, { force: true, recursive: true }); +}); + +describe("the scripts build (tsdown.config.ts)", () => { + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-scripts-build-")); + }); + + it("replaces dist/scripts and leaves the package build's dist/src alone", async () => { + write("package.json", JSON.stringify({ name: "fixture", type: "module" })); + write("scripts/scripts.ts", 'console.log("cli");\n'); + write("scripts/package-watch.ts", 'console.log("runner");\n'); + write("dist/scripts/stale-chunk.js", "// from an older build\n"); + write("dist/src/index.js", "export const kept = true;\n"); + write("dist/tsconfig.build.tsbuildinfo", "{}"); + + await build({ ...config, config: false, cwd: root, logLevel: "silent" }); + + expect(existsSync(join(root, "dist/scripts/scripts.js"))).toBe(true); + expect(existsSync(join(root, "dist/scripts/package-watch.js"))).toBe(true); + expect(existsSync(join(root, "dist/scripts/stale-chunk.js"))).toBe(false); + expect(readFileSync(join(root, "dist/src/index.js"), "utf8")).toBe( + "export const kept = true;\n", + ); + expect(existsSync(join(root, "dist/tsconfig.build.tsbuildinfo"))).toBe( + true, + ); + }, 60_000); +}); + +describe("the built CLI", () => { + beforeEach(() => { + // Inside the package, so the bundle's external dependencies - commander, + // dotenv, jiti - resolve from its node_modules as they do once installed. + const cache = join(packageRoot, "node_modules", ".cache"); + mkdirSync(cache, { recursive: true }); + root = mkdtempSync(join(cache, "vitnode-built-cli-")); + }); + + const buildCli = async () => { + const outDir = join(root, "dist", "scripts"); + await build({ + ...config, + clean: [outDir], + config: false, + cwd: packageRoot, + logLevel: "silent", + outDir, + }); + + return outDir; + }; + + it("runs from the compiled output, not only through a TypeScript runner", async () => { + const outDir = await buildCli(); + + expect( + execFileSync( + process.execPath, + [join(outDir, "scripts.js"), "--version"], + { + encoding: "utf8", + }, + ).trim(), + ).toBe(manifest.version); + expect( + execFileSync(process.execPath, [join(outDir, "scripts.js"), "--help"], { + encoding: "utf8", + }), + ).toContain("vitnode [options]"); + // Minified, ESM, and free of `require` - nothing needs a CommonJS shim. + const entry = readFileSync(join(outDir, "scripts.js"), "utf8"); + expect(entry.startsWith("#!/usr/bin/env node\n")).toBe(true); + expect(entry).toMatch(/^import\b/m); + expect(entry).not.toMatch(/\brequire\(/); + }, 60_000); + + it("ships the watch runner next to it, which reports a missing tsdown and exits", async () => { + const outDir = await buildCli(); + const project = mkdtempSync(join(tmpdir(), "vitnode-no-tsdown-")); + writeFileSync(join(project, "package.json"), "{}"); + + try { + const result = spawnSync( + process.execPath, + [join(outDir, "package-watch.js")], + // pnpm's bin shims put the workspace's whole store on NODE_PATH, + // where tsdown would be found; a project of its own has no such path. + { + cwd: project, + encoding: "utf8", + env: { ...process.env, NODE_PATH: "" }, + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Could not find "tsdown"'); + expect(result.stderr).toContain("pnpm add -D tsdown"); + } finally { + rmSync(project, { force: true, recursive: true }); + } + }, 60_000); +}); diff --git a/packages/vitnode/scripts/cli/commands/build.ts b/packages/vitnode/scripts/cli/commands/build.ts index 680197c0eb..f1235809ac 100644 --- a/packages/vitnode/scripts/cli/commands/build.ts +++ b/packages/vitnode/scripts/cli/commands/build.ts @@ -1,5 +1,5 @@ import type { AppBuildOptions } from "../builder/app-build"; -import type { StepRunner } from "../builder/compiler-build"; +import type { LoadTsdown, StepRunner } from "../builder/compiler-build"; import type { MeasuredFile } from "../builder/output-files"; import type { BuildRoute } from "../builder/routes"; import type { CommandContext, Env, OutputOptions } from "../context"; @@ -11,9 +11,9 @@ import { runAppBuild } from "../builder/app-build"; import { apiBuildSteps, listOutput, - packageBuildSteps, prepareRegistryStep, runCompilerSteps, + runPackageBuild, } from "../builder/compiler-build"; import { createPackageOwner } from "../builder/package-owner"; import { @@ -45,6 +45,7 @@ export interface BuildOptions extends OutputOptions { export interface BuildDeps { appBuild?: Partial>; + loadTsdown?: LoadTsdown; now?: () => number; runStep?: StepRunner; } @@ -152,10 +153,10 @@ export const runBuildCommand = async ( if (project.kind === "package") { ui.header(`Building ${project.name}`); if (isPluginPackage(project.root)) prepareRegistryStep(project, ui); - await runCompilerSteps({ + await runPackageBuild({ + loadTsdown: deps.loadTsdown, project, runStep: deps.runStep, - steps: packageBuildSteps(), ui, }); } else if ( diff --git a/packages/vitnode/scripts/cli/commands/dev.test.ts b/packages/vitnode/scripts/cli/commands/dev.test.ts index 922318774e..e295565120 100644 --- a/packages/vitnode/scripts/cli/commands/dev.test.ts +++ b/packages/vitnode/scripts/cli/commands/dev.test.ts @@ -83,12 +83,17 @@ const until = async (check: () => boolean) => { describe("vitnode dev in a plugin package", () => { beforeEach(() => { - write("package.json", JSON.stringify({ name: "@acme/blog" })); + write( + "package.json", + JSON.stringify({ + exports: { "./*": "./dist/src/*.js" }, + name: "@acme/blog", + }), + ); write("tsconfig.build.json", "{}"); - write(".swcrc", "{}"); }); - it("runs the three compilers in watch mode and stops them on Ctrl+C", async () => { + it("runs tsdown and the type checker in watch mode and stops them on Ctrl+C", async () => { const group = fakeGroup(); const { context, runtime } = createTestContext({ cwd: root }); @@ -98,12 +103,19 @@ describe("vitnode dev in a plugin package", () => { expect(await running).toBe(0); expect(group.stop).toHaveBeenCalledOnce(); + expect(group.spawned[0].args.slice(1)).toEqual([ + "--noEmit", + "-p", + "tsconfig.build.json", + "-w", + "--preserveWatchOutput", + ]); + // tsdown, the JavaScript and the declarations each in a runner of its own. expect( - group.spawned.map(options => options.args.slice(1).join(" ")), + group.spawned.slice(1).map(options => options.args.slice(-2)), ).toEqual([ - "-w -p tsconfig.build.json --preserveWatchOutput", - "src -d dist --config-file .swcrc --copy-files -w", - "-w -p tsconfig.build.json", + [expect.stringMatching(/package-watch\.(?:js|ts)$/), "javascript"], + [expect.stringMatching(/package-watch\.(?:js|ts)$/), "declarations"], ]); // Run by Node itself, never through a shell. expect( diff --git a/packages/vitnode/scripts/cli/commands/dev.ts b/packages/vitnode/scripts/cli/commands/dev.ts index 43e30460a7..50bfd5539f 100644 --- a/packages/vitnode/scripts/cli/commands/dev.ts +++ b/packages/vitnode/scripts/cli/commands/dev.ts @@ -17,6 +17,7 @@ import { toProcess, } from "../dev/watchers"; import { loadConfiguredPluginIds } from "../plugins/discover"; +import { resolveFromProject } from "../project/packages"; import { detectProject } from "../project/project"; import { detectRuntime } from "../project/runtime"; import { plural } from "../ui/format"; @@ -47,8 +48,8 @@ const ownsDatabase = (project: Project) => * * - An app: database bootstrap (when it owns the schema), then Vite. * - An API: database bootstrap, then `tsx watch`. - * - A plugin package: its compilers in watch mode, so the apps importing its - * `dist` reload as it changes - what `vitnode dev` has always done there. + * - A plugin package: tsdown and `tsc` in watch mode, so the apps importing + * its `dist` reload as it changes - what `vitnode dev` has always done there. */ export const runDevCommand = async ( context: CommandContext, @@ -60,6 +61,9 @@ export const runDevCommand = async ( if (project.kind === "package") { ui.header(`Plugin development - ${project.name}`); + // The runner loads tsdown from the package; a missing install is a + // configuration error here, not a watcher crashing a moment later. + resolveFromProject(project.root, "tsdown"); if (writePluginApiRegistry(project.root)) ui.success("API registry generated"); ui.info( diff --git a/packages/vitnode/scripts/cli/commands/plugin.test.ts b/packages/vitnode/scripts/cli/commands/plugin.test.ts index 3165c153de..ac607b3c30 100644 --- a/packages/vitnode/scripts/cli/commands/plugin.test.ts +++ b/packages/vitnode/scripts/cli/commands/plugin.test.ts @@ -1,15 +1,11 @@ // @vitest-environment node -import { transformFile } from "@swc/core"; import { createJiti } from "jiti"; import { cpSync, existsSync, mkdirSync, mkdtempSync, - readdirSync, - readFileSync, rmSync, - statSync, symlinkSync, writeFileSync, } from "node:fs"; @@ -21,6 +17,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { UserError, ValidationError } from "../errors"; import { validatePlugin } from "../plugins/validate"; import { createTestContext } from "../testing"; +import { runBuildCommand } from "./build"; import { runPluginListCommand } from "./plugin-list"; import { runPluginValidateCommand } from "./plugin-validate"; @@ -79,28 +76,30 @@ afterEach(() => { /** * The contract between `create-vitnode-app --plugin` and this CLI, checked * end to end: scaffold a plugin exactly as `create-vitnode-app` writes it, - * compile it with the `.swcrc` it ships - the same compiler step - * `vitnode build` runs - and hand the output to the validator - * `vitnode plugin validate` uses, which loads it through VitNode's real - * plugin, route and API loaders. + * build it with `vitnode build` - tsdown, through the configuration every + * package gets - and hand the output to the validator `vitnode plugin + * validate` uses, which loads it through VitNode's real plugin, route and API + * loaders. */ describe("a generated plugin", () => { - let loadRoot: string; + let source: string; beforeEach(() => { - // Loaded from inside the package, so the compiled plugin resolves `hono` - // and friends from core's own node_modules. Compiled elsewhere, because - // swc leaves path aliases alone in files under node_modules. - const cache = join(packageRoot, "node_modules", ".cache"); - mkdirSync(cache, { recursive: true }); - loadRoot = mkdtempSync(join(cache, "vitnode-generated-plugin-")); + // Outside any node_modules folder, as a plugin in a workspace is; its own + // node_modules is core's, where hono, React and tsdown are installed. + source = mkdtempSync(join(tmpdir(), "vitnode-generated-plugin-")); + symlinkSync( + join(packageRoot, "node_modules"), + join(source, "node_modules"), + "junction", + ); }); afterEach(() => { - rmSync(loadRoot, { force: true, recursive: true }); + rmSync(source, { force: true, recursive: true }); }); - it("compiles and passes validation through the real plugin loaders", async () => { + it("builds and passes validation through the real plugin loaders", async () => { const creator = join(packageRoot, "..", "create-vitnode-app"); const { pluginPackageExports, pluginRouteScaffold } = (await import( pathToFileURL( @@ -110,55 +109,43 @@ describe("a generated plugin", () => { pluginPackageExports: () => Record; pluginRouteScaffold: (name: string) => Record; }; - const source = join(root, "plugins", "blog"); cpSync(join(creator, "copy-of-vitnode-plugin", "root"), source, { recursive: true, }); for (const [file, content] of Object.entries( pluginRouteScaffold("@acme/blog"), )) { - write(join("plugins", "blog", file), content); + mkdirSync(dirname(join(source, file)), { recursive: true }); + writeFileSync(join(source, file), content); } - write("plugins/blog/package.json", { - exports: pluginPackageExports(), - name: "@acme/blog", - type: "module", - version: "0.1.0", - }); - const plugin = join(loadRoot, "blog"); - const swcrc = JSON.parse( - readFileSync(join(source, ".swcrc"), "utf8"), - ) as Record & { - jsc: Record; - }; + writeFileSync( + join(source, "package.json"), + JSON.stringify({ + exports: pluginPackageExports(), + name: "@acme/blog", + type: "module", + version: "0.1.0", + }), + ); - const compile = async (dir: string) => { - for (const entry of readdirSync(join(source, dir))) { - const file = join(source, dir, entry); - const target = join(source, "dist", dir, entry); - if (statSync(file).isDirectory()) { - await compile(join(dir, entry)); - continue; - } - mkdirSync(dirname(target), { recursive: true }); - if (entry.endsWith(".json")) { - cpSync(file, target); - continue; - } - if (entry.includes(".test.")) continue; - const { code } = await transformFile(file, { - ...swcrc, - $schema: undefined, - exclude: undefined, - filename: file, - jsc: { ...swcrc.jsc, baseUrl: source }, - swcrc: false, - } as Parameters[1]); - writeFileSync(target.replace(/\.tsx?$/, ".js"), code); - } - }; - await compile("src"); - cpSync(source, plugin, { recursive: true }); + const { context } = createTestContext({ cwd: source }); + // The type check needs `@vitnode/core`'s own build output to resolve its + // types, which a unit run does not have - so it is the one step left out. + // A failing type check failing the build is covered in package-build.test. + expect( + await runBuildCommand( + context, + {}, + { runStep: async () => await Promise.resolve({ code: 0, output: "" }) }, + ), + ).toBe(0); + expect(existsSync(join(source, "dist/src/config.js"))).toBe(true); + expect(existsSync(join(source, "dist/src/locales/en.json"))).toBe(true); + expect( + existsSync( + join(source, "dist/src/api/modules/hello/hello.module.test.js"), + ), + ).toBe(false); const jiti = createJiti(import.meta.url, { alias: { "@/": `${coreSrc}/`, "@vitnode/core/": `${coreSrc}/` }, @@ -166,7 +153,7 @@ describe("a generated plugin", () => { moduleCache: false, }); - const result = await validatePlugin(plugin, { + const result = await validatePlugin(source, { importModule: async file => jiti.import(file), }); diff --git a/packages/vitnode/scripts/cli/dev/package-watch.test.ts b/packages/vitnode/scripts/cli/dev/package-watch.test.ts new file mode 100644 index 0000000000..c32de411f5 --- /dev/null +++ b/packages/vitnode/scripts/cli/dev/package-watch.test.ts @@ -0,0 +1,145 @@ +// @vitest-environment node +import type { ChildProcess } from "node:child_process"; + +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { RunProcessOptions } from "../project/processes"; + +import { runDevCommand } from "../commands/dev"; +import { ProcessGroup } from "../project/processes"; +import { createTestContext } from "../testing"; + +const packageRoot = join(import.meta.dirname, "..", "..", ".."); +const template = join( + packageRoot, + "..", + "create-vitnode-app", + "copy-of-vitnode-plugin", + "root", +); + +let root: string; + +const write = (path: string, content: string) => { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), content); +}; + +const read = (path: string) => + existsSync(join(root, path)) ? readFileSync(join(root, path), "utf8") : ""; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-package-watch-")); + symlinkSync( + join(packageRoot, "node_modules"), + join(root, "node_modules"), + "junction", + ); + cpSync(join(template, "tsconfig.json"), join(root, "tsconfig.json")); + cpSync( + join(template, "tsconfig.build.json"), + join(root, "tsconfig.build.json"), + ); + write( + "package.json", + JSON.stringify({ + exports: { "./*": "./dist/src/*.js" }, + name: "@acme/watched", + type: "module", + }), + ); + write("src/index.ts", 'export const version = "one";\n'); + write("src/locales/en.json", '{ "hello": "Hi" }\n'); +}); + +afterEach(() => { + rmSync(root, { force: true, recursive: true }); +}); + +/** Resolves once `check` passes, failing the test after `timeout`. */ +const until = async (check: () => boolean, timeout = 30_000) => { + const deadline = Date.now() + timeout; + while (!check()) { + if (Date.now() > deadline) throw new Error("condition never became true"); + await new Promise(resolve => setTimeout(resolve, 100)); + } +}; + +/** The real process group, remembering the process ids it started. */ +class RecordingGroup extends ProcessGroup { + readonly pids: (number | undefined)[] = []; + + override spawn(options: RunProcessOptions): ChildProcess { + const child = super.spawn(options); + this.pids.push(child.pid); + + return child; + } +} + +const isRunning = (pid: number | undefined) => { + if (pid === undefined) return false; + try { + process.kill(pid, 0); + + return true; + } catch { + return false; + } +}; + +describe("vitnode dev in a plugin package", () => { + it("builds from an empty dist, follows every change, recovers from errors and leaves no process behind", async () => { + const group = new RecordingGroup(); + const { context, runtime } = createTestContext({ cwd: root }); + + const running = runDevCommand(context, {}, { group }); + + // The first build, JavaScript and declarations, into a dist that did not exist. + await until(() => read("dist/src/index.js").includes("one")); + await until(() => read("dist/src/index.d.ts").includes("version")); + expect(read("dist/src/locales/en.json")).toBe('{ "hello": "Hi" }\n'); + // Development output is readable and points back at the source. + expect(read("dist/src/index.js")).toContain( + "sourceMappingURL=index.js.map", + ); + + write("src/index.ts", 'export const version = "two";\n'); + await until(() => read("dist/src/index.js").includes("two")); + + write("src/added.ts", "export const added = true;\n"); + await until(() => read("dist/src/added.js").includes("added")); + + rmSync(join(root, "src/added.ts")); + await until(() => !existsSync(join(root, "dist/src/added.js"))); + + write("src/locales/en.json", '{ "hello": "Hello" }\n'); + await until(() => read("dist/src/locales/en.json").includes("Hello")); + + // A broken save is reported and leaves the last good output in place; the + // next good one is built as usual. + write("src/index.ts", "export const version = ;\n"); + await new Promise(resolve => setTimeout(resolve, 1000)); + expect(read("dist/src/index.js")).toContain("two"); + write("src/index.ts", 'export const version = "three";\n'); + await until(() => read("dist/src/index.js").includes("three")); + + runtime.signals.emit("SIGINT"); + + expect(await running).toBe(0); + expect(group.pids).toHaveLength(3); + expect(group.pids.filter(isRunning)).toEqual([]); + }, 180_000); +}); diff --git a/packages/vitnode/scripts/cli/dev/watchers.ts b/packages/vitnode/scripts/cli/dev/watchers.ts index feb8572113..2634e2a2b6 100644 --- a/packages/vitnode/scripts/cli/dev/watchers.ts +++ b/packages/vitnode/scripts/cli/dev/watchers.ts @@ -1,10 +1,13 @@ -import { join } from "node:path"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { RunProcessOptions, SignalSource } from "../project/processes"; import type { Project } from "../project/project"; import type { Runtime } from "../project/runtime"; import type { Ui } from "../ui/ui"; +import { typeCheckStep } from "../builder/compiler-build"; import { EXIT_CODE, RuntimeError } from "../errors"; import { resolveBin } from "../project/packages"; import { ProcessGroup, waitForShutdownSignal } from "../project/processes"; @@ -16,28 +19,36 @@ export interface Watcher { bin: null | { name: string; package: string }; } -/** The plugin package's three compilers, each in watch mode. */ +/** + * How to start the package watch runner: the built `package-watch.js` next to + * the CLI bundle, or - when the CLI itself runs from TypeScript, as in its own + * tests - the runner's source through tsx. + */ +export const packageWatchRunner = (): string[] => { + const here = dirname(fileURLToPath(import.meta.url)); + const built = join(here, "package-watch.js"); + + return existsSync(built) + ? [built] + : ["--import", "tsx", join(here, "..", "..", "package-watch.ts")]; +}; + +/** + * A plugin package in development: `tsc` reporting type errors, and tsdown + * rebuilding `dist/src` as sources change - the JavaScript and the + * declarations in a process each, so the JavaScript an app reloads never + * waits for the TypeScript compiler. + * + * tsdown runs in child processes - the runner above - rather than in the CLI: + * stopping the group then ends every watcher it started. + */ export const packageWatchers = (): Watcher[] => [ { - args: ["-w", "-p", "tsconfig.build.json", "--preserveWatchOutput"], + args: typeCheckStep(true).args, bin: { name: "tsc", package: "typescript" }, }, - { - args: [ - "src", - "-d", - "dist", - "--config-file", - ".swcrc", - "--copy-files", - "-w", - ], - bin: { name: "swc", package: "@swc/cli" }, - }, - { - args: ["-w", "-p", "tsconfig.build.json"], - bin: { name: "tsc-alias", package: "tsc-alias" }, - }, + { args: [...packageWatchRunner(), "javascript"], bin: null }, + { args: [...packageWatchRunner(), "declarations"], bin: null }, ]; /** diff --git a/packages/vitnode/scripts/cli/project/packages.ts b/packages/vitnode/scripts/cli/project/packages.ts index a19f7dcf64..c985a6d41e 100644 --- a/packages/vitnode/scripts/cli/project/packages.ts +++ b/packages/vitnode/scripts/cli/project/packages.ts @@ -110,17 +110,17 @@ export const resolveBin = ( export const importFromProject = async ( root: string, specifier: string, -): Promise => { - let file: string; +): Promise => + (await import(pathToFileURL(resolveFromProject(root, specifier)).href)) as T; +/** Where `specifier` resolves from the project, or a `ConfigError` saying how to install it. */ +export const resolveFromProject = (root: string, specifier: string): string => { try { - file = createRequire(join(root, "package.json")).resolve(specifier); + return createRequire(join(root, "package.json")).resolve(specifier); } catch (error) { throw new ConfigError(`Could not find "${specifier}" from ${root}.`, { cause: error, hint: `Install it in the project, e.g. pnpm add -D ${specifier}`, }); } - - return (await import(pathToFileURL(file).href)) as T; }; diff --git a/packages/vitnode/scripts/cli/project/project.ts b/packages/vitnode/scripts/cli/project/project.ts index 2a90b7f791..a280afc198 100644 --- a/packages/vitnode/scripts/cli/project/project.ts +++ b/packages/vitnode/scripts/cli/project/project.ts @@ -11,8 +11,8 @@ import { findPackageRoot, readPackageJson } from "./packages"; * * - `app` - a TanStack Start application, built and served by Vite. * - `api` - a standalone Hono API, compiled by `tsc` and run by Node. - * - `package` - a plugin or adapter package, compiled into `dist` for apps to - * import. This is what `vitnode build` and `vitnode dev` have always meant + * - `package` - a plugin or adapter package, compiled by tsdown into `dist` + * for apps to import. This is what `vitnode build` and `vitnode dev` have always meant * inside one, and they still do. */ export type ProjectKind = "api" | "app" | "package"; @@ -55,12 +55,25 @@ const firstExisting = (root: string, files: readonly string[]) => { return found === undefined ? null : join(root, found); }; +/** + * Whether a package's `exports` point into `dist/src` - the folder `vitnode + * build` writes. Together with `tsconfig.build.json` that is what makes a + * folder a VitNode package; a tool that only compiles with `tsc` (as + * `create-vitnode-app` does) exports nothing from there. + */ +const exportsBuildOutput = (exports: unknown): boolean => { + if (typeof exports === "string") return exports.startsWith("./dist/src/"); + if (exports === null || typeof exports !== "object") return false; + + return Object.values(exports).some(exportsBuildOutput); +}; + export const kindOf = (root: string): null | ProjectKind => { if (firstExisting(root, VITE_CONFIGS) !== null) return "app"; if (existsSync(join(root, "src", "vitnode.api.config.ts"))) return "api"; if ( existsSync(join(root, "tsconfig.build.json")) && - existsSync(join(root, ".swcrc")) + exportsBuildOutput(readPackageJson(root)?.exports) ) { return "package"; } @@ -91,7 +104,7 @@ export const detectProject = (cwd: string): Project => { { details: [ "An app has a vite.config.ts, an API has src/vitnode.api.config.ts,", - "and a plugin package has tsconfig.build.json and .swcrc.", + "and a plugin package has tsconfig.build.json and exports from ./dist/src/.", ], hint: isWorkspaceRoot(root, packageJson) ? "This is a workspace root - run the command inside one of its apps, e.g. cd apps/web." diff --git a/packages/vitnode/scripts/package-watch.ts b/packages/vitnode/scripts/package-watch.ts new file mode 100644 index 0000000000..adc687368a --- /dev/null +++ b/packages/vitnode/scripts/package-watch.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import color from "picocolors"; + +import type { PackageOutput, TsdownApi } from "./cli/builder/package-build.js"; + +import { + createPackageLogger, + watchPackage, +} from "./cli/builder/package-build.js"; +import { errorMessage, isCliError } from "./cli/errors.js"; +import { importFromProject } from "./cli/project/packages.js"; + +/** + * `vitnode dev` in a plugin package runs this file as child processes: tsdown + * in watch mode over the package in the working directory, for the output + * named by the first argument. + * + * `javascript` is what an app reloads, rebuilt within milliseconds of a save; + * `declarations` follow once the TypeScript compiler has caught up. They run + * as two processes because the compiler blocks the thread it runs on - in one + * process, every JavaScript rebuild would wait for the declarations. + * + * It runs until it is stopped. A compile error is printed and the next change + * rebuilds; only a failure to start at all - tsdown missing, a broken + * tsconfig - exits, with a non-zero code `vitnode dev` reports. + */ +const root = process.cwd(); +const output: PackageOutput = + process.argv[2] === "declarations" ? "declarations" : "javascript"; + +const label = + color.cyan("tsdown") + + color.dim(output === "declarations" ? " types" : " js "); + +const print = (type: "error" | "info" | "warn", message: string) => { + if (type === "info") process.stdout.write(`${label} ${message}\n`); + else if (type === "warn") + process.stderr.write(`${label} ${color.yellow(message)}\n`); + else process.stderr.write(`${label} ${color.red(message)}\n`); +}; + +try { + const tsdown = await importFromProject(root, "tsdown"); + const watcher = await watchPackage({ + logger: createPackageLogger(print), + output, + root, + tsdown, + }); + + const stop = () => { + void watcher.close().finally(() => process.exit(0)); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); +} catch (error) { + print("error", errorMessage(error)); + if (isCliError(error) && error.hint !== undefined) print("error", error.hint); + process.exit(1); +} diff --git a/packages/vitnode/src/api/lib/ai/alt.integration.test.ts b/packages/vitnode/src/api/lib/ai/alt.integration.test.ts deleted file mode 100644 index 7053b1ec3c..0000000000 --- a/packages/vitnode/src/api/lib/ai/alt.integration.test.ts +++ /dev/null @@ -1,415 +0,0 @@ -// @vitest-environment node -import type { Context } from "hono"; - -import { MockLanguageModelV4 } from "ai/test"; -import { and, eq } from "drizzle-orm"; -import { createHash } from "node:crypto"; -import { afterAll, beforeAll, beforeEach, expect, it } from "vitest"; - -import type { EnvVitNode } from "@/api/middlewares/global.middleware"; -import type { AIModelDefinition } from "@/api/models/ai"; -import type { TestDatabaseHandle } from "@/tests/postgres"; - -import { QueueDeferError } from "@/api/lib/queue"; -import { AIModel } from "@/api/models/ai"; -import { QueueModel } from "@/api/models/queue"; -import { processQueueTasks } from "@/api/modules/queue/helpers/process-queue-tasks"; -import * as aiTables from "@/database/ai"; -import { core_ai_runs, core_ai_settings } from "@/database/ai"; -import * as filesTables from "@/database/files"; -import { - core_files, - core_files_alt, - core_files_alt_analysis, - core_files_alt_state, -} from "@/database/files"; -import * as languagesTables from "@/database/languages"; -import { core_languages } from "@/database/languages"; -import * as queueTables from "@/database/queue"; -import { core_queue } from "@/database/queue"; -import * as rolesTables from "@/database/roles"; -import * as usersTables from "@/database/users"; -import { createTestCache } from "@/tests/cache"; -import { createTestDatabase, describePostgres } from "@/tests/postgres"; - -import { - ALT_QUEUE, - ALT_TASK_NAME, - detectMissingAlt, - enqueueAltGeneration, - processAltForFile, -} from "./alt"; -import { altGenerateAiAction, altTranslateAiAction } from "./alt-actions"; -import { collectAiActions } from "./registry"; - -const generatedText = (text: string) => ({ - content: [{ text, type: "text" as const }], - finishReason: { raw: "stop", unified: "stop" as const }, - usage: { - inputTokens: { cacheRead: 0, cacheWrite: 0, noCache: 100, total: 100 }, - outputTokens: { reasoning: 0, text: 10, total: 10 }, - }, - warnings: [], -}); - -const createModel = () => { - const calls = { translate: [] as string[], vision: 0 }; - const model = new MockLanguageModelV4({ - doGenerate: async options => { - const prompt = JSON.stringify(options.prompt); - if (prompt.includes('"type":"file"')) { - calls.vision += 1; - - return Promise.resolve(generatedText("A red bicycle.")); - } - const language = /into ([A-Za-z]+)/.exec(prompt)?.[1] ?? "?"; - calls.translate.push(language); - - return Promise.resolve(generatedText(`[${language}] A red bicycle.`)); - }, - }); - - return { calls, model }; -}; - -describePostgres("automatic ALT (real PostgreSQL)", () => { - let database: TestDatabaseHandle; - let calls: ReturnType["calls"]; - let models: AIModelDefinition[]; - let bytes = Buffer.from("first version of the image"); - const cache = createTestCache(); - const aiActions = collectAiActions([ - { - aiActions: [altGenerateAiAction, altTranslateAiAction], - pluginId: "@vitnode/core", - }, - ]); - - const context = (): Context => { - const store = new Map(); - const c = { - get: (key: string) => store.get(key), - set: (key: string, value: unknown) => store.set(key, value), - } as unknown as Context; - store.set("db", database.db); - store.set("cache", cache); - store.set("core", { - ai: { models }, - aiActions, - queue: [ - { - handler: async ( - ctx: Context, - payload: { fileId: number }, - ) => await processAltForFile(ctx, payload.fileId), - leaseSeconds: 900, - maxAttempts: 3, - module: "ai", - name: ALT_TASK_NAME, - pluginId: "@vitnode/core", - }, - ], - }); - store.set("events", { emit: async () => await Promise.resolve() }); - store.set("log", { error: async () => {}, warn: async () => {} }); - store.set("queue", new QueueModel(c)); - store.set("storage", { - readBytes: async () => await Promise.resolve(bytes), - }); - store.set("user", null); - store.set("admin", null); - store.set("ai", new AIModel(c)); - - return c; - }; - - const insertFile = async ( - overrides: Partial = {}, - ) => { - const [file] = await database.db - .insert(core_files) - .values({ - folder: "test", - key: `key-${Math.random()}`, - mimeType: "image/png", - name: "bike.png", - size: 10, - ...overrides, - }) - .returning({ id: core_files.id }); - - return file.id; - }; - - const altsOf = async (fileId: number) => - Object.fromEntries( - ( - await database.db - .select() - .from(core_files_alt) - .where(eq(core_files_alt.fileId, fileId)) - ).map(row => [row.languageCode, { origin: row.origin, text: row.text }]), - ); - - beforeAll(async () => { - database = await createTestDatabase({ - ...aiTables, - ...filesTables, - ...languagesTables, - ...queueTables, - ...rolesTables, - ...usersTables, - }); - await database.db.insert(core_languages).values([ - { code: "en", default: true, name: "English" }, - { code: "pl", name: "Polski" }, - ]); - }, 60_000); - - beforeEach(async () => { - const created = createModel(); - calls = created.calls; - models = [ - { - capabilities: ["text", "image-input"], - id: "vision", - model: created.model, - name: "Vision", - pricing: { rates: { inputPerMillion: "1", outputPerMillion: "2" } }, - }, - ]; - bytes = Buffer.from("first version of the image"); - await database.db - .insert(core_ai_settings) - .values({ altEnabled: true, id: 1, monthlyBudgetUsd: "100" }) - .onConflictDoUpdate({ - set: { - altEnabled: true, - monthlyBudgetUsd: "100", - systemConcurrency: 2, - }, - target: core_ai_settings.id, - }); - }); - - afterAll(async () => { - await database?.drop(); - }); - - it("describes an image once and translates it into every other language", async () => { - const fileId = await insertFile(); - - const result = await processAltForFile(context(), fileId); - - expect(result.written.sort()).toEqual(["en", "pl"]); - expect(calls).toEqual({ translate: ["Polish"], vision: 1 }); - expect(await altsOf(fileId)).toEqual({ - en: { origin: "ai", text: "A red bicycle." }, - pl: { origin: "ai", text: "[Polish] A red bicycle." }, - }); - const runs = await database.db - .select({ - actorType: core_ai_runs.actorType, - userId: core_ai_runs.userId, - }) - .from(core_ai_runs) - .where(eq(core_ai_runs.resourceId, String(fileId))); - expect(runs).toEqual([ - { actorType: "system", userId: null }, - { actorType: "system", userId: null }, - ]); - }); - - it("never overwrites a person's text, even an intentionally empty one", async () => { - const fileId = await insertFile(); - await database.db.insert(core_files_alt).values({ - fileId, - languageCode: "pl", - origin: "human", - text: "", - }); - - await processAltForFile(context(), fileId); - - expect(await altsOf(fileId)).toEqual({ - en: { origin: "ai", text: "A red bicycle." }, - pl: { origin: "human", text: "" }, - }); - expect(calls.translate).toEqual([]); - }); - - it("fills only the new language when one is added, reusing the analysis", async () => { - const fileId = await insertFile(); - await processAltForFile(context(), fileId); - await database.db - .insert(core_languages) - .values({ code: "de", name: "Deutsch" }); - const before = { ...calls, translate: [...calls.translate] }; - - await processAltForFile(context(), fileId); - - expect(calls.vision).toBe(before.vision); - expect(calls.translate).toEqual([...before.translate, "German"]); - expect((await altsOf(fileId)).de).toEqual({ - origin: "ai", - text: "[German] A red bicycle.", - }); - await database.db - .delete(core_languages) - .where(eq(core_languages.code, "de")); - }); - - it("re-describes a changed file, keeping what people wrote", async () => { - const fileId = await insertFile(); - await processAltForFile(context(), fileId); - await database.db - .update(core_files_alt) - .set({ origin: "human", text: "Mój rower" }) - .where( - and( - eq(core_files_alt.fileId, fileId), - eq(core_files_alt.languageCode, "pl"), - ), - ); - - bytes = Buffer.from("second version of the image"); - await database.db - .update(core_files) - .set({ fingerprint: createHash("sha256").update(bytes).digest("hex") }) - .where(eq(core_files.id, fileId)); - await processAltForFile(context(), fileId); - - expect(calls.vision).toBe(2); - const analyses = await database.db - .select() - .from(core_files_alt_analysis) - .where(eq(core_files_alt_analysis.fileId, fileId)); - expect(analyses).toHaveLength(2); - expect(await altsOf(fileId)).toEqual({ - en: { origin: "ai", text: "A red bicycle." }, - pl: { origin: "human", text: "Mój rower" }, - }); - }); - - it("waits for budget without calling the provider or burning a retry", async () => { - const fileId = await insertFile(); - await database.db - .update(core_ai_settings) - .set({ monthlyBudgetUsd: "0.00000001" }) - .where(eq(core_ai_settings.id, 1)); - - await expect(processAltForFile(context(), fileId)).rejects.toBeInstanceOf( - QueueDeferError, - ); - expect(calls.vision).toBe(0); - const [state] = await database.db - .select({ status: core_files_alt_state.status }) - .from(core_files_alt_state) - .where(eq(core_files_alt_state.fileId, fileId)); - expect(state.status).toBe("waiting_budget"); - - await database.db.delete(core_queue); - await enqueueAltGeneration(context(), fileId); - await processQueueTasks(context(), { queues: [ALT_QUEUE] }); - const [task] = await database.db.select().from(core_queue); - expect(task).toMatchObject({ attempts: 0, status: "pending" }); - expect(task.availableAt.getTime()).toBeGreaterThan(Date.now()); - }); - - it("queues one task per image however often it is dispatched", async () => { - await database.db.delete(core_queue); - const fileId = await insertFile(); - - const results = await Promise.all([ - enqueueAltGeneration(context(), fileId), - enqueueAltGeneration(context(), fileId), - enqueueAltGeneration(context(), fileId), - ]); - - expect(results.filter(Boolean)).toHaveLength(1); - expect(await database.db.select().from(core_queue)).toHaveLength(1); - }); - - it("recovers a task whose worker died, spending the attempt", async () => { - await database.db.delete(core_queue); - const fileId = await insertFile(); - await enqueueAltGeneration(context(), fileId); - await database.db.update(core_queue).set({ - attempts: 1, - reservedAt: new Date(Date.now() - 3_600_000), - status: "processing", - }); - - await processQueueTasks(context(), { queues: [ALT_QUEUE] }); - - const [task] = await database.db.select().from(core_queue); - expect(task).toMatchObject({ attempts: 2, status: "completed" }); - expect(Object.keys(await altsOf(fileId)).sort()).toEqual(["en", "pl"]); - }); - - it("sweeps in bounded batches, skips opted-out files and wraps around", async () => { - await database.db.delete(core_queue); - await database.db.delete(core_files); - const ids: number[] = []; - for (let index = 0; index < 3; index++) ids.push(await insertFile()); - await insertFile({ altPolicy: "disabled" }); - await insertFile({ mimeType: "application/pdf" }); - await database.db - .update(core_ai_settings) - .set({ altBatchSize: 2, altScanCursor: 0 }) - .where(eq(core_ai_settings.id, 1)); - - const first = await detectMissingAlt(context()); - const second = await detectMissingAlt(context()); - - expect(first.enqueued).toBe(2); - expect(second.enqueued).toBe(1); - const queued = (await database.db.select().from(core_queue)).map( - row => (row.payload as { fileId: number }).fileId, - ); - expect(queued.sort((a, b) => a - b)).toEqual(ids); - const [settings] = await database.db.select().from(core_ai_settings); - expect(settings.altScanCursor).toBe(0); - }); - - it("records a configuration problem once instead of burning retries", async () => { - const fileId = await insertFile(); - models = [{ ...models[0], capabilities: ["text"] }]; - - await expect(processAltForFile(context(), fileId)).resolves.toEqual({ - written: [], - }); - const [state] = await database.db - .select() - .from(core_files_alt_state) - .where(eq(core_files_alt_state.fileId, fileId)); - expect(state).toMatchObject({ - lastError: "AI_MODEL_INCOMPATIBLE", - status: "failed", - }); - expect(calls.vision).toBe(0); - }); - - it("lets a recently failed file wait its back-off in the sweep", async () => { - await database.db.delete(core_queue); - await database.db.delete(core_files); - const failed = await insertFile(); - const fresh = await insertFile(); - await database.db.insert(core_files_alt_state).values({ - fileId: failed, - lastError: "AI_PROVIDER_FAILED", - status: "failed", - }); - await database.db - .update(core_ai_settings) - .set({ altBatchSize: 10, altScanCursor: 0 }) - .where(eq(core_ai_settings.id, 1)); - - await detectMissingAlt(context()); - - const queued = (await database.db.select().from(core_queue)).map( - row => (row.payload as { fileId: number }).fileId, - ); - expect(queued).toEqual([fresh]); - }); -}); diff --git a/packages/vitnode/src/api/lib/ai/postgres-ledger.integration.test.ts b/packages/vitnode/src/api/lib/ai/postgres-ledger.integration.test.ts deleted file mode 100644 index dd15862437..0000000000 --- a/packages/vitnode/src/api/lib/ai/postgres-ledger.integration.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -// @vitest-environment node -import { eq } from "drizzle-orm"; -import { afterAll, beforeAll, expect, it } from "vitest"; - -import type { TestDatabaseHandle } from "@/tests/postgres"; - -import * as aiTables from "@/database/ai"; -import { - core_ai_budget_periods, - core_ai_role_permissions, - core_ai_role_policies, - core_ai_runs, - core_ai_user_overrides, -} from "@/database/ai"; -import * as filesTables from "@/database/files"; -import * as languagesTables from "@/database/languages"; -import { core_languages } from "@/database/languages"; -import * as rolesTables from "@/database/roles"; -import { core_roles } from "@/database/roles"; -import * as usersTables from "@/database/users"; -import { core_users, core_users_secondary_roles } from "@/database/users"; -import { createTestDatabase, describePostgres } from "@/tests/postgres"; - -import type { AiReservationRequest } from "./ledger"; - -import { formatDecimal, parseDecimal } from "./decimal"; -import { PostgresAiLedger } from "./postgres-ledger"; - -describePostgres("PostgresAiLedger (real PostgreSQL)", () => { - let database: TestDatabaseHandle; - let ledger: PostgresAiLedger; - let month = 0; - - const freshNow = () => { - month += 1; - - return new Date(Date.UTC(2090, month, 15, 12)); - }; - - const request = ( - overrides: Partial = {}, - ): AiReservationRequest => ({ - actionKey: "@acme/notes:summary.generate", - actorType: "user", - daily: null, - globalMonthlyUsd: null, - idempotencyKey: null, - leaseMs: 60_000, - model: { - modelId: "default", - provider: "mock", - providerModelId: "mock-1", - }, - now: new Date(), - pluginId: "@acme/notes", - promptVersion: 1, - resource: undefined, - sourceFingerprint: null, - systemConcurrency: 100, - systemMonthlyUsd: null, - timeZone: "UTC", - usd: parseDecimal("0.03"), - userConcurrency: 100, - userId: 1, - userMonthlyPoints: null, - userRequestsPerMinute: 1_000, - ...overrides, - }); - - const settle = async (runId: number, delivered = true) => - await ledger.settle(runId, { - delivered, - errorCode: null, - finishedAt: new Date(), - status: delivered ? "succeeded" : "failed", - }); - - const finishCall = async (runId: number, costUsd: null | string) => { - const callId = await ledger.beginCall({ - attempt: 1, - modelId: "default", - provider: "mock", - providerModelId: "mock-1", - runId, - startedAt: new Date(), - }); - await ledger.finishCall(callId, { - cost: - costUsd === null - ? { amountUsd: null, reason: "no pricing", source: "unknown" } - : { amountUsd: costUsd, pricingVersion: "test", source: "pricing" }, - errorCode: null, - finishedAt: new Date(), - images: 0, - pricingSnapshot: null, - providerModelId: "mock-1", - providerRequestId: "req", - status: "succeeded", - usage: { - cacheReadTokens: 0, - cacheWriteTokens: 0, - inputTokens: 10, - outputTokens: 5, - reasoningTokens: null, - }, - }); - }; - - const budgetRow = async (scopeKey: string, now: Date) => { - const rows = await database.db - .select() - .from(core_ai_budget_periods) - .where(eq(core_ai_budget_periods.scopeKey, scopeKey)); - - return rows.find( - row => - row.periodStart.getTime() <= now.getTime() && - row.periodEnd.getTime() > now.getTime(), - ); - }; - - beforeAll(async () => { - database = await createTestDatabase({ - ...aiTables, - ...filesTables, - ...languagesTables, - ...rolesTables, - ...usersTables, - }); - ledger = new PostgresAiLedger(database.db); - await database.db.insert(core_languages).values({ - code: "en", - default: true, - name: "English", - }); - await database.db.insert(core_roles).values([ - { id: 1, updatedAt: new Date() }, - { id: 2, updatedAt: new Date() }, - { id: 3, root: true, updatedAt: new Date() }, - ]); - await database.db.insert(core_users).values( - [1, 2, 3, 4, 5].map(id => ({ - avatarColor: "000000", - email: `user${id}@test.test`, - id, - ipAddress: "127.0.0.1", - name: `user${id}`, - nameCode: `user${id}`, - roleId: id === 5 ? 3 : 1, - })), - ); - }, 60_000); - - afterAll(async () => { - await database?.drop(); - }); - - it("lets concurrent runs take only the global budget that exists", async () => { - const now = freshNow(); - const attempts = await Promise.all( - Array.from( - { length: 12 }, - async () => - await ledger.reserve( - request({ globalMonthlyUsd: parseDecimal("0.1"), now }), - ), - ), - ); - - expect(attempts.filter(result => result.ok)).toHaveLength(3); - expect( - attempts - .filter(result => !result.ok) - .map(result => !result.ok && result.code), - ).toEqual(Array(9).fill("AI_BUDGET_EXHAUSTED")); - const global = await budgetRow("global", now); - expect(global?.reservedAmount).toBe("0.090000000000"); - }); - - it("lets concurrent runs of one user take only the points they have", async () => { - const now = freshNow(); - const attempts = await Promise.all( - Array.from( - { length: 8 }, - async () => - await ledger.reserve( - request({ - now, - userId: 2, - userMonthlyPoints: parseDecimal("50"), - }), - ), - ), - ); - - expect(attempts.filter(result => result.ok)).toHaveLength(1); - expect( - attempts.find( - result => !result.ok && result.code === "AI_USER_LIMIT_REACHED", - ), - ).toBeDefined(); - }); - - it("enforces per-user concurrency under a race", async () => { - const now = freshNow(); - const attempts = await Promise.all( - Array.from( - { length: 6 }, - async () => - await ledger.reserve(request({ now, userConcurrency: 2, userId: 3 })), - ), - ); - - expect(attempts.filter(result => result.ok)).toHaveLength(2); - }); - - it("settles a run exactly once, however many settle at the same time", async () => { - const now = freshNow(); - const reserved = await ledger.reserve(request({ now, userId: 4 })); - if (!reserved.ok) throw new Error(reserved.code); - await finishCall(reserved.runId, "0.012"); - - const results = await Promise.all( - Array.from({ length: 5 }, async () => await settle(reserved.runId)), - ); - - expect(results.filter(result => result.applied)).toHaveLength(1); - const user = await budgetRow("user:4", now); - expect(user?.spentAmount).toBe("12.000000000000"); - expect(user?.reservedAmount).toBe("0.000000000000"); - const global = await budgetRow("global", now); - expect(global?.spentAmount).toBe("0.012000000000"); - }); - - it("charges the site the whole hold for an unknown cost, and the user nothing for a failure", async () => { - const now = freshNow(); - const reserved = await ledger.reserve(request({ now, userId: 4 })); - if (!reserved.ok) throw new Error(reserved.code); - await finishCall(reserved.runId, null); - - const result = await settle(reserved.runId, false); - - expect(result).toMatchObject({ applied: true, costKnown: false }); - expect(formatDecimal(result.chargedUsd)).toBe("0.03"); - const global = await budgetRow("global", now); - expect(global).toMatchObject({ - spentAmount: "0.030000000000", - unknownCount: 1, - }); - const user = await budgetRow("user:4", now); - expect(user?.spentAmount).toBe("0.000000000000"); - }); - - it("runs a duplicated idempotency key once, even when both race", async () => { - const now = freshNow(); - const results = await Promise.all( - Array.from( - { length: 4 }, - async () => - await ledger.reserve( - request({ idempotencyKey: "same", now, userId: 4 }), - ), - ), - ); - - expect(results.filter(result => result.ok)).toHaveLength(1); - expect( - results.filter( - result => !result.ok && result.code === "AI_DUPLICATE_REQUEST", - ), - ).toHaveLength(3); - }); - - it("never deadlocks when users and the system reserve at once", async () => { - const now = freshNow(); - const results = await Promise.all( - Array.from( - { length: 30 }, - async (_, index) => - await ledger.reserve( - request({ - actorType: index % 3 === 0 ? "system" : "user", - daily: - index % 3 === 0 - ? null - : { limit: 100, permissionKey: "@acme/notes:summary" }, - now, - userId: index % 3 === 0 ? null : (index % 2) + 1, - }), - ), - ), - ); - - expect(results.every(result => result.ok)).toBe(true); - }); - - it("resolves policies from real role tables: largest allowance, override wins", async () => { - await database.db - .insert(core_users_secondary_roles) - .values({ roleId: 2, userId: 1 }); - await database.db.insert(core_ai_role_policies).values([ - { monthlyPoints: "100", roleId: 1 }, - { monthlyPoints: "400", roleId: 2 }, - ]); - await database.db.insert(core_ai_role_permissions).values([ - { dailyLimit: 5, permission: "@acme/notes:summary", roleId: 1 }, - { dailyLimit: 9, permission: "@acme/notes:summary", roleId: 2 }, - ]); - - const policy = await ledger.resolveUserPolicy({ - defaultGranted: false, - permissionKey: "@acme/notes:summary", - userId: 1, - }); - - expect(policy).toEqual({ - dailyLimit: 9, - granted: true, - monthlyPoints: parseDecimal("400"), - }); - - await database.db - .insert(core_ai_user_overrides) - .values({ monthlyPoints: "7", userId: 1 }); - const overridden = await ledger.resolveUserPolicy({ - defaultGranted: false, - permissionKey: "@acme/notes:summary", - userId: 1, - }); - expect(overridden.monthlyPoints).toBe(parseDecimal("7")); - - const root = await ledger.resolveUserPolicy({ - defaultGranted: false, - permissionKey: "@acme/notes:other", - userId: 5, - }); - expect(root).toMatchObject({ granted: true, monthlyPoints: null }); - }); - - it("keeps consumed usage when a user's roles change", async () => { - const now = freshNow(); - const reserved = await ledger.reserve( - request({ now, userId: 3, userMonthlyPoints: parseDecimal("100") }), - ); - if (!reserved.ok) throw new Error(reserved.code); - await finishCall(reserved.runId, "0.05"); - await settle(reserved.runId); - - const next = await ledger.reserve( - request({ - now, - userId: 3, - usd: parseDecimal("0.02"), - userMonthlyPoints: parseDecimal("60"), - }), - ); - - expect(next).toMatchObject({ code: "AI_USER_LIMIT_REACHED", ok: false }); - const [run] = await database.db - .select({ chargedPoints: core_ai_runs.chargedPoints }) - .from(core_ai_runs) - .where(eq(core_ai_runs.id, reserved.runId)); - expect(run.chargedPoints).toBe("50.000000000000"); - }); -}); diff --git a/packages/vitnode/src/api/models/notifications/danger.integration.test.ts b/packages/vitnode/src/api/models/notifications/danger.integration.test.ts deleted file mode 100644 index 033efa81a1..0000000000 --- a/packages/vitnode/src/api/models/notifications/danger.integration.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -// @vitest-environment node -import { eq } from "drizzle-orm"; -import { afterAll, beforeAll, expect, it } from "vitest"; -import { z } from "zod"; - -import { buildNotificationType } from "@/api/lib/notifications/registry"; -import { - core_notification_deliveries, - core_notification_events, - core_notification_receipts, - core_notification_user_state, - core_notifications, -} from "@/database/notifications"; -import { - createNotificationsHarness, - type NotificationsHarness, -} from "@/tests/notifications"; -import { describePostgres } from "@/tests/postgres"; - -import { updateNotificationTypePolicy } from "./admin"; -import { - cancelQueuedNotificationEmails, - deleteAllNotifications, - markEverythingRead, - pauseNotifications, - resetMemberNotificationPreferences, - resumeNotifications, -} from "./danger"; -import { processNotificationEvent } from "./fanout"; -import { getNotificationState } from "./inbox"; -import { - getNotificationPreferences, - updateNotificationPreferences, -} from "./preferences"; -import { loadNotificationSettings } from "./shared"; -import { getNotificationStats } from "./stats"; - -const alertType = buildNotificationType({ - id: "test.alert", - version: 1, - schema: z.object({ text: z.string() }), - category: "account", - label: "test.alert.label", - defaults: { email: "immediate", inApp: true }, - email: true, - present: ({ data }) => ({ title: data.text, target: "/alerts" }), -}); - -const newsType = buildNotificationType({ - id: "test.news", - version: 1, - schema: z.object({ text: z.string() }), - category: "content", - label: "test.news.label", - defaults: { email: "daily", inApp: true }, - email: true, - present: ({ data }) => ({ title: data.text, target: "/news" }), -}); - -describePostgres("notification admin actions", () => { - let h: NotificationsHarness; - let seq = 0; - - beforeAll(async () => { - h = await createNotificationsHarness({ types: [alertType, newsType] }); - }); - - afterAll(async () => { - await h?.database.drop(); - }); - - const publish = async (type: typeof alertType, userId: number) => { - seq += 1; - await h.c.get("notifications").publish({ - actorId: null, - data: { text: `n${seq}` }, - idempotencyKey: `admin-${seq}`, - recipients: [userId], - type, - }); - }; - - const itemsOf = async (userId: number) => - await h.c - .get("db") - .select() - .from(core_notifications) - .where(eq(core_notifications.userId, userId)); - - const settle = async () => await h.drainQueue(new Date(Date.now() + 5000)); - - const deliveriesOf = async (userId: number) => - await h.c - .get("db") - .select() - .from(core_notification_deliveries) - .where(eq(core_notification_deliveries.userId, userId)); - - it("merges type policy changes instead of replacing the stored policy", async () => { - await updateNotificationTypePolicy(h.c, "test.news", { inApp: false }); - await updateNotificationTypePolicy(h.c, "test.news", { allowPush: false }); - - const { policies } = await loadNotificationSettings(h.c.get("db")); - expect(policies.get("test.news")).toEqual({ - allowPush: false, - inApp: false, - }); - - await updateNotificationTypePolicy(h.c, "test.news", { - allowPush: true, - inApp: true, - }); - }); - - it("refuses member changes to a type locked by the installation", async () => { - const [user] = await h.createUsers(1); - await updateNotificationTypePolicy(h.c, "test.news", { - memberCanEdit: false, - }); - - await expect( - updateNotificationPreferences(h.c, user, { - types: { "test.news": { inApp: false } }, - }), - ).rejects.toMatchObject({ status: 400 }); - - const view = await getNotificationPreferences(h.c, { userId: user }); - expect(view.types.find(type => type.id === "test.news")?.locked).toBe(true); - - await updateNotificationTypePolicy(h.c, "test.news", { - memberCanEdit: true, - }); - }); - - it("saves a member's push choice and refuses push the installation turned off", async () => { - const [user] = await h.createUsers(1); - await updateNotificationPreferences(h.c, user, { - types: { "test.news": { push: false } }, - }); - - const view = await getNotificationPreferences(h.c, { userId: user }); - expect(view.types.find(type => type.id === "test.news")).toMatchObject({ - pushAvailable: true, - value: { push: false }, - }); - - await updateNotificationTypePolicy(h.c, "test.news", { allowPush: false }); - await expect( - updateNotificationPreferences(h.c, user, { - types: { "test.news": { push: true } }, - }), - ).rejects.toMatchObject({ status: 400 }); - await updateNotificationTypePolicy(h.c, "test.news", { allowPush: true }); - }); - - it("shows a member the installation's email default next to their own choice", async () => { - const [user] = await h.createUsers(1); - await updateNotificationPreferences(h.c, user, { - types: { "test.news": { email: "none" } }, - }); - await updateNotificationTypePolicy(h.c, "test.news", { email: "weekly" }); - - const view = await getNotificationPreferences(h.c, { userId: user }); - expect(view.types.find(type => type.id === "test.news")).toMatchObject({ - defaultEmail: "weekly", - value: { email: "none" }, - }); - - await updateNotificationTypePolicy(h.c, "test.news", { email: "daily" }); - }); - - it("holds every event while paused and delivers them on resume", async () => { - const [user] = await h.createUsers(1); - await pauseNotifications(h.c); - await publish(alertType, user); - await settle(); - - expect(await itemsOf(user)).toHaveLength(0); - expect(await deliveriesOf(user)).toHaveLength(0); - - const { requeuedEvents } = await resumeNotifications(h.c); - await settle(); - - expect(requeuedEvents).toBeGreaterThanOrEqual(1); - expect(await itemsOf(user)).toHaveLength(1); - expect((await deliveriesOf(user)).map(row => row.status)).toEqual(["sent"]); - expect(h.emitted.map(event => event.name)).toEqual( - expect.arrayContaining(["notifications.paused", "notifications.resumed"]), - ); - }); - - it("cancels every queued email and pending digest, and keeps the inbox", async () => { - const [user] = await h.createUsers(1); - seq += 1; - const immediate = await h.c.get("notifications").publish({ - actorId: null, - data: { text: `n${seq}` }, - idempotencyKey: `admin-${seq}`, - recipients: [user], - type: alertType, - }); - seq += 1; - const digest = await h.c.get("notifications").publish({ - actorId: null, - data: { text: `n${seq}` }, - idempotencyKey: `admin-${seq}`, - recipients: [user], - type: newsType, - }); - await processNotificationEvent(h.c, immediate.eventId); - await processNotificationEvent(h.c, digest.eventId); - - const [queued] = await deliveriesOf(user); - expect(queued?.status).toBe("pending"); - - const { cancelled } = await cancelQueuedNotificationEmails(h.c); - expect(cancelled).toBeGreaterThanOrEqual(1); - - expect((await deliveriesOf(user))[0]).toMatchObject({ - skipReason: "cancelled", - status: "skipped", - }); - const receipts = await h.c - .get("db") - .select() - .from(core_notification_receipts) - .where(eq(core_notification_receipts.userId, user)); - expect(receipts.every(row => !row.emailPending)).toBe(true); - expect(await itemsOf(user)).toHaveLength(2); - }); - - it("marks everything read for everyone and zeroes every badge", async () => { - const [first, second] = await h.createUsers(2); - await publish(newsType, first); - await publish(newsType, second); - await publish(newsType, second); - await settle(); - - const result = await markEverythingRead(h.c); - - expect(result.items).toBeGreaterThanOrEqual(3); - expect((await getNotificationState(h.c.get("db"), first)).unread).toBe(0); - expect((await getNotificationState(h.c.get("db"), second)).unread).toBe(0); - expect( - h.realtime.filter(record => record.userId === second).at(-1)?.data, - ).toMatchObject({ reason: "read_all", unread: 0 }); - }); - - it("reports activity per bucket in the viewer's time zone", async () => { - const [first, second] = await h.createUsers(2); - const before = await getNotificationStats(h.c, { - range: "24h", - timeZone: "Europe/Warsaw", - }); - await publish(alertType, first); - await publish(alertType, second); - await settle(); - - const stats = await getNotificationStats(h.c, { - range: "24h", - timeZone: "Europe/Warsaw", - }); - - expect(stats.points).toHaveLength(24); - expect(stats.totals.events).toBe(before.totals.events + 2); - expect(stats.totals.sent).toBe(before.totals.sent + 2); - }); - - it("resets every member's own choices to the installation defaults", async () => { - const [user] = await h.createUsers(1); - await updateNotificationPreferences(h.c, user, { - types: { "test.news": { inApp: false } }, - }); - - const { members } = await resetMemberNotificationPreferences(h.c); - - const [state] = await h.c - .get("db") - .select() - .from(core_notification_user_state) - .where(eq(core_notification_user_state.userId, user)); - expect(members).toBeGreaterThanOrEqual(1); - expect(state).toMatchObject({ - preferences: {}, - }); - }); - - it("deletes every notification and the events nothing points at any more", async () => { - const [user] = await h.createUsers(1); - await publish(newsType, user); - await settle(); - - const result = await deleteAllNotifications(h.c); - - expect(result.items).toBeGreaterThanOrEqual(1); - expect(await h.c.get("db").select().from(core_notifications)).toHaveLength( - 0, - ); - expect( - (await h.c.get("db").select().from(core_notification_events)).every( - event => event.status === "pending" || event.status === "processing", - ), - ).toBe(true); - expect((await getNotificationState(h.c.get("db"), user)).unread).toBe(0); - }); -}); diff --git a/packages/vitnode/src/api/models/notifications/email.integration.test.ts b/packages/vitnode/src/api/models/notifications/email.integration.test.ts deleted file mode 100644 index c0a7478c30..0000000000 --- a/packages/vitnode/src/api/models/notifications/email.integration.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -// @vitest-environment node -import { and, eq, inArray } from "drizzle-orm"; -import { afterAll, beforeAll, expect, it } from "vitest"; -import { z } from "zod"; - -import { buildNotificationType } from "@/api/lib/notifications/registry"; -import { - core_notification_deliveries, - core_notification_receipts, - core_notifications, -} from "@/database/notifications"; -import { core_users } from "@/database/users"; -import { - createNotificationsHarness, - type NotificationsHarness, -} from "@/tests/notifications"; -import { describePostgres } from "@/tests/postgres"; - -import { drainNotificationEmails, planNotificationDigests } from "./email"; -import { markNotificationRead } from "./inbox"; -import { updateNotificationPreferences } from "./preferences"; - -const alertType = buildNotificationType({ - id: "test.alert", - version: 1, - schema: z.object({ text: z.string() }), - category: "account", - label: "test.alert.label", - defaults: { email: "immediate", inApp: true }, - email: true, - present: ({ data }) => ({ title: `Alert: ${data.text}`, target: "/alerts" }), -}); - -const newsType = buildNotificationType({ - id: "test.news", - version: 1, - schema: z.object({ headline: z.string() }), - category: "content", - label: "test.news.label", - defaults: { email: "daily", inApp: true }, - email: true, - present: ({ data }) => ({ title: data.headline, target: "/news" }), -}); - -describePostgres("notification email", () => { - let h: NotificationsHarness; - let seq = 0; - - beforeAll(async () => { - h = await createNotificationsHarness({ types: [alertType, newsType] }); - }); - - afterAll(async () => { - await h?.database.drop(); - }); - - const publish = async ( - type: typeof alertType | typeof newsType, - userId: number, - text: string, - ) => { - seq += 1; - const data = type === alertType ? { text } : { headline: text }; - const result = await h.c.get("notifications").publish({ - actorId: null, - data: data, - idempotencyKey: `email-${seq}`, - recipients: [userId], - type: type as never, - }); - - return result.eventId; - }; - - const deliveriesOf = async (userId: number) => - await h.c - .get("db") - .select() - .from(core_notification_deliveries) - .where(eq(core_notification_deliveries.userId, userId)); - - const sentTo = (userId: number) => - h.sentEmails.filter(email => email.to === `user${userId}@example.com`); - - it("sends one immediate email through the queue, with a stable idempotency key", async () => { - const [user] = await h.createUsers(1); - const eventId = await publish(alertType, user, "login from a new device"); - await h.drainQueue(); - await drainNotificationEmails(h.c); - - const emails = sentTo(user); - expect(emails).toHaveLength(1); - expect(emails[0]).toMatchObject({ - idempotencyKey: `vitnode-notification-immediate:${eventId}:${user}`, - subject: "Alert: login from a new device", - }); - expect(emails[0].entries[0]?.url).toMatch(/\/alerts$/); - - const [delivery] = await deliveriesOf(user); - expect(delivery).toMatchObject({ - attempts: 1, - providerMessageId: "message-1", - status: "sent", - }); - }); - - it("retries a provider failure with backoff and never sends twice", async () => { - const [user] = await h.createUsers(1); - await publish(alertType, user, "password changed"); - await h.drainQueue(); - - h.setEmailFailure( - new Error(`SMTP 451 rejected for user${user}@example.com token=abc123`), - ); - await drainNotificationEmails(h.c); - h.setEmailFailure(null); - - const [failed] = await deliveriesOf(user); - expect(failed.status).toBe("pending"); - expect(failed.attempts).toBe(1); - expect(failed.lastError).toBe( - "SMTP 451 rejected for [email] token=[redacted]", - ); - expect(failed.availableAt.getTime()).toBeGreaterThan(Date.now()); - expect(sentTo(user)).toHaveLength(0); - - await drainNotificationEmails(h.c); - expect(sentTo(user)).toHaveLength(0); - - const later = new Date(Date.now() + 60 * 60 * 1000); - await drainNotificationEmails(h.c, { now: later }); - await drainNotificationEmails(h.c, { now: later }); - - expect(sentTo(user)).toHaveLength(1); - const [sent] = await deliveriesOf(user); - expect(sent).toMatchObject({ attempts: 2, status: "sent" }); - }); - - it("re-checks preferences and read state before a delayed send", async () => { - const [optedOut, alreadyRead] = await h.createUsers(2); - await publish(alertType, optedOut, "a"); - await publish(alertType, alreadyRead, "b"); - await h.drainQueue(); - - await updateNotificationPreferences(h.c, optedOut, { - types: { "test.alert": { email: "none" } }, - }); - const [item] = await h.c - .get("db") - .select() - .from(core_notifications) - .where(eq(core_notifications.userId, alreadyRead)); - await markNotificationRead(h.c, { - notificationId: item.id, - userId: alreadyRead, - }); - - await drainNotificationEmails(h.c); - - expect(sentTo(optedOut)).toHaveLength(0); - expect(sentTo(alreadyRead)).toHaveLength(0); - expect((await deliveriesOf(optedOut))[0]?.status).toBe("skipped"); - expect((await deliveriesOf(alreadyRead))[0]?.status).toBe("skipped"); - }); - - it("builds daily digests per local period across a DST change, once", async () => { - const [user] = await h.createUsers(1); - await h.c - .get("db") - .update(core_users) - .set({ timeZone: "Europe/Warsaw" }) - .where(eq(core_users.id, user)); - - const inside = await publish(newsType, user, "Saturday news"); - const after = await publish(newsType, user, "Sunday news"); - const readOne = await publish(newsType, user, "Already seen"); - await h.drainQueue(); - - const setCreated = async (eventId: number, at: string) => - await h.c - .get("db") - .update(core_notification_receipts) - .set({ createdAt: new Date(at) }) - .where( - and( - eq(core_notification_receipts.eventId, eventId), - eq(core_notification_receipts.userId, user), - ), - ); - await setCreated(inside, "2026-03-28T12:00:00Z"); - await setCreated(readOne, "2026-03-28T13:00:00Z"); - await setCreated(after, "2026-03-29T06:30:00Z"); - - const [seen] = await h.c - .get("db") - .select({ notificationId: core_notification_receipts.notificationId }) - .from(core_notification_receipts) - .where(eq(core_notification_receipts.eventId, readOne)); - await markNotificationRead(h.c, { - notificationId: seen.notificationId ?? 0, - userId: user, - }); - - const warsawEightAmBeforeDstChange = new Date("2026-03-28T07:00:00Z"); - const warsawEightAmAfterDstChange = new Date("2026-03-29T06:00:00Z"); - const firstRun = new Date("2026-03-29T10:00:00Z"); - await planNotificationDigests(h.c, { now: firstRun }); - await planNotificationDigests(h.c, { now: firstRun }); - await drainNotificationEmails(h.c, { now: firstRun }); - - const digests = (await deliveriesOf(user)).filter( - row => row.mode === "daily", - ); - expect(digests).toHaveLength(1); - expect(digests[0]).toMatchObject({ - idempotencyKey: `daily:${user}:2026-03-29`, - itemCount: 1, - periodEnd: warsawEightAmAfterDstChange, - periodStart: warsawEightAmBeforeDstChange, - status: "sent", - }); - expect( - sentTo(user) - .at(-1) - ?.entries.map(entry => entry.title), - ).toEqual(["Saturday news"]); - - const secondRun = new Date("2026-03-30T07:00:00Z"); - await planNotificationDigests(h.c, { now: secondRun }); - await drainNotificationEmails(h.c, { now: secondRun }); - - expect( - sentTo(user) - .at(-1) - ?.entries.map(entry => entry.title), - ).toEqual(["Sunday news"]); - - const sent = sentTo(user).length; - await planNotificationDigests(h.c, { - now: new Date("2026-03-31T07:00:00Z"), - }); - await drainNotificationEmails(h.c, { - now: new Date("2026-03-31T07:00:00Z"), - }); - expect(sentTo(user)).toHaveLength(sent); - - const pending = await h.c - .get("db") - .select() - .from(core_notification_receipts) - .where( - and( - eq(core_notification_receipts.userId, user), - eq(core_notification_receipts.emailPending, true), - ), - ); - expect(pending).toHaveLength(0); - }); - - it("re-renders the same digest when its send is retried", async () => { - const [user] = await h.createUsers(1); - await publish(newsType, user, "One"); - await publish(newsType, user, "Two"); - await h.drainQueue(); - const now = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000); - - await planNotificationDigests(h.c, { now }); - h.setEmailFailure(new Error("provider down")); - await drainNotificationEmails(h.c, { now }); - h.setEmailFailure(null); - - await publish(newsType, user, "Three"); - await h.drainQueue(); - await drainNotificationEmails(h.c, { - now: new Date(now.getTime() + 60 * 60 * 1000), - }); - - const titles = sentTo(user).flatMap(email => - email.entries.map(entry => entry.title), - ); - expect(titles.sort()).toEqual(["One", "Two"]); - - const daily = (await deliveriesOf(user)).filter( - row => row.mode === "daily", - ); - expect(daily.map(row => row.status)).toEqual(["sent"]); - expect( - await h.c - .get("db") - .select() - .from(core_notification_receipts) - .where( - and( - eq(core_notification_receipts.userId, user), - inArray(core_notification_receipts.emailDeliveryId, [daily[0].id]), - ), - ), - ).toHaveLength(2); - }); -}); diff --git a/packages/vitnode/src/api/models/notifications/fanout.integration.test.ts b/packages/vitnode/src/api/models/notifications/fanout.integration.test.ts deleted file mode 100644 index 35dda57691..0000000000 --- a/packages/vitnode/src/api/models/notifications/fanout.integration.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -// @vitest-environment node -import { and, eq, sql } from "drizzle-orm"; -import { afterAll, beforeAll, expect, it } from "vitest"; -import { z } from "zod"; - -import { buildNotificationType } from "@/api/lib/notifications/registry"; -import { - core_notification_events, - core_notification_receipts, - core_notification_user_state, - core_notifications, -} from "@/database/notifications"; -import { core_queue } from "@/database/queue"; -import { - createNotificationsHarness, - type NotificationsHarness, -} from "@/tests/notifications"; -import { describePostgres } from "@/tests/postgres"; - -import { processNotificationEvent } from "./fanout"; -import { getNotificationState } from "./inbox"; - -const commentType = buildNotificationType({ - id: "test.comment", - version: 1, - schema: z.object({ postId: z.number(), title: z.string() }), - category: "social", - label: "test.comment.label", - subjectType: "test.post", - defaults: { email: "none", inApp: true }, - grouping: { windowMinutes: 60 }, - present: ({ actorCount, actors, data }) => ({ - title: `${actors[0]?.name ?? "Someone"} and ${actorCount - 1} others commented on ${data.title}`, - target: `/posts/${data.postId}`, - }), -}); - -const secretType = buildNotificationType({ - id: "test.secret", - version: 1, - schema: z.object({ allowed: z.array(z.number()) }), - category: "content", - label: "test.secret.label", - defaults: { email: "none", inApp: true }, - access: ({ data, userIds }) => - userIds.filter(id => data.allowed.includes(id)), - present: () => ({ title: "Secret", target: "https://evil.example/steal" }), -}); - -describePostgres("notification fan-out", () => { - let h: NotificationsHarness; - - beforeAll(async () => { - h = await createNotificationsHarness({ types: [commentType, secretType] }); - }); - - afterAll(async () => { - await h?.database.drop(); - }); - - const unreadOf = async (userId: number) => - (await getNotificationState(h.c.get("db"), userId)).unread; - - it("rejects unregistered types and invalid payloads", async () => { - await expect( - h.c.get("notifications").publish({ - data: {}, - idempotencyKey: "x", - recipients: [1], - type: "test.missing", - }), - ).rejects.toThrow(/not registered/); - - await expect( - h.c.get("notifications").publish({ - data: { postId: "nope" as unknown as number, title: "t" }, - idempotencyKey: "x", - recipients: [1], - type: commentType, - }), - ).rejects.toThrow(/Invalid data/); - }); - - it("rolls back with the producer's transaction", async () => { - const [user] = await h.createUsers(1); - - await expect( - h.c.get("db").transaction(async tx => { - await h.c.get("notifications").publish({ - data: { postId: 1, title: "Rolled back" }, - idempotencyKey: "rollback-1", - recipients: [user], - subject: { id: 1, type: "test.post" }, - tx, - type: commentType, - }); - throw new Error("producer failed"); - }), - ).rejects.toThrow("producer failed"); - - const events = await h.c - .get("db") - .select() - .from(core_notification_events) - .where(eq(core_notification_events.idempotencyKey, "rollback-1")); - const tasks = await h.c - .get("db") - .select() - .from(core_queue) - .where(sql`${core_queue.payload}->>'eventId' IS NOT NULL`); - - expect(events).toHaveLength(0); - expect(tasks.filter(task => task.status === "pending")).toHaveLength(0); - }); - - it("commits durably with the producer and delivers once, skipping the actor", async () => { - const [actor, a, b] = await h.createUsers(3); - - const first = await h.c.get("db").transaction( - async tx => - await h.c.get("notifications").publish({ - actorId: actor, - data: { postId: 7, title: "Seven" }, - idempotencyKey: "comment-71", - recipients: [a, b, b, actor], - subject: { id: 7, type: "test.post" }, - tx, - type: commentType, - }), - ); - const again = await h.c.get("notifications").publish({ - actorId: actor, - data: { postId: 7, title: "Seven" }, - idempotencyKey: "comment-71", - recipients: [a, b], - subject: { id: 7, type: "test.post" }, - type: commentType, - }); - - expect(again).toEqual({ duplicate: true, eventId: first.eventId }); - - await h.drainQueue(); - await processNotificationEvent(h.c, first.eventId); - - expect(await unreadOf(a)).toBe(1); - expect(await unreadOf(b)).toBe(1); - expect(await unreadOf(actor)).toBe(0); - - const receipts = await h.c - .get("db") - .select() - .from(core_notification_receipts) - .where(eq(core_notification_receipts.eventId, first.eventId)); - const byId = (x: number, y: number) => x - y; - expect(receipts.map(row => row.userId).sort(byId)).toEqual( - [a, b].sort(byId), - ); - }); - - it("replays a crashed batch without duplicating anything", async () => { - const [actor, a] = await h.createUsers(2); - const { eventId } = await h.c.get("notifications").publish({ - actorId: actor, - data: { postId: 8, title: "Eight" }, - idempotencyKey: "comment-81", - recipients: [a], - subject: { id: 8, type: "test.post" }, - type: commentType, - }); - await h.drainQueue(); - - await h.c - .get("db") - .update(core_notification_events) - .set({ recipientCursor: 0, status: "processing" }) - .where(eq(core_notification_events.id, eventId)); - await processNotificationEvent(h.c, eventId); - - const [item] = await h.c - .get("db") - .select() - .from(core_notifications) - .where(eq(core_notifications.userId, a)); - - expect(item?.eventCount).toBe(1); - expect(await unreadOf(a)).toBe(1); - }); - - it("groups events and re-opens a read group exactly once", async () => { - const [x, y, owner] = await h.createUsers(3); - const publish = async (actorId: number, key: string) => - await h.c.get("notifications").publish({ - actorId, - data: { postId: 9, title: "Nine" }, - idempotencyKey: key, - recipients: [owner], - subject: { id: 9, type: "test.post" }, - type: commentType, - }); - - await publish(x, "c-9-1"); - await publish(y, "c-9-2"); - await h.drainQueue(); - - const items = await h.c - .get("db") - .select() - .from(core_notifications) - .where(eq(core_notifications.userId, owner)); - expect(items).toHaveLength(1); - expect(items[0]?.eventCount).toBe(2); - expect(await unreadOf(owner)).toBe(1); - - await h.c - .get("db") - .update(core_notifications) - .set({ readSeq: sql`${core_notifications.activitySeq}` }) - .where(eq(core_notifications.userId, owner)); - await h.c - .get("db") - .update(core_notification_user_state) - .set({ unreadCount: 0 }) - .where(eq(core_notification_user_state.userId, owner)); - - await publish(x, "c-9-3"); - await h.drainQueue(); - - expect(await unreadOf(owner)).toBe(1); - }); - - it("checks access per batch and never sends unsafe targets", async () => { - const [allowed, denied] = await h.createUsers(2); - await h.c.get("notifications").publish({ - actorId: null, - data: { allowed: [allowed] }, - idempotencyKey: "secret-1", - recipients: [allowed, denied], - type: secretType, - }); - await h.drainQueue(); - - const rows = await h.c - .get("db") - .select({ userId: core_notifications.userId }) - .from(core_notifications) - .where(and(eq(core_notifications.type, "test.secret"))); - - expect(rows.map(row => row.userId)).toEqual([allowed]); - }); -}); diff --git a/packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts b/packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts deleted file mode 100644 index aff6ab10e6..0000000000 --- a/packages/vitnode/src/api/models/notifications/fanout.load.integration.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -// @vitest-environment node -import { eq, sql } from "drizzle-orm"; -import { afterAll, beforeAll, expect, it } from "vitest"; -import { z } from "zod"; - -import { buildNotificationType } from "@/api/lib/notifications/registry"; -import { - core_notification_deliveries, - core_notification_events, - core_notification_receipts, - core_notification_user_state, - core_notifications, -} from "@/database/notifications"; -import { - createNotificationsHarness, - type NotificationsHarness, -} from "@/tests/notifications"; -import { describePostgres } from "@/tests/postgres"; - -import { processNotificationEvent } from "./fanout"; - -const RECIPIENTS = 1_000; -const BATCH = 200; - -let accessCalls = 0; -const announcementType = buildNotificationType({ - id: "test.announcement", - version: 1, - schema: z.object({ title: z.string() }), - category: "content", - label: "test.announcement.label", - subjectType: "test.topic", - defaults: { email: "immediate", inApp: true }, - email: true, - access: ({ userIds }) => { - accessCalls += 1; - - return userIds; - }, - present: ({ data }) => ({ title: data.title }), -}); - -describePostgres("notification fan-out with 1,000 recipients", () => { - let h: NotificationsHarness; - let users: number[] = []; - - beforeAll(async () => { - h = await createNotificationsHarness({ - types: [announcementType], - workers: { fanoutBatchSize: BATCH }, - }); - users = await h.createUsers(RECIPIENTS + 1); - - await h.c - .get("db") - .insert(core_notification_user_state) - .values({ - preferences: { "test.announcement": { email: "none", inApp: false } }, - userId: users[700], - }); - }, 60_000); - - afterAll(async () => { - await h?.database.drop(); - }); - - it("delivers once per recipient in bounded batches, and survives repeated processing", async () => { - const actor = users[0]; - const started = performance.now(); - const queriesBefore = h.queryCount(); - - const { eventId } = await h.c.get("notifications").publish({ - actorId: actor, - data: { title: "Big news" }, - idempotencyKey: "announcement-1", - recipients: [actor, ...users.slice(1), ...users.slice(400, 600)], - subject: { id: 1, type: "test.topic" }, - type: announcementType, - }); - const publishMs = performance.now() - started; - - const racingWorker = h.forkContext(); - const fanoutStarted = performance.now(); - await Promise.all([ - processNotificationEvent(h.c, eventId), - processNotificationEvent(racingWorker, eventId), - ]); - const fanoutMs = performance.now() - fanoutStarted; - const fanoutQueries = h.queryCount() - queriesBefore; - - const expected = RECIPIENTS - 1; - const countRows = async ( - table: typeof core_notification_receipts | typeof core_notifications, - ) => - ( - await h.c - .get("db") - .select({ count: sql`count(*)::integer` }) - .from(table) - )[0]?.count; - - expect(await countRows(core_notifications)).toBe(expected); - expect(await countRows(core_notification_receipts)).toBe(expected); - - const [event] = await h.c - .get("db") - .select() - .from(core_notification_events) - .where(eq(core_notification_events.id, eventId)); - expect(event).toMatchObject({ - deliveredCount: expected, - status: "completed", - }); - - await h.c - .get("db") - .update(core_notification_events) - .set({ - recipientCursor: 0, - status: "processing", - }) - .where(eq(core_notification_events.id, eventId)); - await processNotificationEvent(h.c, eventId); - await processNotificationEvent(h.c, eventId); - - expect(await countRows(core_notifications)).toBe(expected); - const [{ total, wrong }] = await h.c - .get("db") - .select({ - total: sql`sum(${core_notification_user_state.unreadCount})::integer`, - wrong: sql`count(*) filter (where ${core_notification_user_state.unreadCount} <> 1)::integer`, - }) - .from(core_notification_user_state); - expect(total).toBe(expected); - expect(wrong).toBe(1); - - const deliveries = await h.c - .get("db") - .select({ count: sql`count(*)::integer` }) - .from(core_notification_deliveries); - expect(deliveries[0]?.count).toBe(expected); - - const batchesPerWorker = Math.ceil(RECIPIENTS / BATCH); - expect(accessCalls).toBeLessThanOrEqual(batchesPerWorker * 2 + 4); - expect(fanoutQueries).toBeLessThan(RECIPIENTS); - - // oxlint-disable-next-line no-console - console.info( - `[1,000 recipients] publish ${publishMs.toFixed(0)} ms, fan-out ${fanoutMs.toFixed(0)} ms, ${fanoutQueries} queries for ${expected} recipients, ${accessCalls} access checks`, - ); - }, 120_000); -}); diff --git a/packages/vitnode/src/api/models/notifications/inbox.integration.test.ts b/packages/vitnode/src/api/models/notifications/inbox.integration.test.ts deleted file mode 100644 index 6c6281d945..0000000000 --- a/packages/vitnode/src/api/models/notifications/inbox.integration.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -// @vitest-environment node -import { eq, sql } from "drizzle-orm"; -import { afterAll, beforeAll, expect, it } from "vitest"; -import { z } from "zod"; - -import { buildNotificationType } from "@/api/lib/notifications/registry"; -import { - core_notification_user_state, - core_notifications, -} from "@/database/notifications"; -import { - createNotificationsHarness, - type NotificationsHarness, -} from "@/tests/notifications"; -import { describePostgres } from "@/tests/postgres"; - -import { processNotificationEvent } from "./fanout"; -import { - archiveNotification, - getNotificationState, - listNotifications, - markAllNotificationsRead, - markNotificationRead, - markNotificationUnread, - reconcileNotificationCounts, -} from "./inbox"; - -const replyType = buildNotificationType({ - id: "test.reply", - version: 1, - schema: z.object({ threadId: z.number() }), - category: "social", - label: "test.reply.label", - subjectType: "test.thread", - defaults: { email: "none", inApp: true }, - grouping: { windowMinutes: 60 }, - present: ({ actorCount, eventCount }) => ({ - title: `${eventCount} replies from ${actorCount} people`, - target: "/threads/1", - }), -}); - -const pingType = buildNotificationType({ - id: "test.ping", - version: 1, - schema: z.object({ n: z.number() }), - category: "system", - label: "test.ping.label", - defaults: { email: "none", inApp: true }, - present: ({ data }) => ({ title: `Ping ${data.n}` }), -}); - -let revokedUserIds = new Set(); -const privateType = buildNotificationType({ - id: "test.private", - version: 1, - schema: z.object({ secret: z.string() }), - category: "content", - label: "test.private.label", - defaults: { email: "none", inApp: true }, - access: ({ userIds }) => userIds.filter(id => !revokedUserIds.has(id)), - present: ({ data }) => ({ - title: `Secret: ${data.secret}`, - target: "/secret", - }), -}); - -describePostgres("notification inbox state", () => { - let h: NotificationsHarness; - let keySeq = 0; - - beforeAll(async () => { - h = await createNotificationsHarness({ - types: [replyType, pingType, privateType], - }); - }); - - afterAll(async () => { - await h?.database.drop(); - }); - - const ping = async (userId: number, n = keySeq) => { - keySeq += 1; - await h.c.get("notifications").publish({ - actorId: null, - data: { n }, - idempotencyKey: `ping-${keySeq}`, - recipients: [userId], - type: pingType, - }); - await h.drainQueue(); - }; - - const reply = async (userId: number, actorId: number) => { - keySeq += 1; - await h.c.get("notifications").publish({ - actorId, - data: { threadId: 1 }, - idempotencyKey: `reply-${keySeq}`, - recipients: [userId], - subject: { id: 1, type: "test.thread" }, - type: replyType, - }); - await h.drainQueue(); - }; - - const state = async (userId: number) => - await getNotificationState(h.c.get("db"), userId); - - const itemsOf = async (userId: number) => - await h.c - .get("db") - .select() - .from(core_notifications) - .where(eq(core_notifications.userId, userId)); - - it("counts read, unread and archive transitions exactly once", async () => { - const [user] = await h.createUsers(1); - await ping(user); - const [item] = await itemsOf(user); - expect((await state(user)).unread).toBe(1); - - const read = await markNotificationRead(h.c, { - notificationId: item.id, - userId: user, - }); - expect(read.unread).toBe(0); - - const again = await markNotificationRead(h.c, { - notificationId: item.id, - userId: user, - }); - expect(again.unread).toBe(0); - - expect( - ( - await markNotificationUnread(h.c, { - notificationId: item.id, - userId: user, - }) - ).unread, - ).toBe(1); - expect( - ( - await markNotificationUnread(h.c, { - notificationId: item.id, - userId: user, - }) - ).unread, - ).toBe(1); - - expect( - ( - await archiveNotification(h.c, { - notificationId: item.id, - userId: user, - }) - ).unread, - ).toBe(0); - expect(await reconcileNotificationCounts(h.c, [user])).toEqual({ - checked: 1, - corrected: 0, - }); - }); - - it("bumps the revision on every change and pushes the absolute count", async () => { - const [user] = await h.createUsers(1); - h.realtime.length = 0; - - await ping(user); - await ping(user); - const [item] = await itemsOf(user); - await markNotificationRead(h.c, { notificationId: item.id, userId: user }); - - const mine = h.realtime.filter(message => message.userId === user); - expect(mine.map(message => message.data.unread)).toEqual([1, 2, 1]); - expect(mine.map(message => message.data.revision)).toEqual([1, 2, 3]); - expect(mine.map(message => message.data.reason)).toEqual([ - "created", - "created", - "read", - ]); - }); - - it("keeps a group unread when activity arrived after the read boundary", async () => { - const [owner, a, b] = await h.createUsers(3); - await reply(owner, a); - const [rendered] = await itemsOf(owner); - - await reply(owner, b); - const after = await markNotificationRead(h.c, { - notificationId: rendered.id, - throughSeq: rendered.activitySeq, - userId: owner, - }); - - expect(after.unread).toBe(1); - const [item] = await itemsOf(owner); - expect(item.eventCount).toBe(2); - expect(item.readSeq).toBe(1); - expect(item.activitySeq).toBe(2); - - await markNotificationRead(h.c, { - notificationId: item.id, - throughSeq: item.activitySeq, - userId: owner, - }); - expect((await state(owner)).unread).toBe(0); - await reply(owner, a); - expect((await state(owner)).unread).toBe(1); - await reply(owner, b); - expect((await state(owner)).unread).toBe(1); - }); - - it("brings an archived group back as unread on new activity", async () => { - const [owner, actor] = await h.createUsers(2); - await reply(owner, actor); - const [item] = await itemsOf(owner); - await archiveNotification(h.c, { notificationId: item.id, userId: owner }); - expect((await state(owner)).unread).toBe(0); - - await reply(owner, actor); - const [reopened] = await itemsOf(owner); - expect(reopened.archivedAt).toBeNull(); - expect((await state(owner)).unread).toBe(1); - }); - - it("starts a new item once the grouping window has passed", async () => { - const [owner, actor] = await h.createUsers(2); - await reply(owner, actor); - await h.c - .get("db") - .update(core_notifications) - .set({ groupBucket: sql`${core_notifications.groupBucket} - 2` }) - .where(eq(core_notifications.userId, owner)); - await reply(owner, actor); - - expect(await itemsOf(owner)).toHaveLength(2); - expect((await state(owner)).unread).toBe(2); - }); - - it("marks all read at a lock boundary that later deliveries cannot cross", async () => { - const [user] = await h.createUsers(1); - await ping(user); - await ping(user); - - const fanoutInProgress = h.forkContext(); - let releaseUserLock: () => void = () => undefined; - const userLockHeld = new Promise(resolve => { - releaseUserLock = resolve; - }); - const fanoutHoldingUserLock = fanoutInProgress - .get("db") - .transaction(async tx => { - await tx - .select() - .from(core_notification_user_state) - .where(eq(core_notification_user_state.userId, user)) - .for("update"); - await userLockHeld; - }); - - const markAll = markAllNotificationsRead(h.c, { userId: user }); - await new Promise(resolve => setTimeout(resolve, 100)); - releaseUserLock(); - await fanoutHoldingUserLock; - const result = await markAll; - - expect(result.marked).toBe(2); - expect(result.unread).toBe(0); - - await ping(user); - expect((await state(user)).unread).toBe(1); - }); - - it("never loses or double-counts with concurrent deliveries and reads", async () => { - const [user] = await h.createUsers(1); - const forks = [h.forkContext(), h.forkContext(), h.forkContext()]; - - const published = await Promise.all( - Array.from( - { length: 12 }, - async (_, index) => - await forks[index % forks.length].get("notifications").publish({ - actorId: null, - data: { n: index }, - idempotencyKey: `concurrent-${user}-${index}`, - recipients: [user], - type: pingType, - }), - ), - ); - - await Promise.all( - published.map(async ({ eventId }, index) => { - await processNotificationEvent(forks[index % forks.length], eventId); - if (index % 3 === 0) { - const items = await itemsOf(user); - const first = items.at(0); - if (first) { - await markNotificationRead(forks[(index + 1) % forks.length], { - notificationId: first.id, - userId: user, - }); - } - } - }), - ); - - const items = await itemsOf(user); - const actual = items.filter( - item => item.archivedAt === null && item.readSeq < item.activitySeq, - ).length; - - expect(items).toHaveLength(12); - expect((await state(user)).unread).toBe(actual); - expect(await reconcileNotificationCounts(h.c, [user])).toEqual({ - checked: 1, - corrected: 0, - }); - }); - - it("isolates users and hides content after access is revoked", async () => { - const [alice, bob] = await h.createUsers(2); - await h.c.get("notifications").publish({ - actorId: null, - data: { secret: "plans" }, - idempotencyKey: `private-${alice}`, - recipients: [alice], - type: privateType, - }); - await h.drainQueue(); - const [item] = await itemsOf(alice); - - await expect( - markNotificationRead(h.c, { notificationId: item.id, userId: bob }), - ).rejects.toMatchObject({ status: 404 }); - expect( - (await listNotifications(h.c, { limit: 20, userId: bob })).items, - ).toEqual([]); - - const visible = await listNotifications(h.c, { limit: 20, userId: alice }); - expect(visible.items[0]).toMatchObject({ - available: true, - target: "/secret", - title: "Secret: plans", - }); - - revokedUserIds = new Set([alice]); - const hidden = await listNotifications(h.c, { limit: 20, userId: alice }); - revokedUserIds = new Set(); - - expect(hidden.items[0]).toMatchObject({ - actors: [], - available: false, - body: null, - target: null, - title: "This notification is no longer available.", - unread: true, - }); - }); - - it("removes items for a subject and keeps counts right", async () => { - const [owner, actor] = await h.createUsers(2); - await reply(owner, actor); - await ping(owner); - expect((await state(owner)).unread).toBe(2); - - const removed = await h.c - .get("notifications") - .remove({ subject: { id: 1, type: "test.thread" }, userIds: [owner] }); - - expect(removed).toBe(1); - expect((await state(owner)).unread).toBe(1); - }); - - it("repairs a drifted count by reconciliation", async () => { - const [user] = await h.createUsers(1); - await ping(user); - await h.c - .get("db") - .update(core_notification_user_state) - .set({ unreadCount: 42 }) - .where(eq(core_notification_user_state.userId, user)); - - expect(await reconcileNotificationCounts(h.c, [user])).toEqual({ - checked: 1, - corrected: 1, - }); - expect((await state(user)).unread).toBe(1); - }); - - it("paginates with a stable cursor and filters unread", async () => { - const [user] = await h.createUsers(1); - for (let index = 0; index < 5; index++) await ping(user, index); - - const first = await listNotifications(h.c, { limit: 2, userId: user }); - const second = await listNotifications(h.c, { - cursor: first.nextCursor ?? undefined, - limit: 2, - userId: user, - }); - const third = await listNotifications(h.c, { - cursor: second.nextCursor ?? undefined, - limit: 2, - userId: user, - }); - - const ids = [...first.items, ...second.items, ...third.items].map( - item => item.id, - ); - expect(new Set(ids).size).toBe(5); - expect(third.nextCursor).toBeNull(); - - await markNotificationRead(h.c, { - notificationId: first.items[0].id, - userId: user, - }); - const unread = await listNotifications(h.c, { - limit: 20, - unreadOnly: true, - userId: user, - }); - expect(unread.items).toHaveLength(4); - }); -}); diff --git a/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.test.ts b/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.test.ts deleted file mode 100644 index 46de11c457..0000000000 --- a/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// @vitest-environment node -import type { Context } from "hono"; - -import { OpenAPIHono } from "@hono/zod-openapi"; -import { afterAll, beforeAll, expect, it } from "vitest"; -import { z } from "zod"; - -import { buildNotificationType } from "@/api/lib/notifications/registry"; -import { createTestCache } from "@/tests/cache"; -import { - createNotificationsHarness, - type NotificationsHarness, -} from "@/tests/notifications"; -import { describePostgres } from "@/tests/postgres"; -import { grantStaffPermissions } from "@/tests/staff-permissions"; - -import { - deleteAllNotificationsRoute, - resetMemberNotificationPreferencesRoute, -} from "./danger.route"; -import { getNotificationStatsRoute } from "./stats.route"; - -const pingType = buildNotificationType({ - id: "test.ping", - version: 1, - schema: z.object({}), - category: "system", - label: "test.ping.label", - defaults: { email: "none", inApp: true }, - present: () => ({ title: "Ping" }), -}); - -const VIEWER = { email: "viewer@example.com", id: 1 }; -const MANAGER = { email: "manager@example.com", id: 2 }; -const PLUGIN = "@vitnode/core"; - -const SHARED_KEYS = [ - "core", - "db", - "email", - "events", - "i18n", - "log", - "notifications", - "queue", - "realtime", -]; - -describePostgres("AdminCP notification danger routes", () => { - let h: NotificationsHarness; - const cache = createTestCache(); - - beforeAll(async () => { - h = await createNotificationsHarness({ types: [pingType] }); - await grantStaffPermissions(cache, { - permissions: [ - { module: "notifications", permission: "can_view", plugin: PLUGIN }, - ], - userId: VIEWER.id, - }); - await grantStaffPermissions(cache, { - permissions: [ - { module: "notifications", permission: "can_view", plugin: PLUGIN }, - { module: "notifications", permission: "can_manage", plugin: PLUGIN }, - ], - userId: MANAGER.id, - }); - }); - - afterAll(async () => { - await h?.database.drop(); - }); - - const appFor = (admin: typeof VIEWER) => { - const app = new OpenAPIHono(); - app.use("*", async (c, next) => { - for (const key of SHARED_KEYS) { - c.set(key as never, h.c.get(key as never)); - } - c.set("cache", cache); - c.set("admin", { user: admin } as unknown as Context["var"]["admin"]); - await next(); - }); - for (const route of [ - deleteAllNotificationsRoute, - resetMemberNotificationPreferencesRoute, - getNotificationStatsRoute, - ]) { - app.openapi(route.route, route.handler); - } - - return app; - }; - - it("refuses destructive actions to staff without the manage permission", async () => { - const app = appFor(VIEWER); - - expect((await app.request("/delete-all", { method: "POST" })).status).toBe( - 403, - ); - expect( - (await app.request("/members/reset-preferences", { method: "POST" })) - .status, - ).toBe(403); - }); - - it("runs them for staff who may manage notifications", async () => { - const app = appFor(MANAGER); - - const response = await app.request("/delete-all", { method: "POST" }); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ events: 0, items: 0 }); - }); - - it("serves activity to staff who may view notifications, and rejects unknown time zones", async () => { - const app = appFor(VIEWER); - - const ok = await app.request("/stats?range=7d&timeZone=Europe%2FWarsaw"); - const bad = await app.request("/stats?range=7d&timeZone=Mars%2FOlympus"); - - expect(ok.status).toBe(200); - expect((await ok.json()).points).toHaveLength(7); - expect(bad.status).toBe(400); - }); -}); diff --git a/packages/vitnode/src/api/modules/ai/ai-routes.integration.test.ts b/packages/vitnode/src/api/modules/ai/ai-routes.integration.test.ts deleted file mode 100644 index 941945b0c9..0000000000 --- a/packages/vitnode/src/api/modules/ai/ai-routes.integration.test.ts +++ /dev/null @@ -1,719 +0,0 @@ -// @vitest-environment node -import type { Context } from "hono"; - -import { OpenAPIHono } from "@hono/zod-openapi"; -import { MockLanguageModelV4 } from "ai/test"; -import { eq } from "drizzle-orm"; -import { afterAll, beforeAll, expect, it } from "vitest"; -import { z } from "zod"; - -import type { AIModelDefinition } from "@/api/models/ai"; -import type { TestDatabaseHandle } from "@/tests/postgres"; - -import { defineAiAction } from "@/api/lib/ai/action"; -import { parseDecimal } from "@/api/lib/ai/decimal"; -import { - applyAiCostAdjustment, - expireAiLeases, -} from "@/api/lib/ai/maintenance"; -import { PostgresAiLedger } from "@/api/lib/ai/postgres-ledger"; -import { collectAiActions } from "@/api/lib/ai/registry"; -import { AIModel } from "@/api/models/ai"; -import * as adminsTables from "@/database/admins"; -import * as aiTables from "@/database/ai"; -import { - core_ai_budget_periods, - core_ai_calls, - core_ai_runs, -} from "@/database/ai"; -import * as filesTables from "@/database/files"; -import * as languagesTables from "@/database/languages"; -import { core_languages, core_languages_words } from "@/database/languages"; -import * as moderatorsTables from "@/database/moderators"; -import * as rolesTables from "@/database/roles"; -import { core_roles } from "@/database/roles"; -import * as sessionsTables from "@/database/sessions"; -import * as usersTables from "@/database/users"; -import { core_users } from "@/database/users"; -import { createTestCache } from "@/tests/cache"; -import { createTestDatabase, describePostgres } from "@/tests/postgres"; -import { grantStaffPermissions } from "@/tests/staff-permissions"; - -import { aiAdminModule } from "../admin/ai/ai.admin.module"; -import { aiModule } from "./ai.module"; - -const ADMIN_ID = 1; -const ALICE = 2; -const BOB = 3; - -const summarize = defineAiAction({ - authorize: () => true, - buildPrompt: input => ({ prompt: input.text }), - defaults: { - maxInputCharacters: 1_000, - maxOutputTokens: 50, - timeoutMs: 10_000, - }, - description: "Test action.", - id: "summary.generate", - title: "Test action", - inputSchema: z.object({ text: z.string() }), - output: "text", - outputSchema: z.string().min(1), - parseText: text => text.trim(), - permission: { defaultGranted: true, key: "summary" }, - promptVersion: 1, - requiredCapabilities: ["text"], -}); - -const aiActions = collectAiActions([ - { aiActions: [summarize], pluginId: "@acme/notes" }, -]); - -const answering = () => - new MockLanguageModelV4({ - doGenerate: async () => - Promise.resolve({ - content: [{ text: "Short.", type: "text" }], - finishReason: { raw: "stop", unified: "stop" }, - usage: { - inputTokens: { cacheRead: 0, cacheWrite: 0, noCache: 10, total: 10 }, - outputTokens: { reasoning: 0, text: 5, total: 5 }, - }, - warnings: [], - }), - }); - -describePostgres("AI routes (real PostgreSQL)", () => { - let database: TestDatabaseHandle; - const cache = createTestCache(); - let models: AIModelDefinition[]; - - const app = () => { - const hono = new OpenAPIHono(); - hono.use("*", async (c, next) => { - const userId = Number(c.req.header("x-user") ?? 0) || null; - const isAdmin = c.req.path.includes("/admin/"); - c.set("db", database.db); - c.set("cache", cache); - c.set("core" as never, { ai: { models }, aiActions } as never); - const identity = userId - ? { id: userId, roleId: userId === ADMIN_ID ? 1 : 2 } - : null; - c.set("user", (identity && !isAdmin ? identity : null) as never); - c.set( - "admin", - (identity && isAdmin ? { user: identity } : null) as never, - ); - c.set("ai", new AIModel(c as unknown as Context)); - await next(); - }); - hono.route("/ai", aiModule.hono); - hono.route("/admin/ai", aiAdminModule.hono); - - return hono; - }; - - const request = async ( - path: string, - { - body, - method = "GET", - user, - }: { body?: unknown; method?: string; user: number }, - ) => - await app().request(path, { - body: body === undefined ? undefined : JSON.stringify(body), - headers: { "Content-Type": "application/json", "x-user": String(user) }, - method, - }); - - const runFor = async (user: number) => - await request("/ai/run", { - body: { action: "@acme/notes:summary.generate", input: { text: "x" } }, - method: "POST", - user, - }); - - beforeAll(async () => { - database = await createTestDatabase({ - ...adminsTables, - ...moderatorsTables, - ...sessionsTables, - ...aiTables, - ...filesTables, - ...languagesTables, - ...rolesTables, - ...usersTables, - }); - models = [ - { - id: "default", - model: answering(), - name: "Test", - pricing: { - rates: { inputPerMillion: "1000", outputPerMillion: "2000" }, - }, - }, - ]; - await database.db - .insert(core_languages) - .values({ code: "en", default: true, name: "English" }); - await database.db.insert(core_roles).values([ - { id: 1, root: true, updatedAt: new Date() }, - { id: 2, updatedAt: new Date() }, - ]); - await database.db.insert(core_languages_words).values({ - itemId: 2, - languageCode: "en", - pluginCode: "core", - tableName: "core_roles", - value: "Member", - variable: "name", - }); - await database.db.insert(core_users).values( - [ADMIN_ID, ALICE, BOB].map(id => ({ - avatarColor: "000000", - email: `u${id}@test.test`, - id, - ipAddress: "127.0.0.1", - name: `u${id}`, - nameCode: `u${id}`, - roleId: id === ADMIN_ID ? 1 : 2, - })), - ); - await database.db.insert(aiTables.core_ai_role_policies).values({ - monthlyPoints: "1000", - roleId: 2, - }); - await grantStaffPermissions(cache, { - permissions: [ - { module: "ai", permission: "can_view", plugin: "@vitnode/core" }, - ], - userId: ADMIN_ID, - }); - }, 60_000); - - afterAll(async () => { - await database?.drop(); - }); - - it("runs an action for the signed-in user and returns safe usage", async () => { - const response = await runFor(ALICE); - - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - output: "Short.", - usage: { chargedPoints: "20", costKnown: true, modelId: "default" }, - }); - }); - - it("refuses anonymous runs", async () => { - const response = await runFor(0); - - expect(response.status).toBe(403); - expect(await response.json()).toMatchObject({ code: "AI_UNAUTHORIZED" }); - }); - - it("shows each user only their own history and usage", async () => { - await runFor(BOB); - await runFor(BOB); - - const alice = (await ( - await request("/ai/history", { user: ALICE }) - ).json()) as { - items: { id: number }[]; - }; - const bob = (await ( - await request("/ai/history", { user: BOB }) - ).json()) as { - items: { id: number }[]; - }; - const aliceRunIds = ( - await database.db - .select({ id: core_ai_runs.id }) - .from(core_ai_runs) - .where(eq(core_ai_runs.userId, ALICE)) - ).map(row => row.id); - - expect(alice.items.map(item => item.id).sort((a, b) => a - b)).toEqual( - aliceRunIds.sort((a, b) => a - b), - ); - expect(bob.items).toHaveLength(2); - const sneaky = (await ( - await request(`/ai/history?before=2147483647`, { - user: ALICE, - }) - ).json()) as { items: { id: number }[] }; - expect(sneaky.items.map(item => item.id).sort((a, b) => a - b)).toEqual( - aliceRunIds.sort((a, b) => a - b), - ); - - const usage = (await ( - await request("/ai/usage", { user: BOB }) - ).json()) as { - points: { total: string; used: string }; - }; - expect(usage.points).toMatchObject({ total: "1000", used: "40" }); - }); - - it("narrows a user's history to one feature and totals its month", async () => { - const page = (await ( - await request( - "/ai/history?action=%40acme%2Fnotes%3Asummary.generate&limit=1", - { user: BOB }, - ) - ).json()) as { - items: { actionKey: string }[]; - nextBefore: null | number; - }; - const other = (await ( - await request("/ai/history?action=%40acme%2Fnotes%3Aother", { - user: BOB, - }) - ).json()) as { items: unknown[] }; - const usage = (await ( - await request("/ai/usage", { user: BOB }) - ).json()) as { - actions: { key: string; monthPoints: string; title: string }[]; - points: { used: string }; - }; - - expect(page.items).toEqual([ - expect.objectContaining({ actionKey: "@acme/notes:summary.generate" }), - ]); - expect(page.nextBefore).not.toBeNull(); - expect(other.items).toEqual([]); - expect(usage.actions).toEqual([ - expect.objectContaining({ - key: "@acme/notes:summary.generate", - monthPoints: usage.points.used, - }), - ]); - expect(usage.actions[0]?.title).toBeTruthy(); - }); - - it("only lets a user give feedback on their own run", async () => { - const [bobsRun] = await database.db - .select({ id: core_ai_runs.id }) - .from(core_ai_runs) - .where(eq(core_ai_runs.userId, BOB)) - .limit(1); - - const foreign = await request(`/ai/runs/${bobsRun.id}/feedback`, { - body: { accepted: true }, - method: "POST", - user: ALICE, - }); - const own = await request(`/ai/runs/${bobsRun.id}/feedback`, { - body: { accepted: true }, - method: "POST", - user: BOB, - }); - - expect(foreign.status).toBe(404); - expect(own.status).toBe(200); - }); - - it("keeps the AdminCP behind staff permissions", async () => { - const withView = await request("/admin/ai/history", { user: ADMIN_ID }); - const without = await request("/admin/ai/history", { user: ALICE }); - const manage = await request("/admin/ai/settings", { - body: { - altBatchSize: 10, - altEnabled: false, - defaultMonthlyPoints: "0", - enabled: true, - historyRetentionDays: 365, - monthlyBudgetUsd: null, - systemConcurrency: 2, - systemMonthlyBudgetUsd: null, - userConcurrency: 2, - userRequestsPerMinute: 10, - }, - method: "PUT", - user: ADMIN_ID, - }); - - expect(withView.status).toBe(200); - expect(without.status).toBe(403); - expect(manage.status).toBe(403); - }); - - it("refuses to switch on automatic ALT without a site budget", async () => { - await grantStaffPermissions(cache, { - permissions: [ - { module: "ai", permission: "can_view", plugin: "@vitnode/core" }, - { module: "ai", permission: "can_manage", plugin: "@vitnode/core" }, - ], - userId: ADMIN_ID, - }); - const settings = { - altBatchSize: 10, - altEnabled: true, - defaultMonthlyPoints: "0", - enabled: true, - historyRetentionDays: 365, - monthlyBudgetUsd: null, - systemConcurrency: 2, - systemMonthlyBudgetUsd: null, - userConcurrency: 2, - userRequestsPerMinute: 10, - }; - - const refused = await request("/admin/ai/settings", { - body: settings, - method: "PUT", - user: ADMIN_ID, - }); - const saved = await request("/admin/ai/settings", { - body: { ...settings, monthlyBudgetUsd: "25" }, - method: "PUT", - user: ADMIN_ID, - }); - - expect(refused.status).toBe(400); - expect(saved.status).toBe(200); - expect( - (await ( - await request("/admin/ai/settings", { user: ADMIN_ID }) - ).json()) as object, - ).toMatchObject({ altEnabled: true, monthlyBudgetUsd: "25" }); - }); - - it("reports known cost, coverage and averages without counting unknown as zero", async () => { - models = [{ ...models[0], pricing: undefined }]; - await database.db - .update(aiTables.core_ai_settings) - .set({ altEnabled: false, monthlyBudgetUsd: null }); - await database.db - .update(aiTables.core_ai_role_policies) - .set({ unlimited: true }) - .where(eq(aiTables.core_ai_role_policies.roleId, 2)); - await runFor(ALICE); - - const response = await request("/admin/ai/overview", { user: ADMIN_ID }); - const overview = (await response.json()) as { - budget: { - days: { costUsd: string; day: string }[]; - forecastUsd: null | string; - live: boolean; - month: string; - }; - byAction: { - current: { knownOperations: number; operations: number }; - key: string; - }[]; - compare: { kind: string }; - range: { - days: { day: string; operations: number }[]; - end: string; - preset: null | string; - start: string; - totals: { - knownCostUsd: string; - knownOperations: number; - operations: number; - }; - }; - today: string; - }; - - expect(response.status).toBe(200); - expect(overview.range.preset).toBe("this-month"); - expect(overview.range.end).toBe(overview.today); - expect(overview.range.start).toBe(`${overview.today.slice(0, 7)}-01`); - expect(overview.compare.kind).toBe("month-to-date"); - expect(overview.range.totals.operations).toBe(4); - expect(overview.range.totals.knownOperations).toBe(3); - expect(overview.range.totals.knownCostUsd).toBe("0.06"); - expect( - overview.range.days.reduce((total, day) => total + day.operations, 0), - ).toBe(4); - expect(overview.byAction).toEqual([ - expect.objectContaining({ - current: expect.objectContaining({ - knownOperations: 3, - operations: 4, - }), - key: "@acme/notes:summary.generate", - }), - ]); - expect(overview.budget.live).toBe(true); - expect(overview.budget.month).toBe(overview.today.slice(0, 7)); - expect(overview.budget.forecastUsd).not.toBeNull(); - }); - - it("reads a custom range and a past budget month, and refuses non-staff", async () => { - const custom = await request( - "/admin/ai/overview?from=2020-01-31&to=2020-01-01&month=2020-01", - { user: ADMIN_ID }, - ); - const notStaff = await request("/admin/ai/overview", { user: ALICE }); - const body = (await custom.json()) as { - budget: { forecastUsd: null | string; live: boolean; month: string }; - compare: { end: string; kind: string; start: string }; - range: { end: string; preset: null | string; start: string }; - }; - - expect(custom.status).toBe(200); - expect(body.range).toMatchObject({ - end: "2020-01-31", - preset: null, - start: "2020-01-01", - }); - expect(body.compare).toMatchObject({ - end: "2019-12-31", - kind: "previous-month", - start: "2019-12-01", - }); - expect(body.budget).toMatchObject({ - forecastUsd: null, - live: false, - month: "2020-01", - }); - expect(notStaff.status).toBe(403); - }); - - it("reads a role's AI access for the role form, with action titles", async () => { - const existing = await request("/admin/ai/access/roles?roleId=1", { - user: ADMIN_ID, - }); - const creating = await request("/admin/ai/access/roles", { - user: ADMIN_ID, - }); - const notStaff = await request("/admin/ai/access/roles?roleId=1", { - user: ALICE, - }); - - expect(existing.status).toBe(200); - expect(await existing.json()).toEqual({ - permissions: [ - { - actions: [ - { - icon: null, - description: "Test action.", - key: "@acme/notes:summary.generate", - title: "Test action", - }, - ], - defaultGranted: true, - key: "@acme/notes:summary", - }, - ], - role: { grants: [], monthlyPoints: null, root: true, unlimited: false }, - }); - expect(await creating.json()).toMatchObject({ role: null }); - expect(notStaff.status).toBe(403); - }); - - it("saves a member's AI exception and reads it back for their page", async () => { - await grantStaffPermissions(cache, { - permissions: [ - { module: "ai", permission: "can_view", plugin: "@vitnode/core" }, - { module: "ai", permission: "can_manage", plugin: "@vitnode/core" }, - ], - userId: ADMIN_ID, - }); - const path = `/admin/ai/access/users/${BOB}`; - const before = await request(path, { user: ADMIN_ID }); - const denied = await request("/admin/ai/access/users", { - body: { - blocked: true, - monthlyPoints: null, - unlimited: false, - userId: BOB, - }, - method: "PUT", - user: ALICE, - }); - const saved = await request("/admin/ai/access/users", { - body: { - blocked: true, - monthlyPoints: null, - unlimited: false, - userId: BOB, - }, - method: "PUT", - user: ADMIN_ID, - }); - const after = await request(path, { user: ADMIN_ID }); - - expect(await before.json()).toEqual({ override: null }); - expect(denied.status).toBe(403); - expect(saved.status).toBe(200); - expect(await after.json()).toEqual({ - override: { blocked: true, monthlyPoints: null, unlimited: false }, - }); - }); -}); - -describePostgres("AI maintenance (real PostgreSQL)", () => { - let database: TestDatabaseHandle; - let ledger: PostgresAiLedger; - - const reserve = async (userId: null | number, now = new Date()) => { - const result = await ledger.reserve({ - actionKey: "@acme/notes:summary.generate", - actorType: userId === null ? "system" : "user", - daily: null, - globalMonthlyUsd: null, - idempotencyKey: null, - leaseMs: 1_000, - model: { - modelId: "default", - provider: "gateway", - providerModelId: "x/y", - }, - now, - pluginId: "@acme/notes", - promptVersion: 1, - resource: undefined, - sourceFingerprint: null, - systemConcurrency: 10, - systemMonthlyUsd: null, - timeZone: "UTC", - usd: parseDecimal("0.05"), - userConcurrency: 10, - userId, - userMonthlyPoints: null, - userRequestsPerMinute: 100, - }); - if (!result.ok) throw new Error(result.code); - - return result.runId; - }; - - const spent = async (scopeKey: string) => { - const [row] = await database.db - .select({ spent: core_ai_budget_periods.spentAmount }) - .from(core_ai_budget_periods) - .where(eq(core_ai_budget_periods.scopeKey, scopeKey)); - - return row?.spent; - }; - - beforeAll(async () => { - database = await createTestDatabase({ - ...adminsTables, - ...moderatorsTables, - ...sessionsTables, - ...aiTables, - ...filesTables, - ...languagesTables, - ...rolesTables, - ...usersTables, - }); - ledger = new PostgresAiLedger(database.db); - await database.db - .insert(core_languages) - .values({ code: "en", default: true, name: "English" }); - await database.db - .insert(core_roles) - .values({ id: 1, updatedAt: new Date() }); - await database.db.insert(core_users).values({ - avatarColor: "000000", - email: "u@test.test", - id: 1, - ipAddress: "127.0.0.1", - name: "u", - nameCode: "u", - roleId: 1, - }); - }, 60_000); - - afterAll(async () => { - await database?.drop(); - }); - - it("settles a run whose process died as uncertain, charging the site but not the user", async () => { - const runId = await reserve(1, new Date(Date.now() - 10_000)); - await ledger.markRunning(runId, new Date(Date.now() - 5_000)); - await ledger.beginCall({ - attempt: 1, - modelId: "default", - provider: "gateway", - providerModelId: "x/y", - runId, - startedAt: new Date(Date.now() - 9_000), - }); - - expect(await expireAiLeases(database.db)).toBe(1); - const [run] = await database.db - .select() - .from(core_ai_runs) - .where(eq(core_ai_runs.id, runId)); - expect(run).toMatchObject({ - chargedPoints: "0.000000000000", - chargedUsd: "0.050000000000", - settlement: "settled", - status: "uncertain", - }); - expect(await expireAiLeases(database.db)).toBe(0); - }); - - it("replaces an estimate with the billed cost once, moving budgets by the difference", async () => { - const runId = await reserve(null); - const callId = await ledger.beginCall({ - attempt: 1, - modelId: "default", - provider: "gateway", - providerModelId: "x/y", - runId, - startedAt: new Date(), - }); - await ledger.finishCall(callId, { - cost: { - amountUsd: "0.01", - pricingVersion: "config:1", - source: "pricing", - }, - errorCode: null, - finishedAt: new Date(), - images: 0, - pricingSnapshot: null, - providerModelId: "x/y", - providerRequestId: "gen_1", - status: "succeeded", - usage: { - cacheReadTokens: 0, - cacheWriteTokens: 0, - inputTokens: 10, - outputTokens: 5, - reasoningTokens: null, - }, - }); - await ledger.settle(runId, { - delivered: true, - errorCode: null, - finishedAt: new Date(), - status: "succeeded", - }); - const before = parseDecimal((await spent("system")) ?? "0"); - - const first = await applyAiCostAdjustment(database.db, { - billedUsd: parseDecimal("0.012"), - callId, - reason: "gateway", - }); - const second = await applyAiCostAdjustment(database.db, { - billedUsd: parseDecimal("0.012"), - callId, - reason: "gateway", - }); - - expect([first, second]).toEqual([true, false]); - expect(parseDecimal((await spent("system")) ?? "0") - before).toBe( - parseDecimal("0.002"), - ); - const [call] = await database.db - .select({ - costSource: core_ai_calls.costSource, - costUsd: core_ai_calls.costUsd, - }) - .from(core_ai_calls) - .where(eq(core_ai_calls.id, callId)); - expect(call).toEqual({ costSource: "provider", costUsd: "0.012000000000" }); - }); -}); diff --git a/packages/vitnode/src/components/ui/sidebar-glide.ts b/packages/vitnode/src/components/ui/sidebar-glide.ts index feb35e7bf4..52de42e673 100644 --- a/packages/vitnode/src/components/ui/sidebar-glide.ts +++ b/packages/vitnode/src/components/ui/sidebar-glide.ts @@ -22,6 +22,19 @@ const findActiveItem = (area: HTMLElement) => { const itemOf = (target: EventTarget | null) => target instanceof Element ? target.closest(ITEM_SELECTOR) : null; +const contains = (rect: DOMRect, x: number, y: number) => + x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + +const itemAt = (area: HTMLElement, x: number, y: number) => { + if (!contains(area.getBoundingClientRect(), x, y)) return null; + + return ( + [...area.querySelectorAll(ITEM_SELECTOR)].find( + item => isShown(item) && contains(item.getBoundingClientRect(), x, y), + ) ?? null + ); +}; + const createIndicator = (area: HTMLElement, indicator: HTMLElement) => { let current: Element | null = null; @@ -54,11 +67,16 @@ const createIndicator = (area: HTMLElement, indicator: HTMLElement) => { indicator.style.width === box.width && indicator.style.height === box.height; + const setVisible = (isVisible: boolean) => { + indicator.style.opacity = isVisible ? "1" : "0"; + indicator.toggleAttribute("data-hidden", !isVisible); + }; + const show = (item: Element | null, isVisible = true) => { if (!item) { - if (current) indicator.style.transitionProperty = "opacity"; + if (current) indicator.style.transitionProperty = "opacity, scale"; current = null; - indicator.style.opacity = "0"; + setVisible(false); return; } @@ -66,21 +84,31 @@ const createIndicator = (area: HTMLElement, indicator: HTMLElement) => { const box = measure(item); if (item === current) { if (!isPlacedAt(box)) { - indicator.style.transitionProperty = "none"; + indicator.style.transitionProperty = "scale"; place(box); } } else { - indicator.style.transitionProperty = current ? "" : "opacity"; + indicator.style.transitionProperty = current ? "" : "opacity, scale"; current = item; place(box); } - indicator.style.opacity = isVisible ? "1" : "0"; + setVisible(isVisible); + }; + + const press = (item: Element | null) => { + indicator.toggleAttribute( + "data-pressed", + item !== null && + item === current && + !indicator.hasAttribute("data-hidden"), + ); }; return { get current() { return current; }, + press, show, }; }; @@ -103,7 +131,13 @@ export const glideSidebarIndicators = (area: HTMLElement) => { const active = createIndicator(area, activeElement); let hoveredItem: Element | null = null; + const press = (item: Element | null) => { + hover.press(item); + active.press(item); + }; + const showHover = (item: Element | null) => { + if (item !== hoveredItem) press(null); hoveredItem = item; hover.show(item, item !== active.current); }; @@ -152,6 +186,34 @@ export const glideSidebarIndicators = (area: HTMLElement) => { }, { signal }, ); + area.addEventListener( + "pointerdown", + event => { + if (event.button === 0) press(itemOf(event.target)); + }, + { signal }, + ); + for (const type of ["pointerup", "pointercancel"] as const) { + area.addEventListener( + type, + () => { + press(null); + }, + { signal }, + ); + } + document.addEventListener( + "pointermove", + event => { + if ( + event.target === document.documentElement && + event.pointerType === "mouse" + ) { + showHover(itemAt(area, event.clientX, event.clientY)); + } + }, + { signal }, + ); area.addEventListener( "pointerleave", () => { diff --git a/packages/vitnode/src/components/ui/sidebar.test.tsx b/packages/vitnode/src/components/ui/sidebar.test.tsx index 72b92cb98b..d81a4ddf68 100644 --- a/packages/vitnode/src/components/ui/sidebar.test.tsx +++ b/packages/vitnode/src/components/ui/sidebar.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + Sidebar, SidebarContent, SidebarMenu, SidebarMenuButton, @@ -87,6 +88,10 @@ describe("SidebarContent gliding indicators", () => { ); vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation( function (this: Element) { + if (this.matches('[data-sidebar="content"]')) { + return new DOMRect(0, 0, 200, 400); + } + return rectOf(ITEM_TOPS[this.textContent] ?? 0); }, ); @@ -154,4 +159,101 @@ describe("SidebarContent gliding indicators", () => { expect(indicator("hover").style.opacity).not.toBe("1"); }); + + it("shrinks the indicator under a pressed item and restores it on release", () => { + render(