diff --git a/.github/actions/playwright-install/action.yml b/.github/actions/playwright-install/action.yml new file mode 100644 index 0000000000..5fc265e15e --- /dev/null +++ b/.github/actions/playwright-install/action.yml @@ -0,0 +1,24 @@ +name: 'Install Playwright' +description: 'Install Playwright browsers and system dependencies with caching. Requires node and npm dependencies to be set up before use.' + +runs: + using: 'composite' + steps: + - name: Get Playwright version + id: playwright-version + run: echo "version=$(pnpm exec playwright --version | awk '{print $NF}')" >> "$GITHUB_OUTPUT" + shell: bash + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ runner.arch }}-${{ steps.playwright-version.outputs.version }} + - name: Install Playwright browsers and system deps + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps + shell: bash + - name: Install Playwright system deps only + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps + shell: bash diff --git a/.github/actions/run-zql-benchmarks/action.yml b/.github/actions/run-zql-benchmarks/action.yml deleted file mode 100644 index 1f2e552ccc..0000000000 --- a/.github/actions/run-zql-benchmarks/action.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: 'Run ZQL Benchmarks' -description: 'Run ZQL benchmarks and convert output to BMF format' -outputs: - benchmark-output: - description: 'The BMF formatted benchmark output' - value: ${{ steps.run-benchmarks.outputs.result }} -runs: - using: 'composite' - steps: - - name: Run ZQL benchmarks - id: run-benchmarks - working-directory: packages/zql-benchmarks - shell: bash - run: | - # Enable debug logging to diagnose the issue - export DEBUG_MITATA_CONVERTER=1 - # First, run benchmarks and save raw output - BENCH_OUTPUT_FORMAT=json npm run bench 2>&1 > bench_output.txt || true - echo "Raw benchmark output (first 1000 chars):" - head -c 1000 bench_output.txt - echo "" - echo "---" - # Now try to convert it - cat bench_output.txt | npx tsx ../shared/src/tool/mitata-json-to-bmf.ts > output.json || (echo "Converter failed" && exit 1) diff --git a/.github/workflows/api-snapshot.yml b/.github/workflows/api-snapshot.yml new file mode 100644 index 0000000000..329481b456 --- /dev/null +++ b/.github/workflows/api-snapshot.yml @@ -0,0 +1,33 @@ +name: API Snapshot + +on: + push: + branches: [main] + pull_request: + branches: [main] + merge_group: + +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: + api-snapshot: + name: API Snapshot + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + + with: + node-version: 24.x + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm run build + - run: pnpm run api-snapshot diff --git a/.github/workflows/bencher-benchmarks-pr.yml b/.github/workflows/bencher-benchmarks-pr.yml deleted file mode 100644 index 7e86cf0106..0000000000 --- a/.github/workflows/bencher-benchmarks-pr.yml +++ /dev/null @@ -1,139 +0,0 @@ -name: Bencher PR - -on: - pull_request: - types: [opened, reopened, edited, synchronize] - -jobs: - file_sizes: - name: File Sizes PR - # DO NOT REMOVE: For handling Fork PRs see Pull Requests from Forks - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - - permissions: - pull-requests: write - runs-on: ubuntu-latest - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: rocicorp - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22.x - cache: 'npm' - - uses: bencherdev/bencher@main - - - run: npm ci - - - run: npm run build - - - name: Build npm zero package - working-directory: packages/zero - run: | - npm pack --pack-destination ./out - mv ./out/*.tgz ./out/zero-package.tgz - - - name: Build zero client single file package - working-directory: packages/zero - run: npx tsx tool/build.ts --bundle-sizes - - - name: Compress - working-directory: packages/zero - run: brotli out/zero.js - - - name: Upload PR benchmarks results to bencher.dev - working-directory: packages/zero - run: | - bencher run \ - --project zero \ - --token '${{ secrets.BENCHER_API_TOKEN }}' \ - --adapter json \ - --github-actions '${{ secrets.GITHUB_TOKEN }}' \ - --ci-id size \ - --branch "$GITHUB_HEAD_REF" \ - --start-point "$GITHUB_BASE_REF" \ - --start-point-hash '${{ github.event.pull_request.base.sha }}' \ - --start-point-clone-thresholds \ - --start-point-reset \ - --threshold-measure file-size \ - --threshold-test percentage \ - --threshold-upper-boundary 0.02 \ - --threshold-max-sample-size 2 \ - --err \ - --file-size out/zero-package.tgz \ - --file-size out/zero.js.br \ - --file-size out/zero.js - - perf: - name: Perf PR - # DO NOT REMOVE: For handling Fork PRs see Pull Requests from Forks - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - uses: ./.github/workflows/reusable-benchmark.yml - strategy: - matrix: - package: - - name: 'zero-client' - directory: 'packages/zero-client' - ci_id: 'perf' - # Replicache has no vitest benchs - # - name: 'replicache' - # directory: 'packages/replicache' - # ci_id: 'replicache-perf' - with: - package_name: ${{ matrix.package.name }} - working_directory: ${{ matrix.package.directory }} - ci_id: ${{ matrix.package.ci_id }} - is_pr: true - base_ref: ${{ github.event.pull_request.base.ref }} - head_ref: ${{ github.event.pull_request.head.ref }} - base_sha: ${{ github.event.pull_request.base.sha }} - secrets: - BENCHER_API_TOKEN: ${{ secrets.BENCHER_API_TOKEN }} - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - zql_benchmarks: - name: ZQL Benchmarks PR - # DO NOT REMOVE: For handling Fork PRs see Pull Requests from Forks - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - - permissions: - pull-requests: write - runs-on: self-hosted - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: rocicorp - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22.x - cache: 'npm' - - uses: bencherdev/bencher@main - - - run: npm ci - - - name: Run ZQL benchmarks - uses: ./.github/actions/run-zql-benchmarks - - - name: Upload PR benchmarks results to bencher.dev - working-directory: packages/zql-benchmarks - run: | - cat output.json | - bencher run \ - --project zero \ - --testbed self-hosted \ - --token '${{ secrets.BENCHER_API_TOKEN }}' \ - --adapter json \ - --github-actions '${{ secrets.GITHUB_TOKEN }}' \ - --ci-id zql \ - --branch "$GITHUB_HEAD_REF" \ - --start-point "$GITHUB_BASE_REF" \ - --start-point-hash '${{ github.event.pull_request.base.sha }}' \ - --start-point-clone-thresholds \ - --start-point-reset \ - --threshold-measure throughput \ - --threshold-test t_test \ - --threshold-lower-boundary 0.99 \ - --threshold-min-sample-size 20 \ - --threshold-max-sample-size 60 \ - --err diff --git a/.github/workflows/bencher-benchmarks.yml b/.github/workflows/bencher-benchmarks.yml deleted file mode 100644 index afb12c6366..0000000000 --- a/.github/workflows/bencher-benchmarks.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Bencher - -on: - push: - branches: main - -jobs: - file_sizes: - name: File Sizes - permissions: - checks: write - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22.x - cache: 'npm' - - uses: bencherdev/bencher@main - - - run: npm ci - - run: npm run build - - - name: Build npm zero package - working-directory: packages/zero - run: | - npm pack --pack-destination ./out - mv ./out/*.tgz ./out/zero-package.tgz - - - name: Build zero client single file package - working-directory: packages/zero - run: npx tsx tool/build.ts --bundle-sizes - - - name: Compress - working-directory: packages/zero - run: brotli out/zero.js - - - name: Upload benchmarks results to bencher.dev - working-directory: packages/zero - run: | - bencher run \ - --project zero \ - --token '${{ secrets.BENCHER_API_TOKEN }}' \ - --adapter json \ - --github-actions '${{ secrets.GITHUB_TOKEN }}' \ - --start-point main \ - --threshold-measure file-size \ - --threshold-test percentage \ - --threshold-upper-boundary 0.02 \ - --threshold-max-sample-size 2 \ - --err \ - --file-size out/zero-package.tgz \ - --file-size out/zero.js.br \ - --file-size out/zero.js - - perf: - name: Perf - uses: ./.github/workflows/reusable-benchmark.yml - strategy: - matrix: - package: - - name: 'zero-client' - directory: 'packages/zero-client' - # Replicache has no vitest benchs - # - name: 'replicache' - # directory: 'packages/replicache' - with: - package_name: ${{ matrix.package.name }} - working_directory: ${{ matrix.package.directory }} - secrets: - BENCHER_API_TOKEN: ${{ secrets.BENCHER_API_TOKEN }} - - zql_benchmarks: - name: ZQL Benchmarks - permissions: - checks: write - runs-on: self-hosted - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22.x - cache: 'npm' - - uses: bencherdev/bencher@main - - - run: npm ci - - - name: Run ZQL benchmarks - uses: ./.github/actions/run-zql-benchmarks - - - name: Upload benchmarks results to bencher.dev - working-directory: packages/zql-benchmarks - run: | - cat output.json | - bencher run \ - --project zero \ - --testbed self-hosted \ - --token '${{ secrets.BENCHER_API_TOKEN }}' \ - --adapter json \ - --github-actions '${{ secrets.GITHUB_TOKEN }}' \ - --start-point main \ - --threshold-measure throughput \ - --threshold-test t_test \ - --threshold-lower-boundary 0.99 \ - --threshold-min-sample-size 20 \ - --threshold-max-sample-size 60 \ - --err diff --git a/.github/workflows/bundle-sizes.js.yml b/.github/workflows/bundle-sizes.js.yml index feba8a1fca..d5569a5f90 100644 --- a/.github/workflows/bundle-sizes.js.yml +++ b/.github/workflows/bundle-sizes.js.yml @@ -6,34 +6,72 @@ on: paths: - 'packages/replicache/**' +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: true + +permissions: + contents: read + jobs: - bundle-size: - name: Bundle Sizes Check + measure: + name: Measure bundle sizes + permissions: + contents: read runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: - node-version: 22.x - cache: 'npm' - - run: npm ci - - run: npm run --workspace=replicache build-bundle-sizes + node-version: 24.x + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm --filter replicache run build-bundle-sizes - run: brotli packages/replicache/out/* # Builds bundles then measures bundles sizes and stores them to a file - name: Measure bundles - run: npx tsx tool/bundle-sizes.ts --bundles replicache.mjs replicache.mjs.br replicache.min.mjs replicache.min.mjs.br | tee bundle-sizes.json + run: node tool/bundle-sizes.ts --bundles replicache.mjs replicache.mjs.br replicache.min.mjs replicache.min.mjs.br | tee bundle-sizes.json working-directory: packages/replicache + - name: Upload bundle size output + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: bundle-size-output + path: packages/replicache/bundle-sizes.json + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish bundle sizes + needs: measure + permissions: + contents: write # Required by github-action-benchmark to push gh-pages data. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Download bundle size output + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: bundle-size-output + path: packages/replicache + # Run `github-action-benchmark` action - name: Store bundle size - uses: rhysd/github-action-benchmark@v1 + uses: rhysd/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1 with: name: 'Bundle Sizes' tool: 'customSmallerIsBetter' output-file-path: packages/replicache/bundle-sizes.json fail-on-alert: true - github-token: ${{ secrets.PERSONAL_GITHUB_TOKEN }} + github-token: ${{ github.token }} benchmark-data-dir-path: bundle-sizes auto-push: true alert-threshold: '105%' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 3eed180c92..0000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index ff54eca7b3..6f7841664b 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -11,127 +11,142 @@ 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: # Gate jobs - must pass before other jobs run syncpack: name: Sync Package Versions runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: - node-version: 22.x - cache: 'npm' - - run: npx syncpack@13 list-mismatches + node-version: 24.x + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm exec syncpack lint verify-deps: name: Verify Package Dependencies runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: - node-version: 22.x - cache: 'npm' - - run: npm install --prefix tools/verify-package-deps - - run: npm run verify-deps + node-version: 24.x + cache: 'pnpm' + - run: pnpm install --frozen-lockfile --filter verify-package-deps + - run: pnpm run verify-deps format: - name: Prettier + name: Oxfmt runs-on: ubuntu-latest needs: [syncpack, verify-deps] - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: rocicorp steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - node-version: 22.x - cache: 'npm' - - uses: oven-sh/setup-bun@v2 + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: - bun-version: latest - - run: bun install - - run: bun run check-format + node-version: 24.x + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm exec oxfmt --check lint: name: Lint runs-on: ubuntu-latest needs: [syncpack, verify-deps] - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: rocicorp steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - node-version: 22.x - cache: 'npm' - - uses: oven-sh/setup-bun@v2 + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: - bun-version: latest - - run: bun install - - run: bun run lint + node-version: 24.x + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm run lint check-types: name: Check Types runs-on: ubuntu-latest needs: [syncpack, verify-deps] - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: rocicorp steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - node-version: 22.x - cache: 'npm' - - run: npm ci - - run: npm run check-types + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + + with: + node-version: 24.x + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm run check-types test: name: Test (Shard ${{ matrix.shard }}/3) runs-on: ubuntu-latest needs: [format, lint, check-types] strategy: + fail-fast: false matrix: shard: [1, 2, 3] - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: rocicorp steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 100 - - uses: actions/setup-node@v6 + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: 22.x - cache: 'npm' - - run: npm ci - - name: Install Playwright - run: npx playwright install chromium firefox webkit --with-deps + node-version: 24.x + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/playwright-install - name: Run tests - run: TEST_PG_MODE=nopg npm run test -- --shard=${{ matrix.shard }}/3 + env: + SHARD: ${{ matrix.shard }} + run: TEST_PG_MODE=nopg pnpm run test --shard="$SHARD"/3 test-pg: - name: Test PG ${{ matrix.pg-version }} + name: Test PG ${{ matrix.pg-version }} (Shard ${{ matrix.shard }}/2) runs-on: ubuntu-latest needs: [format, lint, check-types] strategy: + fail-fast: false matrix: - pg-version: [17, 16, 15] - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: rocicorp + pg-version: [18, 17, 16, 15] + shard: [1, 2] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 100 - - uses: actions/setup-node@v6 + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: - node-version: 22.x - cache: 'npm' - - run: npm ci + node-version: 24.x + cache: 'pnpm' + - run: pnpm install --frozen-lockfile - name: Run tests - run: TEST_PG_MODE=pg-${{ matrix.pg-version }} npm run test + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4 + with: + timeout_minutes: 10 + max_attempts: 3 + command: TEST_PG_MODE=pg-${{ matrix.pg-version }} pnpm run test --shard=${{ matrix.shard }}/2 diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 3d33110093..65e5657e25 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -4,22 +4,38 @@ on: branches: - 'main' +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: true + +permissions: + contents: read + jobs: mirror_job: runs-on: ubuntu-latest - name: Mirror main branch to sandbox and gigabugs branch + name: Mirror main branch to sandbox and terabugs branch + environment: Mirror steps: - - name: Mirror main to sandbox - id: mirror-sandbox - uses: google/mirror-branch-action@v2.0 + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: - github-token: ${{ secrets.PERSONAL_GITHUB_TOKEN }} - source: 'main' - dest: 'sandbox' - - name: Mirror main to gigabugs - id: mirror-gigabugs - uses: google/mirror-branch-action@v2.0 - with: - github-token: ${{ secrets.PERSONAL_GITHUB_TOKEN }} - source: 'main' - dest: 'gigabugs' + client-id: ${{ vars.ROCICORP_MIRROR_APP_CLIENT_ID }} + private-key: ${{ secrets.ROCICORP_MIRROR_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: mono + permission-contents: write + permission-workflows: write + + - name: Mirror main + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + for branch in sandbox terabugs; do + gh api \ + --method PATCH \ + "/repos/$GITHUB_REPOSITORY/git/refs/heads/$branch" \ + -f "sha=$GITHUB_SHA" \ + -F force=true + done diff --git a/.github/workflows/perf-smoke.yml b/.github/workflows/perf-smoke.yml index 446a16bee4..a9b3fdaeea 100644 --- a/.github/workflows/perf-smoke.yml +++ b/.github/workflows/perf-smoke.yml @@ -10,22 +10,28 @@ 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: benchmark-smoke: name: Performance runner smoke test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: - node-version: 22.x - cache: 'npm' - - run: npm ci - - name: Install Playwright - run: npx playwright install --with-deps + node-version: 24.x + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/playwright-install - name: Run benchmark shell: bash working-directory: packages/replicache-perf run: | - npm run build - npx tsx src/runner.ts --format=json --run='populate 1024x1000 \(clean, indexes: 0\)' + pnpm run build + node src/runner.ts --format=json --run='populate 1024x1000 \(clean, indexes: 0\)' diff --git a/.github/workflows/perf-v2.js.yml b/.github/workflows/perf-v2.js.yml index cdb319b917..65c36816c9 100644 --- a/.github/workflows/perf-v2.js.yml +++ b/.github/workflows/perf-v2.js.yml @@ -6,38 +6,104 @@ on: paths: - 'packages/replicache/**' - 'packages/replicache-perf/**' + - '.github/**' + - 'pnpm-lock.yaml' + +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: true + +permissions: + contents: read jobs: + playwright-install: + name: Install Playwright + runs-on: [self-hosted, benchmark] # zizmor: ignore[self-hosted-runner] Benchmark jobs require dedicated hardware. + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + with: + dest: ${{ runner.temp }}/setup-pnpm + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + + with: + node-version: 24.x + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/playwright-install + benchmark: name: Performance regression check - runs-on: self-hosted + needs: playwright-install + permissions: + contents: read + runs-on: [self-hosted, benchmark] # zizmor: ignore[self-hosted-runner] Benchmark jobs require dedicated hardware. steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - node-version: 22.x - cache: 'npm' + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + with: + dest: ${{ runner.temp }}/setup-pnpm + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + + with: + node-version: 24.x + cache: 'pnpm' # Clear out potential user data dirs left over from previous failures. - run: rm -rf /tmp/replicache-playwright-* - - run: npm ci - - name: Install Playwright - run: npx playwright install --with-deps + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install - - run: npm run build + - run: pnpm run build working-directory: packages/replicache-perf # Run benchmark and stores the output to a file - name: Run benchmark working-directory: packages/replicache-perf run: | - npm run build - npx tsx src/runner.ts --format=json | tee perf-temp-output.json - npx tsx tool/split-perf.ts perf-temp-output.json perf-v2-output.json perf-v2-p95-output.json + pnpm run build + node src/runner.ts --format=json | tee perf-temp-output.json + node tool/split-perf.ts perf-temp-output.json perf-v2-output.json perf-v2-p95-output.json shell: bash + - name: Upload benchmark outputs + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: perf-v2-results + path: | + packages/replicache-perf/perf-v2-output.json + packages/replicache-perf/perf-v2-p95-output.json + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish performance results + needs: benchmark + permissions: + contents: write # Required by github-action-benchmark to push gh-pages data. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Download benchmark outputs + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: perf-v2-results + path: packages/replicache-perf + + # Disable git hooks so github-action-benchmark can commit to gh-pages + # (the pre-commit hook references tools/ which doesn't exist on gh-pages) + - run: git config core.hooksPath /dev/null + # Run `github-action-benchmark` action - name: Store benchmark result - uses: rhysd/github-action-benchmark@v1 + uses: rhysd/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1 with: # What benchmark tool the output.json came from tool: 'customSmallerIsBetter' @@ -45,14 +111,14 @@ jobs: output-file-path: packages/replicache-perf/perf-v2-output.json # Workflow will fail when an alert happens fail-on-alert: true - github-token: ${{ secrets.PERSONAL_GITHUB_TOKEN }} + github-token: ${{ github.token }} benchmark-data-dir-path: perf-v2 auto-push: true alert-threshold: '130%' comment-on-alert: true - name: Store benchmark result p95 - uses: rhysd/github-action-benchmark@v1 + uses: rhysd/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1 with: # What benchmark tool the output.json came from tool: 'customSmallerIsBetter' @@ -60,7 +126,7 @@ jobs: output-file-path: packages/replicache-perf/perf-v2-p95-output.json # Workflow will NOT fail when an alert happens fail-on-alert: false - github-token: ${{ secrets.PERSONAL_GITHUB_TOKEN }} + github-token: ${{ github.token }} benchmark-data-dir-path: perf-v2/p95 auto-push: true alert-threshold: '1000%' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..f6e0a630dd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,139 @@ +name: Release + +on: + pull_request: + types: [closed] + workflow_dispatch: + inputs: + mode: + description: Release mode + required: true + type: choice + options: + - canary + - stable + ref: + description: Branch, tag, or commit SHA to build the release from + required: true + type: string + promote_latest: + description: Promote the published version to npm and Docker latest (stable only) + required: false + type: boolean + default: true + dry_run: + description: Build but skip git push, npm publish, and Docker push + required: false + type: boolean + default: false + +permissions: {} + +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + +jobs: + release: + name: Release + if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release-canary')) + runs-on: ubuntu-latest + environment: Release + permissions: + contents: write # Required to push release tags. + id-token: write # Required for cosign keyless signing. + pull-requests: write # Required to comment on canary release PRs. + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + # zizmor: ignore[artipacked] release.ts needs checkout credentials to push release tags. + with: + fetch-depth: 0 + persist-credentials: true + # Always check out the workflow branch so pnpm tooling is current. + # release.ts handles checking out the target ref internally. + + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6 + with: + version: 11.1.2 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 24.x + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Login to Docker Hub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Configure git identity + run: | + git config --global user.email "release-bot@rocicorp.dev" + git config --global user.name "Rocicorp Release Bot" + + - run: pnpm install --frozen-lockfile + + - name: Install cosign + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1 + + - name: Run release + id: release + env: + MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'canary' }} + REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.event.pull_request.base.ref }} + DRY_RUN: ${{ inputs.dry_run == true && '--dry-run' || '' }} + run: | + set -euo pipefail + + if [[ ! "$REF" =~ ^[A-Za-z0-9._/-]+$ ]]; then + echo "Invalid ref: $REF" >&2 + exit 1 + fi + + output=$(node packages/zero/tool/release.ts "$MODE" "$REF" --yes $DRY_RUN 2>&1 | tee /dev/stderr) + version=$(echo "$output" | grep -oP '(?:Published|Would publish) @rocicorp/zero@\K[^\s]+') + if [[ -z "$version" ]]; then + echo "Could not parse released version from output" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Sign Docker image + if: ${{ inputs.dry_run != true }} + env: + VERSION: ${{ steps.release.outputs.version }} + run: cosign sign --yes rocicorp/zero:$VERSION + + - name: Promote latest tags + if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'stable' && inputs.promote_latest && inputs.dry_run != true }} + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + node packages/zero/tool/make-latest.js "$VERSION" + + - name: Sign Docker latest + if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'stable' && inputs.promote_latest && inputs.dry_run != true }} + run: cosign sign --yes rocicorp/zero:latest + + - name: Comment on PR + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + VERSION: ${{ steps.release.outputs.version }} + run: | + { + printf '## Canary Release Published\n\n' + printf '**npm:** `%s`\n' "@rocicorp/zero@$VERSION" + printf '```\n' + printf 'npm install @rocicorp/zero@%s\n' "$VERSION" + printf '```\n\n' + printf '**Docker:** `%s`\n' "rocicorp/zero:$VERSION" + } > release-comment.md + gh pr comment "$PR_NUMBER" --body-file release-comment.md diff --git a/.github/workflows/reusable-benchmark.yml b/.github/workflows/reusable-benchmark.yml deleted file mode 100644 index 82e9edf4cb..0000000000 --- a/.github/workflows/reusable-benchmark.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Reusable Benchmark Workflow - -on: - workflow_call: - inputs: - package_name: - required: true - type: string - description: 'Package name (e.g., zero-client, replicache)' - working_directory: - required: true - type: string - description: 'Working directory (e.g., packages/zero-client)' - ci_id: - required: false - type: string - description: 'CI ID for PR runs' - is_pr: - required: false - type: boolean - default: false - description: 'Whether this is a PR run' - base_ref: - required: false - type: string - description: 'Base ref for PR runs' - head_ref: - required: false - type: string - description: 'Head ref for PR runs' - base_sha: - required: false - type: string - description: 'Base SHA for PR runs' - secrets: - BENCHER_API_TOKEN: - required: true - TURBO_TOKEN: - required: false - -jobs: - benchmark: - name: ${{ inputs.package_name }} Benchmarks - permissions: - checks: write - pull-requests: write - runs-on: self-hosted - env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: rocicorp - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22.x - cache: 'npm' - - uses: bencherdev/bencher@main - - - run: npm ci - - - name: Install Playwright - run: npx playwright install --with-deps - - - name: Run benchmarks - working-directory: ${{ inputs.working_directory }} - run: npm run bench -- --browser.name=chromium --outputJson output.json - - - name: Upload benchmarks results to bencher.dev (main) - if: ${{ !inputs.is_pr }} - working-directory: ${{ inputs.working_directory }} - run: | - cat output.json |\ - npx tsx ../shared/src/tool/vitest-perf-json-to-bmf.ts |\ - bencher run \ - --project zero \ - --testbed self-hosted \ - --token '${{ secrets.BENCHER_API_TOKEN }}' \ - --adapter json \ - --github-actions '${{ github.token }}' \ - --start-point main \ - --threshold-measure throughput \ - --threshold-test t_test \ - --threshold-lower-boundary 0.99 \ - --threshold-min-sample-size 20 \ - --threshold-max-sample-size 60 \ - --err - - - name: Upload PR benchmarks results to bencher.dev - if: ${{ inputs.is_pr }} - working-directory: ${{ inputs.working_directory }} - run: | - cat output.json |\ - npx tsx ../shared/src/tool/vitest-perf-json-to-bmf.ts |\ - bencher run \ - --project zero \ - --testbed self-hosted \ - --token '${{ secrets.BENCHER_API_TOKEN }}' \ - --adapter json \ - --github-actions '${{ github.token }}' \ - --ci-id ${{ inputs.ci_id }} \ - --branch "${{ inputs.head_ref }}" \ - --start-point "${{ inputs.base_ref }}" \ - --start-point-hash '${{ inputs.base_sha }}' \ - --start-point-clone-thresholds \ - --start-point-reset \ - --threshold-measure throughput \ - --threshold-test t_test \ - --threshold-lower-boundary 0.99 \ - --threshold-min-sample-size 20 \ - --threshold-max-sample-size 60 \ - --err diff --git a/.github/workflows/sst-gigabugs-deploy.yml b/.github/workflows/sst-gigabugs-deploy.yml deleted file mode 100644 index 0d3222de09..0000000000 --- a/.github/workflows/sst-gigabugs-deploy.yml +++ /dev/null @@ -1,117 +0,0 @@ -name: Build and Deploy Gigabugs Zero-Cache AWS SST - -on: - push: - branches: [gigabugs] -env: - # Setting an environment variable with the value of a configuration variable - ECR_IMAGE_ZERO_CACHE: zero-zgigabugs - AWS_DEFAULT_REGION: ${{ vars.AWS_DEFAULT_REGION }} -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: 22.x - cache: 'npm' - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v2 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ vars.AWS_DEFAULT_REGION }} - - - name: Set up QEMU for amd64 - run: | - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - if: runner.os == 'Linux' - - - name: Set up Docker for amd64 - uses: docker/setup-qemu-action@v2 - with: - platforms: linux/amd64 - - - name: Set up Docker - uses: docker/setup-buildx-action@v3 - - - name: Login to Amazon ECR - run: | - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com - - name: Install dependencies - run: | - npm run build-ci - - name: Resolve Zero protocol versions - run: | - node --experimental-strip-types -e " - import {PROTOCOL_VERSION, MIN_SERVER_SUPPORTED_SYNC_PROTOCOL} from './packages/zero-protocol/src/protocol-version.ts'; - console.log('ZERO_SYNC_PROTOCOL_VERSION=' + PROTOCOL_VERSION); - console.log('ZERO_MIN_SUPPORTED_SYNC_PROTOCOL_VERSION=' + MIN_SERVER_SUPPORTED_SYNC_PROTOCOL); - " >> "$GITHUB_ENV" - - name: Create package tarball - run: | - cd packages/zero - npm pack - cp rocicorp-zero-*.tgz ./pkgs/ - - name: Get Git SHA - id: git-sha - run: echo "SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - - name: Create package tarball - run: | - cd packages/zero - npm pack - ZERO_TARBALL="$(ls rocicorp-zero-*.tgz | xargs -- basename -s .tgz)+${{ env.SHA }}.tgz" - mv rocicorp-zero-*.tgz ./pkgs/$ZERO_TARBALL - echo "ZERO_VERSION=$ZERO_TARBALL" >> $GITHUB_ENV - - name: Build and push Docker image - run: | - cd packages/zero - docker buildx create --use - docker buildx inspect --bootstrap - docker buildx build --platform linux/amd64 --build-arg ZERO_VERSION=${{ env.ZERO_VERSION }} --build-arg ZERO_SYNC_PROTOCOL_VERSION=${{ env.ZERO_SYNC_PROTOCOL_VERSION }} --build-arg ZERO_MIN_SUPPORTED_SYNC_PROTOCOL_VERSION=${{ env.ZERO_MIN_SUPPORTED_SYNC_PROTOCOL_VERSION }} -t ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_IMAGE_ZERO_CACHE:${{ env.SHA }} -f ./Dockerfile --push . - - name: Deploy SST app - env: - ZERO_UPSTREAM_DB: ${{ secrets.GIGABUGS_ZERO_UPSTREAM_DB }} - ZERO_ENABLE_QUERY_PLANNER: 'true' - ZERO_MUTATE_URL: ${{ vars.GIGABUGS_ZERO_PUSH_URL }} - ZERO_QUERY_URL: ${{ vars.GIGABUGS_ZERO_PULL_URL }} - ZERO_CVR_DB: ${{ secrets.GIGABUGS_ZERO_CVR_DB }} - ZERO_CHANGE_DB: ${{ secrets.GIGABUGS_ZERO_CHANGE_DB }} - ZERO_AUTH_JWK: ${{ secrets.GIGABUGS_ZERO_AUTH_JWK }} - ZERO_APP_PUBLICATIONS: ${{ vars.GIGABUGS_ZERO_APP_PUBLICATIONS }} - AWS_REGION: ${{ vars.AWS_DEFAULT_REGION }} - ZERO_IMAGE_URL: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ vars.AWS_DEFAULT_REGION }}.amazonaws.com/${{ env.ECR_IMAGE_ZERO_CACHE }}:${{ env.SHA }} - DOMAIN_NAME: 'zgigabugs-sync.rocicorp.dev' - DOMAIN_CERT: 'arn:aws:acm:us-east-1:712907626835:certificate/83339ba0-dc7e-4fb7-b834-65e8307eeb4f' - DATADOG_API_KEY: ${{ secrets.GIGABUGS_DATADOG_API_KEY }} - ZERO_ADMIN_PASSWORD: ${{ secrets.ZERO_ADMIN_PASSWORD }} - run: | - cd prod/sst - npm install - npx sst deploy --stage gigabugs-ebs - - name: Deploy SST app non ebs - env: - ZERO_APP_ID: 'gigabugs1' - ZERO_UPSTREAM_DB: ${{ secrets.GIGABUGS_ZERO_UPSTREAM_DB }} - ZERO_ENABLE_QUERY_PLANNER: 'true' - ZERO_MUTATE_URL: ${{ vars.GIGABUGS_ZERO_PUSH_URL }} - ZERO_QUERY_URL: ${{ vars.GIGABUGS_ZERO_PULL_URL }} - ZERO_CVR_DB: ${{ secrets.GIGABUGS_ZERO_CVR_DB }}2 - ZERO_CHANGE_DB: ${{ secrets.GIGABUGS_ZERO_CHANGE_DB }}2 - ZERO_AUTH_JWK: ${{ secrets.GIGABUGS_ZERO_AUTH_JWK }} - AWS_REGION: ${{ vars.AWS_DEFAULT_REGION }} - ZERO_IMAGE_URL: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ vars.AWS_DEFAULT_REGION }}.amazonaws.com/${{ env.ECR_IMAGE_ZERO_CACHE }}:${{ env.SHA }} - DOMAIN_NAME: 'zgigabugs2-sync.rocicorp.dev' - DOMAIN_CERT: 'arn:aws:acm:us-east-1:712907626835:certificate/b0dd8b0b-2007-42e7-b61d-6573b571b19c' - DATADOG_API_KEY: ${{ secrets.GIGABUGS_DATADOG_API_KEY }} - ZERO_ADMIN_PASSWORD: ${{ secrets.ZERO_ADMIN_PASSWORD }} - run: | - cd prod/sst - npm install - npx sst deploy --stage gigabugs diff --git a/.github/workflows/sst-prod-deploy.yml b/.github/workflows/sst-prod-deploy.yml deleted file mode 100644 index f0221250d1..0000000000 --- a/.github/workflows/sst-prod-deploy.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: Build and Deploy Production Zero-Cache AWS SST - -on: - push: - branches: [main] -env: - # Setting an environment variable with the value of a configuration variable - ECR_IMAGE_ZERO_CACHE: zero-zbugs - AWS_DEFAULT_REGION: ${{ vars.AWS_DEFAULT_REGION }} -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: 22.x - cache: 'npm' - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v2 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ vars.AWS_DEFAULT_REGION }} - - - name: Set up QEMU for amd64 - run: | - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - if: runner.os == 'Linux' - - - name: Set up Docker for amd64 - uses: docker/setup-qemu-action@v2 - with: - platforms: linux/amd64 - - - name: Set up Docker - uses: docker/setup-buildx-action@v3 - - - name: Login to Amazon ECR - run: | - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com - - name: Install dependencies - run: | - npm run build-ci - - name: Resolve Zero protocol versions - run: | - node --experimental-strip-types -e " - import {PROTOCOL_VERSION, MIN_SERVER_SUPPORTED_SYNC_PROTOCOL} from './packages/zero-protocol/src/protocol-version.ts'; - console.log('ZERO_SYNC_PROTOCOL_VERSION=' + PROTOCOL_VERSION); - console.log('ZERO_MIN_SUPPORTED_SYNC_PROTOCOL_VERSION=' + MIN_SERVER_SUPPORTED_SYNC_PROTOCOL); - " >> "$GITHUB_ENV" - - name: Get Git SHA - id: git-sha - run: echo "SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - - name: Create package tarball - run: | - cd packages/zero - npm pack - ZERO_TARBALL="$(ls rocicorp-zero-*.tgz | xargs -- basename -s .tgz)+${{ env.SHA }}.tgz" - mv rocicorp-zero-*.tgz ./pkgs/$ZERO_TARBALL - echo "ZERO_VERSION=$ZERO_TARBALL" >> $GITHUB_ENV - - name: Build and push Docker image - run: | - cd packages/zero - docker buildx create --use - docker buildx inspect --bootstrap - docker buildx build --platform linux/amd64 --build-arg ZERO_VERSION=${{ env.ZERO_VERSION }} --build-arg ZERO_SYNC_PROTOCOL_VERSION=${{ env.ZERO_SYNC_PROTOCOL_VERSION }} --build-arg ZERO_MIN_SUPPORTED_SYNC_PROTOCOL_VERSION=${{ env.ZERO_MIN_SUPPORTED_SYNC_PROTOCOL_VERSION }} -t ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_IMAGE_ZERO_CACHE:${{ env.SHA }} -f ./Dockerfile --push . - - name: Deploy SST app - env: - ZERO_UPSTREAM_DB: ${{ secrets.PROD_ZERO_UPSTREAM_DB }} - ZERO_ENABLE_QUERY_PLANNER: 'true' - ZERO_ADMIN_PASSWORD: ${{ secrets.ZERO_ADMIN_PASSWORD }} - ZERO_MUTATE_URL: ${{ vars.PROD_ZERO_PUSH_URL }} - ZERO_QUERY_URL: ${{ vars.PROD_ZERO_PULL_URL }} - ZERO_CVR_DB: ${{ secrets.PROD_ZERO_CVR_DB }} - ZERO_CHANGE_DB: ${{ secrets.PROD_ZERO_CHANGE_DB }} - ZERO_AUTH_JWK: ${{ secrets.PROD_ZERO_AUTH_JWK }} - ZERO_APP_PUBLICATIONS: ${{ vars.PROD_ZERO_APP_PUBLICATIONS }} - AWS_REGION: ${{ vars.AWS_DEFAULT_REGION }} - ZERO_IMAGE_URL: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ vars.AWS_DEFAULT_REGION }}.amazonaws.com/${{ env.ECR_IMAGE_ZERO_CACHE }}:${{ env.SHA }} - DOMAIN_NAME: 'zbugs-sync.rocicorp.dev' - DOMAIN_CERT: ${{ vars.PROD_CERTIFICATE_ARN }} - DATADOG_API_KEY: ${{ secrets.PROD_DATADOG_API_KEY }} - OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.PROD_OTEL_EXPORTER_OTLP_ENDPOINT }} - OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.PROD_OTEL_EXPORTER_OTLP_HEADERS }} - OTEL_RESOURCE_ATTRIBUTES: ${{ vars.PROD_OTEL_RESOURCE_ATTRIBUTES }} - run: | - cd prod/sst - npm install - npx sst deploy --stage production diff --git a/.github/workflows/sst-sandbox-deploy.yml b/.github/workflows/sst-sandbox-deploy.yml deleted file mode 100644 index 8fb716a201..0000000000 --- a/.github/workflows/sst-sandbox-deploy.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: Build and Deploy Sandbox Zero-Cache AWS SST - -on: - push: - branches: [sandbox] -env: - # Setting an environment variable with the value of a configuration variable - ECR_IMAGE_ZERO_CACHE: zero-zbugs-sandbox - AWS_DEFAULT_REGION: ${{ vars.AWS_DEFAULT_REGION }} -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: 22.x - cache: 'npm' - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v2 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ vars.AWS_DEFAULT_REGION }} - - - name: Set up QEMU for amd64 - run: | - docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - if: runner.os == 'Linux' - - - name: Set up Docker for amd64 - uses: docker/setup-qemu-action@v2 - with: - platforms: linux/amd64 - - - name: Set up Docker - uses: docker/setup-buildx-action@v3 - - - name: Login to Amazon ECR - run: | - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com - - name: Install dependencies - run: | - npm run build-ci - - name: Resolve Zero protocol versions - run: | - node --experimental-strip-types -e " - import {PROTOCOL_VERSION, MIN_SERVER_SUPPORTED_SYNC_PROTOCOL} from './packages/zero-protocol/src/protocol-version.ts'; - console.log('ZERO_SYNC_PROTOCOL_VERSION=' + PROTOCOL_VERSION); - console.log('ZERO_MIN_SUPPORTED_SYNC_PROTOCOL_VERSION=' + MIN_SERVER_SUPPORTED_SYNC_PROTOCOL); - " >> "$GITHUB_ENV" - - name: Get Git SHA - id: git-sha - run: echo "SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - - name: Create package tarball - run: | - cd packages/zero - npm pack - ZERO_TARBALL="$(ls rocicorp-zero-*.tgz | xargs -- basename -s .tgz)+${{ env.SHA }}.tgz" - mv rocicorp-zero-*.tgz ./pkgs/$ZERO_TARBALL - echo "ZERO_VERSION=$ZERO_TARBALL" >> $GITHUB_ENV - - name: Build and push Docker image - run: | - cd packages/zero - docker buildx create --use - docker buildx inspect --bootstrap - docker buildx build --platform linux/amd64 --build-arg ZERO_VERSION=${{ env.ZERO_VERSION }} --build-arg ZERO_SYNC_PROTOCOL_VERSION=${{ env.ZERO_SYNC_PROTOCOL_VERSION }} --build-arg ZERO_MIN_SUPPORTED_SYNC_PROTOCOL_VERSION=${{ env.ZERO_MIN_SUPPORTED_SYNC_PROTOCOL_VERSION }} -t ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_IMAGE_ZERO_CACHE:${{ env.SHA }} -f ./Dockerfile --push . - - name: Deploy SST app - env: - ZERO_UPSTREAM_DB: ${{ secrets.SANDBOX_ZERO_UPSTREAM_DB }} - ZERO_ENABLE_QUERY_PLANNER: 'true' - ZERO_MUTATE_URL: ${{ vars.SANDBOX_ZERO_PUSH_URL }} - ZERO_QUERY_URL: ${{ vars.SANDBOX_ZERO_PULL_URL }} - ZERO_CVR_DB: ${{ secrets.SANDBOX_ZERO_CVR_DB }} - ZERO_CHANGE_DB: ${{ secrets.SANDBOX_ZERO_CHANGE_DB }} - ZERO_AUTH_JWK: ${{ secrets.SANDBOX_ZERO_AUTH_JWK }} - ZERO_APP_PUBLICATIONS: ${{ vars.SANDBOX_ZERO_APP_PUBLICATIONS }} - AWS_REGION: ${{ vars.AWS_DEFAULT_REGION }} - ZERO_IMAGE_URL: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ vars.AWS_DEFAULT_REGION }}.amazonaws.com/${{ env.ECR_IMAGE_ZERO_CACHE }}:${{ env.SHA }} - DOMAIN_NAME: 'zbugs-sync-sandbox.rocicorp.dev' - DOMAIN_CERT: ${{ vars.SANDBOX_CERTIFICATE_ARN }} - DATADOG_API_KEY: ${{ secrets.SANDBOX_DATADOG_API_KEY }} - ZERO_ADMIN_PASSWORD: ${{ secrets.ZERO_ADMIN_PASSWORD }} - run: | - cd prod/sst - # The permissions.deployer function's package.json is configured to install - # "@rocicorp/zero" from "file:rocicorp-zero.tgz". This allows us to deploy - # permissions with the same code that the view-syncer is running. - cp ../../packages/zero/pkgs/${{ env.ZERO_VERSION }} ./rocicorp-zero.tgz - npm install - npx sst deploy --stage sandbox diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000000..47e3cc4fef --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,34 @@ +name: GitHub Actions Security Analysis + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: {} + +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + zizmor: + name: Run zizmor + runs-on: ubuntu-latest + permissions: + security-events: write # Required to upload SARIF results. + contents: read # Required to check out private repository contents. + actions: read # Required to read private workflow run metadata. + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Run zizmor + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + with: + version: v1.25.2 + persona: auditor diff --git a/.gitignore b/.gitignore index 47a1e09de5..da4207c9f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +logs + # build outputs **/*.tgz @@ -44,6 +46,7 @@ lerna-debug.log* # Editor directories and files .vscode/* !.vscode/extensions.json +!.vscode/settings.json .idea .DS_Store *.suo @@ -100,3 +103,6 @@ bun.lock .claude/ packages/zql-integration-tests/Chinook_Sqlite.sqlite + +*.ignore.* +package-lock-commit-deps-report.html diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000000..33d93033f0 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,35 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "avoid", + "bracketSpacing": false, + "quoteProps": "consistent", + "printWidth": 80, + "sortImports": { + "newlinesBetween": false, + "partitionByNewline": true + }, + "sortPackageJson": true, + "ignorePatterns": [ + "node_modules", + "dist", + "lib", + "*.log", + "packages/replicache/out", + "packages/replicache/doc/build/", + "packages/replicache/doc/docs/api", + "packages/replicache/doc/.docusaurus/", + "**/.next", + "**/.vercel", + "*.tsbuildinfo", + "renderer", + "vendor", + "!/renderer/src/constants.ts", + "**/bot-recordings.ts", + ".sst", + "prod/sst/sst-env.d.ts", + "apps/zbugs/public/um.js", + "apps/zbugs/db/migrations/", + "**/__snapshots__/**" + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json deleted file mode 100644 index 4f5281a430..0000000000 --- a/.oxlintrc.json +++ /dev/null @@ -1,133 +0,0 @@ -{ - "plugins": ["eslint", "typescript", "unicorn", "jest"], - "ignorePatterns": [ - "node_modules", - "dist", - "coverage", - "out", - "tool", - "bin", - "*_generated.ts", - "src/*/generated/", - "perf/index.js", - "um.js", - "prod/sst/sst-env.d.ts" - ], - "rules": { - // Disable unsafe optional chaining - many legitimate patterns in codebase - "no-unsafe-optional-chaining": "off", - - "no-console": "error", - "eqeqeq": "error", - "arrow-body-style": "error", - - // Handle unused variables consistently with ESLint config - "no-unused-vars": [ - "error", - { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_", - "caughtErrorsIgnorePattern": "^_" - } - ], - - // TypeScript-specific rules - "typescript/no-explicit-any": "error", - "typescript/no-floating-promises": "error", - "typescript/no-non-null-assertion": "warn", - "typescript/consistent-type-imports": "error", - "typescript/no-unused-vars": [ - "error", - { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_", - "caughtErrorsIgnorePattern": "^_" - } - ], - - // Require await in async functions - "require-await": "error", - - // Vitest rules uses "jest" plugin - "jest/no-focused-tests": "error", - "jest/no-standalone-expect": "off", - - // Private class members (with inline disables for false positives) - "no-unused-private-class-members": "error", - - // Import restrictions - preserve the exact patterns from @rocicorp/eslint-config - "no-restricted-imports": [ - "error", - { - "patterns": [ - { - "group": [ - "datadog/*", - "otel/*", - "replicache/*", - "replicache-perf/*", - "shared/*", - "zero/*", - "zero-cache/*", - "zero-client/*", - "zero-protocol/*", - "zero-react/*", - "zero-react-native/*", - "zero-schema/*", - "zero-solid/*", - "zero-vue/*", - "zql/*", - "zqlite/*", - "zqlite-zql-test/*" - ], - "message": "Use relative imports instead" - }, - { - "group": ["**/mod.ts"], - "message": "Don't import from barrel files. Import from the specific module instead." - }, - { - "group": ["**/*.test.ts", "**/*.test.tsx"], - "message": "Do not import from test files." - }, - { - "group": ["sinon"], - "message": "Use vi instead of sinon" - } - ], - "paths": [ - {"name": "datadog", "message": "Use relative imports instead"}, - {"name": "otel", "message": "Use relative imports instead"}, - {"name": "replicache", "message": "Use relative imports instead"}, - { - "name": "replicache-perf", - "message": "Use relative imports instead" - }, - {"name": "shared", "message": "Use relative imports instead"}, - {"name": "zero", "message": "Use relative imports instead"}, - {"name": "zero-cache", "message": "Use relative imports instead"}, - {"name": "zero-client", "message": "Use relative imports instead"}, - {"name": "zero-protocol", "message": "Use relative imports instead"}, - {"name": "zero-react", "message": "Use relative imports instead"}, - { - "name": "zero-react-native", - "message": "Use relative imports instead" - }, - {"name": "zero-schema", "message": "Use relative imports instead"}, - {"name": "zero-solid", "message": "Use relative imports instead"}, - {"name": "zero-vue", "message": "Use relative imports instead"}, - {"name": "zql", "message": "Use relative imports instead"}, - {"name": "zqlite", "message": "Use relative imports instead"}, - {"name": "zqlite-zql-test", "message": "Use relative imports instead"} - ] - } - ], - - // Additional beneficial rules - "unicorn/prefer-set-size": "error", - "unicorn/prefer-string-starts-ends-with": "error", - "unicorn/prefer-array-find": "error", - "unicorn/prefer-array-flat-map": "error", - "unicorn/prefer-set-has": "error" - } -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000..99e2f7ddf7 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["oxc.oxc-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..2969bdab7d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "oxc.configPath": "oxlint.config.ts", + "editor.defaultFormatter": "oxc.oxc-vscode", + "[typescript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": false, + "[jsonc]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + } +} diff --git a/AGENTS.md b/AGENTS.md index e153477c0a..83b8802b06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,16 +34,16 @@ Zero follows a **sync-first** model: client queries are reactive and automatical ```bash # Install and build everything -npm install && npm run build +pnpm install && pnpm run build # Run tests (uses vitest) -npm run test # All tests -npm run test:watch # Watch mode +pnpm run test # All tests +pnpm run test:watch # Watch mode # Type checking and linting -npm run check-types # TypeScript across all packages -npm run lint # oxlint with type-awareness -npm run format # Prettier formatting +pnpm run check-types # TypeScript across all packages +pnpm run lint # oxlint with type-awareness +pnpm run format # oxfmt formatting ``` **Always run `lint`, `format` and `check-types` after every change.** @@ -53,25 +53,25 @@ npm run format # Prettier formatting Prefer package-level commands when possible. Each package supports: `test`, `check-types`, `lint`, `format`, `build`. e.g.: ```bash -npm --workspace=zero-client run format -npm --workspace=zero-cache run lint -npm --workspace=zero-server run check-types +pnpm --filter zero-client run format +pnpm --filter zero-cache run lint +pnpm --filter zero-server run check-types # Run with coverage (prefer using this flag when possible) -npm --workspace=zero-client run test -- --coverage +pnpm --filter zero-client run test --coverage # Run specific test file -npm --workspace=zero-client run test -- zero.test +pnpm --filter zero-client run test zero.test ``` ### Zero Cache Development ```bash # Start Zero cache server for local development -npm run start-zero-cache +pnpm run start-zero-cache # In zbugs app - start Zero cache with schema hot-reload -npm run zero-cache-dev +pnpm run zero-cache-dev ``` ## Code Conventions @@ -197,6 +197,35 @@ Zero is a streaming database: - Zero schema definitions are separate from PostgreSQL schema - Apps like zbugs demonstrate the connection between PostgreSQL tables and Zero schemas +## Known Gotchas + +This section documents surprising behaviors and hard-won lessons. If you discover something non-obvious that caused significant debugging pain, consider adding it here. + +### SQLite: NULL + OR = Full Table Scan + +When building OR queries with bound parameters in SQLite, if **any** branch involves a NULL value, SQLite abandons its MULTI-INDEX OR optimization and falls back to a full table scan. + +```sql +-- Even this simple query becomes a full table scan if ? is NULL: +SELECT * FROM users WHERE id = ? OR email = ?; + +-- If email is NULL, SQLite won't use MULTI-INDEX OR, even for the valid id branch +EXPLAIN QUERY PLAN → "SCAN users" (not "SEARCH users USING INDEX") +``` + +**Why it matters**: This caused 320x slowdowns on tables with nullable unique columns. A query that should take <1ms was taking 320ms. + +**Fix**: Filter out conditions where the value is NULL before building OR queries. NULL values can't violate uniqueness constraints anyway (NULL ≠ NULL in SQL). + +```typescript +// Filter out keys where any column is NULL +const validKeys = keys.filter(key => + key.every(column => row[column] !== null && row[column] !== undefined), +); +``` + +See: https://github.com/rocicorp/mono/pull/5542 + ## Git Conventions ### Commit Messages @@ -225,11 +254,11 @@ git cherry-pick -x ```bash # Debug Zero cache with breakpoints -npm run zero-brk +pnpm run zero-brk # Transform/run queries for debugging -npm run transform-query -npm run run-query +pnpm run transform-query +pnpm run run-query ``` ### Docker Development @@ -237,8 +266,8 @@ npm run run-query Many apps include Docker Compose for local PostgreSQL: ```bash -npm run db-up # Start PostgreSQL -npm run db-down # Stop PostgreSQL +pnpm run db-up # Start PostgreSQL +pnpm run db-down # Stop PostgreSQL ``` ## Package Dependencies @@ -274,7 +303,7 @@ zbugs is the reference Zero application. To run it locally: 2. If you've made changes to any Zero packages (`zero-client`, `zero-cache`, `zero-protocol`, etc.), you must first rebuild: ```bash -npm --workspace=@rocicorp/zero run build +pnpm --filter @rocicorp/zero run build ``` ### Starting the Services @@ -285,13 +314,13 @@ From `apps/zbugs`, start these three services (in background for AI, separate ta cd apps/zbugs # 1. Start PostgreSQL (Docker) - must complete before others -npm run db-up +pnpm run db-up # 2. Start zero-cache with hot-reload -npm run zero-cache-dev +pnpm run zero-cache-dev # 3. Start the Vite dev server -npm run dev +pnpm run dev ``` **For AI assistants**: Run `db-up` in background, wait for PostgreSQL to be ready, then run `zero-cache-dev` and `dev` in background. Use `run_in_background` parameter or `&` suffix. Check logs with `tail` on the output files. @@ -302,8 +331,8 @@ If the database is empty or schema has changed: ```bash cd apps/zbugs -npm run db-migrate # Apply schema migrations -npm run db-seed # Seed with test data +pnpm run db-migrate # Apply schema migrations +pnpm run db-seed # Seed with test data ``` ### Troubleshooting diff --git a/CLAUDE.md b/CLAUDE.md index eef4bd20cf..43c994c2d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -@AGENTS.md \ No newline at end of file +@AGENTS.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index e1c56c7d5f..d36b5b8333 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -17,23 +17,23 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or +- The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities @@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an +standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within diff --git a/apps/otel-proxy/.gitignore b/apps/otel-proxy/.gitignore index e985853ed8..c8a733615b 100644 --- a/apps/otel-proxy/.gitignore +++ b/apps/otel-proxy/.gitignore @@ -1 +1,2 @@ .vercel +.env*.local diff --git a/apps/otel-proxy/api/index.test.ts b/apps/otel-proxy/api/index.test.ts index 0698252cbf..779681f144 100644 --- a/apps/otel-proxy/api/index.test.ts +++ b/apps/otel-proxy/api/index.test.ts @@ -1,7 +1,5 @@ import {describe, it, expect, vi, beforeEach} from 'vitest'; -import type {VercelRequest, VercelResponse} from '@vercel/node'; - -import handler from './index'; +import handler, {type ProxyRequest, type ProxyResponse} from './index'; // Mock fetch const mockFetch = vi.fn(); @@ -45,8 +43,8 @@ const validOtelData = { }; describe('OTEL Proxy Handler', () => { - let req: Partial; - let res: Partial; + let req: Partial; + let res: Partial; beforeEach(() => { vi.clearAllMocks(); @@ -83,7 +81,7 @@ describe('OTEL Proxy Handler', () => { }; mockFetch.mockResolvedValue(mockResponse); - await handler(req as VercelRequest, res as VercelResponse); + await handler(req as ProxyRequest, res as ProxyResponse); expect(mockFetch).toHaveBeenCalledWith( 'https://test-grafana.com/otlp/v1/metrics', @@ -111,7 +109,7 @@ describe('OTEL Proxy Handler', () => { }; mockFetch.mockResolvedValue(mockResponse); - await handler(req as VercelRequest, res as VercelResponse); + await handler(req as ProxyRequest, res as ProxyResponse); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith('OK'); @@ -120,7 +118,7 @@ describe('OTEL Proxy Handler', () => { it('should reject non-POST requests', async () => { req.method = 'GET'; - await handler(req as VercelRequest, res as VercelResponse); + await handler(req as ProxyRequest, res as ProxyResponse); expect(res.status).toHaveBeenCalledWith(405); expect(res.json).toHaveBeenCalledWith({error: 'Method not allowed'}); @@ -130,7 +128,7 @@ describe('OTEL Proxy Handler', () => { it('should reject non-JSON content type', async () => { req.headers = {'content-type': 'application/x-protobuf'}; - await handler(req as VercelRequest, res as VercelResponse); + await handler(req as ProxyRequest, res as ProxyResponse); expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({error: 'Invalid request type'}); @@ -140,7 +138,7 @@ describe('OTEL Proxy Handler', () => { it('should reject requests with missing content type', async () => { req.headers = {}; - await handler(req as VercelRequest, res as VercelResponse); + await handler(req as ProxyRequest, res as ProxyResponse); expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({error: 'Invalid request type'}); @@ -150,7 +148,7 @@ describe('OTEL Proxy Handler', () => { it('should reject requests without OTEL data', async () => { req.body = {some: 'random', data: 'without otel fields'}; - await handler(req as VercelRequest, res as VercelResponse); + await handler(req as ProxyRequest, res as ProxyResponse); expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({error: 'Invalid request body'}); @@ -173,7 +171,7 @@ describe('OTEL Proxy Handler', () => { }; mockFetch.mockResolvedValue(mockResponse); - await handler(req as VercelRequest, res as VercelResponse); + await handler(req as ProxyRequest, res as ProxyResponse); expect(res.status).toHaveBeenCalledWith(200); expect(mockFetch).toHaveBeenCalled(); @@ -182,7 +180,7 @@ describe('OTEL Proxy Handler', () => { it('should handle missing ROCICORP_TELEMETRY_TOKEN', async () => { vi.stubEnv('ROCICORP_TELEMETRY_TOKEN', ''); - await handler(req as VercelRequest, res as VercelResponse); + await handler(req as ProxyRequest, res as ProxyResponse); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ @@ -194,7 +192,7 @@ describe('OTEL Proxy Handler', () => { it('should handle fetch errors', async () => { mockFetch.mockRejectedValue(new Error('Network error')); - await handler(req as VercelRequest, res as VercelResponse); + await handler(req as ProxyRequest, res as ProxyResponse); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({error: 'Failed to forward metrics'}); @@ -210,7 +208,7 @@ describe('OTEL Proxy Handler', () => { }; mockFetch.mockResolvedValue(mockResponse); - await handler(req as VercelRequest, res as VercelResponse); + await handler(req as ProxyRequest, res as ProxyResponse); expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({error: 'Bad Request'}); @@ -228,7 +226,7 @@ describe('OTEL Proxy Handler', () => { }; mockFetch.mockResolvedValue(mockResponse); - await handler(req as VercelRequest, res as VercelResponse); + await handler(req as ProxyRequest, res as ProxyResponse); expect(mockFetch).toHaveBeenCalledWith( 'https://otlp-gateway-prod-us-east-2.grafana.net/otlp/v1/metrics', @@ -239,7 +237,7 @@ describe('OTEL Proxy Handler', () => { it('should validate request body exists', async () => { req.body = undefined; - await handler(req as VercelRequest, res as VercelResponse); + await handler(req as ProxyRequest, res as ProxyResponse); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({error: 'Failed to forward metrics'}); diff --git a/apps/otel-proxy/api/index.ts b/apps/otel-proxy/api/index.ts index 066739ec08..7a0cc1c525 100644 --- a/apps/otel-proxy/api/index.ts +++ b/apps/otel-proxy/api/index.ts @@ -1,5 +1,18 @@ /* oxlint-disable no-console */ -import type {VercelRequest, VercelResponse} from '@vercel/node'; + +// Minimal subset of Vercel's request/response shapes that this handler uses. +// Avoids depending on @vercel/node, which bundles unrelated build tooling. +export interface ProxyRequest { + method?: string | undefined; + body?: unknown; + headers?: Record | undefined; +} + +export interface ProxyResponse { + status(code: number): ProxyResponse; + json(body: unknown): ProxyResponse; + send(body: unknown): ProxyResponse; +} /** * Simple assertion function for validation @@ -36,8 +49,8 @@ function validateOtelData(data: unknown): boolean { * Only accepts JSON requests and validates for OTEL data. */ export default async function handler( - req: VercelRequest, - res: VercelResponse, + req: ProxyRequest, + res: ProxyResponse, ): Promise { console.log(`OTEL Proxy: ${req.method} request received`); diff --git a/apps/otel-proxy/package.json b/apps/otel-proxy/package.json index 8a486757ed..97549ee79a 100644 --- a/apps/otel-proxy/package.json +++ b/apps/otel-proxy/package.json @@ -8,20 +8,18 @@ "deploy": "vercel deploy", "test": "vitest run", "test:watch": "vitest", - "lint": "oxlint --type-aware .", - "format": "prettier --write .", - "check-format": "prettier --check .", - "check-types": "tsc --noEmit" - }, - "dependencies": { - "@vercel/node": "^3.0.0" + "lint": "oxlint --quiet --config ../../oxlint.config.ts .", + "format": "oxfmt .", + "check-format": "oxfmt --check .", + "check-types": "tsc --noEmit", + "fmt": "oxfmt .", + "check-fmt": "oxfmt --check ." }, "devDependencies": { - "@rocicorp/prettier-config": "^0.4.0", "@types/node": "^22.10.5", - "@vitest/coverage-v8": "4.0.15", - "typescript": "~5.9.3", - "vitest": "4.0.15" - }, - "prettier": "@rocicorp/prettier-config" + "@vitest/coverage-v8": "^4.1.6", + "oxfmt": "^0.45.0", + "typescript": "~6.0.2", + "vitest": "^4.1.6" + } } diff --git a/apps/otel-proxy/tsconfig.json b/apps/otel-proxy/tsconfig.json index 0a01ee48e2..1bf2187ad7 100644 --- a/apps/otel-proxy/tsconfig.json +++ b/apps/otel-proxy/tsconfig.json @@ -2,8 +2,8 @@ "compilerOptions": { "target": "es2020", "lib": ["ES2020"], - "module": "commonjs", - "moduleResolution": "node", + "module": "preserve", + "moduleResolution": "bundler", "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, diff --git a/apps/zbugs/.env.example b/apps/zbugs/.env.example index fd2725e045..2a0c23ebe0 100644 --- a/apps/zbugs/.env.example +++ b/apps/zbugs/.env.example @@ -22,14 +22,13 @@ ZERO_LOG_FORMAT = "text" GITHUB_CLIENT_ID = "" # The secret for the client GITHUB_CLIENT_SECRET = "" -# You can create a JWK pair via `npm run create-keys` +# You can create a JWK pair via `pnpm run create-keys` # in the `zbugs` directory. # # The private key goes here # The public key goes in `VITE_PUBLIC_JWK`. PRIVATE_JWK = "" - #### Vite Variables #### VITE_PUBLIC_SERVER="http://localhost:4848" # See comment on `ZERO_AUTH_JWK` diff --git a/apps/zbugs/.oxlintrc.json b/apps/zbugs/.oxlintrc.json deleted file mode 100644 index 81380904e4..0000000000 --- a/apps/zbugs/.oxlintrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": ["../../.oxlintrc.json"], - "plugins": ["react"], - "env": { - "builtin": true, - "browser": true - } -} diff --git a/apps/zbugs/README.md b/apps/zbugs/README.md index b31b8a3468..c64b4bf739 100644 --- a/apps/zbugs/README.md +++ b/apps/zbugs/README.md @@ -17,8 +17,8 @@ Install and build dependencies the `mono` repository root: ```bash # In repository root -npm install -npm run build +pnpm install +pnpm run build ``` ### 2. Create env file @@ -36,17 +36,17 @@ cp .env.example .env ```bash # In apps/zbugs -npm run db-up +pnpm run db-up # In apps/zbugs in another tab -npm run db-migrate -npm run db-seed +pnpm run db-migrate +pnpm run db-seed ``` ### 4. Run the zero-cache server ```bash # In apps/zbugs -npm run zero-cache-dev +pnpm run zero-cache-dev ``` ### 5. Run the web app @@ -55,7 +55,7 @@ npm run zero-cache-dev ```bash # In apps/zbugs -npm run dev +pnpm run dev ``` After you have visited the local website and the sync / replica tables have populated. @@ -80,8 +80,8 @@ docker compose down -v ./getData.sh # In apps/zbugs -npm run db-up +pnpm run db-up # In apps/zbugs in another tab -npm run db-migrate -ZERO_SEED_DATA_DIR=./db/seed-data/gigabugs/ npm run db-seed +pnpm run db-migrate +ZERO_SEED_DATA_DIR=./db/seed-data/gigabugs/ pnpm run db-seed ``` diff --git a/apps/zbugs/SYNTHETIC_DATA.md b/apps/zbugs/SYNTHETIC_DATA.md index 4abfc565b1..c680cc6207 100644 --- a/apps/zbugs/SYNTHETIC_DATA.md +++ b/apps/zbugs/SYNTHETIC_DATA.md @@ -15,7 +15,7 @@ End-to-end process for generating large-scale synthetic data and loading it into Templates are LLM-generated pools of issue titles, descriptions, comments, project names, labels, and components across 10 software categories. They use `{{slot}}` placeholders that get filled with randomized values during CSV generation. ```bash -ANTHROPIC_API_KEY=sk-... npm run generate-templates +ANTHROPIC_API_KEY=sk-... pnpm run generate-templates ``` Output: JSON files written to `db/seed-data/templates/` (one per category plus `summary.json`). @@ -32,23 +32,24 @@ This step calls the Claude API in batches of 3 categories. Each category generat Self-contained generator that produces sharded CSV files based on configuration parameters and templates. ```bash -npm run generate-synthetic +pnpm run generate-synthetic ``` Output: sharded CSV files in `db/seed-data/synthetic/` named `{table}_{shard}.csv` (e.g., `issue_000.csv`, `issue_001.csv`, ...). Tables generated: `user`, `project`, `label`, `issue`, `comment`, `issueLabel`. -| Env Var | Default | Description | -| -------------------- | ------------------------- | -------------------------------- | -| `NUM_ISSUES` | `1000000` | Total issues to generate | -| `NUM_PROJECTS` | `100` | Total projects | -| `NUM_USERS` | `100` | Total users | -| `COMMENTS_PER_ISSUE` | `3.0` | Average comments per issue | -| `LABELS_PER_ISSUE` | `1.5` | Average labels per issue | -| `SHARD_SIZE` | `500000` | Rows per CSV shard file | -| `OUTPUT_DIR` | `db/seed-data/synthetic/` | Output directory | -| `SEED` | `42` | RNG seed for reproducible output | +| Env Var | Default | Description | +| -------------------- | ------------------------- | ----------------------------------------- | +| `NUM_ISSUES` | `1000000` | Total issues to generate | +| `NUM_PROJECTS` | `100` | Total projects | +| `NUM_USERS` | `100` | Total users | +| `COMMENTS_PER_ISSUE` | `3.0` | Average comments per issue | +| `LABELS_PER_ISSUE` | `1.5` | Average labels per issue | +| `SHARD_SIZE` | `500000` | Rows per CSV shard file | +| `OUTPUT_DIR` | `db/seed-data/synthetic/` | Output directory | +| `SEED` | `42` | RNG seed for reproducible output | +| `SKIP_USERS` | `false` | Skip user CSV generation (for batch mode) | ### Distribution Strategy @@ -62,13 +63,13 @@ The generator uses realistic distributions for data: ```bash # Small test dataset (~10K issues) -NUM_ISSUES=10000 npm run generate-synthetic +NUM_ISSUES=10000 pnpm run generate-synthetic # Medium dataset (~1M issues, default) -npm run generate-synthetic +pnpm run generate-synthetic # Large dataset (~100M issues) -NUM_ISSUES=100000000 COMMENTS_PER_ISSUE=2.0 npm run generate-synthetic +NUM_ISSUES=100000000 COMMENTS_PER_ISSUE=2.0 pnpm run generate-synthetic ``` With default settings (`NUM_ISSUES=1000000`, `COMMENTS_PER_ISSUE=3.0`, `LABELS_PER_ISSUE=1.5`): @@ -81,13 +82,13 @@ With default settings (`NUM_ISSUES=1000000`, `COMMENTS_PER_ISSUE=3.0`, `LABELS_P ## Step 3: Start PostgreSQL ```bash -npm run db-up +pnpm run db-up ``` Once PostgreSQL is running, apply the schema migrations: ```bash -npm run db-migrate +pnpm run db-migrate ``` ## Step 4: Load into PostgreSQL @@ -95,7 +96,7 @@ npm run db-migrate The `db-seed-synthetic` script loads synthetic CSVs using PostgreSQL `COPY` with bulk optimizations: ```bash -npm run db-seed-synthetic +pnpm run db-seed-synthetic ``` This runs `seed.ts` with `ZERO_SEED_BULK=true` and `ZERO_SEED_DATA_DIR=db/seed-data/synthetic`. Bulk mode: @@ -112,15 +113,47 @@ This runs `seed.ts` with `ZERO_SEED_BULK=true` and `ZERO_SEED_DATA_DIR=db/seed-d To force re-seeding an already-seeded database: ```bash -ZERO_SEED_FORCE=true npm run db-seed-synthetic +ZERO_SEED_FORCE=true pnpm run db-seed-synthetic ``` -| Env Var | Default | Description | -| -------------------- | ----------------- | -------------------------------------------- | -| `ZERO_UPSTREAM_DB` | _(from `.env`)_ | PostgreSQL connection string | -| `ZERO_SEED_BULK` | _(set by script)_ | Enables bulk load optimizations | -| `ZERO_SEED_DATA_DIR` | _(set by script)_ | Directory containing CSV shards | -| `ZERO_SEED_FORCE` | _(unset)_ | Set to `true` to re-seed even if data exists | +| Env Var | Default | Description | +| -------------------- | ----------------- | ----------------------------------------------- | +| `ZERO_UPSTREAM_DB` | _(from `.env`)_ | PostgreSQL connection string | +| `ZERO_SEED_BULK` | _(set by script)_ | Enables bulk load optimizations | +| `ZERO_SEED_DATA_DIR` | _(set by script)_ | Directory containing CSV shards | +| `ZERO_SEED_FORCE` | _(unset)_ | Set to `true` to re-seed even if data exists | +| `ZERO_SEED_APPEND` | _(unset)_ | Set to `true` to append data without truncating | + +## Generating Additional Batches + +You can generate multiple batches of data that coexist in the same database without ID collisions. Non-user entity IDs use seeded nanoid, so different seeds produce globally unique IDs. + +### Batch Workflow + +```bash +# Batch 1: generate and load the initial dataset +NUM_ISSUES=100000000 NUM_PROJECTS=100 SEED=42 pnpm run generate-synthetic +pnpm run db-seed-synthetic + +# Batch 2: generate additional data with a different seed +NUM_ISSUES=100000000 NUM_PROJECTS=1000 \ + SKIP_USERS=true \ + SEED=99 \ + OUTPUT_DIR=db/seed-data/synthetic-batch-2/ \ + pnpm run generate-synthetic + +# Load batch 2 in append mode +ZERO_SEED_APPEND=true \ + ZERO_SEED_DATA_DIR=db/seed-data/synthetic-batch-2/ \ + pnpm run db-seed-synthetic +``` + +### Key points + +- **Different `SEED` per batch** — each seed produces a different RNG sequence, yielding unique nanoid IDs and different content. +- **`SKIP_USERS=true`** — skips writing the user CSV since users are shared across batches. User IDs are sequential (`usr_0000`, `usr_0001`, ...) and are always the same regardless of `SEED`. +- **`ZERO_SEED_APPEND=true`** — tells the seeder to skip the "already seeded" check and skip truncation, appending data to existing tables. Bulk optimizations (drop indexes, COPY, recreate indexes, ANALYZE) still run. +- **`OUTPUT_DIR`** — point each batch at a separate directory so CSVs don't overwrite each other. ## Verification diff --git a/apps/zbugs/api/index.ts b/apps/zbugs/api/index.ts index 0a4a82e460..86ca613a3f 100644 --- a/apps/zbugs/api/index.ts +++ b/apps/zbugs/api/index.ts @@ -2,6 +2,7 @@ import '../../../packages/shared/src/dotenv.ts'; +import type {IncomingHttpHeaders} from 'http'; import cookie from '@fastify/cookie'; import oauthPlugin, {type OAuth2Namespace} from '@fastify/oauth2'; import {Octokit} from '@octokit/core'; @@ -12,7 +13,6 @@ import { } from '@rocicorp/zero'; import {handleMutateRequest, handleQueryRequest} from '@rocicorp/zero/server'; import Fastify, {type FastifyReply, type FastifyRequest} from 'fastify'; -import type {IncomingHttpHeaders} from 'http'; import {jwtVerify, SignJWT, type JWK} from 'jose'; import {nanoid} from 'nanoid'; import {assert} from '../../../packages/shared/src/asserts.ts'; @@ -120,6 +120,8 @@ fastify.get<{ .cookie('jwt', jwt, { path: '/', expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + secure: !request.hostname.includes('localhost'), + sameSite: 'strict', }) .redirect( request.query.redirect ? decodeURIComponent(request.query.redirect) : '/', @@ -162,37 +164,29 @@ async function mutateHandler( }>, reply: FastifyReply, ) { - let jwtData: JWTData | undefined; - try { - jwtData = await maybeVerifyAuth(request.headers); - } catch (e) { - if (e instanceof Error) { - reply.status(401).send(e.message); - return; - } - throw e; - } - - const postCommitTasks: (() => Promise)[] = []; - const mutators = createServerMutators(postCommitTasks); - - const response = await handleMutateRequest( - dbProvider, - (transact, _mutation) => - transact((tx, name, args) => { - const mutator = mustGetMutator(mutators, name); - return mutator.fn({tx, args, ctx: jwtData}); - }), - request.query, - request.body, - 'info', - ); - - // we don't yet handle errors here, since Loops emails return 429 very often - // and we don't want to block the mutation - await Promise.allSettled(postCommitTasks.map(task => task())); - - reply.send(response); + await withAuth(request, reply, async authData => { + const postCommitTasks: (() => Promise)[] = []; + const mutators = createServerMutators(postCommitTasks); + + const response = await handleMutateRequest({ + dbProvider, + handler: (transact, _mutation) => + transact((tx, name, args) => { + const mutator = mustGetMutator(mutators, name); + return mutator.fn({tx, args, ctx: authData}); + }), + query: request.query, + body: request.body, + userID: authData?.sub, + logLevel: 'info', + }); + + // we don't yet handle errors here, since Loops emails return 429 very often + // and we don't want to block the mutation + await Promise.allSettled(postCommitTasks.map(task => task())); + + reply.send(response); + }); } // this endpoint was kept for backwards compatibility @@ -216,14 +210,17 @@ async function queryHandler( ) { await withAuth(request, reply, async authData => { reply.send( - await handleQueryRequest( - (name: string, args: ReadonlyJSONValue | undefined) => { + await handleQueryRequest({ + handler: (name, args) => { const query = mustGetQuery(queries, name); return query.fn({args, ctx: authData}); }, schema, - request.body, - ), + query: request.query, + body: request.body, + userID: authData?.sub, + logLevel: 'info', + }), ); }); } @@ -305,7 +302,10 @@ async function maybeVerifyAuth( return undefined; } - assert(authorization.toLowerCase().startsWith('bearer ')); + assert( + authorization.toLowerCase().startsWith('bearer '), + 'Expected Authorization header to start with "Bearer "', + ); authorization = authorization.substring('Bearer '.length); const jwk = process.env.VITE_PUBLIC_JWK; @@ -313,9 +313,11 @@ async function maybeVerifyAuth( throw new Error('VITE_PUBLIC_JWK is not set'); } - return jwtDataSchema.parse( + const jwtData = jwtDataSchema.parse( (await jwtVerify(authorization, JSON.parse(jwk))).payload, ); + + return jwtData; } export default async function handler( diff --git a/apps/zbugs/db/drizzle-schema.ts b/apps/zbugs/db/drizzle-schema.ts index a94a7df371..9814bd48d5 100644 --- a/apps/zbugs/db/drizzle-schema.ts +++ b/apps/zbugs/db/drizzle-schema.ts @@ -1,3 +1,4 @@ +import {sql} from 'drizzle-orm'; import { pgTable, uniqueIndex, @@ -11,7 +12,6 @@ import { unique, primaryKey, } from 'drizzle-orm/pg-core'; -import {sql} from 'drizzle-orm'; export const user = pgTable( 'user', @@ -175,7 +175,11 @@ export const label = pgTable( }, table => [ uniqueIndex('label_project_idx').using('btree', table.id, table.projectID), - index('label_name_idx').using('btree', table.name), + uniqueIndex('label_name_project_idx').using( + 'btree', + table.projectID, + table.name, + ), foreignKey({ columns: [table.projectID], foreignColumns: [project.id], diff --git a/apps/zbugs/db/migrations/0007_red_omega_sentinel.sql b/apps/zbugs/db/migrations/0007_red_omega_sentinel.sql new file mode 100644 index 0000000000..c3e3ab29f7 --- /dev/null +++ b/apps/zbugs/db/migrations/0007_red_omega_sentinel.sql @@ -0,0 +1,2 @@ +DROP INDEX "label_name_idx";--> statement-breakpoint +CREATE UNIQUE INDEX "label_name_project_idx" ON "label" USING btree ("projectID","name"); \ No newline at end of file diff --git a/apps/zbugs/db/migrations/meta/0007_snapshot.json b/apps/zbugs/db/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000000..f8a9330324 --- /dev/null +++ b/apps/zbugs/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,1172 @@ +{ + "id": "73ccf630-c78b-4673-bf31-8b6bca2351e2", + "prevId": "071c9aef-f7e6-4a4a-a9ed-96bb724ef53d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.comment": { + "name": "comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "issueID": { + "name": "issueID", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creatorID": { + "name": "creatorID", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "comment_issueid_idx": { + "name": "comment_issueid_idx", + "columns": [ + { + "expression": "issueID", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_issueID_fkey": { + "name": "comment_issueID_fkey", + "tableFrom": "comment", + "tableTo": "issue", + "columnsFrom": [ + "issueID" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_creatorID_fkey": { + "name": "comment_creatorID_fkey", + "tableFrom": "comment", + "tableTo": "user", + "columnsFrom": [ + "creatorID" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emoji": { + "name": "emoji", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "annotation": { + "name": "annotation", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "subjectID": { + "name": "subjectID", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "creatorID": { + "name": "creatorID", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "double precision", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(epoch FROM CURRENT_TIMESTAMP) * (1000)::numeric)" + } + }, + "indexes": { + "emoji_created_idx": { + "name": "emoji_created_idx", + "columns": [ + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emoji_subject_id_idx": { + "name": "emoji_subject_id_idx", + "columns": [ + { + "expression": "subjectID", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "emoji_creatorID_fkey": { + "name": "emoji_creatorID_fkey", + "tableFrom": "emoji", + "tableTo": "user", + "columnsFrom": [ + "creatorID" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "emoji_subjectID_creatorID_value_key": { + "name": "emoji_subjectID_creatorID_value_key", + "nullsNotDistinct": false, + "columns": [ + "value", + "subjectID", + "creatorID" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue": { + "name": "issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "shortID": { + "name": "shortID", + "type": "integer", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "issue_shortID_seq", + "schema": "public", + "increment": "1", + "startWith": "3000", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "open": { + "name": "open", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "modified": { + "name": "modified", + "type": "double precision", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(epoch FROM CURRENT_TIMESTAMP) * (1000)::numeric)" + }, + "created": { + "name": "created", + "type": "double precision", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(epoch FROM CURRENT_TIMESTAMP) * (1000)::numeric)" + }, + "projectID": { + "name": "projectID", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'iCNlS2qEpzYWEes1RTf-D'" + }, + "creatorID": { + "name": "creatorID", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "assigneeID": { + "name": "assigneeID", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "varchar(10240)", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'public'" + } + }, + "indexes": { + "issue_project_idx": { + "name": "issue_project_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "projectID", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_shortID_idx": { + "name": "issue_shortID_idx", + "columns": [ + { + "expression": "shortID", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_created_idx": { + "name": "issue_created_idx", + "columns": [ + { + "expression": "created", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_projectID_open_assigneeID_modified_idx": { + "name": "issue_projectID_open_assigneeID_modified_idx", + "columns": [ + { + "expression": "projectID", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "open", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assigneeID", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "modified", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_projectID_open_creatorID_modified_idx": { + "name": "issue_projectID_open_creatorID_modified_idx", + "columns": [ + { + "expression": "projectID", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "open", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "creatorID", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "modified", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_projectID_open_modified_idx": { + "name": "issue_projectID_open_modified_idx", + "columns": [ + { + "expression": "projectID", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "open", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "modified", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_projectID_assigneeID_modified_idx": { + "name": "issue_projectID_assigneeID_modified_idx", + "columns": [ + { + "expression": "projectID", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assigneeID", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "modified", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_projectID_creatorID_modified_idx": { + "name": "issue_projectID_creatorID_modified_idx", + "columns": [ + { + "expression": "projectID", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "creatorID", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "modified", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_projectID_modified_idx": { + "name": "issue_projectID_modified_idx", + "columns": [ + { + "expression": "projectID", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "modified", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_creatorID_idx": { + "name": "issue_creatorID_idx", + "columns": [ + { + "expression": "creatorID", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_assigneeID_idx": { + "name": "issue_assigneeID_idx", + "columns": [ + { + "expression": "assigneeID", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_projectID_fkey": { + "name": "issue_projectID_fkey", + "tableFrom": "issue", + "tableTo": "project", + "columnsFrom": [ + "projectID" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_creatorID_fkey": { + "name": "issue_creatorID_fkey", + "tableFrom": "issue", + "tableTo": "user", + "columnsFrom": [ + "creatorID" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_assigneeID_fkey": { + "name": "issue_assigneeID_fkey", + "tableFrom": "issue", + "tableTo": "user", + "columnsFrom": [ + "assigneeID" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issueLabel": { + "name": "issueLabel", + "schema": "", + "columns": { + "labelID": { + "name": "labelID", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "issueID": { + "name": "issueID", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "projectID": { + "name": "projectID", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'iCNlS2qEpzYWEes1RTf-D'" + } + }, + "indexes": { + "issuelabel_issueid_idx": { + "name": "issuelabel_issueid_idx", + "columns": [ + { + "expression": "issueID", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issueLabel_labelID_projectID_fkey": { + "name": "issueLabel_labelID_projectID_fkey", + "tableFrom": "issueLabel", + "tableTo": "label", + "columnsFrom": [ + "labelID", + "projectID" + ], + "columnsTo": [ + "id", + "projectID" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issueLabel_issueID_projectID_fkey": { + "name": "issueLabel_issueID_projectID_fkey", + "tableFrom": "issueLabel", + "tableTo": "issue", + "columnsFrom": [ + "issueID", + "projectID" + ], + "columnsTo": [ + "id", + "projectID" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issueLabel_pkey": { + "name": "issueLabel_pkey", + "columns": [ + "labelID", + "issueID" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issueNotifications": { + "name": "issueNotifications", + "schema": "", + "columns": { + "userID": { + "name": "userID", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "issueID": { + "name": "issueID", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "subscribed": { + "name": "subscribed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created": { + "name": "created", + "type": "double precision", + "primaryKey": false, + "notNull": false, + "default": "(EXTRACT(epoch FROM CURRENT_TIMESTAMP) * (1000)::numeric)" + } + }, + "indexes": {}, + "foreignKeys": { + "issueNotifications_userID_fkey": { + "name": "issueNotifications_userID_fkey", + "tableFrom": "issueNotifications", + "tableTo": "user", + "columnsFrom": [ + "userID" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issueNotifications_issueID_fkey": { + "name": "issueNotifications_issueID_fkey", + "tableFrom": "issueNotifications", + "tableTo": "issue", + "columnsFrom": [ + "issueID" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issueNotifications_pkey": { + "name": "issueNotifications_pkey", + "columns": [ + "userID", + "issueID" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.label": { + "name": "label", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "projectID": { + "name": "projectID", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'iCNlS2qEpzYWEes1RTf-D'" + } + }, + "indexes": { + "label_project_idx": { + "name": "label_project_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "projectID", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "label_name_project_idx": { + "name": "label_name_project_idx", + "columns": [ + { + "expression": "projectID", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "label_projectID_fkey": { + "name": "label_projectID_fkey", + "tableFrom": "label", + "tableTo": "project", + "columnsFrom": [ + "projectID" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "lowerCaseName": { + "name": "lowerCaseName", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "issueCountEstimate": { + "name": "issueCountEstimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "supportsSearch": { + "name": "supportsSearch", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "markURL": { + "name": "markURL", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "logoURL": { + "name": "logoURL", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_lower_case_name_idx": { + "name": "project_lower_case_name_idx", + "columns": [ + { + "expression": "lowerCaseName", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "login": { + "name": "login", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "githubID": { + "name": "githubID", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_githubid_idx": { + "name": "user_githubid_idx", + "columns": [ + { + "expression": "githubID", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_login_idx": { + "name": "user_login_idx", + "columns": [ + { + "expression": "login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.userPref": { + "name": "userPref", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "userID": { + "name": "userID", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "userPref_userID_fkey": { + "name": "userPref_userID_fkey", + "tableFrom": "userPref", + "tableTo": "user", + "columnsFrom": [ + "userID" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "userPref_pkey": { + "name": "userPref_pkey", + "columns": [ + "key", + "userID" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.viewState": { + "name": "viewState", + "schema": "", + "columns": { + "userID": { + "name": "userID", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "issueID": { + "name": "issueID", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "viewed": { + "name": "viewed", + "type": "double precision", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewState_userID_fkey": { + "name": "viewState_userID_fkey", + "tableFrom": "viewState", + "tableTo": "user", + "columnsFrom": [ + "userID" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "viewState_issueID_fkey": { + "name": "viewState_issueID_fkey", + "tableFrom": "viewState", + "tableTo": "issue", + "columnsFrom": [ + "issueID" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "viewState_pkey": { + "name": "viewState_pkey", + "columns": [ + "userID", + "issueID" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/zbugs/db/migrations/meta/_journal.json b/apps/zbugs/db/migrations/meta/_journal.json index 1e889aaa19..528d447e7e 100644 --- a/apps/zbugs/db/migrations/meta/_journal.json +++ b/apps/zbugs/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1762890350511, "tag": "0006_exotic_wither", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1770409020281, + "tag": "0007_red_omega_sentinel", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/zbugs/env.d.ts b/apps/zbugs/env.d.ts index c0735685cb..9ca3e3b335 100644 --- a/apps/zbugs/env.d.ts +++ b/apps/zbugs/env.d.ts @@ -3,6 +3,7 @@ interface ImportMetaEnv { readonly VITE_PUBLIC_SERVER: string; + readonly VITEST?: string; // more env variables... } diff --git a/apps/zbugs/oxlint.config.ts b/apps/zbugs/oxlint.config.ts new file mode 100644 index 0000000000..212172c0de --- /dev/null +++ b/apps/zbugs/oxlint.config.ts @@ -0,0 +1,20 @@ +import {defineConfig} from 'oxlint'; +import {baseConfig} from '../../oxlint.base.ts'; + +/** + * Zbugs-specific oxlint configuration. + * This is a nested config (relative to the mono root oxlint.config.ts), + */ +export default defineConfig({ + ...baseConfig, + plugins: [...baseConfig.plugins, 'react'], + rules: { + ...baseConfig.rules, + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', + }, + env: { + builtin: true, + browser: true, + }, +}); diff --git a/apps/zbugs/package.json b/apps/zbugs/package.json index ee4bacf99a..9615aa0aac 100644 --- a/apps/zbugs/package.json +++ b/apps/zbugs/package.json @@ -1,7 +1,7 @@ { "name": "zbugs", - "private": true, "version": "0.0.0", + "private": true, "type": "module", "scripts": { "dev": "vite", @@ -12,27 +12,30 @@ "db-up": "cd docker && docker compose up", "db-down": "cd docker && docker compose down", "db-migrate": "drizzle-kit migrate", - "db-seed": "tsx scripts/seed.ts", - "generate-templates": "tsx scripts/generate-templates.ts", - "generate-synthetic": "tsx scripts/generate-synthetic.ts", - "db-seed-synthetic": "ZERO_SEED_DATA_DIR=db/seed-data/synthetic tsx scripts/seed.ts", + "db-seed": "node scripts/seed.ts", + "generate-templates": "node scripts/generate-templates.ts", + "generate-synthetic": "NODE_OPTIONS='--max-old-space-size=8192' node scripts/generate-synthetic.ts", + "db-seed-synthetic": "ZERO_SEED_DATA_DIR=db/seed-data/synthetic node scripts/seed.ts", "clean": "cd docker && docker compose down && docker volume rm docker_zbugs_pgdata_sync && docker volume rm docker_zbugs_pgdata_upstream && rm -rf /tmp/zbugs-sync-replica.db*", - "format": "prettier --write .", - "check-format": "prettier --check .", - "lint": "oxlint --type-aware --react-plugin src/", + "format": "oxfmt --config ../../.oxfmtrc.json .", + "check-format": "oxfmt --config ../../.oxfmtrc.json --check .", + "lint": "oxlint --quiet --config ../../oxlint.config.ts src/", "preview": "vite preview", - "zero": "tsx --trace-warnings ../../packages/zero-cache/src/server/runner/main.ts", - "zero-cache-dev": "tsx ../../packages/zero/src/zero-cache-dev.ts", - "zero-brk": "tsx --inspect-brk ../../packages/zero-cache/src/server/debug/single.ts", - "transform-query": "tsx ../../packages/zero-cache/src/scripts/transform-query.ts", - "run-query": "tsx ../../packages/zero-cache/src/scripts/run-query.ts", - "run-query-brk": "tsx --inspect-brk ../../packages/zero-cache/src/scripts/run-query.ts", - "create-keys": "tsx ../../packages/zero-cache/src/scripts/create-jwk.ts", - "test": "cd ../../packages/zero && npm run build && cd ../../apps/zbugs && vitest run --config vitest.config.ts --project zbugs/no-pg", + "zero": "node --trace-warnings ../../packages/zero-cache/src/server/runner/main.ts", + "zero-cache-dev": "node ../../packages/zero/src/zero-cache-dev.ts", + "zero-brk": "node --inspect-brk ../../packages/zero-cache/src/server/debug/single.ts", + "transform-query": "node ../../packages/zero-cache/src/scripts/transform-query.ts", + "run-query": "node ../../packages/zero-cache/src/scripts/run-query.ts", + "run-query-brk": "node --inspect-brk ../../packages/zero-cache/src/scripts/run-query.ts", + "create-keys": "node ../../packages/zero-cache/src/scripts/create-jwk.ts", + "analyze-query": "node scripts/analyze.ts", + "test": "cd ../../packages/zero && pnpm run build && cd ../../apps/zbugs && vitest run --config vitest.config.ts --project zbugs/no-pg", "test:watch": "vitest --config vitest.config.ts --project zbugs/no-pg", "analyze": "analyze -c vite.config.ts", "test:pg": "vitest run --config vitest.config.ts --project zbugs/pg-16", - "test:pg:watch": "vitest --config vitest.config.ts --project zbugs/pg-16" + "test:pg:watch": "vitest --config vitest.config.ts --project zbugs/pg-16", + "fmt": "oxfmt .", + "check-fmt": "oxfmt --check ." }, "dependencies": { "@aws-sdk/client-s3": "^3.844.0", @@ -44,9 +47,10 @@ "@floating-ui/react": "^0.26.28", "@headlessui/react": "^2.1.8", "@octokit/core": "^6.1.2", - "@rocicorp/zero": "0.26.0", + "@rocicorp/zero": "workspace:*", + "@rocicorp/zero-virtual": "^0.1.1", "@schickling/fps-meter": "^0.1.2", - "@tanstack/react-virtual": "^3.10.9", + "@tanstack/react-virtual": "^3.13.19", "classnames": "^2.5.1", "emoji-picker-element": "^1.22.8", "emoji-picker-element-data": "^1.6.1", @@ -65,37 +69,35 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", "use-debounce": "^10.0.4", "usehooks-ts": "^3.1.0", "wouter": "^3.7.1", "zod": "^4.1.11" }, "devDependencies": { - "@rocicorp/prettier-config": "^0.4.0", "@tailwindcss/forms": "^0.5.0", + "@types/hast": "^3.0.4", "@types/js-cookie": "^3.0.6", "@types/k6": "^0.53.2", "@types/node": "^22.10.5", - "@types/pg": "^8.11.11", + "@types/pg": "^8.20.0", "@types/react": "^18.3.18", "@types/react-dom": "18.3.5", - "@vitejs/plugin-react": "^5.1.2", - "@vitest/runner": "4.0.15", - "drizzle-kit": "^0.31.4", - "drizzle-orm": "^0.44.2", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/runner": "^4.1.6", + "drizzle-kit": "^0.31.10", + "drizzle-orm": "^0.45.2", + "oxfmt": "^0.45.0", "postcss-preset-env": "^7.4.3", - "prettier": "^3.5.3", "rehype-video": "^2.3.0", - "shared": "0.0.0", + "shared": "workspace:*", "tailwindcss": "^3.0.23", - "tsx": "^4.19.1", - "typescript": "~5.9.3", - "universal-user-agent": "^7.0.2", - "vite": "7.2.7", - "vite-bundle-analyzer": "^1.3.1", - "vite-plugin-svgr": "^4.5.0", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "4.0.15" - }, - "prettier": "@rocicorp/prettier-config" + "typescript": "~6.0.2", + "vite": "^8.0.13", + "vite-bundle-analyzer": "^1.3.8", + "vite-plugin-svgr": "^5.2.0", + "vitest": "^4.1.6" + } } diff --git a/apps/zbugs/playwright/Dockerfile b/apps/zbugs/playwright/Dockerfile index c27cccbb06..8f05d6edd3 100644 --- a/apps/zbugs/playwright/Dockerfile +++ b/apps/zbugs/playwright/Dockerfile @@ -13,7 +13,7 @@ RUN echo '{ \ }' > package.json # Install only Playwright and its dependencies -RUN npm install +RUN pnpm install # Copy your Playwright test scripts (adjust to your actual directory structure) COPY *.spec.ts . diff --git a/apps/zbugs/playwright/README.md b/apps/zbugs/playwright/README.md index 08d7d7b060..960b5e417b 100644 --- a/apps/zbugs/playwright/README.md +++ b/apps/zbugs/playwright/README.md @@ -1,6 +1,6 @@ # Running playwright tests locally -- Add ZERO_AUTH_JWK. Set this to the public key that is output by `npm run create-keys` in `zbugs` +- Add ZERO_AUTH_JWK. Set this to the public key that is output by `pnpm run create-keys` in `zbugs` ## Push docker container for AWS batch diff --git a/apps/zbugs/playwright/playwright.config.ts b/apps/zbugs/playwright/playwright.config.ts index 6bb79de298..421a4cc9cc 100644 --- a/apps/zbugs/playwright/playwright.config.ts +++ b/apps/zbugs/playwright/playwright.config.ts @@ -76,7 +76,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ // webServer: { - // command: 'npm run start', + // command: 'pnpm run start', // url: 'http://127.0.0.1:3000', // reuseExistingServer: !process.env.CI, // }, diff --git a/apps/zbugs/scripts/analyze.ts b/apps/zbugs/scripts/analyze.ts new file mode 100644 index 0000000000..1e3ae24a45 --- /dev/null +++ b/apps/zbugs/scripts/analyze.ts @@ -0,0 +1,6 @@ +import '../../../packages/shared/src/dotenv.ts'; + +import {runAnalyzeCLI} from '../../../packages/zero/src/analyze.ts'; +import {schema} from '../shared/schema.ts'; + +await runAnalyzeCLI({schema}); diff --git a/apps/zbugs/scripts/generate-synthetic.ts b/apps/zbugs/scripts/generate-synthetic.ts index 0e596a751f..27ed858f19 100644 --- a/apps/zbugs/scripts/generate-synthetic.ts +++ b/apps/zbugs/scripts/generate-synthetic.ts @@ -5,7 +5,7 @@ * based on configuration parameters and LLM-generated templates. * * Usage: - * npx tsx scripts/generate-synthetic.ts + * node scripts/generate-synthetic.ts * * Env vars: * NUM_ISSUES - total issues to generate (default 1000000) @@ -16,6 +16,7 @@ * SHARD_SIZE - rows per CSV shard (default 500000) * OUTPUT_DIR - output directory (default db/seed-data/synthetic/) * SEED - RNG seed for reproducibility (default 42) + * SKIP_USERS - when "true", skip user CSV generation (default false) */ import * as fs from 'fs'; @@ -35,22 +36,47 @@ const OUTPUT_DIR = path.resolve( process.env.OUTPUT_DIR ?? path.join(__dirname, '../db/seed-data/synthetic/'), ); const SEED = parseInt(process.env.SEED ?? '42', 10); +const SKIP_USERS = process.env.SKIP_USERS === 'true'; const TEMPLATES_DIR = path.join(__dirname, '../db/seed-data/templates'); -// --- Seeded PRNG (mulberry32) --- -function mulberry32(seed: number): () => number { - let s = seed | 0; +// --- Seeded PRNG (xoshiro128** — 128-bit state, period 2^128-1) --- +// SplitMix32 expands a single 32-bit seed into the 4×32-bit xoshiro state. +function xoshiro128ss(seed: number): () => number { + // SplitMix32 to initialize state + let z = seed | 0; + function splitmix32(): number { + z = (z + 0x9e3779b9) | 0; + let t = z ^ (z >>> 16); + t = Math.imul(t, 0x21f0aaad); + t = t ^ (t >>> 15); + t = Math.imul(t, 0x735a2d97); + t = t ^ (t >>> 15); + return t >>> 0; + } + let s0 = splitmix32(); + let s1 = splitmix32(); + let s2 = splitmix32(); + let s3 = splitmix32(); + return () => { - s = (s + 0x6d2b79f5) | 0; - let t = Math.imul(s ^ (s >>> 15), 1 | s); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + const result = Math.imul(s1 * 5, 9); + const r = (((result << 7) | (result >>> 25)) * 9) >>> 0; + + const t = s1 << 9; + s2 ^= s0; + s3 ^= s1; + s1 ^= s2; + s0 ^= s3; + s2 ^= t; + s3 = ((s3 << 11) | (s3 >>> 21)) >>> 0; + + return r / 4294967296; }; } -const rng = mulberry32(SEED); +const rng = xoshiro128ss(SEED); -// Seed faker for reproducibility (use same SEED as mulberry32) +// Seed faker for reproducibility (use same SEED as xoshiro128ss) faker.seed(SEED); function randomInt(min: number, max: number): number { @@ -61,39 +87,41 @@ function pick(arr: readonly T[]): T { return arr[Math.floor(rng() * arr.length)]; } -// --- Pareto/Power-law distribution helpers --- -/** - * Assigns numItems to buckets using power-law distribution. - * Lower alpha = more uniform, higher alpha = more skewed. - * Returns array where result[i] = bucket index for item i. - */ -function assignWithPareto( - numItems: number, - numBuckets: number, - alpha = 2, -): number[] { - const result = new Array(numItems); - for (let i = 0; i < numItems; i++) { - result[i] = Math.floor(Math.pow(rng(), alpha) * numBuckets); +// --- Seeded nanoid --- +const NANOID_ALPHABET = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-'; + +function seededNanoid(size = 21): string { + let id = ''; + for (let i = 0; i < size; i++) { + id += NANOID_ALPHABET[Math.floor(rng() * NANOID_ALPHABET.length)]; } - return result; + return id; } -/** - * Distributes totalItems across numBuckets with Pareto distribution. - * Returns array where result[i] = count for bucket i. - */ -function distributeWithPareto( - numBuckets: number, - totalItems: number, - alpha = 2, -): number[] { - const result = new Array(numBuckets).fill(0); - for (let i = 0; i < totalItems; i++) { - const idx = Math.floor(Math.pow(rng(), alpha) * numBuckets); - result[idx]++; +// --- Deterministic per-index helpers (avoid large pre-allocated arrays) --- + +/** Derive a deterministic issue ID for a given index without storing all IDs. */ +function issueIDForIndex(i: number): string { + const idRng = xoshiro128ss(SEED ^ (i * 2654435761)); // Knuth multiplicative hash + let id = ''; + for (let j = 0; j < 21; j++) { + id += NANOID_ALPHABET[Math.floor(idRng() * NANOID_ALPHABET.length)]; } - return result; + return id; +} + +/** Derive the project index for a given issue index (Pareto distribution). */ +function projectForIssue(i: number, numBuckets: number, alpha = 2): number { + const r = xoshiro128ss(SEED ^ ((i + 1) * 2246822519))(); // single draw + return Math.floor(Math.pow(r, alpha) * numBuckets); +} + +/** Derive the comment count for a given issue index (exponential distribution matching mean). */ +function commentsForIssue(i: number, mean: number): number { + const r = xoshiro128ss(SEED ^ ((i + 2) * 3266489917))(); // single draw + // Exponential distribution: -mean * ln(1 - r), floored + return Math.floor(-mean * Math.log(1 - r * 0.9999)); } // --- Template types --- @@ -439,12 +467,12 @@ async function main() { for (let pi = 0; pi < projectCount; pi++) { if (allProjects.length >= NUM_PROJECTS) break; const proj = cat.projects[pi]; - const projectID = `proj_${String(allProjects.length).padStart(3, '0')}`; + const projectID = seededNanoid(); const labelIDs: string[] = []; // Generate labels for this project for (let li = 0; li < cat.labels.length; li++) { - const labelID = `lbl_${projectID}_${String(li).padStart(2, '0')}`; + const labelID = seededNanoid(); allLabels.push({ id: labelID, name: cat.labels[li], @@ -491,27 +519,32 @@ async function main() { }); } - // Write users CSV - const userWriter = new ShardedCSVWriter( - OUTPUT_DIR, - 'user', - 'id,login,name,avatar,role,githubID,email', - SHARD_SIZE, - ); - for (const u of users) { - await userWriter.writeRow( - [ - u.id, - u.login, - escapeCSV(u.name), - u.avatar, - u.role, - String(u.githubID), - u.email, - ].join(','), + // Write users CSV (skip if SKIP_USERS=true, e.g. when appending a batch) + if (SKIP_USERS) { + // oxlint-disable-next-line no-console + console.log(' Skipping user CSV generation (SKIP_USERS=true)'); + } else { + const userWriter = new ShardedCSVWriter( + OUTPUT_DIR, + 'user', + 'id,login,name,avatar,role,githubID,email', + SHARD_SIZE, ); + for (const u of users) { + await userWriter.writeRow( + [ + u.id, + u.login, + escapeCSV(u.name), + u.avatar, + u.role, + String(u.githubID), + u.email, + ].join(','), + ); + } + await userWriter.close(); } - await userWriter.close(); // Write projects CSV // oxlint-disable-next-line no-console @@ -545,20 +578,6 @@ async function main() { } await labelWriter.close(); - // --- Pre-compute distributions --- - // oxlint-disable-next-line no-console - console.log('\nPre-computing distributions...'); - - // Assign issues to projects using Pareto distribution (some projects get many more issues) - const issueProjects = assignWithPareto(NUM_ISSUES, allProjects.length, 2); - - // Distribute comments across issues using Pareto distribution (some issues get many comments) - const totalComments = Math.floor(NUM_ISSUES * COMMENTS_PER_ISSUE); - const commentsPerIssue = distributeWithPareto(NUM_ISSUES, totalComments, 2); - - // Calculate total issueLabels (average LABELS_PER_ISSUE per issue) - const totalIssueLabels = Math.floor(NUM_ISSUES * LABELS_PER_ISSUE); - // --- Generate issues --- // oxlint-disable-next-line no-console console.log(`\nGenerating ${NUM_ISSUES.toLocaleString()} issues...`); @@ -586,8 +605,8 @@ async function main() { ); } - const issueID = `iss_${String(i).padStart(9, '0')}`; - const projectIdx = issueProjects[i]; + const issueID = issueIDForIndex(i); + const projectIdx = projectForIssue(i, allProjects.length, 2); const project = allProjects[projectIdx]; const cat = categories[project.categoryIndex]; @@ -600,8 +619,9 @@ async function main() { const descTemplate = cat.descriptionTemplates[(i * 3) % cat.descriptionTemplates.length]; let description = fillTemplate(descTemplate, project.components); - if (description.length > 10240) + if (description.length > 10240) { description = description.slice(0, 10237) + '...'; + } // Timestamps - use faker for realistic date spread const created = faker.date @@ -637,9 +657,7 @@ async function main() { // --- Generate comments --- // oxlint-disable-next-line no-console - console.log( - `\nGenerating ${totalComments.toLocaleString()} comments (avg ${COMMENTS_PER_ISSUE} per issue)...`, - ); + console.log(`\nGenerating comments (avg ${COMMENTS_PER_ISSUE} per issue)...`); const commentWriter = new ShardedCSVWriter( OUTPUT_DIR, @@ -657,12 +675,12 @@ async function main() { ); } - const issueID = `iss_${String(issueIdx).padStart(9, '0')}`; - const projectIdx = issueProjects[issueIdx]; + const issueID = issueIDForIndex(issueIdx); + const projectIdx = projectForIssue(issueIdx, allProjects.length, 2); const project = allProjects[projectIdx]; const cat = categories[project.categoryIndex]; - const numComments = commentsPerIssue[issueIdx]; + const numComments = commentsForIssue(issueIdx, COMMENTS_PER_ISSUE); // Issue created timestamp (use faker for realistic spread) const issueCreated = faker.date @@ -670,7 +688,8 @@ async function main() { .getTime(); for (let c = 0; c < numComments; c++) { - const commentID = `cmt_${String(commentIdx++).padStart(9, '0')}`; + const commentID = seededNanoid(); + commentIdx++; // Generate body from template const bodyTemplate = @@ -697,7 +716,7 @@ async function main() { // --- Generate issueLabels --- // oxlint-disable-next-line no-console console.log( - `\nGenerating ~${totalIssueLabels.toLocaleString()} issueLabels (avg ${LABELS_PER_ISSUE} per issue)...`, + `\nGenerating issueLabels (avg ${LABELS_PER_ISSUE} per issue)...`, ); const issueLabelWriter = new ShardedCSVWriter( @@ -716,8 +735,8 @@ async function main() { ); } - const issueID = `iss_${String(issueIdx).padStart(9, '0')}`; - const projectIdx = issueProjects[issueIdx]; + const issueID = issueIDForIndex(issueIdx); + const projectIdx = projectForIssue(issueIdx, allProjects.length, 2); const project = allProjects[projectIdx]; // Uniform distribution: 1-3 labels per issue (or fewer if project has fewer labels) diff --git a/apps/zbugs/scripts/generate-templates.ts b/apps/zbugs/scripts/generate-templates.ts index ab0d6ef18c..10c083deba 100644 --- a/apps/zbugs/scripts/generate-templates.ts +++ b/apps/zbugs/scripts/generate-templates.ts @@ -1,3 +1,4 @@ +// oxlint-disable e18e/prefer-static-regex /** * Generates parameterized template pools via Claude API for synthetic data generation. * @@ -10,7 +11,7 @@ * - 150 comment templates × ~50K slot combinations = 7.5M unique comments per project * * Usage: - * ANTHROPIC_API_KEY=sk-... npx tsx scripts/generate-templates.ts + * ANTHROPIC_API_KEY=sk-... node scripts/generate-templates.ts * * Env vars: * ANTHROPIC_API_KEY - required diff --git a/apps/zbugs/scripts/seed.ts b/apps/zbugs/scripts/seed.ts index c464177085..a5a2302d34 100644 --- a/apps/zbugs/scripts/seed.ts +++ b/apps/zbugs/scripts/seed.ts @@ -1,11 +1,11 @@ import '../../../packages/shared/src/dotenv.ts'; import * as fs from 'fs'; -import * as readline from 'readline'; import {dirname, join} from 'path'; -import postgres from 'postgres'; +import * as readline from 'readline'; import {pipeline} from 'stream/promises'; import {fileURLToPath} from 'url'; +import postgres from 'postgres'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -22,8 +22,10 @@ const TABLE_CSV_FILE_REGEX = `^(${TABLES_IN_SEED_ORDER.join('|')})(_.*)?.csv$`; // Types for dynamically discovered schema objects interface IndexInfo { + tableName: string; indexName: string; indexDdl: string; + constraintName: string | null; } interface ForeignKeyInfo { @@ -37,6 +39,62 @@ interface TriggerInfo { triggerName: string; } +// --- Progress tracking helpers --- + +async function ensureProgressTable(sql: postgres.Sql): Promise { + await sql` + CREATE TABLE IF NOT EXISTS _seed_progress ( + key TEXT PRIMARY KEY, + value TEXT, + created_at TIMESTAMPTZ DEFAULT now() + ) + `; +} + +async function hasProgress(sql: postgres.Sql, key: string): Promise { + const rows = await sql` + SELECT 1 FROM _seed_progress WHERE key = ${key} + `; + return rows.length > 0; +} + +async function getProgress( + sql: postgres.Sql, + key: string, +): Promise { + const rows = await sql<{value: string}[]>` + SELECT value FROM _seed_progress WHERE key = ${key} + `; + return rows.length > 0 ? rows[0].value : undefined; +} + +async function setProgress( + sql: postgres.Sql, + key: string, + value: string, +): Promise { + await sql` + INSERT INTO _seed_progress (key, value) + VALUES (${key}, ${value}) + ON CONFLICT (key) DO UPDATE SET value = ${value} + `; +} + +async function clearProgress(sql: postgres.Sql): Promise { + await sql`DELETE FROM _seed_progress`; +} + +async function dropProgressTable(sql: postgres.Sql): Promise { + await sql`DROP TABLE IF EXISTS _seed_progress`; +} + +async function hasAnyProgress(sql: postgres.Sql): Promise { + const rows = await sql`SELECT 1 FROM _seed_progress LIMIT 1`; + return rows.length > 0; +} + +// --- Schema discovery --- + /** * Discover all non-primary-key indexes for the given tables. */ @@ -46,23 +104,34 @@ async function discoverIndexes( ): Promise { const results: IndexInfo[] = []; for (const table of tables) { - const rows = await sql<{indexname: string; index_ddl: string}[]>` + const rows = await sql< + { + indexname: string; + index_ddl: string; + constraint_name: string | null; + }[] + >` SELECT pg_indexes.indexname, - pg_get_indexdef(pg_class.oid) as index_ddl + pg_get_indexdef(pg_class.oid) as index_ddl, + pg_constraint.conname as constraint_name FROM pg_indexes JOIN pg_namespace ON pg_indexes.schemaname = pg_namespace.nspname JOIN pg_class ON pg_class.relname = pg_indexes.indexname AND pg_class.relnamespace = pg_namespace.oid JOIN pg_index ON pg_index.indexrelid = pg_class.oid + LEFT JOIN pg_constraint ON pg_constraint.conindid = pg_class.oid + AND pg_constraint.contype IN ('u', 'p') WHERE pg_indexes.tablename = ${table} AND pg_indexes.schemaname = 'public' AND pg_index.indisprimary = FALSE `; for (const row of rows) { results.push({ + tableName: table, indexName: row.indexname, indexDdl: row.index_ddl, + constraintName: row.constraint_name, }); } } @@ -125,16 +194,26 @@ async function discoverTriggers( return results; } +function parseBoolEnv(value: string | undefined): boolean { + return ( + value !== undefined && + ['t', 'true', '1', ''].includes(value.toLocaleLowerCase().trim()) + ); +} + async function seed() { const dataDir = process.env.ZERO_SEED_DATA_DIR ?? join(__dirname, '../db/seed-data/github/'); - const forceSeed = - process.env.ZERO_SEED_FORCE !== undefined && - ['t', 'true', '1', ''].indexOf( - process.env.ZERO_SEED_FORCE.toLocaleLowerCase().trim(), - ) !== -1; + const forceSeed = parseBoolEnv(process.env.ZERO_SEED_FORCE); + const resetSeed = parseBoolEnv(process.env.ZERO_SEED_RESET); + + const appendMode = + process.env.ZERO_SEED_APPEND !== undefined && + ['t', 'true', '1', ''].includes( + process.env.ZERO_SEED_APPEND.toLocaleLowerCase().trim(), + ); // oxlint-disable-next-line no-console console.log(process.env.ZERO_UPSTREAM_DB); @@ -161,91 +240,161 @@ async function seed() { process.exit(0); } - // Check if already seeded - if (!forceSeed) { + // Ensure progress table exists + await ensureProgressTable(sql); + + // Handle ZERO_SEED_RESET: clear progress and start fresh + if (resetSeed) { + // oxlint-disable-next-line no-console + console.log('Reset mode: clearing progress and starting fresh...'); + await clearProgress(sql); + } + + // Detect mode: resume vs fresh + const resuming = await hasAnyProgress(sql); + if (resuming) { + // oxlint-disable-next-line no-console + console.log('Resuming previous seed run...'); + } + + // Check if already seeded (only for fresh, non-force, non-append runs) + if (!resuming && !forceSeed && !appendMode) { const result = await sql`select 1 from "user" limit 1`; if (result.length === 1) { // oxlint-disable-next-line no-console console.log('Database already seeded.'); + await dropProgressTable(sql); process.exit(0); } } - // If forcing, truncate existing data - if (forceSeed) { - // oxlint-disable-next-line no-console - console.log('Force mode: truncating existing data...'); - // Truncate in reverse order to respect foreign key dependencies - for (const tableName of [...TABLES_IN_SEED_ORDER].reverse()) { - await sql`TRUNCATE ${sql(tableName)} CASCADE`; + // --- FRESH: truncate and discover schema --- + if (!resuming) { + // If forcing (not appending), truncate existing data + if (forceSeed && !appendMode) { + // oxlint-disable-next-line no-console + console.log('Force mode: truncating existing data...'); + for (const tableName of TABLES_IN_SEED_ORDER.toReversed()) { + const exists = await sql` + SELECT 1 FROM pg_tables + WHERE schemaname = 'public' AND tablename = ${tableName} + `; + if (exists.length > 0) { + await sql`TRUNCATE ${sql(tableName)} CASCADE`; + } + } } + await setProgress(sql, 'phase:truncated', 'done'); + + // Discover all tables in public schema for comprehensive discovery + const allTablesResult = await sql<{tablename: string}[]>` + SELECT tablename FROM pg_tables WHERE schemaname = 'public' + `; + const allTables = allTablesResult + .map(r => r.tablename) + .filter(t => t !== '_seed_progress'); + + // Discover schema objects and persist to progress table + // oxlint-disable-next-line no-console + console.log('Discovering schema objects...'); + const [indexes, foreignKeys, triggers] = await Promise.all([ + discoverIndexes(sql, allTables), + discoverForeignKeys(sql), + discoverTriggers(sql, allTables), + ]); + // oxlint-disable-next-line no-console + console.log( + ` Found ${indexes.length} indexes, ${foreignKeys.length} FKs, ${triggers.length} triggers`, + ); + + await setProgress(sql, 'schema:indexes', JSON.stringify(indexes)); + await setProgress(sql, 'schema:foreignKeys', JSON.stringify(foreignKeys)); + await setProgress(sql, 'schema:triggers', JSON.stringify(triggers)); } - // Discover all tables in public schema for comprehensive discovery - const allTablesResult = await sql<{tablename: string}[]>` - SELECT tablename FROM pg_tables WHERE schemaname = 'public' - `; - const allTables = allTablesResult.map(r => r.tablename); + if (appendMode) { + // oxlint-disable-next-line no-console + console.log('Append mode: adding data to existing tables...'); + } - // Discover schema objects - // oxlint-disable-next-line no-console - console.log('Discovering schema objects...'); - const [indexes, foreignKeys, triggers] = await Promise.all([ - discoverIndexes(sql, allTables), - discoverForeignKeys(sql), - discoverTriggers(sql, allTables), - ]); - // oxlint-disable-next-line no-console - console.log( - ` Found ${indexes.length} indexes, ${foreignKeys.length} FKs, ${triggers.length} triggers`, + // --- Load schema objects (from progress table for both fresh and resume) --- + const indexes: IndexInfo[] = JSON.parse( + (await getProgress(sql, 'schema:indexes')) ?? '[]', + ); + const foreignKeys: ForeignKeyInfo[] = JSON.parse( + (await getProgress(sql, 'schema:foreignKeys')) ?? '[]', + ); + const triggers: TriggerInfo[] = JSON.parse( + (await getProgress(sql, 'schema:triggers')) ?? '[]', ); - // Set memory parameters for faster index rebuilds - // oxlint-disable-next-line no-console - console.log('Setting memory parameters...'); - await sql`SET maintenance_work_mem = '2GB'`; - await sql`SET work_mem = '256MB'`; + if (resuming) { + // oxlint-disable-next-line no-console + console.log( + ` Loaded ${indexes.length} indexes, ${foreignKeys.length} FKs, ${triggers.length} triggers from progress`, + ); + } - // Disable all triggers - // oxlint-disable-next-line no-console - console.log('Disabling triggers...'); - for (const {tableName, triggerName} of triggers) { - try { - await sql`ALTER TABLE ${sql(tableName)} DISABLE TRIGGER ${sql.unsafe('"' + triggerName + '"')}`; - } catch (e) { - // oxlint-disable-next-line no-console - console.log( - ` Warning: could not disable trigger ${triggerName} on ${tableName}: ${e}`, - ); + // --- Drop schema objects (skip if already done) --- + if (!(await hasProgress(sql, 'phase:schema_dropped'))) { + // Disable all triggers + // oxlint-disable-next-line no-console + console.log('Disabling triggers...'); + for (const {tableName, triggerName} of triggers) { + try { + await sql`ALTER TABLE ${sql(tableName)} DISABLE TRIGGER ${sql.unsafe('"' + triggerName + '"')}`; + } catch (e) { + // oxlint-disable-next-line no-console + console.log( + ` Warning: could not disable trigger ${triggerName} on ${tableName}: ${e}`, + ); + } } - } - // Drop foreign key constraints - // oxlint-disable-next-line no-console - console.log('Dropping foreign key constraints...'); - for (const {tableName, constraintName} of foreignKeys) { - try { - await sql.unsafe( - `ALTER TABLE "${tableName}" DROP CONSTRAINT IF EXISTS "${constraintName}"`, - ); - } catch (e) { - // oxlint-disable-next-line no-console - console.log(` Warning: could not drop FK ${constraintName}: ${e}`); + // Drop foreign key constraints + // oxlint-disable-next-line no-console + console.log('Dropping foreign key constraints...'); + for (const {tableName, constraintName} of foreignKeys) { + try { + await sql.unsafe( + `ALTER TABLE "${tableName}" DROP CONSTRAINT IF EXISTS "${constraintName}"`, + ); + } catch (e) { + // oxlint-disable-next-line no-console + console.log(` Warning: could not drop FK ${constraintName}: ${e}`); + } } - } - // Drop non-PK indexes - // oxlint-disable-next-line no-console - console.log('Dropping indexes...'); - for (const {indexName} of indexes) { - try { - await sql.unsafe(`DROP INDEX IF EXISTS "${indexName}"`); - } catch (e) { - // oxlint-disable-next-line no-console - console.log(` Warning: could not drop index ${indexName}: ${e}`); + // Drop non-PK indexes (constraint-backed indexes must be dropped via ALTER TABLE) + // oxlint-disable-next-line no-console + console.log('Dropping indexes...'); + for (const {tableName, indexName, constraintName} of indexes) { + try { + if (constraintName) { + await sql.unsafe( + `ALTER TABLE "${tableName}" DROP CONSTRAINT IF EXISTS "${constraintName}"`, + ); + } else { + await sql.unsafe(`DROP INDEX IF EXISTS "${indexName}"`); + } + } catch (e) { + // oxlint-disable-next-line no-console + console.log(` Warning: could not drop index ${indexName}: ${e}`); + } } + + await setProgress(sql, 'phase:schema_dropped', 'done'); + } else { + // oxlint-disable-next-line no-console + console.log('Schema already dropped, skipping...'); } + // Set memory parameters for faster index rebuilds (session-level, always set) + // oxlint-disable-next-line no-console + console.log('Setting memory parameters...'); + await sql`SET maintenance_work_mem = '2GB'`; + await sql`SET work_mem = '256MB'`; + // COPY data (no transaction - each file is its own implicit transaction) // oxlint-disable-next-line no-console console.log('Loading data via COPY...'); @@ -260,6 +409,14 @@ async function seed() { let tableRowCount = 0; for (const file of tableFiles) { + const progressKey = `file:${file}`; + if (await hasProgress(sql, progressKey)) { + // oxlint-disable-next-line no-console + console.log(` Skipping ${file} (already loaded)`); + tableRowCount++; + continue; + } + const filePath = join(dataDir, file); const headerLine = await readFirstLine(filePath); @@ -278,6 +435,7 @@ async function seed() { const query = await sql`COPY ${sql(tableName)} (${sql(columns)}) FROM STDIN DELIMITER ',' CSV HEADER`.writable(); await pipeline(fileStream, query); + await setProgress(sql, progressKey, 'done'); tableRowCount++; } @@ -285,63 +443,91 @@ async function seed() { console.log(` ${tableName}: loaded ${tableRowCount} files`); } - // Recreate indexes - // oxlint-disable-next-line no-console - console.log('Recreating indexes (this may take a while)...'); - for (const {indexName, indexDdl} of indexes) { + // --- Restore schema objects (skip if already done) --- + if (!(await hasProgress(sql, 'phase:schema_restored'))) { + // Recreate indexes (constraint-backed indexes are recreated via ALTER TABLE) // oxlint-disable-next-line no-console - console.log(` Creating index ${indexName}...`); - try { - await sql.unsafe(indexDdl); - } catch (e) { + console.log('Recreating indexes (this may take a while)...'); + for (const {tableName, indexName, indexDdl, constraintName} of indexes) { // oxlint-disable-next-line no-console - console.log(` Warning: could not create index ${indexName}: ${e}`); + console.log(` Creating index ${indexName}...`); + try { + if (constraintName) { + // The DDL from pg_get_indexdef is a CREATE UNIQUE INDEX statement. + // First create the index, then add it as a constraint. + await sql.unsafe(indexDdl); + await sql.unsafe( + `ALTER TABLE "${tableName}" ADD CONSTRAINT "${constraintName}" UNIQUE USING INDEX "${indexName}"`, + ); + } else { + await sql.unsafe(indexDdl); + } + } catch (e) { + // oxlint-disable-next-line no-console + console.log(` Warning: could not create index ${indexName}: ${e}`); + } } - } - // Recreate foreign key constraints - // oxlint-disable-next-line no-console - console.log('Recreating foreign key constraints...'); - for (const {tableName, constraintName, fkDefinition} of foreignKeys) { + // Recreate foreign key constraints // oxlint-disable-next-line no-console - console.log(` Creating FK ${constraintName}...`); - try { - await sql.unsafe( - `ALTER TABLE "${tableName}" ADD CONSTRAINT "${constraintName}" ${fkDefinition}`, - ); - } catch (e) { + console.log('Recreating foreign key constraints...'); + for (const {tableName, constraintName, fkDefinition} of foreignKeys) { // oxlint-disable-next-line no-console - console.log(` Warning: could not create FK ${constraintName}: ${e}`); + console.log(` Creating FK ${constraintName}...`); + try { + await sql.unsafe( + `ALTER TABLE "${tableName}" ADD CONSTRAINT "${constraintName}" ${fkDefinition}`, + ); + } catch (e) { + // oxlint-disable-next-line no-console + console.log(` Warning: could not create FK ${constraintName}: ${e}`); + } } - } - // Re-enable triggers - // oxlint-disable-next-line no-console - console.log('Re-enabling triggers...'); - for (const {tableName, triggerName} of triggers) { - try { - await sql`ALTER TABLE ${sql(tableName)} ENABLE TRIGGER ${sql.unsafe('"' + triggerName + '"')}`; - } catch (e) { - // oxlint-disable-next-line no-console - console.log( - ` Warning: could not enable trigger ${triggerName} on ${tableName}: ${e}`, - ); + // Re-enable triggers + // oxlint-disable-next-line no-console + console.log('Re-enabling triggers...'); + for (const {tableName, triggerName} of triggers) { + try { + await sql`ALTER TABLE ${sql(tableName)} ENABLE TRIGGER ${sql.unsafe('"' + triggerName + '"')}`; + } catch (e) { + // oxlint-disable-next-line no-console + console.log( + ` Warning: could not enable trigger ${triggerName} on ${tableName}: ${e}`, + ); + } } + + await setProgress(sql, 'phase:schema_restored', 'done'); + } else { + // oxlint-disable-next-line no-console + console.log('Schema already restored, skipping...'); } - // Analyze tables for query planner - // oxlint-disable-next-line no-console - console.log('Running ANALYZE...'); - for (const tableName of TABLES_IN_SEED_ORDER) { - await sql`ANALYZE ${sql(tableName)}`; + // Analyze tables for query planner (skip if already done) + if (!(await hasProgress(sql, 'phase:analyzed'))) { + // oxlint-disable-next-line no-console + console.log('Running ANALYZE...'); + for (const tableName of TABLES_IN_SEED_ORDER) { + await sql`ANALYZE ${sql(tableName)}`; + } + await setProgress(sql, 'phase:analyzed', 'done'); + } else { + // oxlint-disable-next-line no-console + console.log('ANALYZE already done, skipping...'); } + // Clean exit: drop progress table + await dropProgressTable(sql); + // oxlint-disable-next-line no-console console.log('Seeding complete.'); process.exit(0); } catch (err) { // oxlint-disable-next-line no-console console.error('Seeding failed:', err); + // oxlint-disable-next-line no-console + console.error('Re-run the script to resume from where it left off.'); process.exit(1); } } diff --git a/apps/zbugs/shared/consts.ts b/apps/zbugs/shared/consts.ts index 6b82ffc1e6..3327854919 100644 --- a/apps/zbugs/shared/consts.ts +++ b/apps/zbugs/shared/consts.ts @@ -1 +1,9 @@ export const INITIAL_COMMENT_LIMIT = 100; + +export const ALLOWED_CONTENT_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', +]; diff --git a/apps/zbugs/shared/queries.ts b/apps/zbugs/shared/queries.ts index e9e19bbbde..7f921533f8 100644 --- a/apps/zbugs/shared/queries.ts +++ b/apps/zbugs/shared/queries.ts @@ -55,8 +55,10 @@ function labelsOrderByName({ orderBy?: 'asc' | 'desc' | undefined; }; }) { - let q = builder.label.whereExists('project', q => - q.where('lowerCaseName', projectName.toLocaleLowerCase()), + let q = builder.label.whereExists( + 'project', + q => q.where('lowerCaseName', projectName.toLocaleLowerCase()), + {scalar: true}, ); if (orderBy !== undefined) { q = q.orderBy('name', orderBy); @@ -96,18 +98,20 @@ export const queries = defineQueries({ issuePreloadV2: defineQuery( z.object({ - userID: z.string(), projectName: z.string(), }), - - ({ctx: auth, args: {userID, projectName}}) => + ({ctx: auth, args: {projectName}}) => applyIssuePermissions( builder.issue - .whereExists('project', p => - p.where('lowerCaseName', projectName.toLocaleLowerCase()), + .whereExists( + 'project', + q => q.where('lowerCaseName', projectName.toLocaleLowerCase()), + {scalar: true}, ) .related('labels') - .related('viewState', q => q.where('userID', userID)) + .related('viewState', q => + auth?.sub ? q.where('userID', auth.sub) : alwaysFalse(q), + ) .related('creator') .related('assignee') .related('emoji', emoji => emoji.related('creator')) @@ -147,14 +151,18 @@ export const queries = defineQueries({ ); } else if (filter === 'creators') { q = q.whereExists('createdIssues', i => - i.whereExists('project', p => - p.where('lowerCaseName', projectName.toLocaleLowerCase()), + i.whereExists( + 'project', + q => q.where('lowerCaseName', projectName.toLocaleLowerCase()), + {scalar: true}, ), ); } else if (filter === 'assignees') { q = q.whereExists('assignedIssues', i => - i.whereExists('project', p => - p.where('lowerCaseName', projectName.toLocaleLowerCase()), + i.whereExists( + 'project', + q => q.where('lowerCaseName', projectName.toLocaleLowerCase()), + {scalar: true}, ), ); } else { @@ -173,10 +181,8 @@ export const queries = defineQueries({ z.object({ idField: z.union([z.literal('shortID'), z.literal('id')]), id: z.union([z.string(), z.number()]), - userID: z.string(), }), - - ({args: {idField, id, userID}, ctx: auth}) => + ({args: {idField, id}, ctx: auth}) => applyIssuePermissions( builder.issue .where(idField, id) @@ -185,9 +191,14 @@ export const queries = defineQueries({ .related('creator') .related('assignee') .related('labels') - .related('notificationState', q => q.where('userID', userID)) + .related('notificationState', q => + auth?.sub ? q.where('userID', auth.sub) : alwaysFalse(q), + ) .related('viewState', viewState => - viewState.where('userID', userID).one(), + (auth?.sub + ? viewState.where('userID', auth.sub) + : alwaysFalse(viewState) + ).one(), ) .related('comments', comments => comments @@ -206,15 +217,14 @@ export const queries = defineQueries({ issueListV2: defineQuery( z.object({ listContext: listContextParams, - userID: z.string(), limit: z.nullable(z.number()), start: z.nullable(issueRowSortSchema), dir: z.union([z.literal('forward'), z.literal('backward')]), inclusive: z.optional(z.boolean()), }), - ({ctx: auth, args: {listContext, userID, limit, start, dir, inclusive}}) => - issueListV2(listContext, limit, userID, auth, start, dir, inclusive), + ({ctx: auth, args: {listContext, limit, start, dir, inclusive}}) => + issueListV2(listContext, limit, auth?.sub, auth, start, dir, inclusive), ), /** @@ -241,27 +251,6 @@ export const queries = defineQueries({ // TODO(arv): Remove // The below queries are DEPRECATED - issuePreload: defineQuery(idValidator, ({ctx: auth, args: userID}) => - applyIssuePermissions( - builder.issue - .related('labels') - .related('viewState', q => q.where('userID', userID)) - .related('creator') - .related('assignee') - .related('emoji', emoji => emoji.related('creator')) - .related('comments', comments => - comments - .related('creator') - .related('emoji', emoji => emoji.related('creator')) - .limit(10) - .orderBy('created', 'desc'), - ) - .orderBy('modified', 'desc') - .orderBy('id', 'desc') - .limit(1000), - auth?.role, - ), - ), prevNext: defineQuery( z.object({ listContext: z.nullable(listContextParams), @@ -280,11 +269,10 @@ export const queries = defineQueries({ issueList: defineQuery( z.object({ listContext: listContextParams, - userID: z.string(), limit: z.number(), }), - ({ctx: auth, args: {listContext, userID, limit}}) => - issueListV2(listContext, limit, userID, auth, null, 'forward', false), + ({ctx: auth, args: {listContext, limit}}) => + issueListV2(listContext, limit, auth?.sub, auth, null, 'forward', false), ), userPicker: defineQuery( @@ -326,7 +314,7 @@ export type ListContext = { function issueListV2( listContext: ListContextParams, limit: number | null, - userID: string, + userID: string | undefined, auth: AuthData | undefined, start: IssueRowSort | null, dir: 'forward' | 'backward', @@ -347,7 +335,7 @@ export type ListQueryArgs = { issueQuery?: (typeof builder)['issue'] | undefined; listContext?: ListContext['params'] | undefined; project?: string | undefined; - userID?: string; + userID?: string | undefined; role?: Role | undefined; limit?: number | undefined; start?: IssueRowSort | undefined; @@ -390,8 +378,10 @@ export function buildListQuery(args: ListQueryArgs) { const {projectName = ZERO_PROJECT_NAME} = listContext; - q = q.whereExists('project', q => - q.where('lowerCaseName', projectName.toLocaleLowerCase()), + q = q.whereExists( + 'project', + q => q.where('lowerCaseName', projectName.toLocaleLowerCase()), + {scalar: true}, ); const {sortField, sortDirection} = listContext; @@ -415,10 +405,6 @@ export function buildListQuery(args: ListQueryArgs) { and( // oxlint-disable-next-line eqeqeq open != null ? cmp('open', open) : undefined, - creator ? exists('creator', q => q.where('login', creator)) : undefined, - assignee - ? exists('assignee', q => q.where('login', assignee)) - : undefined, textFilter ? or( cmp('title', 'ILIKE', `%${escapeLike(textFilter)}%`), @@ -429,11 +415,36 @@ export function buildListQuery(args: ListQueryArgs) { ) : undefined, ...(labels ?? []).map(label => - exists('labels', q => q.where('name', label)), + exists('issueLabels', q => + q.whereExists( + 'label', + q => + q + .where('name', label) + .whereExists( + 'project', + q => + q.where('lowerCaseName', projectName.toLocaleLowerCase()), + {scalar: true}, + ), + {scalar: true}, + ), + ), ), ), ); + if (creator) { + q = q.whereExists('creator', q => q.where('login', creator), { + scalar: true, + }); + } + if (assignee) { + q = q.whereExists('assignee', q => q.where('login', assignee), { + scalar: true, + }); + } + return applyIssuePermissions(q, role); } diff --git a/apps/zbugs/shared/schema.ts b/apps/zbugs/shared/schema.ts index 9e54e78673..e8b25bfd5b 100644 --- a/apps/zbugs/shared/schema.ts +++ b/apps/zbugs/shared/schema.ts @@ -144,6 +144,11 @@ const issueRelationships = relationships(issue, ({many, one}) => ({ destField: ['id'], destSchema: project, }), + issueLabels: many({ + sourceField: ['id'], + destField: ['issueID'], + destSchema: issueLabel, + }), labels: many( { sourceField: ['id'], @@ -212,6 +217,11 @@ const issueLabelRelationships = relationships(issueLabel, ({one}) => ({ destField: ['id'], destSchema: issue, }), + label: one({ + sourceField: ['labelID'], + destField: ['id'], + destSchema: label, + }), })); const labelRelationships = relationships(label, ({one}) => ({ diff --git a/apps/zbugs/src/components/combobox.tsx b/apps/zbugs/src/components/combobox.tsx index 1938e35b4f..caa3a5ca9c 100644 --- a/apps/zbugs/src/components/combobox.tsx +++ b/apps/zbugs/src/components/combobox.tsx @@ -12,8 +12,8 @@ import DropdownArrow from '../assets/icons/dropdown-arrow.svg?react'; import {useClickOutside} from '../hooks/use-click-outside.ts'; import {useIsOffline} from '../hooks/use-is-offline.ts'; import {umami} from '../umami.ts'; -import styles from './combobox.module.css'; import {fuzzySearch} from './fuzzy-search.ts'; +import styles from './combobox.module.css'; type Item = { text: string; diff --git a/apps/zbugs/src/components/emoji-panel.tsx b/apps/zbugs/src/components/emoji-panel.tsx index 682f397de4..b3d39e26f9 100644 --- a/apps/zbugs/src/components/emoji-panel.tsx +++ b/apps/zbugs/src/components/emoji-panel.tsx @@ -203,7 +203,7 @@ const EmojiMenuButton = memo( function groupAndSortEmojis(emojis: readonly Emoji[]): Record { // Sort the emojis by creation time. Not sure how to sort this with ZQL. - const sortedEmojis = [...emojis].sort((a, b) => a.created - b.created); + const sortedEmojis = emojis.toSorted((a, b) => a.created - b.created); const rv: Record = {}; for (const emoji of sortedEmojis) { const normalizedEmoji = normalizeEmoji(emoji.value); diff --git a/apps/zbugs/src/components/filter.tsx b/apps/zbugs/src/components/filter.tsx index 1e5bbe165a..42aa0cb57f 100644 --- a/apps/zbugs/src/components/filter.tsx +++ b/apps/zbugs/src/components/filter.tsx @@ -1,7 +1,6 @@ import {useQuery} from '@rocicorp/zero/react'; import classNames from 'classnames'; import {memo, useMemo, useState} from 'react'; -import {toSorted} from '../../../../packages/shared/src/to-sorted.ts'; import {queries} from '../../shared/queries.ts'; import labelIcon from '../assets/icons/label.svg'; import {Button} from './button.tsx'; @@ -24,7 +23,7 @@ export const Filter = memo(function Filter({projectName, onSelect}: Props) { const [unsortedLabels] = useQuery(queries.labels({projectName})); // TODO: Support case-insensitive sorting in ZQL. const labels = useMemo( - () => toSorted(unsortedLabels, (a, b) => a.name.localeCompare(b.name)), + () => unsortedLabels.toSorted((a, b) => a.name.localeCompare(b.name)), [unsortedLabels], ); diff --git a/apps/zbugs/src/components/fuzzy-search.ts b/apps/zbugs/src/components/fuzzy-search.ts index bfd12fca97..277c393deb 100644 --- a/apps/zbugs/src/components/fuzzy-search.ts +++ b/apps/zbugs/src/components/fuzzy-search.ts @@ -1,3 +1,5 @@ +const whitespaceRegex = /\s/; + export function fuzzySearch( searchQuery: string, items: readonly T[], @@ -38,7 +40,7 @@ export function fuzzySearch( } function isWhitespace(c: string): boolean { - return /\s/.test(c); + return whitespaceRegex.test(c); } const ranked = items diff --git a/apps/zbugs/src/components/image-upload-area.tsx b/apps/zbugs/src/components/image-upload-area.tsx index fc3edaa8ce..e4f5c8cf64 100644 --- a/apps/zbugs/src/components/image-upload-area.tsx +++ b/apps/zbugs/src/components/image-upload-area.tsx @@ -2,6 +2,7 @@ import classNames from 'classnames'; import type {ReactNode} from 'react'; import {useCallback, useState} from 'react'; import {useDropzone, type FileRejection} from 'react-dropzone'; +import {ALLOWED_CONTENT_TYPES} from '../../shared/consts.ts'; import {useLogin} from '../hooks/use-login.tsx'; import {Button} from './button.tsx'; import styles from './image-upload-area.module.css'; @@ -24,9 +25,8 @@ type ImageUploadAreaProps = { // Image upload logic (from use-image-upload.ts) const validateFile = (file: File): string | null => { - const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; - if (!validTypes.includes(file.type)) { - return 'Invalid file type. Please select a JPG, PNG, WEBP, or GIF image.'; + if (!ALLOWED_CONTENT_TYPES.includes(file.type)) { + return `Invalid file type. Allowed types: ${ALLOWED_CONTENT_TYPES.join(', ')}`; } if (file.size > 10 * 1024 * 1024) { @@ -190,12 +190,7 @@ export function ImageUploadArea({ ); const {getRootProps, getInputProps, isDragActive, open} = useDropzone({ - accept: { - 'image/jpeg': [], - 'image/png': [], - 'image/webp': [], - 'image/gif': [], - }, + accept: Object.fromEntries(ALLOWED_CONTENT_TYPES.map(t => [t, []])), multiple: true, maxSize: 10 * 1024 * 1024, noClick: true, diff --git a/apps/zbugs/src/components/label-picker.module.css b/apps/zbugs/src/components/label-picker.module.css index 6e1813a55a..2d10827be9 100644 --- a/apps/zbugs/src/components/label-picker.module.css +++ b/apps/zbugs/src/components/label-picker.module.css @@ -48,7 +48,7 @@ top: 2.65rem; /* Center vertically */ transform: translateY(-50%); border-style: solid; - z-index: var(--z-index-popover + 1); + z-index: calc(var(--z-index-popover) + 1); } .popoverWrapper::before { diff --git a/apps/zbugs/src/components/label-picker.tsx b/apps/zbugs/src/components/label-picker.tsx index 37dbc6f338..d5fed9e99e 100644 --- a/apps/zbugs/src/components/label-picker.tsx +++ b/apps/zbugs/src/components/label-picker.tsx @@ -83,7 +83,7 @@ function LabelPopover({ const handleCreateNewLabel = () => { if ( input && - !filteredLabels.find( + !filteredLabels.some( label => label.name.toLowerCase() === input.toLowerCase(), ) ) { diff --git a/apps/zbugs/src/components/loading-spinner.tsx b/apps/zbugs/src/components/loading-spinner.tsx index 504894b472..1ee01c5cb5 100644 --- a/apps/zbugs/src/components/loading-spinner.tsx +++ b/apps/zbugs/src/components/loading-spinner.tsx @@ -23,7 +23,7 @@ export function LoadingSpinner({forceShow}: {forceShow?: boolean | undefined}) { } useEffect(() => { - const timer = setTimeout(() => setShow(true), 500); + const timer = setTimeout(setShow, 500, true); return () => clearTimeout(timer); }, []); @@ -44,20 +44,14 @@ export function LoadingSpinner({forceShow}: {forceShow?: boolean | undefined}) { } function DemoLoadingSpinner() { - const [dots, setDots] = useState('.'); const [elapsed, setElapsed] = useState(0); useEffect(() => { - const dotsInterval = setInterval(() => { - setDots(d => (d.length >= 3 ? '.' : d + '.')); - }, 400); - const timerInterval = setInterval(() => { setElapsed(Date.now() - (demoStartTime ?? Date.now())); }, 100); return () => { - clearInterval(dotsInterval); clearInterval(timerInterval); }; }, []); @@ -67,10 +61,15 @@ function DemoLoadingSpinner() { return (
- - Loading 1.2 million bugs - - in {seconds} seconds{dots} +
+ +
+ + Loading 1.2 million bugs + + + in {seconds}{' '} + seconds...
diff --git a/apps/zbugs/src/components/login-provider.tsx b/apps/zbugs/src/components/login-provider.tsx index 5721752323..a15e50e29b 100644 --- a/apps/zbugs/src/components/login-provider.tsx +++ b/apps/zbugs/src/components/login-provider.tsx @@ -1,7 +1,7 @@ -import {loginContext} from '../hooks/use-login.tsx'; -import {clearJwt, getJwt, getRawJwt} from '../jwt.ts'; import {useState} from 'react'; import {jwtDataSchema, type JWTData} from '../../shared/auth.ts'; +import {loginContext} from '../hooks/use-login.tsx'; +import {clearJwt, getJwt, getRawJwt} from '../jwt.ts'; export type LoginState = { encoded: string; diff --git a/apps/zbugs/src/components/markdown.tsx b/apps/zbugs/src/components/markdown.tsx index c6f021546f..b7c6798dcc 100644 --- a/apps/zbugs/src/components/markdown.tsx +++ b/apps/zbugs/src/components/markdown.tsx @@ -10,6 +10,8 @@ function isElement(node: Parent | null): node is Element { return node !== null && node.type === 'element' && 'tagName' in node; } +const videoExtensionsRe = /\.(mp4|webm|ogg)$/; + /** * Custom rehype plugin to transform with video extensions to