diff --git a/.agents/setup b/.agents/setup index f397615d65..b81ad429fb 100755 --- a/.agents/setup +++ b/.agents/setup @@ -7,7 +7,8 @@ export DEBIAN_FRONTEND=noninteractive echo '==> Checking native build prerequisites' missing=() -for package in build-essential python3 pkg-config curl ca-certificates xz-utils; do +# postgresql backs the Swift end-to-end tests (packages/zero-conformance/src/e2e). +for package in build-essential python3 pkg-config curl ca-certificates xz-utils postgresql; do if [[ "$(dpkg-query -W -f='${Status}' "$package" 2>/dev/null || true)" != 'install ok installed' ]]; then missing+=("$package") fi @@ -17,6 +18,26 @@ if ((${#missing[@]})); then time sudo apt-get install -y "${missing[@]}" fi +echo '==> Installing the Swift toolchain for swift/' +swift_version='6.2.4' +if [[ "$(/opt/swift/usr/bin/swift --version 2>/dev/null || true)" != *"swift-${swift_version}-RELEASE"* ]]; then + time sudo apt-get update + time sudo apt-get install -y binutils-gold libicu-dev libcurl4-openssl-dev \ + libedit-dev libsqlite3-dev libncurses-dev libpython3-dev libxml2-dev \ + tzdata uuid-dev gnupg2 libstdc++-12-dev zlib1g-dev + swift_tmp="$(mktemp -d)" + time curl -sSfL -o "$swift_tmp/swift.tar.gz" \ + "https://download.swift.org/swift-${swift_version}-release/debian12/swift-${swift_version}-RELEASE/swift-${swift_version}-RELEASE-debian12.tar.gz" + sudo rm -rf /opt/swift + sudo mkdir -p /opt/swift + sudo tar -xzf "$swift_tmp/swift.tar.gz" -C /opt/swift --strip-components=1 + rm -rf "$swift_tmp" +fi +for tool in swift swiftc sourcekit-lsp docc; do + sudo ln -sf "/opt/swift/usr/bin/$tool" "/usr/local/bin/$tool" +done +swift --version + echo '==> Selecting the repository Node and pnpm versions' node_version="$(tr -d '[:space:]' < .nvmrc)" if [[ "$(node -p 'process.versions.node')" != "$node_version" && "$(node -p 'process.versions.node')" != "$node_version".* ]]; then @@ -40,6 +61,14 @@ pnpm --version PATH="${PNPM_HOME:-$HOME/.local/share/pnpm}/bin:$PATH" \ pnpm config set --global verify-deps-before-run false +# zero-cache runs from source only on Node 24 (`using`), which the Swift +# end-to-end server needs; the .nvmrc Node stays the default. +echo '==> Installing Node 24 for zero-cache into ~/.local/node24' +if [[ "$("$HOME/.local/node24/bin/node" --version 2>/dev/null || true)" != v24.* ]]; then + time N_PREFIX="$HOME/.local/node24" n install 24 +fi +"$HOME/.local/node24/bin/node" --version + echo '==> Installing workspace dependencies from the lockfile' time pnpm install --frozen-lockfile diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000000..95a50ed7ad --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,21 @@ +# Changesets + +Changesets version the Swift client: the `zero-swift` npm package +(`packages/zero-swift-cli`) and the Swift package (`swift/`), which share one +version. Upstream Zero's packages are released separately and are ignored here. + +To release a change to either, add a changeset for `zero-swift`: + +```sh +pnpm dlx @changesets/cli@3.0.3 +``` + +Changesets isn't a workspace dependency, so the root `package.json` and +`pnpm-lock.yaml` stay as upstream has them and syncing upstream doesn't +conflict there. + +On `main`, `.github/workflows/swift-release.yml` keeps a "Version Packages" pull +request open with the pending version and changelog. Merging it sets the +version in `packages/zero-swift-cli/package.json` and `swift/VERSION`, and the +workflow then publishes the npm package and tags the SwiftPM mirror after +approval in the `swift-release` environment. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000000..9bfbad2ead --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@4.0.1/schema.json", + "baseBranch": "main", + "access": "public", + "changelog": "@changesets/cli/changelog", + "commit": false, + "format": "oxfmt", + "ignore": ["@rocicorp/zero-events", "replicache", "otel-proxy"], + "privatePackages": {"version": false, "tag": false} +} diff --git a/.github/actions/playwright-install/action.yml b/.github/actions/playwright-install/action.yml index 2f0af9f0de..03bc885ff3 100644 --- a/.github/actions/playwright-install/action.yml +++ b/.github/actions/playwright-install/action.yml @@ -6,14 +6,18 @@ runs: steps: - name: Get Playwright version id: playwright-version - run: echo "version=$(pnpm exec playwright --version | awk '{print $NF}')" >> "$GITHUB_OUTPUT" + # The OS release too: browsers built for one Ubuntu release don't run + # on the next, which ubuntu-latest moves to. + run: | + echo "version=$(pnpm exec playwright --version | awk '{print $NF}')" >> "$GITHUB_OUTPUT" + echo "os=$(. /etc/os-release && echo "$ID-$VERSION_ID")" >> "$GITHUB_OUTPUT" shell: bash - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright-version.outputs.version }} + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.os }}-${{ runner.arch }}-${{ steps.playwright-version.outputs.version }} - name: Install Playwright browsers and system deps if: steps.playwright-cache.outputs.cache-hit != 'true' run: timeout -k 30s 20m pnpm exec playwright install --with-deps diff --git a/.github/workflows/public-js.yml b/.github/workflows/public-js.yml index 85ab6e02d0..9ec324be90 100644 --- a/.github/workflows/public-js.yml +++ b/.github/workflows/public-js.yml @@ -79,8 +79,9 @@ jobs: test: name: Public Test if: github.repository == 'rocicorp/mono' || github.repository == 'rocicorp/mono-internal' || github.repository == 'wevm/zero' - # See Public Checks. x64, since this runs WebKit and Firefox too. - runs-on: ${{ github.repository == 'rocicorp/mono-internal' && 'ubuntu-x64-8-core' || 'ubuntu-latest' }} + # See Public Checks. x64, since this runs WebKit and Firefox too. Ubuntu + # 24.04, not ubuntu-latest: Playwright 1.60 has no browsers for 26.04. + runs-on: ${{ github.repository == 'rocicorp/mono-internal' && 'ubuntu-x64-8-core' || 'ubuntu-24.04' }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: diff --git a/.github/workflows/swift-macos.yml b/.github/workflows/swift-macos.yml new file mode 100644 index 0000000000..4269b0a3fb --- /dev/null +++ b/.github/workflows/swift-macos.yml @@ -0,0 +1,81 @@ +name: Swift (macOS) + +# The Apple-only layer (ZeroUI, the URLSession transport) and iOS simulator +# tests. Path-filtered because macOS runners are slow to start. + +on: + push: + # swift-client is the development branch until the client lands on main. + branches: [main, swift-client] + paths: + - 'swift/**' + - 'packages/zero-conformance/**' + - 'apps/zbugs-swift/**' + - '.github/workflows/swift-macos.yml' + pull_request: + branches: [main] + paths: + - 'swift/**' + - 'packages/zero-conformance/**' + - 'apps/zbugs-swift/**' + - '.github/workflows/swift-macos.yml' + +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + macos: + name: Swift (macOS) + runs-on: macos-26 + timeout-minutes: 45 + defaults: + run: + working-directory: swift + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - run: swift --version + - name: Build and test (macOS) + run: swift test + - name: Docs + run: scripts/docs.sh + - name: Test (iOS Simulator) + run: | + device="$(xcrun simctl list devices available -j | + python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; print(next(x["udid"] for k,v in d.items() if "iOS" in k for x in v if x["name"].startswith("iPhone")))')" + xcodebuild test -scheme Zero-Package -destination "id=$device" -skipPackagePluginValidation + + zbugs: + name: zbugs app (iOS and macOS) + runs-on: macos-26 + timeout-minutes: 45 + defaults: + run: + working-directory: apps/zbugs-swift + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + # The drift test again, and ZBugsUI (empty on Linux) for macOS. + - name: Build and test (macOS) + run: swift test + - name: Generate the Xcode project + working-directory: apps/zbugs-swift/App + run: brew install xcodegen && xcodegen generate + - name: Build the app (iOS Simulator) + working-directory: apps/zbugs-swift/App + run: >- + xcodebuild build -project ZBugs.xcodeproj -scheme ZBugsApp + -destination 'generic/platform=iOS Simulator' + -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO + - name: Build the app (macOS) + working-directory: apps/zbugs-swift/App + run: >- + xcodebuild build -project ZBugs.xcodeproj -scheme ZBugsApp + -destination 'generic/platform=macOS' + -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/swift-release.yml b/.github/workflows/swift-release.yml new file mode 100644 index 0000000000..c23e0d01a4 --- /dev/null +++ b/.github/workflows/swift-release.yml @@ -0,0 +1,167 @@ +name: Swift Release + +# Releases the Swift client, versioned with Changesets (see .changeset/README.md). +# The zero-swift npm package (packages/zero-swift-cli) and the Swift package +# (swift/) share one version, kept in step by `pnpm --filter zero-swift version-packages`. +# +# On each push to main: +# - With pending changesets, opens or updates the "Version Packages" PR. +# - Otherwise, if the version in swift/VERSION isn't released yet, after +# approval in the swift-release environment: +# - publishes swift/ to the SwiftPM mirror (wevm/zero-swift) and tags the +# version there. SwiftPM needs Package.swift at a repository root and +# clones whole repositories, so consumers install from the mirror. +# - publishes the zero-swift CLI to npm with trusted publishing. +# Each step is skipped when its release already exists, so a failed run can be +# rerun. + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: swift-release + cancel-in-progress: false + +jobs: + version: + name: Version PR + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + has-changesets: ${{ steps.changesets.outputs.has-changesets }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - id: changesets + uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 + with: + version-script: pnpm --filter zero-swift version-packages + commit-message: 'chore(zero-swift): version packages' + pr-title: 'chore(zero-swift): version packages' + + plan: + name: Plan release + runs-on: ubuntu-latest + needs: version + if: needs.version.outputs.has-changesets == 'false' + outputs: + version: ${{ steps.plan.outputs.version }} + mirror: ${{ steps.plan.outputs.mirror }} + npm: ${{ steps.plan.outputs.npm }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + sparse-checkout: | + swift/VERSION + packages/zero-swift-cli/package.json + sparse-checkout-cone-mode: false + - id: plan + run: | + set -euo pipefail + version="$(tr -d '[:space:]' < swift/VERSION)" + package="$(jq -r .version packages/zero-swift-cli/package.json)" + if [ "$package" != "$version" ]; then + echo "packages/zero-swift-cli is $package but swift/VERSION is $version." >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + if git ls-remote --exit-code --tags https://github.com/wevm/zero-swift.git "refs/tags/$version" >/dev/null; then + echo "mirror=false" >> "$GITHUB_OUTPUT" + else + echo "mirror=true" >> "$GITHUB_OUTPUT" + fi + if npm view "zero-swift@$version" version >/dev/null 2>&1; then + echo "npm=false" >> "$GITHUB_OUTPUT" + else + echo "npm=true" >> "$GITHUB_OUTPUT" + fi + + mirror: + name: Publish mirror + runs-on: ubuntu-latest + needs: plan + if: needs.plan.outputs.mirror == 'true' + environment: swift-release + env: + MIRROR_TOKEN: ${{ secrets.ZERO_SWIFT_MIRROR_TOKEN }} + MIRROR_REPO: wevm/zero-swift + VERSION: ${{ needs.plan.outputs.version }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + - name: Split and push + run: | + set -euo pipefail + git subtree split --prefix=swift -b mirror + remote="https://x-access-token:${MIRROR_TOKEN}@github.com/${MIRROR_REPO}.git" + git push "$remote" mirror:main + git tag "$VERSION" mirror + git push "$remote" "refs/tags/$VERSION" + + npm-build: + name: Build npm package + runs-on: ubuntu-latest + needs: plan + if: needs.plan.outputs.npm == 'true' + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - name: Build and pack + run: | + pnpm --filter zero-swift build + pnpm --filter zero-swift pack --pack-destination "$GITHUB_WORKSPACE/dist/npm" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: zero-swift-npm + path: dist/npm/*.tgz + if-no-files-found: error + + npm-publish: + name: Publish npm package + runs-on: ubuntu-latest + needs: [plan, npm-build] + environment: swift-release + permissions: + contents: read + id-token: write # For npm trusted publishing. + env: + VERSION: ${{ needs.plan.outputs.version }} + steps: + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.x + registry-url: 'https://registry.npmjs.org' + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: zero-swift-npm + path: dist/npm + - name: Publish + run: npm publish "dist/npm/zero-swift-$VERSION.tgz" --provenance --access public diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000000..f25bd4e635 --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,174 @@ +name: Swift + +# CI for the Swift client in swift/ and the conformance vectors it replays +# (packages/zero-conformance). Linux runs on every change because the vectors +# can go stale from any JS change. The Apple-only layer runs in swift-macos.yml. + +on: + push: + # swift-client is the development branch until the client lands on main. + branches: [main, swift-client] + pull_request: + branches: [main] + +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + vectors: + name: Conformance Vectors + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + # The vectors are JS's answers, so they come from the repo's pinned Node. + # Node 24.22's JSON.parse misreads some escaped keys (see + # assertSoundJSONParse in packages/zero-conformance/src/vectors.ts). + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + package-manager-cache: false + - run: pnpm install --frozen-lockfile + # Fails when the checked-in vectors no longer match the JS behavior. Also + # writes the seeded fuzz corpora (generated/), which aren't checked in. + - name: Check vectors are current + run: pnpm --filter zero-conformance run test + # The Swift tests compile the Swift that zero-swift generates, and check + # the Swift ports of apps against the drift vectors it records. + - name: Check generated Swift and drift vectors are current + run: pnpm --filter zero-swift run test + - name: Check types + run: pnpm --filter zero-conformance --filter zero-swift run check-types + # The published CLI is a bundle; this keeps it bundling. + - name: Build the zero-swift CLI + run: pnpm --filter zero-swift run build + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: generated-vectors + path: packages/zero-conformance/generated/ + if-no-files-found: error + + linux: + name: Swift (Linux) + needs: vectors + runs-on: ubuntu-latest + container: swift:6.2.4-bookworm + defaults: + run: + working-directory: swift + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: generated-vectors + path: packages/zero-conformance/generated/ + - name: Install system libraries + run: apt-get update && apt-get install -y --no-install-recommends libsqlite3-dev + - name: Format + run: swift format lint --strict --recursive Sources Tests Package.swift + - name: Build + run: swift build --build-tests + - name: Test + run: swift test --skip-build + # Broken symbol links and undocumented parameters fail. ZeroUI is + # Apple-only; swift-macos.yml checks it. + - name: Docs + run: scripts/docs.sh Zero ZeroTesting + # zbugs' Swift port, checked against zbugs' TS queries and mutators on + # the samples in drift-samples.ts (the vectors job checks the record is + # current). + - name: zbugs + working-directory: apps/zbugs-swift + run: | + swift format lint --strict --recursive Sources Tests Package.swift + swift build --build-tests + swift test --skip-build + + e2e: + name: Swift end-to-end (Linux) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + # zero-cache runs from source, which needs Node 24 (`using`). + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.x + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - name: Start Postgres + run: | + sudo systemctl start postgresql.service + sudo -u postgres psql -q -c "ALTER SYSTEM SET wal_level = logical" \ + -c "ALTER USER postgres PASSWORD 'postgres'" + sudo systemctl restart postgresql.service + until pg_isready -q -h 127.0.0.1; do sleep 1; done + - name: Install Swift + run: | + curl -sSfL -o "$RUNNER_TEMP/swift.tar.gz" \ + https://download.swift.org/swift-6.2.4-release/ubuntu2404/swift-6.2.4-RELEASE/swift-6.2.4-RELEASE-ubuntu24.04.tar.gz + mkdir -p "$RUNNER_TEMP/swift" + tar -xzf "$RUNNER_TEMP/swift.tar.gz" -C "$RUNNER_TEMP/swift" --strip-components=1 + echo "$RUNNER_TEMP/swift/usr/bin" >> "$GITHUB_PATH" + - name: Install system libraries + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libsqlite3-dev + - name: Build + working-directory: swift + run: swift build --build-tests + - name: Start zero-cache + env: + ZERO_E2E_PG: postgresql://postgres:postgres@127.0.0.1:5432/postgres + run: | + node packages/zero-conformance/src/e2e/server.ts > "$RUNNER_TEMP/server.log" 2>&1 & + for _ in $(seq 1 120); do + grep -q '^ready ' "$RUNNER_TEMP/server.log" && exit 0 + sleep 1 + done + cat "$RUNNER_TEMP/server.log" + exit 1 + - name: Test + working-directory: swift + env: + ZERO_E2E_URL: ws://127.0.0.1:4848 + run: swift test --skip-build --filter E2ETests + # zbugs' Swift port against zbugs' backend: its migrations and seed + # data, its API server running the TS queries and mutators, and + # zero-cache. + - name: Start zbugs' backend + env: + ZBUGS_PG: postgresql://postgres:postgres@127.0.0.1:5432/postgres + ZBUGS_PORT: '4858' + ZBUGS_API_PORT: '5183' + run: | + node apps/zbugs-swift/server/server.ts > "$RUNNER_TEMP/zbugs.log" 2>&1 & + for _ in $(seq 1 300); do + grep -q '^ready ' "$RUNNER_TEMP/zbugs.log" && exit 0 + sleep 1 + done + cat "$RUNNER_TEMP/zbugs.log" + exit 1 + - name: Test zbugs against its backend + working-directory: apps/zbugs-swift + run: | + ZBUGS_E2E_TOKEN="$(grep '^token ' "$RUNNER_TEMP/zbugs.log" | cut -d' ' -f2)" \ + ZBUGS_E2E_URL=http://127.0.0.1:4858 ZBUGS_E2E_API=http://127.0.0.1:5183 \ + swift test --filter BackendTests + - name: Server logs + if: failure() + run: | + cat "$RUNNER_TEMP/server.log" + cat "$RUNNER_TEMP/zbugs.log" || true diff --git a/.github/workflows/zbugs-screenshots.yml b/.github/workflows/zbugs-screenshots.yml new file mode 100644 index 0000000000..df9a3486d8 --- /dev/null +++ b/.github/workflows/zbugs-screenshots.yml @@ -0,0 +1,206 @@ +name: zbugs screenshots + +# Runs the Swift zbugs app on an iPhone and an iPad simulator and on macOS +# against zbugs' backend (apps/zbugs-swift/server/server.ts), and keeps a +# screenshot of each screen (apps/zbugs-swift/App/UITests) as the +# zbugs-screenshots artifact. Then it stops the backend and runs each again +# offline, from what the app stored locally. Alongside, the virtual list +# UI tests run the ZeroVirtual test app (swift/TestHost), which needs no +# backend. +# Path-filtered to the app and the UI layer: macOS runners are slow to start. + +on: + push: + branches: [main, swift-client] + paths: + - 'apps/zbugs-swift/**' + - 'swift/Sources/ZeroUI/**' + - 'swift/Sources/ZeroVirtual/**' + - 'swift/Sources/ZeroTesting/**' + - 'swift/TestHost/**' + - '.github/workflows/zbugs-screenshots.yml' + pull_request: + branches: [main] + paths: + - 'apps/zbugs-swift/**' + - 'swift/Sources/ZeroUI/**' + - 'swift/Sources/ZeroVirtual/**' + - 'swift/Sources/ZeroTesting/**' + - 'swift/TestHost/**' + - '.github/workflows/zbugs-screenshots.yml' + workflow_dispatch: + +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: true + +permissions: + contents: read + +jobs: + screenshots: + name: zbugs screenshots (iPhone, iPad, and macOS) + runs-on: macos-26 + timeout-minutes: 60 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + # zero-cache runs from source, which needs Node 24 (`using`). + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.x + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - name: Start Postgres + run: | + brew install postgresql@17 + pg="$(brew --prefix postgresql@17)/bin" + echo "$pg" >> "$GITHUB_PATH" + "$pg/initdb" -D "$RUNNER_TEMP/pg" -U postgres --auth=trust + "$pg/pg_ctl" -D "$RUNNER_TEMP/pg" -l "$RUNNER_TEMP/pg.log" \ + -o "-c wal_level=logical -p 5432" start + until "$pg/pg_isready" -q -h 127.0.0.1; do sleep 1; done + # On the ports the app connects to by default. + - name: Start zbugs' backend + env: + ZBUGS_PG: postgresql://postgres@127.0.0.1:5432/postgres + run: | + node apps/zbugs-swift/server/server.ts > "$RUNNER_TEMP/zbugs.log" 2>&1 & + for _ in $(seq 1 300); do + grep -q '^ready ' "$RUNNER_TEMP/zbugs.log" && exit 0 + sleep 1 + done + cat "$RUNNER_TEMP/zbugs.log" + exit 1 + - name: Generate the Xcode project + working-directory: apps/zbugs-swift/App + run: brew install xcodegen && xcodegen generate + - name: Take screenshots + working-directory: apps/zbugs-swift/App + run: | + TEST_RUNNER_ZBUGS_TOKEN="$(grep '^token ' "$RUNNER_TEMP/zbugs.log" | cut -d' ' -f2)" + export TEST_RUNNER_ZBUGS_TOKEN + # The first available simulator whose name starts with $1. + simulator() { + xcrun simctl list devices available -j | + python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; print(next(x["udid"] for k,v in d.items() if "iOS" in k for x in v if x["name"].startswith(sys.argv[1])))' "$1" + } + # Booted, and done booting, before the test launches the app: a + # simulator xcodebuild boots itself can still be starting up when + # the first launch times out. One at a time, as the runner is small. + boot() { + xcrun simctl shutdown all + xcrun simctl bootstatus "$1" -b + } + # Every run, even after one fails, so each keeps its screenshots. + status=0 + screenshots() { + name="$1" + shift + case "$1 $2" in + "-destination id="*) boot "${2#id=}" ;; + *) xcrun simctl shutdown all ;; + esac + xcodebuild test -project ZBugs.xcodeproj -scheme ZBugsApp \ + -resultBundlePath "$RUNNER_TEMP/results/$name.xcresult" \ + -skipPackagePluginValidation "$@" || status=1 + } + screenshots iPhone -destination "id=$(simulator iPhone)" CODE_SIGNING_ALLOWED=NO + screenshots iPad -destination "id=$(simulator iPad)" CODE_SIGNING_ALLOWED=NO + # A macOS UI test needs a signed app; ad hoc does. + screenshots macOS -destination platform=macOS CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- + # Again offline: with the backend stopped, each screen comes from + # what the app stored locally while it synced. + pkill -f apps/zbugs-swift/server/server.ts + for _ in $(seq 1 30); do + curl -s -o /dev/null http://127.0.0.1:4848/ || break + sleep 1 + done + if curl -s -o /dev/null http://127.0.0.1:4848/; then + echo "zero-cache is still serving" + exit 1 + fi + # Not the paging test: a list pages on once its page's query has the + # server's answer, as zero-virtual's does. + online=-skip-testing:ZBugsUITests/ScreenshotTests/testListPagesPastTheFirst200 + screenshots iPhone-offline -destination "id=$(simulator iPhone)" CODE_SIGNING_ALLOWED=NO "$online" + screenshots iPad-offline -destination "id=$(simulator iPad)" CODE_SIGNING_ALLOWED=NO "$online" + screenshots macOS-offline -destination platform=macOS CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- "$online" + exit "$status" + - name: Export screenshots + if: always() + run: | + for result in "$RUNNER_TEMP"/results/*.xcresult; do + xcrun xcresulttool export attachments --path "$result" \ + --output-path "$RUNNER_TEMP/screenshots/$(basename "$result" .xcresult)" || true + done + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: zbugs-screenshots + path: ${{ runner.temp }}/screenshots + - name: Server logs + if: failure() + run: | + cat "$RUNNER_TEMP/zbugs.log" + cat "$RUNNER_TEMP/pg.log" || true + + virtual-list: + # Each platform and suite in its own job: together they outrun a job's + # timeout (the frame probe's scripts drive minutes of scrolling). + name: Virtual list UI tests (${{ matrix.platform }}, ${{ matrix.suite }}) + runs-on: macos-26 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + platform: [iPhone, macOS] + # The Native suites run the same tests on the screens' UIKit and + # AppKit lists (VirtualListView). + suite: + [ + VirtualListUITests, + FrameProbeUITests, + NativeVirtualListUITests, + NativeFrameProbeUITests, + ] + defaults: + run: + working-directory: swift/TestHost + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Generate the Xcode project + run: brew install xcodegen && xcodegen generate + - name: UI tests + env: + PLATFORM: ${{ matrix.platform }} + SUITE: ${{ matrix.suite }} + run: | + simulator() { + xcrun simctl list devices available -j | + python3 -c 'import json,sys; d=json.load(sys.stdin)["devices"]; print(next(x["udid"] for k,v in d.items() if "iOS" in k for x in v if x["name"].startswith(sys.argv[1])))' "$1" + } + if [ "$PLATFORM" = macOS ]; then + set -- -destination platform=macOS CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- + else + device="$(simulator "$PLATFORM")" + # Booted, and done booting, before the first test launches the + # app, which otherwise can time out on a simulator still starting. + xcrun simctl bootstatus "$device" -b + set -- -destination "id=$device" CODE_SIGNING_ALLOWED=NO + fi + xcodebuild test -project TestHost.xcodeproj -scheme TestHost \ + -only-testing:"TestHostUITests/$SUITE" \ + -resultBundlePath "$RUNNER_TEMP/results/$PLATFORM-$SUITE.xcresult" \ + -skipPackagePluginValidation "$@" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: failure() + with: + name: virtual-list-results-${{ matrix.platform }}-${{ matrix.suite }} + path: ${{ runner.temp }}/results diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 520512f33c..474a7e16f6 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -12,6 +12,7 @@ "sortPackageJson": true, "ignorePatterns": [ "node_modules", + "packages/zero-conformance/vectors", "dist", "lib", "*.log", @@ -32,6 +33,8 @@ "apps/zbugs/db/migrations/", "**/__snapshots__/**", "packages/zero-client/src/client/zero-stress-queries-test.ts", - "packages/zql-benchmarks/src/planner-cost.bench.ts" + "packages/zql-benchmarks/src/planner-cost.bench.ts", + "swift/Sources/*/*.docc", + "**/drift-vectors.json" ] } diff --git a/.syncpackrc b/.syncpackrc index 5aeae05581..da0b6dfb39 100644 --- a/.syncpackrc +++ b/.syncpackrc @@ -14,6 +14,29 @@ ], "isIgnored": true }, + { + "label": "zero-swift's CLI (packages/zero-swift-cli) reads schemas with the TypeScript compiler API at runtime, which TypeScript 7 does not have", + "dependencies": [ + "typescript" + ], + "packages": [ + "zero-swift" + ], + "pinVersion": "6.0.3" + }, + { + "label": "zero-swift's CLI takes the app's @rocicorp/zero, any release it supports, not the workspace's", + "dependencies": [ + "@rocicorp/zero" + ], + "dependencyTypes": [ + "peer" + ], + "packages": [ + "zero-swift" + ], + "isIgnored": true + }, { "label": "replicache-doc stays on TypeScript 6 because typedoc does not support TypeScript 7", "dependencies": [ diff --git a/apps/zbugs-swift/.gitignore b/apps/zbugs-swift/.gitignore new file mode 100644 index 0000000000..e089cf59e9 --- /dev/null +++ b/apps/zbugs-swift/.gitignore @@ -0,0 +1,5 @@ +.build/ +.swiftpm/ +*.xcodeproj/xcuserdata/ +Package.resolved +App/*.xcodeproj/ diff --git a/apps/zbugs-swift/App/Info.plist b/apps/zbugs-swift/App/Info.plist new file mode 100644 index 0000000000..cd2a6287d7 --- /dev/null +++ b/apps/zbugs-swift/App/Info.plist @@ -0,0 +1,16 @@ + + + + + NSLocalNetworkUsageDescription + Zero Bugs connects to a zbugs server on your local network. + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + + diff --git a/apps/zbugs-swift/App/UITests/ScreenshotTests.swift b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift new file mode 100644 index 0000000000..c4509f74a7 --- /dev/null +++ b/apps/zbugs-swift/App/UITests/ScreenshotTests.swift @@ -0,0 +1,216 @@ +import XCTest + +/// Drives the app against zbugs' backend (`server/server.ts`) and keeps a +/// screenshot of each screen, which CI uploads. `ZBUGS_TOKEN` is a token for +/// the seeded crew user; xcodebuild passes `TEST_RUNNER_ZBUGS_TOKEN` as it, +/// and likewise `ZBUGS_CACHE_URL` and `ZBUGS_API_URL` when they're set. +/// +/// A step whose screen doesn't appear records a failure, with the app's +/// accessibility tree, and the test goes on, so one run shows every screen +/// it can reach. +final class ScreenshotTests: XCTestCase { + @MainActor + func testScreens() throws { + let environment = ProcessInfo.processInfo.environment + let token = try XCTUnwrap(environment["ZBUGS_TOKEN"], "Set TEST_RUNNER_ZBUGS_TOKEN") + #if os(iOS) + if UIDevice.current.userInterfaceIdiom == .pad { + XCUIDevice.shared.orientation = .landscapeLeft + } + #endif + let app = XCUIApplication() + app.launchEnvironment["ZBUGS_TOKEN"] = token + // On a device, the backend's network address. + for key in ["ZBUGS_CACHE_URL", "ZBUGS_API_URL"] { + app.launchEnvironment[key] = environment[key] + } + app.launch() + #if os(iOS) && !targetEnvironment(simulator) + // A device asks for local network access on the first launch. + let allow = XCUIApplication(bundleIdentifier: "com.apple.springboard").alerts.buttons["Allow"] + if allow.waitForExistence(timeout: 10) { allow.tap() } + #endif + + // The issue list, once zero-cache has synced it. + let firstIssue = app.descendants(matching: .any).matching(identifier: "IssueRow").firstMatch + guard expect(app, firstIssue, "an issue in the list", timeout: 120) else { return } + settle() + snapshot(app, "1 Issues") + + // An issue with its comments. + press(firstIssue) + if expect(app, app.buttons["Comment"], "the issue's comment button") { + settle() + snapshot(app, "2 Issue") + } + + // A new issue. + if isPhone { + back(app) + } + let newIssue = app.buttons["New Issue"] + if expect(app, newIssue, "the New Issue button") { + press(newIssue) + if expect(app, app.buttons["Create"], "the new issue sheet") { + settle() + snapshot(app, "3 New Issue") + press(app.buttons["Cancel"]) + } + } + + // The filters, then the UIKit or AppKit list. + if isPhone { + back(app) + } + #if os(macOS) + // The pop-up button has no label; its title is a text beside it. + let listPicker = app.popUpButtons.matching(NSPredicate(format: "value == 'SwiftUI'")) + .firstMatch + let native = app.menuItems["AppKit"] + #else + // A menu picker's label is its title and its value. + let listPicker = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'List,'")) + .firstMatch + let native = app.buttons["UIKit"] + #endif + if expect(app, listPicker, "the filters' List picker") { + settle() + snapshot(app, "4 Filters") + press(listPicker) + if expect(app, native, "the native list option") { + press(native) + } + if isPhone { + let issues = app.buttons["Issues"] + if expect(app, issues, "the Issues button") { press(issues) } + } + let cell = app.descendants(matching: .any).matching(identifier: "IssueCell").firstMatch + if expect(app, cell, "an issue in the native list") { + settle() + snapshot(app, "5 Issues (native)") + } + } + } + + /// All the issues, more than the 200 the list's first page had before it + /// paged: scrolled down past 200 of them, they keep coming. + @MainActor + func testListPagesPastTheFirst200() throws { + let environment = ProcessInfo.processInfo.environment + let token = try XCTUnwrap(environment["ZBUGS_TOKEN"], "Set TEST_RUNNER_ZBUGS_TOKEN") + let app = XCUIApplication() + app.launchEnvironment["ZBUGS_TOKEN"] = token + for key in ["ZBUGS_CACHE_URL", "ZBUGS_API_URL"] { + app.launchEnvironment[key] = environment[key] + } + app.launch() + let rows = app.descendants(matching: .any).matching(identifier: "IssueRow") + guard expect(app, rows.firstMatch, "an issue in the list", timeout: 120) else { return } + + // Status: All. + if isPhone { + back(app) + } + #if os(macOS) + let status = app.popUpButtons.matching(NSPredicate(format: "value == 'Open'")).firstMatch + let all = app.menuItems["All"] + #else + let status = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Status,'")) + .firstMatch + let all = app.buttons["All"] + #endif + guard expect(app, status, "the Status picker") else { return } + press(status) + guard expect(app, all, "the All status") else { return } + press(all) + if isPhone { + let issues = app.buttons["Issues"] + if expect(app, issues, "the Issues button") { press(issues) } + } + guard expect(app, rows.firstMatch, "an issue in the list of all issues") else { return } + settle() + + let list = app.scrollViews.containing(NSPredicate(format: "identifier == 'IssueRow'")) + .firstMatch + var seen: Set = [] + for _ in 0..<120 where seen.count <= 200 { + if let snapshot = try? list.snapshot() { + func walk(_ e: any XCUIElementSnapshot) { + if e.identifier == "IssueRow" { seen.insert(e.label) } + e.children.forEach(walk) + } + walk(snapshot) + } + #if os(macOS) + list.scroll(byDeltaX: 0, deltaY: -800) + #else + list.swipeUp(velocity: .fast) + #endif + Thread.sleep(forTimeInterval: 0.5) + } + snapshot(app, "6 Issues past 200") + XCTAssertGreaterThan(seen.count, 200, "the list stopped at \(seen.count) issues") + } + + @MainActor + private var isPhone: Bool { + #if os(iOS) + UIDevice.current.userInterfaceIdiom == .phone + #else + false + #endif + } + + /// Waits for `element`. When it doesn't appear, records a failure with a + /// screenshot and the accessibility tree. + @MainActor + private func expect( + _ app: XCUIApplication, _ element: XCUIElement, _ what: String, timeout: Double = 20 + ) -> Bool { + if element.waitForExistence(timeout: timeout) { return true } + snapshot(app, "Missing \(what)") + let tree = XCTAttachment(string: app.debugDescription) + tree.name = "Missing \(what).txt" + tree.lifetime = .keepAlways + add(tree) + XCTFail("No \(what)") + return false + } + + @MainActor + private func press(_ element: XCUIElement) { + #if os(macOS) + element.click() + #else + element.tap() + #endif + } + + /// The navigation bar's back button. + @MainActor + private func back(_ app: XCUIApplication) { + let button = app.navigationBars.buttons.element(boundBy: 0) + if button.waitForExistence(timeout: 5) { press(button) } + } + + /// Lets animations and the rows' relationships finish. + private func settle() { + Thread.sleep(forTimeInterval: 2) + } + + /// The screen on iOS, the window and its sheets on macOS. + @MainActor + private func snapshot(_ app: XCUIApplication, _ name: String) { + // On an iPad in landscape, a PNG of the portrait screen's pixels with an + // EXIF orientation that most viewers apply. + #if os(macOS) + let screenshot = app.windows.firstMatch.screenshot() + #else + let screenshot = XCUIScreen.main.screenshot() + #endif + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/apps/zbugs-swift/App/ZBugsApp.swift b/apps/zbugs-swift/App/ZBugsApp.swift new file mode 100644 index 0000000000..f01693aa9c --- /dev/null +++ b/apps/zbugs-swift/App/ZBugsApp.swift @@ -0,0 +1,15 @@ +import SwiftUI +import ZBugsUI + +@main +struct ZBugsApp: App { + // Once: each model starts a client. + @State private var model = AppModel() + + var body: some Scene { + WindowGroup { + RootView(model: model) + } + .defaultSize(width: 1200, height: 800) + } +} diff --git a/apps/zbugs-swift/App/project.yml b/apps/zbugs-swift/App/project.yml new file mode 100644 index 0000000000..de5f915ef6 --- /dev/null +++ b/apps/zbugs-swift/App/project.yml @@ -0,0 +1,56 @@ +# The zbugs app for iOS and macOS. Generate the Xcode project with +# XcodeGen (`brew install xcodegen`): `xcodegen generate`, then open +# ZBugs.xcodeproj. +name: ZBugs +options: + bundleIdPrefix: dev.rocicorp + deploymentTarget: + iOS: '26.0' + macOS: '26.0' +packages: + ZBugs: + path: .. +targets: + ZBugsApp: + type: application + supportedDestinations: [iOS, macOS] + scheme: + testTargets: [ZBugsUITests] + sources: + - path: ZBugsApp.swift + - path: Info.plist + buildPhase: none + dependencies: + - package: ZBugs + product: ZBugsUI + settings: + base: + PRODUCT_NAME: ZBugs + # Not ZBugs, the package module it imports. + PRODUCT_MODULE_NAME: ZBugsApp + PRODUCT_BUNDLE_IDENTIFIER: dev.rocicorp.zbugs + SWIFT_VERSION: '6.0' + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_FILE: Info.plist + INFOPLIST_KEY_CFBundleDisplayName: Zero Bugs + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES + INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.developer-tools + TARGETED_DEVICE_FAMILY: '1,2' + CODE_SIGN_STYLE: Automatic + ENABLE_USER_SCRIPT_SANDBOXING: YES + # Screenshots of each screen against zbugs' backend (UITests/). + ZBugsUITests: + type: bundle.ui-testing + supportedDestinations: [iOS, macOS] + sources: + - path: UITests + dependencies: + - target: ZBugsApp + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.rocicorp.zbugs.uitests + SWIFT_VERSION: '6.0' + GENERATE_INFOPLIST_FILE: YES + TEST_TARGET_NAME: ZBugsApp + CODE_SIGN_STYLE: Automatic diff --git a/apps/zbugs-swift/Package.swift b/apps/zbugs-swift/Package.swift new file mode 100644 index 0000000000..315be05491 --- /dev/null +++ b/apps/zbugs-swift/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 6.2 +import PackageDescription + +/// zbugs on the Zero Swift client: the schema, queries, and mutators of +/// apps/zbugs/shared, ported to Swift and checked against the TS ones. +let package = Package( + name: "ZBugs", + platforms: [.iOS(.v26), .macOS(.v26)], + products: [ + .library(name: "ZBugs", targets: ["ZBugs"]), + // The app's screens; the Xcode project in App/ wraps them in an app. + .library(name: "ZBugsUI", targets: ["ZBugsUI"]), + ], + dependencies: [ + .package(name: "Zero", path: "../../swift") + ], + targets: [ + .target(name: "ZBugs", dependencies: [.product(name: "Zero", package: "Zero")]), + // SwiftUI, with a UIKit or AppKit list; empty where there is no SwiftUI. + .target( + name: "ZBugsUI", + dependencies: [ + "ZBugs", + .product(name: "Zero", package: "Zero"), + .product(name: "ZeroUI", package: "Zero"), + .product(name: "ZeroVirtual", package: "Zero"), + ]), + .testTarget( + name: "ZBugsTests", + dependencies: [ + "ZBugs", + .product(name: "Zero", package: "Zero"), + .product(name: "ZeroTesting", package: "Zero"), + .product(name: "ZeroNIOTesting", package: "Zero"), + ], + exclude: ["drift-vectors.json"]), + ], + swiftLanguageModes: [.v6] +) diff --git a/apps/zbugs-swift/README.md b/apps/zbugs-swift/README.md new file mode 100644 index 0000000000..652de5a96e --- /dev/null +++ b/apps/zbugs-swift/README.md @@ -0,0 +1,119 @@ +# zbugs on the Zero Swift client + +[zbugs](../zbugs) as a native app for iOS and macOS: + +- `ZBugs`: zbugs' schema, queries, and mutators (`apps/zbugs/shared`), + ported to Swift. Builds on Linux too. +- `ZBugsUI`: the app's screens in SwiftUI. The issue list and an issue's + comments page in as they scroll, through `ZeroVirtual`'s `VirtualList`, as + zbugs' list page and comments do (`useZeroVirtualizer`); the issue list is + also in UIKit (iOS) and AppKit (macOS), through `VirtualListView`. A + relaunch opens the list at the selected issue, or else where it was. +- `App/`: the app target, as an [XcodeGen](https://github.com/yonaskolb/XcodeGen) + project. +- `server/server.ts`: zbugs' backend from this repository's sources, for the + app and the end-to-end tests. + +## Running the app + +Start the backend. It needs Node 24, `psql`, and Postgres with +`wal_level = logical` (for example `docker compose up` in +`apps/zbugs/docker`, which listens on 6434): + +```sh +ZBUGS_PG=postgresql://user:password@127.0.0.1:6434/postgres \ + node apps/zbugs-swift/server/server.ts +``` + +It recreates the `zbugs_swift` database with zbugs' seed data, then prints a +token for a crew user and `ready ` (ports 4848 and 5173). + +Each start makes new signing keys and a new database, so an app that ran +against the last one needs the new token and starts over from an empty local +store. To keep both across restarts, set `ZBUGS_KEEP=1` and a directory for +the replica and the keys: + +```sh +ZBUGS_KEEP=1 ZBUGS_DIR=~/.zbugs-swift ZBUGS_PG=... \ + node apps/zbugs-swift/server/server.ts +``` + +It seeds the database only when it doesn't exist; drop it, or run once +without `ZBUGS_KEEP`, to start over. + +Then generate the Xcode project and run the `ZBugsApp` scheme on a Mac or an +iOS simulator: + +```sh +cd apps/zbugs-swift/App +xcodegen generate +open ZBugs.xcodeproj +``` + +Log in from the account menu with the printed token, or through GitHub when +the API server has `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` (it +redirects back to `zbugs-swift://auth`). Servers… points the app elsewhere. + +On an iPhone or an iPad, start the backend with `ZBUGS_HOST=0.0.0.0` so the +device can reach it, and in the app set Servers… to this Mac's address on the +network (for example `http://192.168.1.20:4848` and +`http://192.168.1.20:5173`), then log in with the token. The app keeps both. +A token and servers passed in its environment, as Xcode's scheme or the UI +test passes them, last only for that launch: opened from the home screen, the +app goes back to the saved ones. + +## End-to-end tests + +`BackendTests` runs the Swift queries and mutators against the backend: the +API server runs the TS versions, and the test checks that both sides agree. +It is skipped unless the backend's URLs and a token are set: + +```sh +ZBUGS_E2E_URL=http://127.0.0.1:4848 ZBUGS_E2E_API=http://127.0.0.1:5173 \ + ZBUGS_E2E_TOKEN= swift test --filter BackendTests +``` + +`App/UITests` drives the app through each screen against the backend and +keeps a screenshot of each one. The `zbugs screenshots` workflow runs it on +an iPhone and an iPad simulator and on macOS, then again with the backend +stopped, where each screen comes from the app's local store, and uploads the +screenshots as an artifact. To run it locally, generate the project and pass +the backend's token: + +```sh +TEST_RUNNER_ZBUGS_TOKEN= xcodebuild test -project App/ZBugs.xcodeproj \ + -scheme ZBugsApp -destination 'platform=iOS Simulator,name=iPhone 17' +``` + +On macOS the destination is `platform=macOS`. On an iPhone or an iPad, start +the backend with `ZBUGS_HOST=0.0.0.0` so the device can reach it, and pass +its address on this Mac's network as well, for example +`TEST_RUNNER_ZBUGS_CACHE_URL=http://192.168.1.20:4848` and +`TEST_RUNNER_ZBUGS_API_URL=http://192.168.1.20:5173`. + +The app reads `ZBUGS_TOKEN`, `ZBUGS_CACHE_URL`, and `ZBUGS_API_URL` from its +environment as well; they override the saved login and servers. + +## Keeping the port current + +- `Sources/ZBugs/Schema.swift` is generated from `apps/zbugs/shared/schema.ts` + by `zero-swift codegen` (config in `zero-swift.json`). +- `Queries.swift` and `Mutators.swift` are ports of `queries.ts` and + `mutators.ts` (with `auth.ts` in `Auth.swift`). +- `Tests/ZBugsTests/drift-vectors.json` records what the TS queries and + mutators do on the samples in `drift-samples.ts`. `DriftTests` fails when + the Swift port does something else, or when a query or mutator exists on + only one side. + +After changing a TS query or mutator (or the samples), re-record and port the +change: + +```sh +cd packages/zero-swift-cli +UPDATE_GENERATED=1 pnpm test # rewrites Schema.swift and drift-vectors.json +cd ../../apps/zbugs-swift +swift test +``` + +CI fails while the record is out of date (`pnpm --filter zero-swift run test`) +or the port drifts from it (`swift test` here). diff --git a/apps/zbugs-swift/Sources/ZBugs/Auth.swift b/apps/zbugs-swift/Sources/ZBugs/Auth.swift new file mode 100644 index 0000000000..ec497860e1 --- /dev/null +++ b/apps/zbugs-swift/Sources/ZBugs/Auth.swift @@ -0,0 +1,141 @@ +import Foundation +import Zero + +/// Who the client acts for: the `sub` and `role` of the zbugs JWT +/// (`AuthData` in apps/zbugs/shared/auth.ts). The context of every query +/// and mutator; nil when logged out. +public struct AuthData: Codable, Sendable, Hashable { + public var sub: String + public var role: UserRole + + public init(sub: String, role: UserRole) { + self.sub = sub + self.role = role + } +} + +public typealias Role = UserRole + +/// apps/zbugs/shared/error.ts. +public enum MutationErrorCode: String, Sendable { + case notLoggedIn = "NOT_LOGGED_IN" + case notAuthorized = "NOT_AUTHORIZED" + case notificationFailed = "NOTIFICATION_FAILED" + case entityNotFound = "ENTITY_NOT_FOUND" +} + +/// An `ApplicationError` with zbugs' details: `{code, id}`. +func mutationError(_ message: String, _ code: MutationErrorCode, _ id: String? = nil) + -> ApplicationError +{ + var details: [String: JSONValue] = ["code": .string(code.rawValue)] + if let id { details["id"] = .string(id) } + return ApplicationError(message, details: .object(details)) +} + +/// `must` in packages/shared: the value, or the error JS throws for a +/// missing one. +func must(_ value: T?) throws -> T { + guard let value else { throw ApplicationError("Unexpected undefined value") } + return value +} + +func assertIsLoggedIn(_ auth: AuthData?) throws -> AuthData { + guard let auth else { + throw mutationError("User must be logged in for this operation", .notLoggedIn) + } + return auth +} + +func isAdmin(_ auth: AuthData?) throws -> Bool { + try assertIsLoggedIn(auth).role == .crew +} + +/// A row with a creator: an issue, a comment, or an emoji. +protocol Created: ZeroRow { + var creatorID: String { get } + var id: String { get } +} + +extension Issue: Created {} +extension Comment: Created {} +extension Emoji: Created {} + +@MainActor +func assertIsCreatorOrAdmin( + _ tx: Transaction, _ auth: AuthData?, _ query: Query, _ id: String +) throws { + let auth = try assertIsLoggedIn(auth) + if try isAdmin(auth) { return } + // By name: a key path through `Created` is not the row's own `\.id`. + let entity = try tx.run(Query(query.any.where("id", .string(id))).one()) + // Security: the same message whether or not the entity exists. + guard let entity, auth.sub == entity.creatorID else { + throw mutationError("Not authorized to access this resource", .notAuthorized, id) + } +} + +@MainActor +func assertUserCanSeeIssue(_ tx: Transaction, _ userID: String, _ issueID: String) throws { + let issue = try must(tx.run(zql.issue.where(\.id, issueID).one())) + let user = try must(tx.run(zql.user.where(\.id, userID).one())) + if issue.visibility != .public && userID != issue.creatorID && user.role != .crew { + throw mutationError( + "User does not have permission to view this issue", .notAuthorized, issueID) + } +} + +@MainActor +func assertUserCanSeeComment(_ tx: Transaction, _ userID: String, _ commentID: String) throws { + let comment = try must(tx.run(zql.comment.where(\.id, commentID).one())) + try assertUserCanSeeIssue(tx, userID, comment.issueID) +} + +extension AuthData { + /// The claims of a zbugs JWT, without checking its signature (the API + /// server checks it): nil when `jwt` is not one, or has expired by `now` + /// (seconds since 1970). + public init?(jwt: String, now: Double) { + let parts = jwt.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3, let payload = base64URLDecode(parts[1]), + let claims = try? JSONDecoder().decode(JSONValue.self, from: Data(payload)), + let sub = claims["sub"]?.stringValue, + let role = claims["role"]?.stringValue.flatMap(UserRole.init(rawValue:)) + else { return nil } + if let exp = claims["exp"]?.numberValue, exp < now { return nil } + self.init(sub: sub, role: role) + } +} + +/// RFC 4648 §5 base64url, padded or not. +private func base64URLDecode(_ s: Substring) -> [UInt8]? { + var bits: UInt32 = 0 + var count = 0 + var out: [UInt8] = [] + for c in s.utf8 { + let v: UInt32 + switch c { + case UInt8(ascii: "A")...UInt8(ascii: "Z"): v = UInt32(c - UInt8(ascii: "A")) + case UInt8(ascii: "a")...UInt8(ascii: "z"): v = UInt32(c - UInt8(ascii: "a")) + 26 + case UInt8(ascii: "0")...UInt8(ascii: "9"): v = UInt32(c - UInt8(ascii: "0")) + 52 + case UInt8(ascii: "-"), UInt8(ascii: "+"): v = 62 + case UInt8(ascii: "_"), UInt8(ascii: "/"): v = 63 + case UInt8(ascii: "="): continue + default: return nil + } + bits = bits << 6 | v + count += 6 + if count >= 8 { + count -= 8 + out.append(UInt8(truncatingIfNeeded: bits >> UInt32(count))) + } + } + return out +} + +/// A new row ID, as zbugs makes them (`nanoid`): 21 URL-safe characters. +public func makeID() -> String { + let alphabet = Array("useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict") + var rng = SystemRandomNumberGenerator() + return String((0..<21).map { _ in alphabet[Int(rng.next() % 64)] }) +} diff --git a/apps/zbugs-swift/Sources/ZBugs/Mutators.swift b/apps/zbugs-swift/Sources/ZBugs/Mutators.swift new file mode 100644 index 0000000000..38da63c834 --- /dev/null +++ b/apps/zbugs-swift/Sources/ZBugs/Mutators.swift @@ -0,0 +1,416 @@ +import Zero + +// The mutators of apps/zbugs/shared/mutators.ts. The drift test checks +// that each changes the same rows as its TS definition, and fails the same +// way. + +public struct CreateIssueArgs: Codable, Sendable, Hashable { + public var id: String + public var title: String + public var description: String? + public var created: Double + public var modified: Double + public var projectID: String + + public init( + id: String, title: String, description: String? = nil, created: Double, modified: Double, + projectID: String + ) { + self.id = id + self.title = title + self.description = description + self.created = created + self.modified = modified + self.projectID = projectID + } +} + +/// The fields of an issue to change; nil leaves a field as it is. +public struct UpdateIssueArgs: Codable, Sendable, Hashable { + public var id: String + public var title: String? + public var description: String? + public var `open`: Bool? + /// `.some(nil)` unassigns the issue. + public var assigneeID: String?? + public var visibility: IssueVisibility? + public var modified: Double + + public init( + id: String, title: String? = nil, description: String? = nil, open: Bool? = nil, + assigneeID: String?? = nil, visibility: IssueVisibility? = nil, modified: Double + ) { + self.id = id + self.title = title + self.description = description + self.open = open + self.assigneeID = assigneeID + self.visibility = visibility + self.modified = modified + } + + enum CodingKeys: String, CodingKey { + case id, title, description, open, assigneeID, visibility, modified + } + + // By hand, since a left-out `assigneeID` and a null one differ. + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + title = try c.decodeIfPresent(String.self, forKey: .title) + description = try c.decodeIfPresent(String.self, forKey: .description) + open = try c.decodeIfPresent(Bool.self, forKey: .open) + if c.contains(.assigneeID) { + assigneeID = .some( + try c.decodeNil(forKey: .assigneeID) ? nil : c.decode(String.self, forKey: .assigneeID)) + } + visibility = try c.decodeIfPresent(IssueVisibility.self, forKey: .visibility) + modified = try c.decode(Double.self, forKey: .modified) + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encodeIfPresent(title, forKey: .title) + try c.encodeIfPresent(description, forKey: .description) + try c.encodeIfPresent(open, forKey: .open) + if let assigneeID { try c.encode(assigneeID, forKey: .assigneeID) } + try c.encodeIfPresent(visibility, forKey: .visibility) + try c.encode(modified, forKey: .modified) + } +} + +public struct IssueLabelArgs: Codable, Sendable, Hashable { + public var issueID: String + public var labelID: String + /// Ignored: the issue's project is the label's. Still accepted for the + /// clients that send it. + public var projectID: String? + + public init(issueID: String, labelID: String) { + self.issueID = issueID + self.labelID = labelID + } +} + +public struct RemoveLabelArgs: Codable, Sendable, Hashable { + public var issueID: String + public var labelID: String + public init(issueID: String, labelID: String) { + self.issueID = issueID + self.labelID = labelID + } +} + +public enum NotificationType: String, Codable, Sendable, Hashable { + case subscribe, unsubscribe +} + +public struct NotificationUpdateArgs: Codable, Sendable, Hashable { + public var issueID: String + public var subscribed: NotificationType + public var created: Double + public init(issueID: String, subscribed: NotificationType, created: Double) { + self.issueID = issueID + self.subscribed = subscribed + self.created = created + } +} + +public struct AddEmojiArgs: Codable, Sendable, Hashable { + public var id: String + public var unicode: String + public var annotation: String + public var subjectID: String + public var created: Double + public init(id: String, unicode: String, annotation: String, subjectID: String, created: Double) { + self.id = id + self.unicode = unicode + self.annotation = annotation + self.subjectID = subjectID + self.created = created + } +} + +public struct AddCommentArgs: Codable, Sendable, Hashable { + public var id: String + public var issueID: String + public var body: String + public var created: Double + public init(id: String, issueID: String, body: String, created: Double) { + self.id = id + self.issueID = issueID + self.body = body + self.created = created + } +} + +public struct EditCommentArgs: Codable, Sendable, Hashable { + public var id: String + public var body: String + public init(id: String, body: String) { + self.id = id + self.body = body + } +} + +public struct CreateLabelArgs: Codable, Sendable, Hashable { + public var id: String + public var name: String + public var projectID: String + public init(id: String, name: String, projectID: String) { + self.id = id + self.name = name + self.projectID = projectID + } +} + +public struct CreateAndAddLabelArgs: Codable, Sendable, Hashable { + public var issueID: String + public var labelID: String + public var labelName: String + /// Ignored, as in `IssueLabelArgs`. + public var projectID: String? + public init(issueID: String, labelID: String, labelName: String) { + self.issueID = issueID + self.labelID = labelID + self.labelName = labelName + } +} + +public struct ViewStateArgs: Codable, Sendable, Hashable { + public var issueID: String + public var viewed: Double + public init(issueID: String, viewed: Double) { + self.issueID = issueID + self.viewed = viewed + } +} + +public struct UserPrefArgs: Codable, Sendable, Hashable { + public var key: String + public var value: String + public init(key: String, value: String) { + self.key = key + self.value = value + } +} + +public enum Mutators { + public static let createIssue = defineMutator("issue.create") { + (tx, args: CreateIssueArgs, auth: AuthData?) in + let auth = try assertIsLoggedIn(auth) + try tx.mutate.insert( + Issue( + id: args.id, title: args.title, open: true, modified: args.modified, + created: args.created, projectID: args.projectID, creatorID: auth.sub, + description: args.description ?? "", visibility: .public)) + // The creator follows the issue. + try updateIssueNotification( + tx, userID: auth.sub, issueID: args.id, subscribed: .subscribe, created: args.created) + } + + public static let updateIssue = defineMutator("issue.update") { + (tx, change: UpdateIssueArgs, auth: AuthData?) in + // Authorization comes first, so the error does not tell whether a + // private issue exists. + try assertIsCreatorOrAdmin(tx, auth, zql.issue, change.id) + guard let oldIssue = try tx.run(zql.issue.where(\.id, change.id).one()) else { + // An admin skips the creator check; the same error as for anyone else. + throw mutationError("Issue not found or not authorized", .notAuthorized, change.id) + } + try tx.mutate.issue.update(JSONValueEncoder.encode(change).objectValue ?? [:]) + + var previousAssigneeID: String? + if case .some(let assigneeID) = change.assigneeID, assigneeID != oldIssue.assigneeID { + previousAssigneeID = oldIssue.assigneeID + } + // The assignee follows the issue; the one before stops. + if let assigneeID = truthy(change.assigneeID ?? nil) { + try updateIssueNotification( + tx, userID: assigneeID, issueID: change.id, subscribed: .subscribe, + created: change.modified) + } + if let previous = truthy(previousAssigneeID) { + try updateIssueNotification( + tx, userID: previous, issueID: change.id, subscribed: .unsubscribe, + created: change.modified) + } + } + + public static let deleteIssue = defineMutator("issue.delete") { + (tx, id: String, auth: AuthData?) in + try assertIsCreatorOrAdmin(tx, auth, zql.issue, id) + try tx.mutate.issue.delete(["id": .string(id)]) + } + + /// Deletes the issues if the user created every one of them (or is an + /// admin); otherwise none. + public static let deleteIssues = defineMutator("issue.deleteMany") { + (tx, ids: [String], auth: AuthData?) in + _ = try assertIsLoggedIn(auth) + for id in ids { + try assertIsCreatorOrAdmin(tx, auth, zql.issue, id) + } + for id in ids { + try tx.mutate.issue.delete(["id": .string(id)]) + } + } + + public static let addLabel = defineMutator("issue.addLabel") { + (tx, args: IssueLabelArgs, auth: AuthData?) in + try assertIsCreatorOrAdmin(tx, auth, zql.issue, args.issueID) + try tx.mutate.insert( + IssueLabel( + issueID: args.issueID, labelID: args.labelID, + projectID: issueProjectID(tx, args.issueID))) + } + + public static let removeLabel = defineMutator("issue.removeLabel") { + (tx, args: RemoveLabelArgs, auth: AuthData?) in + try assertIsCreatorOrAdmin(tx, auth, zql.issue, args.issueID) + try tx.mutate.issueLabel.delete([ + "issueID": .string(args.issueID), "labelID": .string(args.labelID), + ]) + } + + public static let updateNotification = defineMutator("notification.update") { + (tx, args: NotificationUpdateArgs, auth: AuthData?) in + let auth = try assertIsLoggedIn(auth) + try updateIssueNotification( + tx, userID: auth.sub, issueID: args.issueID, subscribed: args.subscribed, + created: args.created, forceUpdate: true) + } + + public static let addEmojiToIssue = defineMutator("emoji.addToIssue") { + (tx, args: AddEmojiArgs, auth: AuthData?) in + try addEmoji(tx, .issue, args, auth) + } + + public static let addEmojiToComment = defineMutator("emoji.addToComment") { + (tx, args: AddEmojiArgs, auth: AuthData?) in + try addEmoji(tx, .comment, args, auth) + } + + public static let removeEmoji = defineMutator("emoji.remove") { + (tx, id: String, auth: AuthData?) in + try assertIsCreatorOrAdmin(tx, auth, zql.emoji, id) + try tx.mutate.emoji.delete(["id": .string(id)]) + } + + public static let addComment = defineMutator("comment.add") { + (tx, args: AddCommentArgs, auth: AuthData?) in + let creatorID = try assertIsLoggedIn(auth).sub + try assertUserCanSeeIssue(tx, creatorID, args.issueID) + try tx.mutate.insert( + Comment( + id: args.id, issueID: args.issueID, created: args.created, body: args.body, + creatorID: creatorID)) + try updateIssueNotification( + tx, userID: creatorID, issueID: args.issueID, subscribed: .subscribe, + created: args.created) + } + + public static let editComment = defineMutator("comment.edit") { + (tx, args: EditCommentArgs, auth: AuthData?) in + try assertIsCreatorOrAdmin(tx, auth, zql.comment, args.id) + try tx.mutate.comment.update(["id": .string(args.id), "body": .string(args.body)]) + } + + public static let removeComment = defineMutator("comment.remove") { + (tx, id: String, auth: AuthData?) in + try assertIsCreatorOrAdmin(tx, auth, zql.comment, id) + try tx.mutate.comment.delete(["id": .string(id)]) + } + + public static let createLabel = defineMutator("label.create") { + (tx, args: CreateLabelArgs, auth: AuthData?) in + guard try isAdmin(auth) else { + throw mutationError("Only admins can create labels", .notAuthorized, args.id) + } + try tx.mutate.insert(Label(id: args.id, name: args.name, projectID: args.projectID)) + } + + public static let createAndAddLabel = defineMutator("label.createAndAddToIssue") { + (tx, args: CreateAndAddLabelArgs, auth: AuthData?) in + guard try isAdmin(auth) else { + throw mutationError("Only admins can create labels", .notAuthorized, args.labelID) + } + let projectID = try issueProjectID(tx, args.issueID) + try tx.mutate.insert(Label(id: args.labelID, name: args.labelName, projectID: projectID)) + try tx.mutate.insert( + IssueLabel(issueID: args.issueID, labelID: args.labelID, projectID: projectID)) + } + + public static let setViewState = defineMutator("viewState.set") { + (tx, args: ViewStateArgs, auth: AuthData?) in + let userID = try assertIsLoggedIn(auth).sub + try tx.mutate.upsert(ViewState(issueID: args.issueID, userID: userID, viewed: args.viewed)) + } + + public static let setUserPref = defineMutator("userPref.set") { + (tx, args: UserPrefArgs, auth: AuthData?) in + let userID = try assertIsLoggedIn(auth).sub + try tx.mutate.upsert(UserPref(key: args.key, userID: userID, value: args.value)) + } + + /// Every mutator, for the client and the drift test. + public static let all: [any Mutator] = [ + createIssue, updateIssue, deleteIssue, deleteIssues, addLabel, removeLabel, + updateNotification, addEmojiToIssue, addEmojiToComment, removeEmoji, addComment, editComment, + removeComment, createLabel, createAndAddLabel, setViewState, setUserPref, + ] +} + +/// The project of an issue: the issue decides it, never the caller. +@MainActor +func issueProjectID(_ tx: Transaction, _ issueID: String) throws -> String { + guard let issue = try tx.run(zql.issue.where(\.id, issueID).one()) else { + throw mutationError("Issue not found", .entityNotFound, issueID) + } + return issue.projectID +} + +enum EmojiSubject { + case issue, comment +} + +@MainActor +func addEmoji(_ tx: Transaction, _ subject: EmojiSubject, _ args: AddEmojiArgs, _ auth: AuthData?) + throws +{ + let creatorID = try assertIsLoggedIn(auth).sub + switch subject { + case .issue: try assertUserCanSeeIssue(tx, creatorID, args.subjectID) + case .comment: try assertUserCanSeeComment(tx, creatorID, args.subjectID) + } + try tx.mutate.insert( + Emoji( + id: args.id, value: args.unicode, annotation: args.annotation, subjectID: args.subjectID, + creatorID: creatorID, created: args.created)) + // An emoji on the issue itself follows the issue. + if subject == .issue { + try updateIssueNotification( + tx, userID: creatorID, issueID: args.subjectID, subscribed: .subscribe, + created: args.created) + } +} + +/// Subscribes to an issue, unless the user chose before and `forceUpdate` +/// is false, or unsubscribes from it. +@MainActor +func updateIssueNotification( + _ tx: Transaction, userID: String, issueID: String, subscribed: NotificationType, + created: Double, forceUpdate: Bool = false +) throws { + try assertUserCanSeeIssue(tx, userID, issueID) + let existing = try tx.run( + zql.issueNotifications.where(\.userID, userID).where(\.issueID, issueID).one()) + if subscribed == .subscribe && (existing == nil || forceUpdate) { + try tx.mutate.upsert( + IssueNotifications(userID: userID, issueID: issueID, subscribed: true, created: created)) + } else if subscribed == .unsubscribe { + try tx.mutate.upsert( + IssueNotifications(userID: userID, issueID: issueID, subscribed: false, created: created)) + } +} diff --git a/apps/zbugs-swift/Sources/ZBugs/Queries.swift b/apps/zbugs-swift/Sources/ZBugs/Queries.swift new file mode 100644 index 0000000000..37c8aded38 --- /dev/null +++ b/apps/zbugs-swift/Sources/ZBugs/Queries.swift @@ -0,0 +1,543 @@ +import Zero + +// The queries of apps/zbugs/shared/queries.ts. The drift test checks that +// each builds the same AST as its TS definition. + +public let zeroProjectName = "Zero" + +public enum SortField: String, Codable, Sendable, Hashable { + case modified, created +} + +/// The filters and sort of an issue list (`listContextParams`). +public struct ListContextParams: Codable, Sendable, Hashable { + @Nullable public var open: Bool? + public var projectName: String? + @Nullable public var assignee: String? + @Nullable public var creator: String? + @Nullable public var labels: [String]? + @Nullable public var textFilter: String? + public var sortField: SortField + public var sortDirection: AST.Direction + + public init( + open: Bool?, projectName: String? = nil, assignee: String?, creator: String?, + labels: [String]?, textFilter: String?, sortField: SortField, sortDirection: AST.Direction + ) { + self.open = open + self.projectName = projectName + self.assignee = assignee + self.creator = creator + self.labels = labels + self.textFilter = textFilter + self.sortField = sortField + self.sortDirection = sortDirection + } +} + +/// Where an issue list starts: the issue's sort keys (`issueRowSortSchema`). +public struct IssueRowSort: Codable, Sendable, Hashable { + public var id: String + public var created: Double + public var modified: Double + + public init(id: String, created: Double, modified: Double) { + self.id = id + self.created = created + self.modified = modified + } +} + +public enum PageDirection: String, Codable, Sendable, Hashable { + case forward, backward +} + +public enum IDField: String, Codable, Sendable, Hashable { + case shortID, id +} + +/// An issue's `id` or its `shortID`. +public enum IssueKey: Codable, Sendable, Hashable { + case string(String) + case number(Double) + + public init(from decoder: any Decoder) throws { + let c = try decoder.singleValueContainer() + if let s = try? c.decode(String.self) { + self = .string(s) + } else { + self = .number(try c.decode(Double.self)) + } + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .string(let s): try c.encode(s) + case .number(let n): try c.encode(n) + } + } + + var json: JSONValue { + switch self { + case .string(let s): .string(s) + case .number(let n): .number(n) + } + } + + /// An issue's id, or its short id when it's all digits + /// (`getIDFromString` in `shared/issue-id.ts`). + public static func parse(_ s: String) -> (field: IDField, value: IssueKey) { + if s.contains(where: { !("0"..."9").contains($0) }) { return (.id, .string(s)) } + return (.shortID, .number(Double(s) ?? .nan)) + } +} + +public struct ProjectArgs: Codable, Sendable, Hashable { + public var projectName: String + public init(projectName: String) { self.projectName = projectName } +} + +public struct LabelsOrderByNameArgs: Codable, Sendable, Hashable { + public var projectName: String + public var orderBy: AST.Direction + public init(projectName: String, orderBy: AST.Direction) { + self.projectName = projectName + self.orderBy = orderBy + } +} + +public struct UsersForProjectArgs: Codable, Sendable, Hashable { + public enum Filter: String, Codable, Sendable, Hashable { + case crew, creators, assignees + } + + public var disabled: Bool + public var login: String? + public var projectName: String + public var filter: Filter? + + public init(disabled: Bool, login: String? = nil, projectName: String, filter: Filter? = nil) { + self.disabled = disabled + self.login = login + self.projectName = projectName + self.filter = filter + } +} + +public struct CommentsPageArgs: Codable, Sendable, Hashable { + public struct Cursor: Codable, Sendable, Hashable { + public var id: String + public var created: Double + public init(id: String, created: Double) { + self.id = id + self.created = created + } + } + + public var issueID: String + public var limit: Int + @Nullable public var start: Cursor? + public var dir: PageDirection + public var inclusive: Bool + + public init(issueID: String, limit: Int, start: Cursor?, dir: PageDirection, inclusive: Bool) { + self.issueID = issueID + self.limit = limit + self.start = start + self.dir = dir + self.inclusive = inclusive + } +} + +public struct IssueDetailArgs: Codable, Sendable, Hashable { + public var idField: IDField + public var id: IssueKey + public init(idField: IDField, id: IssueKey) { + self.idField = idField + self.id = id + } +} + +public struct IssueListV2Args: Codable, Sendable, Hashable { + public var listContext: ListContextParams + @Nullable public var limit: Int? + @Nullable public var start: IssueRowSort? + public var dir: PageDirection + public var inclusive: Bool? + + public init( + listContext: ListContextParams, limit: Int?, start: IssueRowSort?, dir: PageDirection, + inclusive: Bool? = nil + ) { + self.listContext = listContext + self.limit = limit + self.start = start + self.dir = dir + self.inclusive = inclusive + } +} + +public struct ListIssueByIDArgs: Codable, Sendable, Hashable { + public var listContext: ListContextParams + public var idField: IDField + public var idValue: IssueKey + public init(listContext: ListContextParams, idField: IDField, idValue: IssueKey) { + self.listContext = listContext + self.idField = idField + self.idValue = idValue + } +} + +public struct PrevNextArgs: Codable, Sendable, Hashable { + public enum Direction: String, Codable, Sendable, Hashable { + case next, prev + } + + @Nullable public var listContext: ListContextParams? + @Nullable public var issue: IssueRowSort? + public var dir: Direction + + public init(listContext: ListContextParams?, issue: IssueRowSort?, dir: Direction) { + self.listContext = listContext + self.issue = issue + self.dir = dir + } +} + +public struct IssueListArgs: Codable, Sendable, Hashable { + public var listContext: ListContextParams + public var limit: Int + public init(listContext: ListContextParams, limit: Int) { + self.listContext = listContext + self.limit = limit + } +} + +public struct UserPickerArgs: Codable, Sendable, Hashable { + public enum Filter: String, Codable, Sendable, Hashable { + case crew, creators + } + + public var disabled: Bool + @Nullable public var login: String? + @Nullable public var filter: Filter? + + public init(disabled: Bool, login: String?, filter: Filter?) { + self.disabled = disabled + self.login = login + self.filter = filter + } +} + +public enum Queries { + public static let allLabels = defineQuery("allLabels") { (_: NoArgs, _: AuthData?) in + zql.label + } + + public static let allUsers = defineQuery("allUsers") { (_: NoArgs, _: AuthData?) in + zql.user + } + + public static let allProjects = defineQuery("allProjects") { (_: NoArgs, _: AuthData?) in + zql.project + } + + public static let user = defineQuery("user") { (userID: String, _: AuthData?) in + zql.user.where(\.id, userID).one() + } + + public static let crewUser = defineQuery("crewUser") { (userID: String, _: AuthData?) in + zql.user.where(\.id, userID).where(\.role, .crew).one() + } + + public static let labels = defineQuery("labels") { (args: ProjectArgs, _: AuthData?) in + labelsQuery(args.projectName, nil) + } + + public static let labelsOrderByName = defineQuery("labelsOrderByName") { + (args: LabelsOrderByNameArgs, _: AuthData?) in + labelsQuery(args.projectName, args.orderBy) + } + + public static let issuePreloadV2 = defineQuery("issuePreloadV2") { + (args: ProjectArgs, auth: AuthData?) in + applyIssuePermissions( + zql.issue + .whereExists(\.project, scalar: true) { + $0.where(\.lowerCaseName, args.projectName.lowercased()) + } + .related(\.labels) + .related(\.viewState) { q in + truthy(auth?.sub).map { q.where(\.userID, $0) } ?? alwaysFalse(q) + } + .related(\.creator) + .related(\.assignee) + .related(\.emoji) { $0.related(\.creator) } + .related(\.comments) { comments in + comments + .related(\.creator) + .related(\.emoji) { $0.related(\.creator) } + .limit(10) + .orderBy(\.created, .desc) + } + .orderBy(\.modified, .desc) + .orderBy(\.id, .desc) + .limit(1000), + auth?.role) + } + + public static let userPref = defineQuery("userPref") { (key: String, auth: AuthData?) in + zql.userPref.where(\.key, key).where(\.userID, .eq, auth?.sub).one() + } + + public static let usersForProject = defineQuery("usersForProject") { + (args: UsersForProjectArgs, _: AuthData?) in + var q = zql.user + if args.disabled, let login = truthy(args.login) { + q = q.where(\.login, login) + } else if let filter = args.filter { + let inProject = { (issues: Query) in + issues.whereExists(\.project, scalar: true) { + $0.where(\.lowerCaseName, args.projectName.lowercased()) + } + } + switch filter { + case .crew: q = q.where(crew) + case .creators: q = q.whereExists(\.createdIssues, inProject) + case .assignees: q = q.whereExists(\.assignedIssues, inProject) + } + } + return q + } + + /// A page of an issue's comments, ordered by (created, id); a backward + /// page flips the order. `start` is the cursor row. + public static let commentsPage = defineQuery("commentsPage") { + (args: CommentsPageArgs, _: AuthData?) in + let dir: AST.Direction = args.dir == .forward ? .asc : .desc + var q = zql.comment + .related(\.creator) + .related(\.emoji) { $0.related(\.creator) } + .where(\.issueID, args.issueID) + .orderBy(\.created, dir) + .orderBy(\.id, dir) + if let start = args.start { + q = q.start([\.id: start.id, \.created: start.created], inclusive: args.inclusive) + } + return q.limit(args.limit) + } + + public static let comment = defineQuery("comment") { (id: String, _: AuthData?) in + zql.comment + .related(\.creator) + .related(\.emoji) { $0.related(\.creator) } + .where(\.id, id) + .one() + } + + public static let issueDetail = defineQuery("issueDetail") { + (args: IssueDetailArgs, auth: AuthData?) in + applyIssuePermissions( + zql.issue + .where(args.idField.rawValue, args.id.json) + .related(\.project) + .related(\.emoji) { $0.related(\.creator) } + .related(\.creator) + .related(\.assignee) + .related(\.labels) + .related(\.notificationState) { q in + truthy(auth?.sub).map { q.where(\.userID, $0) } ?? alwaysFalse(q) + } + .related(\.viewState) { q in + (truthy(auth?.sub).map { q.where(\.userID, $0) } ?? alwaysFalse(q)).one() + } + // The most recent comments, so the comments list renders locally on + // navigation. + .related(\.comments) { comments in + comments + .related(\.creator) + .related(\.emoji) { $0.related(\.creator) } + .limit(50) + .orderBy(\.created, .desc) + .orderBy(\.id, .desc) + } + .one(), + auth?.role) + } + + public static let issueListV2 = defineQuery("issueListV2") { + (args: IssueListV2Args, auth: AuthData?) in + buildListQuery( + ListQueryArgs( + listContext: args.listContext, userID: auth?.sub, role: auth?.role, limit: args.limit, + start: args.start, dir: args.dir, inclusive: args.inclusive ?? false)) + } + + /// One issue, for the order to start a list at. + public static let listIssueByID = defineQuery("listIssueByID") { + (args: ListIssueByIDArgs, auth: AuthData?) in + buildListQuery(ListQueryArgs(listContext: args.listContext, role: auth?.role)) + .where(args.idField.rawValue, args.idValue.json) + .one() + } + + public static let emojiChange = defineQuery("emojiChange") { (subjectID: String, _: AuthData?) in + zql.emoji + .where(\.subjectID, subjectID) + .related(\.creator) { $0.one() } + } + + /// Deprecated in zbugs. + public static let prevNext = defineQuery("prevNext") { (args: PrevNextArgs, auth: AuthData?) in + buildListQuery( + ListQueryArgs( + listContext: args.listContext, role: auth?.role, start: args.issue, + dir: args.dir == .next ? .forward : .backward) + ).one() + } + + /// Deprecated in zbugs. + public static let issueList = defineQuery("issueList") { (args: IssueListArgs, auth: AuthData?) in + buildListQuery( + ListQueryArgs( + listContext: args.listContext, userID: auth?.sub, role: auth?.role, limit: args.limit)) + } + + /// Deprecated in zbugs. + public static let userPicker = defineQuery("userPicker") { (args: UserPickerArgs, _: AuthData?) in + var q = zql.user + if args.disabled, let login = truthy(args.login) { + q = q.where(\.login, login) + } else if let filter = args.filter { + switch filter { + case .crew: q = q.where(crew) + case .creators: q = q.whereExists(\.createdIssues) + } + } + return q + } + + /// Every query, for the server's registry and the drift test. + public static let all: [any NamedQuery] = [ + allLabels, allUsers, allProjects, user, crewUser, labels, labelsOrderByName, issuePreloadV2, + userPref, usersForProject, commentsPage, comment, issueDetail, issueListV2, listIssueByID, + emojiChange, prevNext, issueList, userPicker, + ] +} + +/// The crew, without the bots. +private func crew(_ user: RowExpression) -> Condition { + user.and(user.role == .crew, user.not(user.login.like("rocibot%"))) +} + +func applyIssuePermissions( + _ q: Query, _ role: Role? +) + -> Query +where Result.Row == Issue { + role == .crew ? q : q.where(\.visibility, .public) +} + +func labelsQuery(_ projectName: String, _ orderBy: AST.Direction?) -> Query { + var q = zql.label.whereExists(\.project, scalar: true) { + $0.where(\.lowerCaseName, projectName.lowercased()) + } + if let orderBy { + q = q.orderBy(\.name, orderBy) + } + return q +} + +/// Matches no row. +func alwaysFalse(_ q: Query) -> Query where S.Row == R { + q.where { $0.or() } +} + +/// The string when JS would take it as true: neither nil nor empty. +func truthy(_ s: String?) -> String? { + s.flatMap { $0.isEmpty ? nil : $0 } +} + +struct ListQueryArgs { + var issueQuery = zql.issue + var listContext: ListContextParams? + var userID: String? + var role: Role? + var limit: Int? + var start: IssueRowSort? + var dir = PageDirection.forward + var inclusive = false +} + +func buildListQuery(_ args: ListQueryArgs) -> Query { + var q = args.issueQuery + .related(\.viewState) { q in + (truthy(args.userID).map { q.where(\.userID, $0) } ?? alwaysFalse(q)).one() + } + .related(\.labels) + // The issue page cannot render until the creator is present; loading it + // here lets a click from the list render locally. + .related(\.creator) + + guard let listContext = args.listContext else { + return alwaysFalse(q) + } + let projectName = (listContext.projectName ?? zeroProjectName).lowercased() + q = q.whereExists(\.project, scalar: true) { $0.where(\.lowerCaseName, projectName) } + + let dir: AST.Direction = + args.dir == .forward + ? listContext.sortDirection : listContext.sortDirection == .asc ? .desc : .asc + let sortField: KeyPath = + listContext.sortField == .modified ? \.modified : \.created + q = q.orderBy(sortField, dir).orderBy(\.id, dir) + + if let start = args.start { + q = q.start( + [\.id: start.id, \.created: start.created, \.modified: start.modified], + inclusive: args.inclusive) + } + if let limit = args.limit, limit != 0 { + q = q.limit(limit) + } + + let text = truthy(listContext.textFilter).map { "%\(escapeLike($0))%" } + q = q.where { issue in + issue.and( + [ + listContext.open.map { issue.open == $0 }, + text.map { text in + issue.or( + issue.title.ilike(text), issue.description.ilike(text), + issue.exists(\.comments) { $0.where(\.body, .ilike, text) }) + }, + ] + + (listContext.labels ?? []).map { label in + // `label`'s unique key is (projectID, name); the scalar `project` + // gate pins projectID before the `name` gate runs. + issue.exists(\.issueLabels) { + $0.whereExists(\.label, scalar: true) { + $0.where(\.name, label).whereExists(\.project, scalar: true) { + $0.where(\.lowerCaseName, projectName) + } + } + } + }) + } + + if let creator = truthy(listContext.creator) { + q = q.whereExists(\.creator, scalar: true) { $0.where(\.login, creator) } + } + if let assignee = truthy(listContext.assignee) { + q = q.whereExists(\.assignee, scalar: true) { $0.where(\.login, assignee) } + } + return applyIssuePermissions(q, args.role) +} + +extension Query { + /// `where(column, value)` by the column's name, as JS writes it. + func `where`(_ column: String, _ value: JSONValue) -> Self { + Self(any.where(column, value)) + } +} diff --git a/apps/zbugs-swift/Sources/ZBugs/Schema.swift b/apps/zbugs-swift/Sources/ZBugs/Schema.swift new file mode 100644 index 0000000000..bd72d46ea7 --- /dev/null +++ b/apps/zbugs-swift/Sources/ZBugs/Schema.swift @@ -0,0 +1,884 @@ +// Generated by `zero-swift codegen` from ../zbugs/shared/schema.ts. Do not edit. +// swift-format-ignore-file + +import Zero + +public struct User: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var login: String + public var name: String? + public var avatar: String + public var role: UserRole + + // Relationships, filled when a query asks for them with `related`. + public var createdIssues: [Issue]? + public var assignedIssues: [Issue]? + + public init( + id: String, + login: String, + name: String? = nil, + avatar: String, + role: UserRole, + createdIssues: [Issue]? = nil, + assignedIssues: [Issue]? = nil + ) { + self.id = id + self.login = login + self.name = name + self.avatar = avatar + self.role = role + self.createdIssues = createdIssues + self.assignedIssues = assignedIssues + } + + public static let tableName = "user" + + public static let columns: KeyPathNames = [ + \User.id: "id", + \User.login: "login", + \User.name: "name", + \User.avatar: "avatar", + \User.role: "role", + ] + + public static let relationships: KeyPathNames = [ + \User.createdIssues: "createdIssues", + \User.assignedIssues: "assignedIssues", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + login = try entry.column("login", 1) + name = try entry.column("name", 2) + avatar = try entry.column("avatar", 3) + role = try entry.column("role", 4) + createdIssues = try entry.relationship("createdIssues") + assignedIssues = try entry.relationship("assignedIssues") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "login": login.zeroValue, + "name": name.zeroValue, + "avatar": avatar.zeroValue, + "role": role.zeroValue, + ] + } +} + +public struct Project: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var name: String + public var lowerCaseName: String + public var issueCountEstimate: Int? + public var supportsSearch: Bool + public var markURL: String? + public var logoURL: String? + + // Relationships, filled when a query asks for them with `related`. + public var issues: [Issue]? + public var labels: [Label]? + + public init( + id: String, + name: String, + lowerCaseName: String, + issueCountEstimate: Int? = nil, + supportsSearch: Bool, + markURL: String? = nil, + logoURL: String? = nil, + issues: [Issue]? = nil, + labels: [Label]? = nil + ) { + self.id = id + self.name = name + self.lowerCaseName = lowerCaseName + self.issueCountEstimate = issueCountEstimate + self.supportsSearch = supportsSearch + self.markURL = markURL + self.logoURL = logoURL + self.issues = issues + self.labels = labels + } + + public static let tableName = "project" + + public static let columns: KeyPathNames = [ + \Project.id: "id", + \Project.name: "name", + \Project.lowerCaseName: "lowerCaseName", + \Project.issueCountEstimate: "issueCountEstimate", + \Project.supportsSearch: "supportsSearch", + \Project.markURL: "markURL", + \Project.logoURL: "logoURL", + ] + + public static let relationships: KeyPathNames = [ + \Project.issues: "issues", + \Project.labels: "labels", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + name = try entry.column("name", 1) + lowerCaseName = try entry.column("lowerCaseName", 2) + issueCountEstimate = try entry.column("issueCountEstimate", 3) + supportsSearch = try entry.column("supportsSearch", 4) + markURL = try entry.column("markURL", 5) + logoURL = try entry.column("logoURL", 6) + issues = try entry.relationship("issues") + labels = try entry.relationship("labels") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "name": name.zeroValue, + "lowerCaseName": lowerCaseName.zeroValue, + "issueCountEstimate": issueCountEstimate.zeroValue, + "supportsSearch": supportsSearch.zeroValue, + "markURL": markURL.zeroValue, + "logoURL": logoURL.zeroValue, + ] + } +} + +public struct Issue: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var shortID: Int? + public var title: String + public var `open`: Bool + public var modified: Double + public var created: Double + public var projectID: String + public var creatorID: String + public var assigneeID: String? + public var description: String + public var visibility: IssueVisibility + + // Relationships, filled when a query asks for them with `related`. + @Indirect public var project: Project? + public var issueLabels: [IssueLabel]? + public var labels: [Label]? + public var comments: [Comment]? + @Indirect public var creator: User? + @Indirect public var assignee: User? + public var viewState: [ViewState]? + public var emoji: [Emoji]? + @Indirect public var notificationState: IssueNotifications? + + public init( + id: String, + shortID: Int? = nil, + title: String, + `open`: Bool, + modified: Double, + created: Double, + projectID: String, + creatorID: String, + assigneeID: String? = nil, + description: String, + visibility: IssueVisibility, + project: Project? = nil, + issueLabels: [IssueLabel]? = nil, + labels: [Label]? = nil, + comments: [Comment]? = nil, + creator: User? = nil, + assignee: User? = nil, + viewState: [ViewState]? = nil, + emoji: [Emoji]? = nil, + notificationState: IssueNotifications? = nil + ) { + self.id = id + self.shortID = shortID + self.title = title + self.`open` = `open` + self.modified = modified + self.created = created + self.projectID = projectID + self.creatorID = creatorID + self.assigneeID = assigneeID + self.description = description + self.visibility = visibility + self.project = project + self.issueLabels = issueLabels + self.labels = labels + self.comments = comments + self.creator = creator + self.assignee = assignee + self.viewState = viewState + self.emoji = emoji + self.notificationState = notificationState + } + + public static let tableName = "issue" + + public static let columns: KeyPathNames = [ + \Issue.id: "id", + \Issue.shortID: "shortID", + \Issue.title: "title", + \Issue.`open`: "open", + \Issue.modified: "modified", + \Issue.created: "created", + \Issue.projectID: "projectID", + \Issue.creatorID: "creatorID", + \Issue.assigneeID: "assigneeID", + \Issue.description: "description", + \Issue.visibility: "visibility", + ] + + public static let relationships: KeyPathNames = [ + \Issue.project: "project", + \Issue.issueLabels: "issueLabels", + \Issue.labels: "labels", + \Issue.comments: "comments", + \Issue.creator: "creator", + \Issue.assignee: "assignee", + \Issue.viewState: "viewState", + \Issue.emoji: "emoji", + \Issue.notificationState: "notificationState", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + shortID = try entry.column("shortID", 1) + title = try entry.column("title", 2) + `open` = try entry.column("open", 3) + modified = try entry.column("modified", 4) + created = try entry.column("created", 5) + projectID = try entry.column("projectID", 6) + creatorID = try entry.column("creatorID", 7) + assigneeID = try entry.column("assigneeID", 8) + description = try entry.column("description", 9) + visibility = try entry.column("visibility", 10) + project = try entry.relationship("project") + issueLabels = try entry.relationship("issueLabels") + labels = try entry.relationship("labels") + comments = try entry.relationship("comments") + creator = try entry.relationship("creator") + assignee = try entry.relationship("assignee") + viewState = try entry.relationship("viewState") + emoji = try entry.relationship("emoji") + notificationState = try entry.relationship("notificationState") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "shortID": shortID.zeroValue, + "title": title.zeroValue, + "open": `open`.zeroValue, + "modified": modified.zeroValue, + "created": created.zeroValue, + "projectID": projectID.zeroValue, + "creatorID": creatorID.zeroValue, + "assigneeID": assigneeID.zeroValue, + "description": description.zeroValue, + "visibility": visibility.zeroValue, + ] + } +} + +public struct Comment: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var issueID: String + public var created: Double + public var body: String + public var creatorID: String + + // Relationships, filled when a query asks for them with `related`. + @Indirect public var creator: User? + public var emoji: [Emoji]? + @Indirect public var issue: Issue? + + public init( + id: String, + issueID: String, + created: Double, + body: String, + creatorID: String, + creator: User? = nil, + emoji: [Emoji]? = nil, + issue: Issue? = nil + ) { + self.id = id + self.issueID = issueID + self.created = created + self.body = body + self.creatorID = creatorID + self.creator = creator + self.emoji = emoji + self.issue = issue + } + + public static let tableName = "comment" + + public static let columns: KeyPathNames = [ + \Comment.id: "id", + \Comment.issueID: "issueID", + \Comment.created: "created", + \Comment.body: "body", + \Comment.creatorID: "creatorID", + ] + + public static let relationships: KeyPathNames = [ + \Comment.creator: "creator", + \Comment.emoji: "emoji", + \Comment.issue: "issue", + ] + + public init(entry: ViewEntry) throws(ZeroValueError) { + id = try entry.column("id", 0) + issueID = try entry.column("issueID", 1) + created = try entry.column("created", 2) + body = try entry.column("body", 3) + creatorID = try entry.column("creatorID", 4) + creator = try entry.relationship("creator") + emoji = try entry.relationship("emoji") + issue = try entry.relationship("issue") + } + + public var columnValues: Row { + [ + "id": id.zeroValue, + "issueID": issueID.zeroValue, + "created": created.zeroValue, + "body": body.zeroValue, + "creatorID": creatorID.zeroValue, + ] + } +} + +public struct Label: ZeroRow, Codable, Hashable, Identifiable { + public var id: String + public var name: String + public var projectID: String + + // Relationships, filled when a query asks for them with `related`. + @Indirect public var project: Project? + + public init( + id: String, + name: String, + projectID: String, + project: Project? = nil + ) { + self.id = id + self.name = name + self.projectID = projectID + self.project = project + } + + public static let tableName = "label" + + public static let columns: KeyPathNames