diff --git a/.envrc b/.envrc deleted file mode 100644 index 3550a30..0000000 --- a/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..120c689 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..821fb35 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,61 @@ +name-template: '$RESOLVED_VERSION' +tag-template: '$RESOLVED_VERSION' + +version-resolver: + major: + labels: + - 'major' + minor: + labels: + - 'minor' + patch: + labels: + - 'patch' + default: minor + +categories: + - title: 'Features' + label: 'enhancement' + - title: 'Bug Fixes' + label: 'bug' + - title: 'Dependencies' + label: 'dependencies' + +exclude-labels: + - 'skip' + +autolabeler: + - label: 'bug' + title: + - '/.*\[fix\].*/' + - label: 'patch' + title: + - '/.*\[fix\].*/' + - label: 'enhancement' + title: + - '/.*\[feat\].*/' + - label: 'minor' + title: + - '/.*\[feat\].*/' + - label: 'skip' + title: + - '/.*\[skip\].*/' + - label: 'major' + title: + - '/.*\[breaking\].*/' + +replacers: + - search: '/\[feat\]/g' + replace: '' + - search: '/\[fix\]/g' + replace: '' + - search: '/\[skip\]/g' + replace: '' + - search: '/\[breaking\]/g' + replace: '' + +template: | + # What's Changed + + $CHANGES + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f62bb6..5bb79ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,299 +1,254 @@ -# This file was automatically generated by sbt-github-actions using the -# githubWorkflowGenerate task. You should add and commit this file to -# your git repository. It goes without saying that you shouldn't edit -# this file by hand! Instead, if you wish to make changes, you should -# change your sbt build configuration to revise the workflow description -# to meet your needs, then regenerate this file. - -name: Continuous Integration - -on: - pull_request: - branches: ['**', '!update/**', '!pr/**'] - push: - branches: ['**', '!update/**', '!pr/**'] - tags: [v*] +# This file was autogenerated using `zio-sbt-ci` plugin via `sbt ciGenerateGithubWorkflow` +# task and should be included in the git repository. Please do not edit it manually. +name: CI env: - PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} - SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} - SONATYPE_CREDENTIAL_HOST: ${{ secrets.SONATYPE_CREDENTIAL_HOST }} - SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} - PGP_SECRET: ${{ secrets.PGP_SECRET }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + JDK_JAVA_OPTIONS: -XX:+PrintCommandLineFlags -Xms6G -Xmx6G -Xss4M -XX:+UseG1GC + JVM_OPTS: -XX:+PrintCommandLineFlags -Xms6G -Xmx6G -Xss4M -XX:+UseG1GC +'on': + workflow_dispatch: {} + release: + types: + - published + push: + branches: + - main + pull_request: {} + create: {} jobs: build: - name: Build and Test - strategy: - matrix: - os: [ubuntu-latest] - scala: [2.13.11, 3.3.0] - java: [temurin@8] - project: [rootJS, rootJVM] - runs-on: ${{ matrix.os }} + name: Build + runs-on: ubuntu-latest + continue-on-error: true steps: - - name: Checkout current branch (full) - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Download Java (temurin@8) - id: download-java-temurin-8 - if: matrix.java == 'temurin@8' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 8 - - - name: Setup Java (temurin@8) - if: matrix.java == 'temurin@8' - uses: actions/setup-java@v3 - with: - distribution: jdkfile - java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} - - - name: Cache sbt - uses: actions/cache@v3 - with: - path: | - ~/.sbt - ~/.ivy2/cache - ~/.coursier/cache/v1 - ~/.cache/coursier/v1 - ~/AppData/Local/Coursier/Cache/v1 - ~/Library/Caches/Coursier/v1 - key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} - - - name: Check that workflows are up to date - run: sbt githubWorkflowCheck - - - name: Check headers and formatting - if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' - run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' headerCheckAll scalafmtCheckAll 'project /' scalafmtSbtCheck - - - name: scalaJSLink - if: matrix.project == 'rootJS' - run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' Test/scalaJSLinkerResult - - - name: Test - run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' test - - - name: Check binary compatibility - if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' - run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' mimaReportBinaryIssues - - - name: Generate API documentation - if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-latest' - run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' doc - - - name: Make target directories - if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p target .js/target site/target core/.js/target core/.jvm/target .jvm/target .native/target project/target - - - name: Compress target directories - if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar target .js/target site/target core/.js/target core/.jvm/target .jvm/target .native/target project/target - - - name: Upload target directories - if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - uses: actions/upload-artifact@v3 - with: - name: target-${{ matrix.os }}-${{ matrix.java }}-${{ matrix.scala }}-${{ matrix.project }} - path: targets.tar - - publish: - name: Publish Artifacts - needs: [build] - if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - strategy: - matrix: - os: [ubuntu-latest] - java: [temurin@8] - runs-on: ${{ matrix.os }} + - name: Git Checkout + uses: actions/checkout@v3.5.0 + with: + fetch-depth: '0' + - name: Install libuv + run: sudo apt-get update && sudo apt-get install -y libuv1-dev + - name: Setup Scala + uses: actions/setup-java@v3.11.0 + with: + distribution: temurin + java-version: '8' + check-latest: true + - name: Cache Dependencies + uses: coursier/cache-action@v6 + - name: Check all code compiles + run: sbt +Test/compile + - name: Check artifacts build process + run: sbt +publishLocal + - name: Check website build process + run: sbt docs/clean; sbt docs/buildWebsite + lint: + name: Lint + runs-on: ubuntu-latest + continue-on-error: false steps: - - name: Checkout current branch (full) - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Download Java (temurin@8) - id: download-java-temurin-8 - if: matrix.java == 'temurin@8' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 8 - - - name: Setup Java (temurin@8) - if: matrix.java == 'temurin@8' - uses: actions/setup-java@v3 - with: - distribution: jdkfile - java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} - - - name: Cache sbt - uses: actions/cache@v3 - with: - path: | - ~/.sbt - ~/.ivy2/cache - ~/.coursier/cache/v1 - ~/.cache/coursier/v1 - ~/AppData/Local/Coursier/Cache/v1 - ~/Library/Caches/Coursier/v1 - key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} - - - name: Download target directories (2.13.11, rootJS) - uses: actions/download-artifact@v3 - with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.11-rootJS - - - name: Inflate target directories (2.13.11, rootJS) - run: | - tar xf targets.tar - rm targets.tar - - - name: Download target directories (2.13.11, rootJVM) - uses: actions/download-artifact@v3 - with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.13.11-rootJVM - - - name: Inflate target directories (2.13.11, rootJVM) - run: | - tar xf targets.tar - rm targets.tar - - - name: Download target directories (3.3.0, rootJS) - uses: actions/download-artifact@v3 - with: - name: target-${{ matrix.os }}-${{ matrix.java }}-3.3.0-rootJS - - - name: Inflate target directories (3.3.0, rootJS) - run: | - tar xf targets.tar - rm targets.tar - - - name: Download target directories (3.3.0, rootJVM) - uses: actions/download-artifact@v3 - with: - name: target-${{ matrix.os }}-${{ matrix.java }}-3.3.0-rootJVM - - - name: Inflate target directories (3.3.0, rootJVM) - run: | - tar xf targets.tar - rm targets.tar - - - name: Import signing key - if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE == '' - run: echo $PGP_SECRET | base64 -di | gpg --import - - - name: Import signing key and strip passphrase - if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE != '' - run: | - echo "$PGP_SECRET" | base64 -di > /tmp/signing-key.gpg - echo "$PGP_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --import /tmp/signing-key.gpg - (echo "$PGP_PASSPHRASE"; echo; echo) | gpg --command-fd 0 --pinentry-mode loopback --change-passphrase $(gpg --list-secret-keys --with-colons 2> /dev/null | grep '^sec:' | cut --delimiter ':' --fields 5 | tail -n 1) - - - name: Publish - run: sbt tlCiRelease - - coverage: - name: Generate coverage report + - name: Git Checkout + uses: actions/checkout@v3.5.0 + with: + fetch-depth: '0' + - name: Install libuv + run: sudo apt-get update && sudo apt-get install -y libuv1-dev + - name: Setup Scala + uses: actions/setup-java@v3.11.0 + with: + distribution: temurin + java-version: '8' + check-latest: true + - name: Cache Dependencies + uses: coursier/cache-action@v6 + - name: Check if the site workflow is up to date + run: sbt ciCheckGithubWorkflow + - name: Lint + run: sbt lint + test: + name: Test + runs-on: ubuntu-latest + continue-on-error: false strategy: + fail-fast: false matrix: - os: [ubuntu-latest] - scala: [2.13.11] - java: [temurin@8] - runs-on: ${{ matrix.os }} + java: + - '8' + - '11' + - '17' steps: - - name: Checkout current branch (fast) - uses: actions/checkout@v3 - - - name: Download Java (temurin@8) - id: download-java-temurin-8 - if: matrix.java == 'temurin@8' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 8 - - - name: Setup Java (temurin@8) - if: matrix.java == 'temurin@8' - uses: actions/setup-java@v3 - with: - distribution: jdkfile - java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} - - - name: Cache sbt - uses: actions/cache@v3 - with: - path: | - ~/.sbt - ~/.ivy2/cache - ~/.coursier/cache/v1 - ~/.cache/coursier/v1 - ~/AppData/Local/Coursier/Cache/v1 - ~/Library/Caches/Coursier/v1 - key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} - - - run: sbt '++ ${{ matrix.scala }}' coverage rootJVM/test coverageAggregate - - - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - uses: codecov/codecov-action@v3 - - site: - name: Generate Site - strategy: - matrix: - os: [ubuntu-latest] - java: [temurin@8] - runs-on: ${{ matrix.os }} + - name: Install libuv + run: sudo apt-get update && sudo apt-get install -y libuv1-dev + - name: Setup Scala + uses: actions/setup-java@v3.11.0 + with: + distribution: temurin + java-version: ${{ matrix.java }} + check-latest: true + - name: Cache Dependencies + uses: coursier/cache-action@v6 + - name: Git Checkout + uses: actions/checkout@v3.5.0 + with: + fetch-depth: '0' + - name: Test + run: sbt +test + update-readme: + name: Update README + runs-on: ubuntu-latest + continue-on-error: false + if: ${{ github.event_name == 'push' }} steps: - - name: Checkout current branch (full) - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Download Java (temurin@8) - id: download-java-temurin-8 - if: matrix.java == 'temurin@8' - uses: typelevel/download-java@v2 - with: - distribution: temurin - java-version: 8 - - - name: Setup Java (temurin@8) - if: matrix.java == 'temurin@8' - uses: actions/setup-java@v3 - with: - distribution: jdkfile - java-version: 8 - jdkFile: ${{ steps.download-java-temurin-8.outputs.jdkFile }} - - - name: Cache sbt - uses: actions/cache@v3 - with: - path: | - ~/.sbt - ~/.ivy2/cache - ~/.coursier/cache/v1 - ~/.cache/coursier/v1 - ~/AppData/Local/Coursier/Cache/v1 - ~/Library/Caches/Coursier/v1 - key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} - - - name: Generate site - run: sbt docs/tlSite - - - name: Publish site - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - uses: peaceiris/actions-gh-pages@v3.9.0 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: site/target/docs/site - keep_files: true + - name: Git Checkout + uses: actions/checkout@v3.5.0 + with: + fetch-depth: '0' + - name: Install libuv + run: sudo apt-get update && sudo apt-get install -y libuv1-dev + - name: Setup Scala + uses: actions/setup-java@v3.11.0 + with: + distribution: temurin + java-version: '8' + check-latest: true + - name: Cache Dependencies + uses: coursier/cache-action@v6 + - name: Generate Readme + run: sbt docs/generateReadme + - name: Commit Changes + run: | + git config --local user.email "zio-assistant[bot]@users.noreply.github.com" + git config --local user.name "ZIO Assistant" + git add README.md + git commit -m "Update README.md" || echo "No changes to commit" + - name: Generate Token + id: generate-token + uses: zio/generate-github-app-token@v1.0.0 + with: + app_id: ${{ secrets.APP_ID }} + app_private_key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Create Pull Request + id: cpr + uses: peter-evans/create-pull-request@v5.0.0 + with: + body: |- + Autogenerated changes after running the `sbt docs/generateReadme` command of the [zio-sbt-website](https://zio.dev/zio-sbt) plugin. + + I will automatically update the README.md file whenever there is new change for README.md, e.g. + - After each release, I will update the version in the installation section. + - After any changes to the "docs/index.md" file, I will update the README.md file accordingly. + branch: zio-sbt-website/update-readme + commit-message: Update README.md + token: ${{ steps.generate-token.outputs.token }} + delete-branch: true + title: Update README.md + - name: Approve PR + if: ${{ steps.cpr.outputs.pull-request-number }} + run: gh pr review "$PR_URL" --approve + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.cpr.outputs.pull-request-url }} + - name: Enable Auto-Merge + if: ${{ steps.cpr.outputs.pull-request-number }} + run: gh pr merge --auto --squash "$PR_URL" || gh pr merge --squash "$PR_URL" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.cpr.outputs.pull-request-url }} + ci: + name: ci + runs-on: ubuntu-latest + continue-on-error: false + needs: + - lint + - test + - build + steps: + - name: Report Successful CI + run: echo "ci passed" + release: + name: Release + runs-on: ubuntu-latest + continue-on-error: false + needs: + - ci + if: ${{ github.event_name != 'pull_request' }} + steps: + - name: Git Checkout + uses: actions/checkout@v3.5.0 + with: + fetch-depth: '0' + - name: Install libuv + run: sudo apt-get update && sudo apt-get install -y libuv1-dev + - name: Setup Scala + uses: actions/setup-java@v3.11.0 + with: + distribution: temurin + java-version: '8' + check-latest: true + - name: Cache Dependencies + uses: coursier/cache-action@v6 + - name: Release + run: sbt ci-release + env: + PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} + PGP_SECRET: ${{ secrets.PGP_SECRET }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + release-docs: + name: Release Docs + runs-on: ubuntu-latest + continue-on-error: false + needs: + - release + if: ${{ ((github.event_name == 'release') && (github.event.action == 'published')) || (github.event_name == 'workflow_dispatch') }} + steps: + - name: Git Checkout + uses: actions/checkout@v3.5.0 + with: + fetch-depth: '0' + - name: Install libuv + run: sudo apt-get update && sudo apt-get install -y libuv1-dev + - name: Setup Scala + uses: actions/setup-java@v3.11.0 + with: + distribution: temurin + java-version: '8' + check-latest: true + - name: Cache Dependencies + uses: coursier/cache-action@v6 + - name: Setup NodeJs + uses: actions/setup-node@v3 + with: + node-version: 16.x + registry-url: https://registry.npmjs.org + - name: Publish Docs to NPM Registry + run: sbt docs/publishToNpm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + notify-docs-release: + name: Notify Docs Release + runs-on: ubuntu-latest + continue-on-error: false + needs: + - release-docs + if: ${{ (github.event_name == 'release') && (github.event.action == 'published') }} + steps: + - name: Git Checkout + uses: actions/checkout@v3.5.0 + with: + fetch-depth: '0' + - name: notify the main repo about the new release of docs package + run: | + PACKAGE_NAME=$(cat docs/package.json | grep '"name"' | awk -F'"' '{print $4}') + PACKAGE_VERSION=$(npm view $PACKAGE_NAME version) + curl -L \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: token ${{ secrets.PAT_TOKEN }}"\ + https://api.github.com/repos/zio/zio/dispatches \ + -d '{ + "event_type":"update-docs", + "client_payload":{ + "package_name":"'"${PACKAGE_NAME}"'", + "package_version": "'"${PACKAGE_VERSION}"'" + } + }' diff --git a/.github/workflows/clean.yml b/.github/workflows/clean.yml deleted file mode 100644 index 547aaa4..0000000 --- a/.github/workflows/clean.yml +++ /dev/null @@ -1,59 +0,0 @@ -# This file was automatically generated by sbt-github-actions using the -# githubWorkflowGenerate task. You should add and commit this file to -# your git repository. It goes without saying that you shouldn't edit -# this file by hand! Instead, if you wish to make changes, you should -# change your sbt build configuration to revise the workflow description -# to meet your needs, then regenerate this file. - -name: Clean - -on: push - -jobs: - delete-artifacts: - name: Delete Artifacts - runs-on: ubuntu-latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - name: Delete artifacts - run: | - # Customize those three lines with your repository and credentials: - REPO=${GITHUB_API_URL}/repos/${{ github.repository }} - - # A shortcut to call GitHub API. - ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } - - # A temporary file which receives HTTP response headers. - TMPFILE=/tmp/tmp.$$ - - # An associative array, key: artifact name, value: number of artifacts of that name. - declare -A ARTCOUNT - - # Process all artifacts on this repository, loop on returned "pages". - URL=$REPO/actions/artifacts - while [[ -n "$URL" ]]; do - - # Get current page, get response headers in a temporary file. - JSON=$(ghapi --dump-header $TMPFILE "$URL") - - # Get URL of next page. Will be empty if we are at the last page. - URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') - rm -f $TMPFILE - - # Number of artifacts on this page: - COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) - - # Loop on all artifacts on this page. - for ((i=0; $i < $COUNT; i++)); do - - # Get name of artifact and count instances of this name. - name=$(jq <<<$JSON -r ".artifacts[$i].name?") - ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) - - id=$(jq <<<$JSON -r ".artifacts[$i].id?") - size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) - printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size - ghapi -X DELETE $REPO/actions/artifacts/$id - done - done diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..58bddce --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,13 @@ +name: Release Drafter + +on: + push: + branches: ['master'] + +jobs: + update_release_draft: + runs-on: ubuntu-20.04 + steps: + - uses: release-drafter/release-drafter@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/scala-steward.yml b/.github/workflows/scala-steward.yml new file mode 100644 index 0000000..c4ee397 --- /dev/null +++ b/.github/workflows/scala-steward.yml @@ -0,0 +1,16 @@ +name: Scala Steward + +# This workflow will launch everyday at 00:00 +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: {} + +jobs: + scala-steward: + timeout-minutes: 45 + runs-on: ubuntu-latest + name: Scala Steward + steps: + - name: Scala Steward + uses: scala-steward-org/scala-steward-action@v2.61.0 diff --git a/.gitignore b/.gitignore index 7b314d1..235ce04 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,216 @@ -# sbt + +# Generated files +bin/ +gen/ +out/ + +# IntelliJ +*.iml +.idea + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +dist/* target/ -project/plugins/project/ -boot/ lib_managed/ src_managed/ +project/boot/ +project/plugins/project/ +.history +.cache +.lib/ +*.class +*.log + +.metals/ +metals.sbt +.bloop/ +project/secret +.bsp/ +.vscode/ -# vim -*.sw? +.env +.env.* +# Simple Build Tool +# http://www.scala-sbt.org/release/docs/Getting-Started/Directories.html#configuring-version-control -# intellij -.idea/ -# ignore [ce]tags files -tags +### Linux template +*~ -# metals -.metals/ -.bsp/ -.bloop/ -metals.sbt -.vscode +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ + +# mpeltonen/sbt-idea plugin + +# JIRA plugin +atlassian-ide-plugin.xml -# npm -node_modules/ +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff + +# Generated files + +# Sensitive or high-churn files + +# Gradle + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake + +# Mongo Explorer plugin + +# File-based project format + +# IntelliJ + +# mpeltonen/sbt-idea plugin + +# JIRA plugin + +# Cursive Clojure plugin + +# Crashlytics plugin (for Android Studio and IntelliJ) + +# Editor-based Rest Client + +# Android studio 3.1+ serialized cache file + +### Scala template + +### VisualStudioCode template +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +### Metals template + # Generated Metals (Scala Language Server) files + # Reference: https://scalameta.org/metals/ +project/metals.sbt + +.idea/ diff --git a/.mergify.yml b/.mergify.yml deleted file mode 100644 index 8ec2180..0000000 --- a/.mergify.yml +++ /dev/null @@ -1,11 +0,0 @@ -pull_request_rules: - - name: Automatic merge of scala-steward PR - conditions: - - author=scala-steward - - check-success=Build and Test (ubuntu-latest, 2.13.11, temurin@8, rootJVM) - - check-success=Build and Test (ubuntu-latest, 2.13.11, temurin@8, rootJVM) - - check-success=Build and Test (ubuntu-latest, 3.3.0, temurin@8, rootJVM) - - check-success=Build and Test (ubuntu-latest, 3.3.0, temurin@8, rootJS) - actions: - merge: - method: merge diff --git a/.scalafix.conf b/.scalafix.conf new file mode 100644 index 0000000..3f25405 --- /dev/null +++ b/.scalafix.conf @@ -0,0 +1,30 @@ +rules = [ + Disable + DisableSyntax + ExplicitResultTypes + LeakingImplicitClassVal + NoAutoTupling + NoValInForComprehension + ProcedureSyntax + RemoveUnused + MissingFinal + EmptyCollectionsUnified +] + +Disable { + ifSynthetic = [ + "scala/Option.option2Iterable" + "scala/Predef.any2stringadd" + ] +} + +DisableSyntax.regex = [] + +RemoveUnused { + imports = true +} + +DisableSyntax.noReturns = true +DisableSyntax.noXml = true +DisableSyntax.noFinalize = true +DisableSyntax.noValPatterns = true diff --git a/.scalafmt.conf b/.scalafmt.conf index 4c17c21..1e660b8 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,8 +1,30 @@ -version = 3.7.12 -runner.dialect = scala213 +version = "3.7.12" +runner.dialect = Scala213Source3 # https://scalameta.org/scalafmt/docs/configuration.html#scala-2-with--xsource3 +maxColumn = 140 +align.preset = most +continuationIndent.defnSite = 2 +assumeStandardLibraryStripMargin = true +docstrings.style = Asterisk +lineEndings = preserve +includeCurlyBraceInSelectChains = true +danglingParentheses.preset = true +optIn.annotationNewlines = true +newlines.alwaysBeforeMultilineDef = false +newlines.implicitParamListModifierPrefer = before +trailingCommas = multiple +docstrings.wrap = no -fileOverride { - "glob:**/scala-3/**" { - runner.dialect = scala3 - } +rewrite.rules = [RedundantBraces, SortModifiers] + +rewrite.sortModifiers.order = [ + "implicit", "override", "private", "protected", "final", "sealed", "abstract", "lazy" +] +rewrite.redundantBraces.generalExpressions = false +rewrite.redundantBraces.stringInterpolation = true +rewriteTokens = { + "⇒": "=>" + "→": "->" + "←": "<-" } + +project.excludePaths = [] diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 3e74120..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,14 +0,0 @@ -# Code of Conduct - -We are committed to providing a friendly, safe and welcoming environment for all, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other such characteristics. - -Everyone is expected to follow the [Scala Code of Conduct] when discussing the project on the available communication channels. - - -## Moderation - -If you have any questions, concerns, or moderation requests, please contact a member of the project. - -- [Antoine Comte](mailto:antoine@comte.cc) - -[Scala Code of Conduct]: https://scala-lang.org/conduct/ diff --git a/NOTICE b/NOTICE deleted file mode 100644 index 851f484..0000000 --- a/NOTICE +++ /dev/null @@ -1,3 +0,0 @@ -uuid4cats -Copyright 2023 Ant8e -Licensed under Apache License 2.0 (see LICENSE) diff --git a/Readme.md b/Readme.md index 31af908..5d86682 100644 --- a/Readme.md +++ b/Readme.md @@ -1,58 +1,111 @@ +[//]: # (This file was autogenerated using `zio-sbt-website` plugin via `sbt generateReadme` command.) +[//]: # (So please do not edit it manually. Instead, change "docs/index.md" file or sbt setting keys) +[//]: # (e.g. "readmeDocumentation" and "readmeSupport".) -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/tech.ant8e/uuid4cats-effect_2.13/badge.svg)](https://maven-badges.herokuapp.com/maven-central/tech.ant8e/uuid4cats-effect_2.13) -[![Code of Conduct](https://img.shields.io/badge/Code%20of%20Conduct-Scala-blue.svg)](CODE_OF_CONDUCT.md) -![](https://github.com/ant8e/uuid4cats-effect/actions/workflows/ci.yml/badge.svg) -[![codecov](https://codecov.io/gh/ant8e/uuid4cats-effect/branch/main/graph/badge.svg?token=QEUSQ3T053)](https://codecov.io/gh/ant8e/uuid4cats-effect) -# uuid4cats-effect - UUID and TypeID Generation for cats effect +# zio-uuid +[![Production Ready](https://img.shields.io/badge/Project%20Stage-Production%20Ready-brightgreen.svg)](https://github.com/zio/zio/wiki/Project-Stages) ![CI Badge](https://github.com/zio/zio-uuid/workflows/CI/badge.svg) [![Sonatype Snapshots](https://img.shields.io/nexus/s/https/oss.sonatype.org/dev.zio/zio-uuid_2.13.svg?label=Sonatype%20Snapshot)](https://oss.sonatype.org/content/repositories/snapshots/dev/zio/zio-uuid_2.13/) [![zio-uuid](https://img.shields.io/github/stars/zio/zio-uuid?style=social)](https://github.com/zio/zio-uuid) -Although cats-effect has some support for [generating UUIDs](https://typelevel.org/cats-effect/api/3.x/cats/effect/std/UUIDGen.html), it is limited to the UUIDv4 pseudo-random type. - +[zio-uuid](https://github.com/guizmaii/zio-uuid) is a "ZIOfied" fork +of [uuid4cats-effect](https://github.com/ant8e/uuid4cats-effect) by [Antoine Comte](https://github.com/ant8e) -This library add support for the following types: +## Introduction + +This library adds support for the following types: | | time-based | sortable | random | |--------:|:--------------------------:|:--------:|:------:| | UUID v1 | ✅
gregorian calendar | | | -| UUID v4 | | | ✅ | -| UUID v6 | ✅
gregorian calendar | ✅ | | -| UUID v7 | ✅
unix epoch | ✅ | ✅ | +| UUID v6 | ✅
gregorian calendar | ✅ | | +| UUID v7 | ✅
unix epoch | ✅ | ✅ | Implementation based on this [UUID RFC Draft](https://datatracker.ietf.org/doc/html/draft-ietf-uuidrev-rfc4122bis-03) -In addition to UUID, there is also support for [TypeIDs](https://github.com/jetpack-io/typeid). TypeIDs are a modern, -type-safe extension of UUIDv7 +In addition to UUID, there is also support for [TypeIDs](https://github.com/jetpack-io/typeid). TypeIDs are a modern, +type-safe extension of UUIDv7 + +_ZIO implementation note:_ +Note, that we don't provide a UUIDv4 implementation in this lib. ZIO is already providing one +with `ZIO.randomWith(_.nextUUID)` -## Quickstart +## Installation -To use uuid4cats-effect in an existing SBT project with Scala 2.13 or a later version, add the following dependency to your -`build.sbt`: +In order to use this library, we need to add the following line in our `build.sbt` file: ```scala -libraryDependencies += "ant8e.tech" %% "uuid4cats-effect" % "" +libraryDependencies += "dev.zio" %% "zio-uuid" % "" ``` ## Example ```scala -import cats.effect.IO -import cats.effect.unsafe.implicits.global -import tech.ant8e.uuid4cats.UUIDv6 -import tech.ant8e.uuid4cats.TypeID - -val ids = for { - generator <- UUIDv6.generator[IO] - typeIDGenerator <- TypeID.generator[IO] - uuid1 <- generator.uuid - uuid2 <- generator.uuid - typeid <- typeIDGenerator.typeid("myprefix") -} yield (uuid1, uuid2, typeid.value) - -ids.unsafeRunSync() - -val ids: cats.effect.IO[(java.util.UUID, java.util.UUID, String)] = IO(...) -val res1: (java.util.UUID, java.util.UUID, String) = (1ee22392-7669-6aa0-8000-ca7de6e5d540,1ee22392-766c-61b0-8000-422a97a9dbaa,myprefix_01h5a2ccabe0080m1hrkj0p0qp) +import zio.uuid.* + +val ids = + ( + for { + uuid1 <- UUIDGenerator.uuidV7 + uuid2 <- UUIDGenerator.uuidV7 + typeid <- TypeIDGenerator.generate("myprefix") + } yield (uuid1, uuid2, typeid.value) + ).provideLayers(UUIDGenerator.live, TypeIDGenerator.live) ``` -Uniqueness of generated time-based UUIDs is guaranteed when using the same generator. -Collisions across generators are theoretically possible although unlikely. +## ⚠️ Warnings ⚠️ + +Uniqueness of generated time-based UUIDs is guaranteed when using the same generator. + +The generators are stateful! They are using a `Ref` internally to keep track of their internal state. + +The `UUIDGenerator` and `TypeIDGenerator` companion object are providing accessor functions to ease their usage but, because the generators are stateful, +the way the generator instance is provided to these functions calls can lead to generated UUIDs/TypeIDs being invalid regarding the RFC. + +Do not do this: +```scala +val id0 = UUIDGenerator.uuidV7.provideLayer(UUIDGenerator.live) +val id1 = UUIDGenerator.uuidV7.provideLayer(UUIDGenerator.live) +``` +This will lead to non-monotonically increasing UUIDs/TypeIDs, which is invalid regarding the RFCs. + +Do this instead: +```scala +( + for { + id0 <- UUIDGenerator.uuidV7 + id1 <- UUIDGenerator.uuidV7 + // ... + } yield () +).provideLayer(UUIDGenerator.live) +``` + +The best way to inject a `UUIDGenerator` or a `TypeIDGenerator` instance is to inject its `live` layer in the boot sequence of your program +so that the same instance is reused everywhere in your program and you don't risk any issue. + +## Documentation + +Learn more on the [zio-uuid homepage](https://zio.dev/zio-uuid)! + +## Contributing + +For the general guidelines, see ZIO [contributor's guide](https://zio.dev/contributor-guidelines). + +## Code of Conduct + +See the [Code of Conduct](https://zio.dev/code-of-conduct) + +## Support + +Come chat with us on [![Badge-Discord]][Link-Discord]. + +[Badge-Discord]: https://img.shields.io/discord/629491597070827530?logo=discord "chat on discord" +[Link-Discord]: https://discord.gg/2ccFBr4 "Discord" + +## Credits + +This library is a fork of the [uuid4cats-effect](https://github.com/ant8e/uuid4cats-effect) library made by Antoine Comte (https://github.com/ant8e) + +## License + +[License](LICENSE) + +Copyright 2023-2023 Jules Ivanic and the zio-uuid contributors. diff --git a/build.sbt b/build.sbt index ef41818..92c03ff 100644 --- a/build.sbt +++ b/build.sbt @@ -1,67 +1,89 @@ -import com.typesafe.tools.mima.core._ +import scala.collection.immutable.Seq -ThisBuild / tlBaseVersion := "0.3" // your current series x.y - -ThisBuild / organization := "tech.ant8e" -ThisBuild / organizationName := "Antoine Comte" -ThisBuild / startYear := Some(2023) -ThisBuild / licenses := Seq(License.Apache2) -ThisBuild / developers := List( - // your GitHub handle and name - tlGitHubDev("ant8e", "Antoine Comte") +enablePlugins( + ZioSbtEcosystemPlugin, + ZioSbtCiPlugin, ) -// publish to s01.oss.sonatype.org (set to true to publish to oss.sonatype.org instead) -ThisBuild / tlSonatypeUseLegacyHost := false - -ThisBuild / tlFatalWarnings := false -// publish website from this branch -//ThisBuild / tlSitePublishBranch := Some("main") +inThisBuild( + List( + name := "zio-uuid", + zioVersion := "2.0.16", + scala213 := "2.13.11", + scala3 := "3.3.0", + crossScalaVersions -= scala211.value, + crossScalaVersions -= scala212.value, + ciEnabledBranches := Seq("main"), + Test / parallelExecution := false, + Test / fork := true, + run / fork := true, + ciJvmOptions ++= Seq("-Xms6G", "-Xmx6G", "-Xss4M", "-XX:+UseG1GC"), + scalafixDependencies ++= List( + "com.github.vovapolu" %% "scaluzzi" % "0.1.23", + "io.github.ghostbuster91.scalafix-unified" %% "unified" % "0.0.9", + ), + licenses := Seq(License.Apache2), + developers := List( + Developer( + "ant8e", + "Antoine Comte", + "", + url("https://github.com/ant8e"), + ), + Developer( + "guizmaii", + "Jules Ivanic", + "", + url("https://github.com/guizmaii"), + ), + ), + ) +) -val Scala213 = "2.13.11" -ThisBuild / crossScalaVersions := Seq(Scala213, "3.3.0") -ThisBuild / scalaVersion := Scala213 // the default Scala +addCommandAlias("updateReadme", "docs/generateReadme") -lazy val root = tlCrossRootProject.aggregate(core) +lazy val root = + project + .in(file(".")) + .settings( + name := "zio-uuid", + publish / skip := true, + crossScalaVersions := Nil,// https://www.scala-sbt.org/1.x/docs/Cross-Build.html#Cross+building+a+project+statefully + ) + .aggregate( + `zio-uuid` + ) -lazy val core = crossProject(JVMPlatform, JSPlatform) - .crossType(CrossType.Pure) - .in(file("core")) - .settings( - name := "uuid4cats-effect", - libraryDependencies ++= Seq( - "org.typelevel" %%% "cats-core" % "2.10.0", - "org.typelevel" %%% "cats-effect" % "3.5.1", - "org.scalameta" %%% "munit" % "0.7.29" % Test, - "org.typelevel" %%% "munit-cats-effect-3" % "1.0.7" % Test - ), - mimaBinaryIssueFilters ++= Seq( - ProblemFilters.exclude[ReversedMissingMethodProblem]( - "tech.ant8e.uuid4cats.TimestampedUUIDGeneratorBuilder.tech$ant8e$uuid4cats$TimestampedUUIDGeneratorBuilder$$GeneratorState" +lazy val `zio-uuid` = + project + .in(file("zio-uuid")) + .settings(stdSettings(Some("zio-uuid"))) + .settings(addOptionsOn("2.13")("-Xsource:3")) + .settings( + libraryDependencies ++= Seq( + "dev.zio" %%% "zio" % zioVersion.value, + "dev.zio" %% "zio-prelude" % "1.0.0-RC20", + "dev.zio" %%% "zio-json" % "0.6.1" % Optional, + "dev.zio" %%% "zio-test" % zioVersion.value % Test, + "org.scalameta" %%% "munit" % "0.7.29" % Test, + "com.github.poslegm" %% "munit-zio" % "0.1.1" % Test, ) ) - ) - -lazy val docs = project.in(file("site")).enablePlugins(TypelevelSitePlugin) -ThisBuild / githubWorkflowAddedJobs ++= Seq( - WorkflowJob( - id = "coverage", - name = "Generate coverage report", - scalas = List(Scala213), - javas = List(githubWorkflowJavaVersions.value.last), - steps = List(WorkflowStep.Checkout) ++ WorkflowStep.SetupJava( - List(githubWorkflowJavaVersions.value.last) - ) ++ githubWorkflowGeneratedCacheSteps.value ++ List( - WorkflowStep.Sbt(List("coverage", "rootJVM/test", "coverageAggregate")), - WorkflowStep.Use( - UseRef.Public( - "codecov", - "codecov-action", - "v3" - ), - env = Map("CODECOV_TOKEN" -> "${{ secrets.CODECOV_TOKEN }}") - ) +lazy val docs = + project + .in(file("zio-uuid-docs")) + .settings( + moduleName := "zio-uuid-docs", + scalacOptions -= "-Yno-imports", + scalacOptions -= "-Xfatal-warnings", + projectName := "zio-uuid", + mainModuleName := (`zio-uuid` / moduleName).value, + projectStage := ProjectStage.ProductionReady, + ScalaUnidoc / unidoc / unidocProjectFilter := inProjects(`zio-uuid`), + readmeCredits := + "This library is a fork of the [uuid4cats-effect](https://github.com/ant8e/uuid4cats-effect) library made by Antoine Comte (https://github.com/ant8e)", + readmeLicense += s"\n\nCopyright 2023-${java.time.Year.now()} Jules Ivanic and the zio-uuid contributors.", ) - ) -) + .enablePlugins(WebsitePlugin) + .dependsOn(`zio-uuid`) diff --git a/core/.js/src/main/scala/tech/ant8e/uuid4cats/PlatformSpecificMethods.scala b/core/.js/src/main/scala/tech/ant8e/uuid4cats/PlatformSpecificMethods.scala deleted file mode 100644 index 79c62b6..0000000 --- a/core/.js/src/main/scala/tech/ant8e/uuid4cats/PlatformSpecificMethods.scala +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2023 Antoine Comte - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package tech.ant8e.uuid4cats - -object PlatformSpecificMethods { - def stringFromArray(bytes: Array[Char]): String = new String(bytes) -} diff --git a/core/.jvm/src/main/scala/tech/ant8e/uuid4cats/PlatformSpecificMethods.scala b/core/.jvm/src/main/scala/tech/ant8e/uuid4cats/PlatformSpecificMethods.scala deleted file mode 100644 index 66860da..0000000 --- a/core/.jvm/src/main/scala/tech/ant8e/uuid4cats/PlatformSpecificMethods.scala +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2023 Antoine Comte - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package tech.ant8e.uuid4cats - -object PlatformSpecificMethods { - def stringFromArray(bytes: Array[Char]): String = String.copyValueOf(bytes) -} diff --git a/core/src/main/scala/tech/ant8e/uuid4cats/TypeID.scala b/core/src/main/scala/tech/ant8e/uuid4cats/TypeID.scala deleted file mode 100644 index fe69e65..0000000 --- a/core/src/main/scala/tech/ant8e/uuid4cats/TypeID.scala +++ /dev/null @@ -1,404 +0,0 @@ -/* - * Copyright 2023 Antoine Comte - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package tech.ant8e.uuid4cats - -import cats.data.{Validated, ValidatedNec} -import cats.effect.Async -import cats.syntax.all._ -import cats.{ApplicativeError, Eq, Order, Show} -import tech.ant8e.uuid4cats.TypeID.BuildError.{InvalidPrefix, InvalidUUID} -import tech.ant8e.uuid4cats.TypeID.DecodeError._ - -import java.util.UUID -import scala.annotation.switch -import scala.util.{Failure, Success, Try} - -/** TypeIDs are a type-safe extension of UUIDv7, they encode UUIDs in base32 and - * add a type prefix. see https://github.com/jetpack-io/typeid/tree/main/spec - */ -trait TypeID { - def prefix: String - def uuid: UUID - def value: String -} - -object TypeID { - - trait TypeIDGenerator[F[_]] { - - /** Create a new TypeID. - * - * @param prefix - * a string at most 63 characters in all lowercase ASCII [a-z]. Will lift - * an error in the F context if the prefix is invalid. - */ - def typeid(prefix: String): F[TypeID] - } - - /** Return a UUID V7 based TypeID generator. */ - def generator[F[_]: Async]: F[TypeIDGenerator[F]] = UUIDv7 - .generator[F] - .map(uuidGenerator => - (prefix: String) => - uuidGenerator.uuid.flatMap { uuid => - val typeID = build(prefix, uuid) - .leftMap(errors => new IllegalArgumentException(errors.show)) - ApplicativeError[F, Throwable].fromValidated(typeID) - } - ) - - /** Return a UUID V7 based TypeID generator with fixed prefix. - * - * @param prefix - * a string at most 63 characters in all lowercase ASCII [a-z]. Will lift - * an error in the F context if the prefix is invalid. - */ - def generator[F[_]: Async](prefix: String): F[() => F[TypeID]] = - validatePrefix(prefix) - .map(validatedPrefix => - generator[F].map { generator => () => - generator.typeid(validatedPrefix) - } - ) - .valueOr(error => - ApplicativeError[F, Throwable].raiseError( - new IllegalArgumentException(show"$error") - ) - ) - - /** Build a TypeID based on the supplied prefix and uuid. - * - * @param prefix - * a string at most 63 characters in all lowercase ASCII [a-z]. - * @param enforceUUIDV7 - * true by default. When set to false, allows to build an out of spec - * TypeID based on a other type of UUID. - */ - def build( - prefix: String, - uuid: UUID, - enforceUUIDV7: Boolean = true - ): ValidatedNec[BuildError, TypeID] = - ( - validatePrefix(prefix).toValidatedNec, - validateUUID(uuid, enforceUUIDV7).toValidatedNec - ) - .mapN { case (prefix_, uuid_) => newTypeID(prefix_, uuid_) } - - /** Decode a TypeID from a string representation. - * - * @param enforceUUIDV7 - * true by default. When set to false, allows to decode an out of spec - * TypeID based on a other type of UUID. - */ - def decode( - typeIDString: String, - enforceUUIDV7: Boolean = true - ): ValidatedNec[DecodeError, TypeID] = { - val re = - "^([a-z]{0,63})(_?)([0123456789abcdefghjkmnpqrstvwxyz]+)$".r - - def parseID(id: String): Validated[DecodeError, UUID] = - UUIDBase32 - .fromBase32(id) - .leftMap(InvalidTypeID.apply) - .toValidated - .andThen { - case uuid if enforceUUIDV7 && uuid.version != 7 => - NotUUIDV7.invalid[UUID].leftWiden[DecodeError] - case uuid => uuid.valid - } - - typeIDString match { - case re(prefix, sep, id) => - ( - Validated - .cond( - prefix.isEmpty && sep.isEmpty || prefix.nonEmpty && sep.nonEmpty, - prefix, - if (prefix.isEmpty) BadSeparator else MissingSeparator - ) - .toValidatedNec, - parseID(id).toValidatedNec - ).mapN { case (prefix_, uuid_) => newTypeID(prefix_, uuid_) } - case _ => Validated.invalidNec(NotParseableTypeID) - } - - } - - implicit val typeIDShow: Show[TypeID] = Show.show(_.toString) - implicit val typeIDOrd: Order[TypeID] = Order.by(_.value) - implicit val typeIDOrdering: Ordering[TypeID] = Ordering.by(_.value) - implicit val typeIDEq: Eq[TypeID] = Eq.instance { case (a, b) => - a.prefix === b.prefix && a.uuid === b.uuid - } - - sealed trait BuildError - object BuildError { - case object InvalidPrefix extends BuildError { - val message = - "prefix is not at most 63 characters in all lowercase ASCII [a-z]" - } - - case object InvalidUUID extends BuildError { - val message = "uuid is not a UUIDv7" - } - - implicit val buildErrorShow: Show[BuildError] = Show.show { - case InvalidPrefix => s"InvalidPrefix: ${InvalidPrefix.message}" - case InvalidUUID => s"InvalidUUID: ${InvalidUUID.message}" - } - } - - sealed trait DecodeError { - def message: String - } - - object DecodeError { - case object NotParseableTypeID extends DecodeError { - val message = "Could not parse the supplied string as a TypeID" - } - - case object BadSeparator extends DecodeError { - val message = "An empty prefix should not have a separator" - } - - case object MissingSeparator extends DecodeError { - val message = "A separator was not found" - } - - case object NotUUIDV7 extends DecodeError { - val message = "The decoded UUID is not a mandatory V7" - } - - case class InvalidTypeID(message: String) extends DecodeError - } - - private def newTypeID(prefix_ : String, uuid_ : UUID): TypeID = new TypeID { - override val prefix: String = prefix_ - override val uuid: UUID = uuid_ - override def value: String = repr - private lazy val repr = - prefix_ + (if (prefix_.nonEmpty) "_" else "") + UUIDBase32.toBase32(uuid_) - override def toString: String = s"TypeID:$repr($uuid_)" - - override def equals(obj: Any): Boolean = obj match { - case other: TypeID => TypeID.typeIDEq.eqv(this, other) - case _ => false - } - - } - - private[uuid4cats] def validatePrefix( - prefix: String - ): Validated[BuildError, String] = Option(prefix) - .map(prefix_ => { - if ("[a-z]{0,63}".r.matches(prefix_)) - Validated.Valid(prefix_) - else Validated.Invalid(InvalidPrefix) - }) - .getOrElse(Validated.Invalid(InvalidPrefix)) - - private[uuid4cats] def validateUUID( - uuid: UUID, - enforceUUIDV7: Boolean - ): Validated[BuildError, UUID] = Option(uuid) - .map(uuid => { - if (!enforceUUIDV7 || uuid.version() === 7) - Validated.Valid(uuid) - else Validated.Invalid(InvalidUUID) - }) - .getOrElse(Validated.Invalid(InvalidUUID)) - - private object UUIDBase32 { - private val encodingTable: Array[Char] = - "0123456789abcdefghjkmnpqrstvwxyz".toArray - - def toBase32(uuid: UUID): String = { - @inline def enc(i: Int): Char = encodingTable(i) - - val b0 = ((uuid.getMostSignificantBits >>> 56) & 0xff).toInt - val b1 = ((uuid.getMostSignificantBits >>> 48) & 0xff).toInt - val b2 = ((uuid.getMostSignificantBits >>> 40) & 0xff).toInt - val b3 = ((uuid.getMostSignificantBits >>> 32) & 0xff).toInt - val b4 = ((uuid.getMostSignificantBits >>> 24) & 0xff).toInt - val b5 = ((uuid.getMostSignificantBits >>> 16) & 0xff).toInt - val b6 = ((uuid.getMostSignificantBits >>> 8) & 0xff).toInt - val b7 = (uuid.getMostSignificantBits & 0xff).toInt - - val b8 = (uuid.getLeastSignificantBits >>> 56 & 0xff).toInt - val b9 = (uuid.getLeastSignificantBits >>> 48 & 0xff).toInt - val b10 = (uuid.getLeastSignificantBits >>> 40 & 0xff).toInt - val b11 = (uuid.getLeastSignificantBits >>> 32 & 0xff).toInt - val b12 = (uuid.getLeastSignificantBits >>> 24 & 0xff).toInt - val b13 = (uuid.getLeastSignificantBits >>> 16 & 0xff).toInt - val b14 = (uuid.getLeastSignificantBits >>> 8 & 0xff).toInt - val b15 = (uuid.getLeastSignificantBits & 0xff).toInt - - val out = new Array[Char](26) - out(0) = enc(ms3b(b0)) - out(1) = enc(ls5b(b0)) - out(2) = enc(ms5b(b1)) - out(3) = enc(ls3b(b1) << 2 | ms2b(b2)) - out(4) = enc(ls5b(b2 >> 1)) - out(5) = enc(ls1b(b2) << 4 | ms4b(b3)) - out(6) = enc(ls4b(b3) << 1 | ms1b(b4)) - out(7) = enc(ls5b(b4 >> 2)) - out(8) = enc(ls2b(b4) << 3 | ms3b(b5)) - out(9) = enc(ls5b(b5)) - out(10) = enc(ms5b(b6)) - out(11) = enc(ls3b(b6) << 2 | ms2b(b7)) - out(12) = enc(ls5b(b7 >> 1)) - out(13) = enc(ls1b(b7) << 4 | ms4b(b8)) - out(14) = enc(ls4b(b8) << 1 | ms1b(b9)) - out(15) = enc(ls5b(b9 >> 2)) - out(16) = enc(ls2b(b9) << 3 | ms3b(b10)) - out(17) = enc(ls5b(b10)) - out(18) = enc(ms5b(b11)) - out(19) = enc(ls3b(b11) << 2 | ms2b(b12)) - out(20) = enc(ls5b(b12 >> 1)) - out(21) = enc(ls1b(b12) << 4 | ms4b(b13)) - out(22) = enc(ls4b(b13) << 1 | ms1b(b14)) - out(23) = enc(ls5b(b14 >> 2)) - out(24) = enc(ls2b(b14) << 3 | ms3b(b15)) - out(25) = enc(ls5b(b15)) - - PlatformSpecificMethods.stringFromArray(out) - } - - def fromBase32(s: String): Either[String, UUID] = { - val values = Try( - s.map(v => - (v: @switch) match { - case '0' => 0L - case '1' => 1L - case '2' => 2L - case '3' => 3L - case '4' => 4L - case '5' => 5L - case '6' => 6L - case '7' => 7L - case '8' => 8L - case '9' => 9L - case 'a' => 10L - case 'b' => 11L - case 'c' => 12L - case 'd' => 13L - case 'e' => 14L - case 'f' => 15L - case 'g' => 16L - case 'h' => 17L - case 'j' => 18L - case 'k' => 19L - case 'm' => 20L - case 'n' => 21L - case 'p' => 22L - case 'q' => 23L - case 'r' => 24L - case 's' => 25L - case 't' => 26L - case 'v' => 27L - case 'w' => 28L - case 'x' => 29L - case 'y' => 30L - case 'z' => 31L - } - ) - ) - - values match { - case Failure(_) => - "String representation contains at least an invalid characters".asLeft - case Success(values) if (values.length != 26) => - "String representation should be exactly 26 significant characters".asLeft - case Success(values) if (values(0) > 7) => - "The String representation encodes more than 128 bits".asLeft - case Success(values) => - // format: off - val msb = (values(0) << 61) | // We have only 3 significant bits at pos. 0 because of the padding - (values(1) << 56) | (values(2) << 51) | (values(3) << 46) | - (values(4) << 41) | (values(5) << 36) | (values(6) << 31) | (values(7) << 26) | - (values(8) << 21) | (values(9) << 16) | (values(10) << 11) | (values(11) << 6) | - (values(12) << 1) | ls1b((values(13) >> 4).toInt) - - val lsb = (values(13) << 60) | - (values(14) << 55) | (values(15) << 50) | (values(16) << 45) | (values(17) << 40) | - (values(18) << 35) | (values(19) << 30) | (values(20) << 25) | (values(21) << 20) | - (values(22) << 15) | (values(23) << 10) | (values(24) << 5) | values(25) - // format: on - - new UUID(msb, lsb).asRight - } - } - - private val Mask1Bits: Byte = 0x01 - private val Mask2Bits: Byte = 0x03 - private val Mask3Bits: Byte = 0x07 - private val Mask4Bits: Byte = 0x0f - private val Mask5Bits: Byte = 0x1f - - @inline - private def ls1b(b: Int) = { - b & Mask1Bits - } - - @inline - private def ls2b(b: Int) = { - b & Mask2Bits - } - - @inline - private def ls3b(b: Int) = { - b & Mask3Bits - } - - @inline - private def ls4b(b: Int) = { - b & Mask4Bits - } - - @inline - private def ls5b(b: Int) = { - b & Mask5Bits - } - - @inline - private def ms5b(b: Int) = { - (b >> 3) & Mask5Bits - } - - @inline - private def ms4b(b: Int) = { - (b >> 4) & Mask4Bits - } - - @inline - private def ms3b(b: Int) = { - (b >> 5) & Mask3Bits - } - - @inline - private def ms2b(b: Int) = { - (b >> 6) & Mask2Bits - } - - @inline - private def ms1b(b: Int) = { - (b >> 7) & Mask1Bits - } - } -} diff --git a/core/src/main/scala/tech/ant8e/uuid4cats/UUID4cats.scala b/core/src/main/scala/tech/ant8e/uuid4cats/UUID4cats.scala deleted file mode 100644 index 85c0057..0000000 --- a/core/src/main/scala/tech/ant8e/uuid4cats/UUID4cats.scala +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright 2023 Antoine Comte - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package tech.ant8e.uuid4cats - -import cats.effect.std.{Mutex, Random, SecureRandom} -import cats.effect.{Async, Clock, Ref} -import cats.implicits._ -import tech.ant8e.uuid4cats.TimestampedUUIDGeneratorBuilder.GeneratorState - -import java.util.UUID - -trait UUIDGenerator[F[_]] { - def uuid: F[UUID] -} - -object UUIDv1 extends TimestampedUUIDGeneratorBuilder { - - /** return a UUIDv1 (gregorian timestamp based, non-sortable) generator with - * guarantee about the uniqueness of the UUID, even within the same - * millisecond timestamp. - * - * This function uses a randomized MAC address. - */ - def generator[F[_]: Async]: F[UUIDGenerator[F]] = - buildGenerator(UUIDBuilder.buildUUIDv1) -} - -object UUIDv4 { - - /** return a UUIDv4 (full random) generator. */ - def generator[F[_]: Async]: F[UUIDGenerator[F]] = SecureRandom - .javaSecuritySecureRandom[F] - .map(random => - new UUIDGenerator[F] { - override def uuid: F[UUID] = for { - low <- random.nextLong - high <- random.nextLong - } yield UUIDBuilder.buildUUIDv4(high, low) - } - ) -} - -object UUIDv6 extends TimestampedUUIDGeneratorBuilder { - - /** return a UUIDv6 (gregorian timestamp based, sortable) generator with - * guarantee about the uniqueness of the UUID, even within the same - * millisecond timestamp. - */ - def generator[F[_]: Async]: F[UUIDGenerator[F]] = - buildGenerator(UUIDBuilder.buildUUIDv6) -} - -object UUIDv7 extends TimestampedUUIDGeneratorBuilder { - - /** return a UUIDv7 (unix epoch timestamp based, sortable) generator with - * guarantee about the uniqueness of the UUID, even within the same - * millisecond timestamp. - */ - def generator[F[_]: Async]: F[UUIDGenerator[F]] = - buildGenerator(UUIDBuilder.buildUUIDV7) -} - -object TimestampedUUIDGeneratorBuilder { - private[uuid4cats] case class GeneratorState( - lastUsedEpochMillis: Long, - sequence: Long - ) -} - -sealed trait TimestampedUUIDGeneratorBuilder { - private type UUIDBuilder = (Long, Long, Long) => UUID - private def generate[F[_]: Async]( - state: Ref[F, GeneratorState], - mutex: Mutex[F], - random: Random[F], - builder: UUIDBuilder - ): F[UUID] = for { - random <- random.nextLong - uuid <- mutex.lock.surround( - for { - timestamp <- Clock[F].realTime.map(_.toMillis) - modifiedState <- state.modify { currentState => - // realTime clock may run backward - val actualTimestamp = - Math.max(currentState.lastUsedEpochMillis, timestamp) - val sequence = - if (currentState.lastUsedEpochMillis === actualTimestamp) - currentState.sequence + 1 - else 0L - - val newState = GeneratorState(actualTimestamp, sequence) - (newState, newState) - } - } yield builder( - modifiedState.lastUsedEpochMillis, - modifiedState.sequence, - random - ) - ) - } yield uuid - - protected def buildGenerator[F[_]: Async]( - builder: UUIDBuilder - ): F[UUIDGenerator[F]] = { - val generatorInitialState = Ref[F].of(GeneratorState(0L, 0L)) - for { - state <- generatorInitialState - mutex <- Mutex[F] - random <- SecureRandom.javaSecuritySecureRandom[F] - } yield new UUIDGenerator[F] { - override def uuid: F[UUID] = generate(state, mutex, random, builder) - } - } -} - -object UUIDBuilder { - val Variant = 0x2L - - def buildUUIDv1(epochMillis: Long, sequence: Long, random: Long): UUID = { - val Version = 0x1L - val gregorianTimestamp = toUUIDTimestamp(epochMillis) - val time_high = - gregorianTimestamp >>> 48 // 12 most significant bits of the timestamp - val time__mid = - (gregorianTimestamp >>> 32) & 0xffff // 16 middle bits of the timestamp - val time_low = - gregorianTimestamp & 0xffff_ffff // 32 least significant bits of the timestamp - val node = - ((random << 16) >>> 16) | (0x1L << 40) // 48 bits (MAC address with the unicast bit set to 1) - val clock_seq = sequence & 0x3fff // 14 bits - val msb = (time_low << 32) | time__mid << 16 | (Version << 12) | time_high - val lsb = (Variant << 62) | clock_seq << 48 | node - new UUID(msb, lsb) - } - - def buildUUIDv4(randomHigh: Long, randomLow: Long): UUID = { - val Version = 0x4L - val msb = randomHigh & ~(0xf << 12) | (Version << 12) - val lsb = (Variant << 62) | (randomLow << 2 >>> 2) - new UUID(msb, lsb) - } - - def buildUUIDv6(epochMillis: Long, sequence: Long, random: Long): UUID = { - val Version = 0x6L - val gregorianTimestamp = toUUIDTimestamp(epochMillis) - val time_high_and_mid = - gregorianTimestamp >>> 12 // 48 most significant bits of the timestamp - val time_low = - gregorianTimestamp & 0xfff // 12 least significant bits of the timestamp - val node = (random << 16) >>> 16 // 48 bits - val clock_seq = sequence & 0x3fff // 14 bits - val msb = (time_high_and_mid << 16) | (Version << 12) | time_low - val lsb = (Variant << 62) | clock_seq << 48 | node - new UUID(msb, lsb) - } - - def buildUUIDV7(epochMillis: Long, sequence: Long, random: Long): UUID = { - val Version = 0x7L - val rand_a = sequence & 0xfffL // 12 bits - val rand_b = (random << 2) >>> 2 // we need only 62 bits of randomness - val msb = (epochMillis << 16) | (Version << 12) | rand_a - val lsb = (Variant << 62) | rand_b - new UUID(msb, lsb) - } - - /** number of 100 nanosecond intervals since the beginning of the gregorian - * calendar (15-oct-1582) to Unix Epoch - */ - private val UnixEpochClockOffset = 0x01b21dd213814000L - - @inline - final def toUUIDTimestamp(epochMillis: Long): Long = { - val ClockMultiplier = - 10000L // count of 100 nanosecond intervals in a milli - val ts = epochMillis * ClockMultiplier + UnixEpochClockOffset - (ts << 4) >>> 4 // Keeping only the 60 least significant bits - } -} diff --git a/core/src/test/scala/tech/ant8e/uuid4cats/GeneratorSuite.scala b/core/src/test/scala/tech/ant8e/uuid4cats/GeneratorSuite.scala deleted file mode 100644 index 0c321cb..0000000 --- a/core/src/test/scala/tech/ant8e/uuid4cats/GeneratorSuite.scala +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2023 Antoine Comte - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package tech.ant8e.uuid4cats - -import cats.effect.IO -import cats.syntax.all._ -import munit.CatsEffectSuite - -import java.util.UUID - -class GeneratorSuite extends CatsEffectSuite { - - private val n = 10000 - test("UUIDv1 should generate UUIDs") { - for { - uuids <- UUIDv1.generator[IO].flatMap(genN(_, n)) - _ <- assertAllUnique(uuids) - } yield () - } - - test("UUIDv4 should generate UUIDs") { - for { - uuids <- UUIDv4.generator[IO].flatMap(genN(_, n)) - _ <- assertAllUnique(uuids) - } yield () - } - - test("UUIDv6 should generate UUIDs") { - for { - uuids <- UUIDv6.generator[IO].flatMap(genN(_, n)) - _ <- assertAllUnique(uuids) - _ <- assertSorted(uuids) - } yield () - } - - test("UUIDv7 should generate UUIDs") { - for { - uuids <- UUIDv7.generator[IO].flatMap(genN(_, n)) - _ <- assertAllUnique(uuids) - _ <- assertSorted(uuids) - } yield () - } - - test("TypeID should generate TypeIds") { - for { - typeids <- TypeID - .generator[IO] - .flatMap(generator => - List.tabulate(n)(_ => generator.typeid("prefix")).sequence - ) - _ <- IO(typeids.distinct.size === typeids.size).assert - _ <- IO(isSeqSorted(typeids)).assert - } yield () - - for { - typeids <- TypeID - .generator[IO]("prefix") - .flatMap(generator => List.tabulate(n)(_ => generator()).sequence) - _ <- IO(typeids.distinct.size === typeids.size).assert - _ <- IO(isSeqSorted(typeids)).assert - } yield () - } - - test("TypeID generator should not accept illegal prefix") { - TypeID - .generator[IO] - .flatMap(generator => generator.typeid("WRONG")) - .intercept[IllegalArgumentException] - - TypeID.generator[IO]("WRONG").intercept[IllegalArgumentException] - } - - private def genN(generator: UUIDGenerator[IO], n: Int): IO[List[UUID]] = - List.tabulate(n)(_ => generator.uuid).sequence - - implicit class UUIDsOps(uuids: List[UUID]) { - def allUniques: Boolean = uuids.distinct.size === uuids.size - def isSorted: Boolean = isSeqSorted(uuids) - } - - private def assertAllUnique(uuids: List[UUID]) = { - assertIOBoolean(uuids.allUniques.pure[IO], s"Not Unique : $uuids") - } - - private def assertSorted(uuids: List[UUID]) = { - assertIOBoolean( - uuids.isSorted.pure[IO], - s"Not sorted : ${findNotSorted(uuids)}" - ) - } - - // Using a custom ordering based on String because UUID compareTo() is not reliable - // https://github.com/scala-js/scala-js/issues/4882 and - // https://bugs.openjdk.org/browse/JDK-7025832 - implicit val uuidOrdering: Ordering[UUID] = - Ordering.by[UUID, String](_.toString) - - def isSeqSorted[T]( - seq: List[T] - )(implicit ordering: Ordering[T]): Boolean = { - val lastIndex = seq.length - 1 - !seq.zipWithIndex.exists { case (t, index) => - index != lastIndex && ordering.gt(t, seq(index + 1)) - } - } - - def findNotSorted[T](seq: List[T])(implicit ordering: Ordering[T]): String = { - val lastIndex = seq.length - 1 - seq.zipWithIndex - .find { case (t, index) => - index != lastIndex && ordering.gt(t, seq(index + 1)) - } - .map { case (t, index) => - s"found non sorted values $t ${seq(index + 1)}} at index $index" - } - .getOrElse("No non sorted values found") - } - -} diff --git a/docs/index.md b/docs/index.md index 696a5df..54b0c5d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,13 +1,82 @@ -## uuid4cats +--- +id: index +title: "Getting Started with zio-uuid" +sidebar_label: "Getting Started" +--- -### Usage +@PROJECT_BADGES@ -This library is currently available for Scala binary versions 2.13 and 3.1. +[zio-uuid](https://github.com/guizmaii/zio-uuid) is a "ZIOfied" fork +of [uuid4cats-effect](https://github.com/ant8e/uuid4cats-effect) by [Antoine Comte](https://github.com/ant8e) -To use the latest version, include the following in your `build.sbt`: +## Introduction + +This library adds support for the following types: + +| | time-based | sortable | random | +|--------:|:--------------------------:|:--------:|:------:| +| UUID v1 | ✅
gregorian calendar | | | +| UUID v6 | ✅
gregorian calendar | ✅ | | +| UUID v7 | ✅
unix epoch | ✅ | ✅ | + +Implementation based on this [UUID RFC Draft](https://datatracker.ietf.org/doc/html/draft-ietf-uuidrev-rfc4122bis-03) + +In addition to UUID, there is also support for [TypeIDs](https://github.com/jetpack-io/typeid). TypeIDs are a modern, +type-safe extension of UUIDv7 + +_ZIO implementation note:_ +Note, that we don't provide a UUIDv4 implementation in this lib. ZIO is already providing one +with `ZIO.randomWith(_.nextUUID)` + +## Installation + +In order to use this library, we need to add the following line in our `build.sbt` file: + +```scala +libraryDependencies += "dev.zio" %% "zio-uuid" % "@VERSION@" +``` + +## Example ```scala -libraryDependencies ++= Seq( - "ant8e.tech" %% "uuid4cats" % "@VERSION@" -) +import zio.uuid.* + +val ids = + ( + for { + uuid1 <- UUIDGenerator.uuidV7 + uuid2 <- UUIDGenerator.uuidV7 + typeid <- TypeIDGenerator.generate("myprefix") + } yield (uuid1, uuid2, typeid.value) + ).provideLayers(UUIDGenerator.live, TypeIDGenerator.live) ``` + +## ⚠️ Warnings ⚠️ + +Uniqueness of generated time-based UUIDs is guaranteed when using the same generator. + +The generators are stateful! They are using a `Ref` internally to keep track of their internal state. + +The `UUIDGenerator` and `TypeIDGenerator` companion object are providing accessor functions to ease their usage but, because the generators are stateful, +the way the generator instance is provided to these functions calls can lead to generated UUIDs/TypeIDs being invalid regarding the RFC. + +Do not do this: +```scala +val id0 = UUIDGenerator.uuidV7.provideLayer(UUIDGenerator.live) +val id1 = UUIDGenerator.uuidV7.provideLayer(UUIDGenerator.live) +``` +This will lead to non-monotonically increasing UUIDs/TypeIDs, which is invalid regarding the RFCs. + +Do this instead: +```scala +( + for { + id0 <- UUIDGenerator.uuidV7 + id1 <- UUIDGenerator.uuidV7 + // ... + } yield () +).provideLayer(UUIDGenerator.live) +``` + +The best way to inject a `UUIDGenerator` or a `TypeIDGenerator` instance is to inject its `live` layer in the boot sequence of your program +so that the same instance is reused everywhere in your program and you don't risk any issue. \ No newline at end of file diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..5e34ead --- /dev/null +++ b/docs/package.json @@ -0,0 +1,5 @@ +{ + "name": "@zio.dev/zio-uuid", + "description": "zio-uuid Documentation", + "license": "Apache-2.0" +} diff --git a/docs/sidebars.js b/docs/sidebars.js new file mode 100644 index 0000000..5f548d2 --- /dev/null +++ b/docs/sidebars.js @@ -0,0 +1,13 @@ +const sidebars = { + sidebar: [ + { + type: "category", + label: "zio-uuid", + collapsed: false, + link: { type: "doc", id: "index" }, + items: [] + } + ] +}; + +module.exports = sidebars; diff --git a/flake.nix b/flake.nix deleted file mode 100644 index a250d86..0000000 --- a/flake.nix +++ /dev/null @@ -1,27 +0,0 @@ -{ - inputs = { - typelevel-nix.url = "github:typelevel/typelevel-nix"; - nixpkgs.follows = "typelevel-nix/nixpkgs"; - flake-utils.follows = "typelevel-nix/flake-utils"; - }; - - outputs = { self, nixpkgs, flake-utils, typelevel-nix }: - flake-utils.lib.eachDefaultSystem (system: - let - pkgs = import nixpkgs { - inherit system; - overlays = [ typelevel-nix.overlay ]; - }; - in - { - devShell = pkgs.devshell.mkShell { - imports = [ typelevel-nix.typelevelShell ]; - name = "uuid4cats-shell"; - typelevelShell = { - jdk.package = pkgs.jdk8; - nodejs.enable = true; - }; - }; - } - ); -} diff --git a/project/build.properties b/project/build.properties index 46e43a9..06969a3 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.8.2 +sbt.version = 1.9.4 diff --git a/project/plugins.sbt b/project/plugins.sbt index 7f44850..c82a63c 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,12 @@ -addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.4.22") -addSbtPlugin("org.typelevel" % "sbt-typelevel-site" % "0.4.22") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.2") -addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.3.7") +val zioSbtVersion = "0.4.0-alpha.14" + +addSbtPlugin("dev.zio" % "zio-sbt-ecosystem" % zioSbtVersion) +addSbtPlugin("dev.zio" % "zio-sbt-ci" % zioSbtVersion) +addSbtPlugin("dev.zio" % "zio-sbt-website" % zioSbtVersion) + +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.11.0") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0") addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8") +addSbtPlugin("org.typelevel" % "sbt-tpolecat" % "0.5.0") + +resolvers ++= Resolver.sonatypeOssRepos("public") diff --git a/zio-uuid/src/main/scala/zio/uuid/TypeID.scala b/zio-uuid/src/main/scala/zio/uuid/TypeID.scala new file mode 100644 index 0000000..c2ba0a5 --- /dev/null +++ b/zio-uuid/src/main/scala/zio/uuid/TypeID.scala @@ -0,0 +1,139 @@ +/* + * Copyright 2023 Antoine Comte + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.uuid + +import zio.json.JsonCodec +import zio.prelude.{Debug, Equal, Validation} +import zio.uuid.internals.UUIDBase32 + +import java.util.UUID + +/** + * TypeIDs are a type-safe extension of UUIDv7, they encode UUIDs in base32 and + * add a type prefix. see https://github.com/jetpack-io/typeid/tree/main/spec + */ +final case class TypeID(prefix: String, uuid: UUID) { + def value: String = repr + + private lazy val repr = prefix + (if (prefix.nonEmpty) "_" else "") + UUIDBase32.toBase32(uuid) + + override def toString: String = s"TypeID:$repr($uuid)" +} + +object TypeID { + implicit val typeIdDebug: Debug[TypeID] = Debug.make(_.toString) + implicit val typeIdOrdering: Ordering[TypeID] = Ordering.by(_.value) + implicit val typeIdEqual: Equal[TypeID] = Equal.default + + implicit val typeIDCodec: JsonCodec[TypeID] = + JsonCodec.string.transformOrFail[TypeID](TypeID.decode(_).toEitherWith(_.mkString(", ")), _.value) + + /** + * Build a TypeID based on the supplied prefix and uuid. + * + * @param prefix + * a string at most 63 characters in all lowercase ASCII [a-z]. + * @param enforceUUIDV7 + * true by default. When set to false, allows to build an out of spec + * TypeID based on a other type of UUID. + */ + def build( + prefix: String, + uuid: UUID, + enforceUUIDV7: Boolean = true, + ): Validation[BuildError, TypeID] = + Validation + .validateWith( + validatePrefix(prefix), + validateUUID(uuid, enforceUUIDV7), + )(TypeID.apply) + + /** + * Decode a TypeID from a string representation. + * + * @param enforceUUIDV7 + * true by default. When set to false, allows to decode an out of spec + * TypeID based on a other type of UUID. + */ + def decode( + typeIDString: String, + enforceUUIDV7: Boolean = true, + ): Validation[DecodeError, TypeID] = { + val regex = "^([a-z]{0,63})(_?)([0123456789abcdefghjkmnpqrstvwxyz]+)$".r + + def parseID(id: String): Validation[DecodeError, UUID] = + UUIDBase32.fromBase32(id) match { + case Left(e) => Validation.fail(DecodeError.InvalidTypeID(e)) + case Right(uuid) if enforceUUIDV7 && uuid.version != 7 => Validation.fail(DecodeError.NotUUIDV7) + case Right(uuid) => Validation.succeed(uuid) + } + + typeIDString match { + case regex(prefix, sep, id) => + val validatePrefix = + if (prefix.isEmpty && sep.isEmpty || prefix.nonEmpty && sep.nonEmpty) Validation.succeed(prefix) + else Validation.fail(if (prefix.isEmpty) DecodeError.BadSeparator else DecodeError.MissingSeparator) + + val validateId = parseID(id) + + Validation.validateWith(validatePrefix, validateId)(TypeID.apply) + case _ => Validation.fail(DecodeError.NotParseableTypeID) + } + } + + private[zio] def validatePrefix(prefix: String): Validation[BuildError, String] = + if ((prefix ne null) && "[a-z]{0,63}".r.matches(prefix)) Validation.succeed(prefix) + else Validation.fail(BuildError.InvalidPrefix) + + private[zio] def validateUUID(uuid: UUID, enforceUUIDV7: Boolean): Validation[BuildError, UUID] = + if ((uuid ne null) && (!enforceUUIDV7 || uuid.version() == 7)) Validation.succeed(uuid) + else Validation.fail(BuildError.InvalidUUID) + + sealed trait BuildError extends Product with Serializable + object BuildError { + case object InvalidPrefix extends BuildError { + val message = "prefix is not at most 63 characters in all lowercase ASCII [a-z]" + } + case object InvalidUUID extends BuildError { + val message = "uuid is not a UUIDv7" + } + + implicit val buildErrorShow: Debug[BuildError] = Debug.make { + case InvalidPrefix => s"InvalidPrefix: ${InvalidPrefix.message}" + case InvalidUUID => s"InvalidUUID: ${InvalidUUID.message}" + } + } + + sealed trait DecodeError extends Product with Serializable { + def message: String + } + object DecodeError { + case object NotParseableTypeID extends DecodeError { + override val message = "Could not parse the supplied string as a TypeID" + } + case object BadSeparator extends DecodeError { + override val message = "An empty prefix should not have a separator" + } + case object MissingSeparator extends DecodeError { + override val message = "A separator was not found" + } + case object NotUUIDV7 extends DecodeError { + override val message = "The decoded UUID is not a mandatory V7" + } + final case class InvalidTypeID(message: String) extends DecodeError + } +} diff --git a/zio-uuid/src/main/scala/zio/uuid/TypeIDGenerator.scala b/zio-uuid/src/main/scala/zio/uuid/TypeIDGenerator.scala new file mode 100644 index 0000000..53bdb47 --- /dev/null +++ b/zio-uuid/src/main/scala/zio/uuid/TypeIDGenerator.scala @@ -0,0 +1,71 @@ +package zio.uuid + +import zio.prelude.DebugOps +import zio.{IO, ULayer, ZIO, ZLayer} + +trait TypeIDGenerator { + + /** + * Create a new TypeID. + * + * @param prefix + * a string at most 63 characters in all lowercase ASCII [a-z]. Will lift + * an error in the F context if the prefix is invalid. + */ + def typeid(prefix: String): IO[IllegalArgumentException, TypeID] +} + +object TypeIDGenerator { + + /** + * Return a UUID V7 based TypeID generator + */ + val live: ULayer[TypeIDGenerator] = + UUIDGenerator.live >>> ZLayer.fromZIO { + ZIO.serviceWith[UUIDGenerator](uuidGenerator => + new TypeIDGenerator { + override def typeid(prefix: String): IO[IllegalArgumentException, TypeID] = + uuidGenerator.uuidV7.flatMap { uuid => + TypeID + .build(prefix, uuid) + .mapError(errors => new IllegalArgumentException(errors.render)) + .toZIO + } + } + ) + } + + /** + * Accessor function + * + * ⚠️⚠️⚠️ + * Should not be used this way: + * {{{ + * val id0 = TypeIDGenerator.generate("prefix").provideLayer(TypeIDGenerator.live) + * val id1 = TypeIDGenerator.generate("prefix").provideLayer(TypeIDGenerator.live) + * }}} + * + * Instead, use the following: + * {{{ + * ( + * for { + * id0 <- TypeIDGenerator.generate("prefix") + * id1 <- TypeIDGenerator.generate("prefix") + * } yield ... + * ).provideLayer(TypeIDGenerator.live) + * }}} + * + * The best way to use the `TypeIDGenerator` is to inject its 'live' layer in the boot sequence of your program so that the same instance + * is reused everywhere in your program. + * + * If incorrectly used, the generated TypedIDs are not guaranteed to be generated in a monotonic order. + * ⚠️⚠️⚠️ + * + * @param prefix + * a string at most 63 characters in all lowercase ASCII [a-z]. Will lift + * an error in the F context if the prefix is invalid. + */ + def generate(prefix: String): ZIO[TypeIDGenerator, IllegalArgumentException, TypeID] = + ZIO.serviceWithZIO[TypeIDGenerator](_.typeid(prefix)) + +} diff --git a/zio-uuid/src/main/scala/zio/uuid/UUIDGenerator.scala b/zio-uuid/src/main/scala/zio/uuid/UUIDGenerator.scala new file mode 100644 index 0000000..5936922 --- /dev/null +++ b/zio-uuid/src/main/scala/zio/uuid/UUIDGenerator.scala @@ -0,0 +1,138 @@ +/* + * Copyright 2023 Antoine Comte + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.uuid + +import zio.uuid.internals.UUIDBuilder +import zio.uuid.internals.UUIDGeneratorBuilder.buildGenerator +import zio.uuid.types.{UUIDv1, UUIDv6, UUIDv7} +import zio.{UIO, ULayer, URIO, ZIO, ZLayer} + +trait UUIDGenerator { + def uuidV1: UIO[UUIDv1] + def uuidV6: UIO[UUIDv6] + def uuidV7: UIO[UUIDv7] +} + +final class UUIDGeneratorLive(private val v1: UIO[UUIDv1], private val v6: UIO[UUIDv6], private val v7: UIO[UUIDv7]) extends UUIDGenerator { + override def uuidV1: UIO[UUIDv1] = v1 + override def uuidV6: UIO[UUIDv6] = v6 + override def uuidV7: UIO[UUIDv7] = v7 +} + +object UUIDGenerator { + + /** + * Accessor functions + * + * ⚠️⚠️⚠️ + * Should not be used this way: + * {{{ + * val id0 = UUIDGenerator.uuidV1.provideLayer(UUIDGenerator.live) + * val id1 = UUIDGenerator.uuidV1.provideLayer(UUIDGenerator.live) + * }}} + * + * Instead, use the following: + * {{{ + * ( + * for { + * id0 <- UUIDGenerator.uuidV1 + * id1 <- UUIDGenerator.uuidV1 + * } yield ... + * ).provideLayer(UUIDGenerator.live) + * }}} + * + * The best way to use the `UUIDGenerator` is to inject its 'live' layer in the boot sequence of your program so that the same instance + * is reused everywhere in your program. + * + * If incorrectly used, the generated UUIDs are not guaranteed to be generated in a monotonic order. + * ⚠️⚠️⚠️ + */ + val uuidV1: URIO[UUIDGenerator, UUIDv1] = ZIO.serviceWithZIO(_.uuidV1) + + /** + * Accessor functions + * + * ⚠️⚠️⚠️ + * Should not be used this way: + * {{{ + * val id0 = UUIDGenerator.uuidV6.provideLayer(UUIDGenerator.live) + * val id1 = UUIDGenerator.uuidV6.provideLayer(UUIDGenerator.live) + * }}} + * + * Instead, use the following: + * {{{ + * ( + * for { + * id0 <- UUIDGenerator.uuidV6 + * id1 <- UUIDGenerator.uuidV6 + * } yield ... + * ).provideLayer(UUIDGenerator.live) + * }}} + * + * The best way to use the `UUIDGenerator` is to inject its 'live' layer in the boot sequence of your program so that the same instance + * is reused everywhere in your program. + * + * If incorrectly used, the generated UUIDs are not guaranteed to be generated in a monotonic order. + * ⚠️⚠️⚠️ + */ + val uuidV6: URIO[UUIDGenerator, UUIDv6] = ZIO.serviceWithZIO(_.uuidV6) + + /** + * Accessor functions + * + * ⚠️⚠️⚠️ + * Should not be used this way: + * {{{ + * val id0 = UUIDGenerator.uuidV7.provideLayer(UUIDGenerator.live) + * val id1 = UUIDGenerator.uuidV7.provideLayer(UUIDGenerator.live) + * }}} + * + * Instead, use the following: + * {{{ + * ( + * for { + * id0 <- UUIDGenerator.uuidV7 + * id1 <- UUIDGenerator.uuidV7 + * } yield ... + * ).provideLayer(UUIDGenerator.live) + * }}} + * + * The best way to use the `UUIDGenerator` is to inject its 'live' layer in the boot sequence of your program so that the same instance + * is reused everywhere in your program. + * + * If incorrectly used, the generated UUIDs are not guaranteed to be generated in a monotonic order. + * ⚠️⚠️⚠️ + */ + val uuidV7: URIO[UUIDGenerator, UUIDv7] = ZIO.serviceWithZIO(_.uuidV7) + + /** + * return a UUIDv1 (gregorian timestamp based, non-sortable) generator with + * guarantee about the uniqueness of the UUID, even within the same + * millisecond timestamp. + * + * This function uses a randomized MAC address. + */ + val live: ULayer[UUIDGenerator] = + ZLayer.fromZIO { + for { + v1 <- buildGenerator(UUIDBuilder.buildUUIDv1) + v6 <- buildGenerator(UUIDBuilder.buildUUIDv6) + v7 <- buildGenerator(UUIDBuilder.buildUUIDv7) + } yield new UUIDGeneratorLive(v1, v6, v7) + } + +} diff --git a/zio-uuid/src/main/scala/zio/uuid/internals/UUIDBase32.scala b/zio-uuid/src/main/scala/zio/uuid/internals/UUIDBase32.scala new file mode 100644 index 0000000..119a739 --- /dev/null +++ b/zio-uuid/src/main/scala/zio/uuid/internals/UUIDBase32.scala @@ -0,0 +1,172 @@ +package zio.uuid.internals + +import java.util.UUID +import scala.annotation.switch +import scala.util.{Failure, Success, Try} + +object UUIDBase32 { + private val encodingTable: Array[Char] = "0123456789abcdefghjkmnpqrstvwxyz".toArray + + def toBase32(uuid: UUID): String = { + @inline def enc(i: Int): Char = encodingTable(i) + + val b0 = ((uuid.getMostSignificantBits >>> 56) & 0xff).toInt + val b1 = ((uuid.getMostSignificantBits >>> 48) & 0xff).toInt + val b2 = ((uuid.getMostSignificantBits >>> 40) & 0xff).toInt + val b3 = ((uuid.getMostSignificantBits >>> 32) & 0xff).toInt + val b4 = ((uuid.getMostSignificantBits >>> 24) & 0xff).toInt + val b5 = ((uuid.getMostSignificantBits >>> 16) & 0xff).toInt + val b6 = ((uuid.getMostSignificantBits >>> 8) & 0xff).toInt + val b7 = (uuid.getMostSignificantBits & 0xff).toInt + + val b8 = (uuid.getLeastSignificantBits >>> 56 & 0xff).toInt + val b9 = (uuid.getLeastSignificantBits >>> 48 & 0xff).toInt + val b10 = (uuid.getLeastSignificantBits >>> 40 & 0xff).toInt + val b11 = (uuid.getLeastSignificantBits >>> 32 & 0xff).toInt + val b12 = (uuid.getLeastSignificantBits >>> 24 & 0xff).toInt + val b13 = (uuid.getLeastSignificantBits >>> 16 & 0xff).toInt + val b14 = (uuid.getLeastSignificantBits >>> 8 & 0xff).toInt + val b15 = (uuid.getLeastSignificantBits & 0xff).toInt + + val out = new Array[Char](26) + out(0) = enc(ms3b(b0)) + out(1) = enc(ls5b(b0)) + out(2) = enc(ms5b(b1)) + out(3) = enc(ls3b(b1) << 2 | ms2b(b2)) + out(4) = enc(ls5b(b2 >> 1)) + out(5) = enc(ls1b(b2) << 4 | ms4b(b3)) + out(6) = enc(ls4b(b3) << 1 | ms1b(b4)) + out(7) = enc(ls5b(b4 >> 2)) + out(8) = enc(ls2b(b4) << 3 | ms3b(b5)) + out(9) = enc(ls5b(b5)) + out(10) = enc(ms5b(b6)) + out(11) = enc(ls3b(b6) << 2 | ms2b(b7)) + out(12) = enc(ls5b(b7 >> 1)) + out(13) = enc(ls1b(b7) << 4 | ms4b(b8)) + out(14) = enc(ls4b(b8) << 1 | ms1b(b9)) + out(15) = enc(ls5b(b9 >> 2)) + out(16) = enc(ls2b(b9) << 3 | ms3b(b10)) + out(17) = enc(ls5b(b10)) + out(18) = enc(ms5b(b11)) + out(19) = enc(ls3b(b11) << 2 | ms2b(b12)) + out(20) = enc(ls5b(b12 >> 1)) + out(21) = enc(ls1b(b12) << 4 | ms4b(b13)) + out(22) = enc(ls4b(b13) << 1 | ms1b(b14)) + out(23) = enc(ls5b(b14 >> 2)) + out(24) = enc(ls2b(b14) << 3 | ms3b(b15)) + out(25) = enc(ls5b(b15)) + + new String(out) + } + + def fromBase32(s: String): Either[String, UUID] = { + val values = Try( + s.map(v => + (v: @switch) match { + case '0' => 0L + case '1' => 1L + case '2' => 2L + case '3' => 3L + case '4' => 4L + case '5' => 5L + case '6' => 6L + case '7' => 7L + case '8' => 8L + case '9' => 9L + case 'a' => 10L + case 'b' => 11L + case 'c' => 12L + case 'd' => 13L + case 'e' => 14L + case 'f' => 15L + case 'g' => 16L + case 'h' => 17L + case 'j' => 18L + case 'k' => 19L + case 'm' => 20L + case 'n' => 21L + case 'p' => 22L + case 'q' => 23L + case 'r' => 24L + case 's' => 25L + case 't' => 26L + case 'v' => 27L + case 'w' => 28L + case 'x' => 29L + case 'y' => 30L + case 'z' => 31L + } + ) + ) + + values match { + case Failure(_) => + Left("String representation contains at least an invalid characters") + case Success(values) if (values.length != 26) => + Left("String representation should be exactly 26 significant characters") + case Success(values) if (values(0) > 7) => + Left("The String representation encodes more than 128 bits") + case Success(values) => + // format: off + val msb = (values(0) << 61) | // We have only 3 significant bits at pos. 0 because of the padding + (values(1) << 56) | (values(2) << 51) | (values(3) << 46) | + (values(4) << 41) | (values(5) << 36) | (values(6) << 31) | (values(7) << 26) | + (values(8) << 21) | (values(9) << 16) | (values(10) << 11) | (values(11) << 6) | + (values(12) << 1) | ls1b((values(13) >> 4).toInt) + + val lsb = (values(13) << 60) | + (values(14) << 55) | (values(15) << 50) | (values(16) << 45) | (values(17) << 40) | + (values(18) << 35) | (values(19) << 30) | (values(20) << 25) | (values(21) << 20) | + (values(22) << 15) | (values(23) << 10) | (values(24) << 5) | values(25) + // format: on + + Right(new UUID(msb, lsb)) + } + } + + private val Mask1Bits: Byte = 0x01 + private val Mask2Bits: Byte = 0x03 + private val Mask3Bits: Byte = 0x07 + private val Mask4Bits: Byte = 0x0f + private val Mask5Bits: Byte = 0x1f + + @inline + private def ls1b(b: Int): Int = + b & Mask1Bits + + @inline + private def ls2b(b: Int): Int = + b & Mask2Bits + + @inline + private def ls3b(b: Int): Int = + b & Mask3Bits + + @inline + private def ls4b(b: Int): Int = + b & Mask4Bits + + @inline + private def ls5b(b: Int): Int = + b & Mask5Bits + + @inline + private def ms5b(b: Int): Int = + (b >> 3) & Mask5Bits + + @inline + private def ms4b(b: Int): Int = + (b >> 4) & Mask4Bits + + @inline + private def ms3b(b: Int): Int = + (b >> 5) & Mask3Bits + + @inline + private def ms2b(b: Int): Int = + (b >> 6) & Mask2Bits + + @inline + private def ms1b(b: Int): Int = + (b >> 7) & Mask1Bits +} diff --git a/zio-uuid/src/main/scala/zio/uuid/internals/UUIDBuilder.scala b/zio-uuid/src/main/scala/zio/uuid/internals/UUIDBuilder.scala new file mode 100644 index 0000000..a834ca7 --- /dev/null +++ b/zio-uuid/src/main/scala/zio/uuid/internals/UUIDBuilder.scala @@ -0,0 +1,62 @@ +package zio.uuid.internals + +import zio.uuid.types.{UUIDv1, UUIDv6, UUIDv7} + +import java.util.UUID + +private[zio] object UUIDBuilder { + val Variant = 0x2L + + def buildUUIDv1(epochMillis: Long, sequence: Long, random: Long): UUIDv1 = { + val Version = 0x1L + val gregorianTimestamp = toUUIDTimestamp(epochMillis) + val time_high = + gregorianTimestamp >>> 48 // 12 most significant bits of the timestamp + val time__mid = + (gregorianTimestamp >>> 32) & 0xffff // 16 middle bits of the timestamp + val time_low = + gregorianTimestamp & 0xffff_ffff // 32 least significant bits of the timestamp + val node = + ((random << 16) >>> 16) | (0x1L << 40) // 48 bits (MAC address with the unicast bit set to 1) + val clock_seq = sequence & 0x3fff // 14 bits + val msb = (time_low << 32) | time__mid << 16 | (Version << 12) | time_high + val lsb = (Variant << 62) | clock_seq << 48 | node + UUIDv1.unsafe(new UUID(msb, lsb)) + } + + def buildUUIDv6(epochMillis: Long, sequence: Long, random: Long): UUIDv6 = { + val Version = 0x6L + val gregorianTimestamp = toUUIDTimestamp(epochMillis) + val time_high_and_mid = + gregorianTimestamp >>> 12 // 48 most significant bits of the timestamp + val time_low = + gregorianTimestamp & 0xfff // 12 least significant bits of the timestamp + val node = (random << 16) >>> 16 // 48 bits + val clock_seq = sequence & 0x3fff // 14 bits + val msb = (time_high_and_mid << 16) | (Version << 12) | time_low + val lsb = (Variant << 62) | clock_seq << 48 | node + UUIDv6.unsafe(new UUID(msb, lsb)) + } + + def buildUUIDv7(epochMillis: Long, sequence: Long, random: Long): UUIDv7 = { + val Version = 0x7L + val rand_a = sequence & 0xfffL // 12 bits + val rand_b = (random << 2) >>> 2 // we need only 62 bits of randomness + val msb = (epochMillis << 16) | (Version << 12) | rand_a + val lsb = (Variant << 62) | rand_b + UUIDv7.unsafe(new UUID(msb, lsb)) + } + + /** + * number of 100 nanosecond intervals since the beginning of the gregorian + * calendar (15-oct-1582) to Unix Epoch + */ + private val UnixEpochClockOffset = 0x01b21dd213814000L + + @inline + final def toUUIDTimestamp(epochMillis: Long): Long = { + val ClockMultiplier = 10000L // count of 100 nanosecond intervals in a milli + val ts = epochMillis * ClockMultiplier + UnixEpochClockOffset + (ts << 4) >>> 4 // Keeping only the 60 least significant bits + } +} diff --git a/zio-uuid/src/main/scala/zio/uuid/internals/UUIDGeneratorBuilder.scala b/zio-uuid/src/main/scala/zio/uuid/internals/UUIDGeneratorBuilder.scala new file mode 100644 index 0000000..8196a6f --- /dev/null +++ b/zio-uuid/src/main/scala/zio/uuid/internals/UUIDGeneratorBuilder.scala @@ -0,0 +1,55 @@ +package zio.uuid.internals + +import zio.{Clock, Ref, Semaphore, UIO, ZIO} + +import java.util.concurrent.TimeUnit + +private[zio] final case class GeneratorState(lastUsedEpochMillis: Long, sequence: Long) + +private[zio] object GeneratorState { + val initial: GeneratorState = GeneratorState(lastUsedEpochMillis = 0L, sequence = 0L) +} + +private[zio] object UUIDGeneratorBuilder { + type UUIDBuilder[UUIDvX] = (Long, Long, Long) => UUIDvX + + def generate[UUIDvX]( + state: Ref[GeneratorState], + mutex: Semaphore, + random: zio.Random, + clock: Clock, + builder: UUIDBuilder[UUIDvX], + ): UIO[UUIDvX] = + for { + random <- random.nextLong + uuid <- mutex.withPermit { + for { + timestamp <- clock.currentTime(TimeUnit.MILLISECONDS) + modifiedState <- state.modify { currentState => + // currentTime clock may run backward + val actualTimestamp = Math.max(currentState.lastUsedEpochMillis, timestamp) + val sequence = + if (currentState.lastUsedEpochMillis == actualTimestamp) { + currentState.sequence + 1 + } else 0L + + val newState = GeneratorState(lastUsedEpochMillis = actualTimestamp, sequence = sequence) + (newState, newState) + } + } yield builder( + modifiedState.lastUsedEpochMillis, + modifiedState.sequence, + random, + ) + } + } yield uuid + + // noinspection YieldingZIOEffectInspection + def buildGenerator[UUIDvX](builder: UUIDBuilder[UUIDvX]): UIO[UIO[UUIDvX]] = + for { + state <- Ref.make(GeneratorState.initial) + mutex <- Semaphore.make(1) + random <- ZIO.random + clock <- ZIO.clock + } yield generate(state, mutex, random, clock, builder) +} diff --git a/zio-uuid/src/main/scala/zio/uuid/types.scala b/zio-uuid/src/main/scala/zio/uuid/types.scala new file mode 100644 index 0000000..beba178 --- /dev/null +++ b/zio-uuid/src/main/scala/zio/uuid/types.scala @@ -0,0 +1,31 @@ +package zio.uuid + +import zio.json.JsonCodec +import zio.prelude.Subtype + +import java.util.UUID + +object types { + + type UUIDv1 = UUIDv1.Type + object UUIDv1 extends Subtype[UUID] { + private[zio] def unsafe(uuid: UUID): Type = wrap(uuid) + + implicit val UUIDv1Codec: JsonCodec[Type] = derive + } + + type UUIDv6 = UUIDv6.Type + object UUIDv6 extends Subtype[UUID] { + private[zio] def unsafe(uuid: UUID): Type = wrap(uuid) + + implicit val UUIDv6Codec: JsonCodec[Type] = derive + } + + type UUIDv7 = UUIDv7.Type + object UUIDv7 extends Subtype[UUID] { + private[zio] def unsafe(uuid: UUID): Type = wrap(uuid) + + implicit val UUIDv7Codec: JsonCodec[Type] = derive + } + +} diff --git a/core/src/test/scala/tech/ant8e/uuid4cats/BuilderSuite.scala b/zio-uuid/src/test/scala/zio/uuid/BuilderSuite.scala similarity index 72% rename from core/src/test/scala/tech/ant8e/uuid4cats/BuilderSuite.scala rename to zio-uuid/src/test/scala/zio/uuid/BuilderSuite.scala index 373169e..f245c47 100644 --- a/core/src/test/scala/tech/ant8e/uuid4cats/BuilderSuite.scala +++ b/zio-uuid/src/test/scala/zio/uuid/BuilderSuite.scala @@ -14,9 +14,11 @@ * limitations under the License. */ -package tech.ant8e.uuid4cats +package zio.uuid +import zio.uuid.internals.UUIDBuilder import munit.FunSuite +import zio.uuid.types.{UUIDv1, UUIDv6, UUIDv7} import java.util.UUID @@ -24,31 +26,24 @@ class BuilderSuite extends FunSuite { test("UUIDBuilder should correctly build UUIDv1") { // Differ by one bit of the test value in the RFC as we force the unicast bit to 1 - val expected = uuid"C232AB00-9414-11EC-B3C8-9F6BDECED846" + val expected = UUIDv1.unsafe(uuid"C232AB00-9414-11EC-B3C8-9F6BDECED846") val obtained = UUIDBuilder.buildUUIDv1(1645557742000L, 0x33c8L, 0x9e6bdeced846L) assertEquals(obtained, expected) } - test("UUIDBuilder should correctly build UUIDv4") { - val expected = uuid"919108f7-52d1-4320-9bac-f847db4148a8" - val obtained = - UUIDBuilder.buildUUIDv4(0x919108f752d10320L, 0x1bacf847db4148a8L) - assertEquals(obtained, expected) - } - test("UUIDBuilder should correctly build UUIDv6") { - val expected = uuid"1EC9414C-232A-6B00-B3C8-9E6BDECED846" + val expected = UUIDv6.unsafe(uuid"1EC9414C-232A-6B00-B3C8-9E6BDECED846") val obtained = UUIDBuilder.buildUUIDv6(1645557742000L, 0x33c8L, 0x9e6bdeced846L) assertEquals(obtained, expected) } test("UUIDBuilder should correctly build UUIDv7") { - val expected = uuid"017F22E2-79B0-7CC3-98C4-DC0C0C07398F" + val expected = UUIDv7.unsafe(uuid"017F22E2-79B0-7CC3-98C4-DC0C0C07398F") val obtained = - UUIDBuilder.buildUUIDV7(0x17f22e279b0L, 0xcc3L, 0x18c4dc0c0c07398fL) + UUIDBuilder.buildUUIDv7(0x17f22e279b0L, 0xcc3L, 0x18c4dc0c0c07398fL) assertEquals(obtained, expected) } diff --git a/zio-uuid/src/test/scala/zio/uuid/GeneratorSuite.scala b/zio-uuid/src/test/scala/zio/uuid/GeneratorSuite.scala new file mode 100644 index 0000000..4514087 --- /dev/null +++ b/zio-uuid/src/test/scala/zio/uuid/GeneratorSuite.scala @@ -0,0 +1,110 @@ +/* + * Copyright 2023 Antoine Comte + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.uuid + +import munit.ZSuite +import zio.{URIO, ZIO} + +import java.util.UUID + +class GeneratorSuite extends ZSuite { + + private val n = 10000 + + testZ("UUIDv1 should generate UUIDs") { + ( + for { + uuids <- genN(UUIDGenerator.uuidV1, n) + _ <- assertAllUnique(uuids) + } yield () + ).provideLayer(UUIDGenerator.live) + } + + testZ("UUIDv6 should generate UUIDs") { + ( + for { + uuids <- genN(UUIDGenerator.uuidV6, n) + _ <- assertAllUnique(uuids) + _ <- assertSorted(uuids) + } yield () + ).provideLayer(UUIDGenerator.live) + } + + testZ("UUIDv7 should generate UUIDs") { + ( + for { + uuids <- genN(UUIDGenerator.uuidV7, n) + _ <- assertAllUnique(uuids) + _ <- assertSorted(uuids) + } yield () + ).provideLayer(UUIDGenerator.live) + } + + testZ("TypeID should generate TypeIds") { + for { + typeids <- ZIO.collectAll(List.tabulate(n)(_ => TypeIDGenerator.generate("prefix"))).provideLayer(TypeIDGenerator.live) + _ <- ZIO.succeed(assert(typeids.distinct.size == typeids.size)) + _ <- ZIO.succeed(assert(isSeqSorted(typeids))) + } yield () + } + + testZ("TypeID generator should not accept illegal prefix") { + ZIO + .serviceWithZIO[TypeIDGenerator](_.typeid("WRONG")) + .provideLayer(TypeIDGenerator.live) + .interceptFailure[IllegalArgumentException] + } + + private def genN[R, UUIDvX](gen: URIO[R, UUIDvX], n: Int): URIO[R, List[UUIDvX]] = + ZIO.collectAll(List.tabulate(n)(_ => gen)) + + implicit class UUIDsOps(uuids: List[UUID]) { + def allUniques: Boolean = uuids.distinct.size == uuids.size + def isSorted: Boolean = isSeqSorted(uuids) + } + + private def assertAllUnique(uuids: List[UUID]) = ZIO.succeed(assert(uuids.allUniques, s"Not Unique : $uuids")) + private def assertSorted(uuids: List[UUID]) = ZIO.succeed(assert(uuids.isSorted, s"Not sorted : ${findNotSorted(uuids)}")) + + // Using a custom ordering based on String because UUID compareTo() is not reliable + // https://github.com/scala-js/scala-js/issues/4882 and + // https://bugs.openjdk.org/browse/JDK-7025832 + implicit val uuidOrdering: Ordering[UUID] = + Ordering.by[UUID, String](_.toString) + + def isSeqSorted[T]( + seq: List[T] + )(implicit ordering: Ordering[T]): Boolean = { + val lastIndex = seq.length - 1 + !seq.zipWithIndex.exists { case (t, index) => + index != lastIndex && ordering.gt(t, seq(index + 1)) + } + } + + def findNotSorted[T](seq: List[T])(implicit ordering: Ordering[T]): String = { + val lastIndex = seq.length - 1 + seq.zipWithIndex + .find { case (t, index) => + index != lastIndex && ordering.gt(t, seq(index + 1)) + } + .map { case (t, index) => + s"found non sorted values $t ${seq(index + 1)}} at index $index" + } + .getOrElse("No non sorted values found") + } + +} diff --git a/core/src/test/scala/tech/ant8e/uuid4cats/TypeIDSuite.scala b/zio-uuid/src/test/scala/zio/uuid/TypeIDSuite.scala similarity index 76% rename from core/src/test/scala/tech/ant8e/uuid4cats/TypeIDSuite.scala rename to zio-uuid/src/test/scala/zio/uuid/TypeIDSuite.scala index b690820..0d1a52a 100644 --- a/core/src/test/scala/tech/ant8e/uuid4cats/TypeIDSuite.scala +++ b/zio-uuid/src/test/scala/zio/uuid/TypeIDSuite.scala @@ -14,12 +14,11 @@ * limitations under the License. */ -package tech.ant8e.uuid4cats +package zio.uuid -import cats.Eq -import cats.data.NonEmptyChain -import cats.syntax.all._ import munit.FunSuite +import zio.prelude.Debug.Renderer +import zio.prelude.{DebugOps, Equal, Validation} import java.util.UUID @@ -31,56 +30,56 @@ class TypeIDSuite extends FunSuite { assertValidEncoding( typeID = "00000000000000000000000000", prefix = "", - uuid = uuid"00000000-0000-0000-0000-000000000000" + uuid = uuid"00000000-0000-0000-0000-000000000000", ) // one assertValidEncoding( typeID = "00000000000000000000000001", prefix = "", - uuid = uuid"00000000-0000-0000-0000-000000000001" + uuid = uuid"00000000-0000-0000-0000-000000000001", ) // ten assertValidEncoding( typeID = "0000000000000000000000000a", prefix = "", - uuid = uuid"00000000-0000-0000-0000-00000000000a" + uuid = uuid"00000000-0000-0000-0000-00000000000a", ) // sixteen assertValidEncoding( typeID = "0000000000000000000000000g", prefix = "", - uuid = uuid"00000000-0000-0000-0000-000000000010" + uuid = uuid"00000000-0000-0000-0000-000000000010", ) // thirty-two assertValidEncoding( typeID = "00000000000000000000000010", prefix = "", - uuid = uuid"00000000-0000-0000-0000-000000000020" + uuid = uuid"00000000-0000-0000-0000-000000000020", ) // max-valid assertValidEncoding( typeID = "7zzzzzzzzzzzzzzzzzzzzzzzzz", prefix = "", - uuid = uuid"ffffffff-ffff-ffff-ffff-ffffffffffff" + uuid = uuid"ffffffff-ffff-ffff-ffff-ffffffffffff", ) // valid-alphabet assertValidEncoding( typeID = "prefix_0123456789abcdefghjkmnpqrs", prefix = "prefix", - uuid = uuid"0110c853-1d09-52d8-d73e-1194e95b5f19" + uuid = uuid"0110c853-1d09-52d8-d73e-1194e95b5f19", ) // valid-uuidv7 assertValidEncoding( typeID = "prefix_01h455vb4pex5vsknk084sn02q", prefix = "prefix", - uuid = uuid"01890a5d-ac96-774b-bcce-b302099a8057" + uuid = uuid"01890a5d-ac96-774b-bcce-b302099a8057", ) } test("TypeID should not build invalid typeIDs") { @@ -88,7 +87,7 @@ class TypeIDSuite extends FunSuite { "0123456789012345678901234567890123456789012345678901234567890123456789" assertInvalidEncoding( tooLongPrefix, - uuid"01890a5d-ac96-774b-bcce-b302099a8057" + uuid"01890a5d-ac96-774b-bcce-b302099a8057", ) val v4UUID = uuid"3054b437-160c-42ac-9b68-f814f93bfc28" @@ -103,49 +102,49 @@ class TypeIDSuite extends FunSuite { assertValidDecoding( typeID = "00000000000000000000000000", prefix = "", - uuid = uuid"00000000-0000-0000-0000-000000000000" + uuid = uuid"00000000-0000-0000-0000-000000000000", ) // one assertValidDecoding( typeID = "00000000000000000000000001", prefix = "", - uuid = uuid"00000000-0000-0000-0000-000000000001" + uuid = uuid"00000000-0000-0000-0000-000000000001", ) // ten assertValidDecoding( typeID = "0000000000000000000000000a", prefix = "", - uuid = uuid"00000000-0000-0000-0000-00000000000a" + uuid = uuid"00000000-0000-0000-0000-00000000000a", ) // sixteen assertValidDecoding( typeID = "0000000000000000000000000g", prefix = "", - uuid = uuid"00000000-0000-0000-0000-000000000010" + uuid = uuid"00000000-0000-0000-0000-000000000010", ) // thirty-two assertValidDecoding( typeID = "00000000000000000000000010", prefix = "", - uuid = uuid"00000000-0000-0000-0000-000000000020" + uuid = uuid"00000000-0000-0000-0000-000000000020", ) // max-valid assertValidDecoding( typeID = "7zzzzzzzzzzzzzzzzzzzzzzzzz", prefix = "", - uuid = uuid"ffffffff-ffff-ffff-ffff-ffffffffffff" + uuid = uuid"ffffffff-ffff-ffff-ffff-ffffffffffff", ) // valid-alphabet assertValidDecoding( typeID = "prefix_0123456789abcdefghjkmnpqrs", prefix = "prefix", - uuid = uuid"0110c853-1d09-52d8-d73e-1194e95b5f19" + uuid = uuid"0110c853-1d09-52d8-d73e-1194e95b5f19", ) // valid-uuidv7 @@ -153,7 +152,7 @@ class TypeIDSuite extends FunSuite { typeID = "prefix_01h455vb4pex5vsknk084sn02q", prefix = "prefix", uuid = uuid"01890a5d-ac96-774b-bcce-b302099a8057", - enforceUUIDV7 = true + enforceUUIDV7 = true, ) } @@ -177,9 +176,7 @@ class TypeIDSuite extends FunSuite { assertInvalidDecoding(typeID = " prefix_00000000000000000000000000") // prefix-64-chars - assertInvalidDecoding(typeID = - "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl_00000000000000000000000000" - ) + assertInvalidDecoding(typeID = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl_00000000000000000000000000") // separator-empty-prefix assertInvalidDecoding(typeID = "_00000000000000000000000000") @@ -217,7 +214,7 @@ class TypeIDSuite extends FunSuite { // sixteen with mandatory UUIDV7 assertInvalidDecoding( typeID = "0000000000000000000000000g", - enforceUUIDV7 = true + enforceUUIDV7 = true, ) // missing separator @@ -227,66 +224,61 @@ class TypeIDSuite extends FunSuite { } test("TypeID should have an Eq instance") { - val eq = implicitly[Eq[TypeID]] + val eq = implicitly[Equal[TypeID]] assert( - eq.eqv( + eq.equal( + TypeID.decode("prefix_01h455vb4pex5vsknk084sn02q").toOption.get, TypeID.decode("prefix_01h455vb4pex5vsknk084sn02q").toOption.get, - TypeID.decode("prefix_01h455vb4pex5vsknk084sn02q").toOption.get ) ) } - test("TypeID should have a Show instance") { + test("TypeID should have a Debug instance") { assertEquals( - TypeID.decode("prefix_01h455vb4pex5vsknk084sn02q").toOption.get.show, - "TypeID:prefix_01h455vb4pex5vsknk084sn02q(01890a5d-ac96-774b-bcce-b302099a8057)" + TypeID.decode("prefix_01h455vb4pex5vsknk084sn02q").toOption.get.debug.render(Renderer.Scala), + "TypeID:prefix_01h455vb4pex5vsknk084sn02q(01890a5d-ac96-774b-bcce-b302099a8057)", ) } private def assertValidEncoding( - typeID: String, - prefix: String, - uuid: UUID - ): Unit = { + typeID: String, + prefix: String, + uuid: UUID, + ): Unit = assertEquals( TypeID .build(prefix = prefix, uuid = uuid, uuid.version() == 7) .map(_.value), - typeID.valid[TypeID.BuildError].toValidatedNec + Validation.succeed(typeID), ) - } private def assertInvalidEncoding( - prefix: String, - uuid: UUID - ): Unit = { + prefix: String, + uuid: UUID, + ): Unit = assert( TypeID .build(prefix = prefix, uuid = uuid) .isInvalid ) - } + private def assertValidDecoding( - typeID: String, - prefix: String, - uuid: UUID, - enforceUUIDV7: Boolean = false + typeID: String, + prefix: String, + uuid: UUID, + enforceUUIDV7: Boolean = false, ): Unit = { val obtained = TypeID.decode(typeID, enforceUUIDV7) val expected = TypeID .build(prefix = prefix, uuid = uuid, uuid.version() == 7) - .leftMap(_ => - NonEmptyChain.one( - TypeID.DecodeError.InvalidTypeID("Test"): TypeID.DecodeError - ) - ) + .mapError(_ => TypeID.DecodeError.InvalidTypeID("Test"): TypeID.DecodeError) assertEquals(obtained, expected) } private def assertInvalidDecoding( - typeID: String, - enforceUUIDV7: Boolean = false + typeID: String, + enforceUUIDV7: Boolean = false, ): Unit = { val obtained = TypeID.decode(typeID, enforceUUIDV7) assert(obtained.isInvalid, s"$typeID should not decode as a valid TypeID") @@ -295,4 +287,12 @@ class TypeIDSuite extends FunSuite { implicit class uuidOps(sc: StringContext) { def uuid(args: Any*): UUID = UUID.fromString(sc.s(args: _*)) } + + implicit class ValidationOps[E, A](v: Validation[E, A]) { + def isInvalid: Boolean = + v match { + case Validation.Failure(_, _) => true + case _ => false + } + } }