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