diff --git a/.dockerignore b/.dockerignore index fcb52cf3e..1e69499f3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ **/dist **/*.log .env +.env.prod client/.env server/.env docker/postgres/data diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 000000000..2ffa60bc9 --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,31 @@ +# Immutable image reference built by CI (use a sha-* tag or @sha256 digest) +APP_IMAGE=ghcr.io/your-org/projectx:sha- + +# App publish port on host +APP_PORT=3000 + +# Database credentials +POSTGRES_USER=projectx +POSTGRES_PASSWORD=change-this-password +POSTGRES_DB=projectx + +# App secrets +JWT_SECRET=change-this-to-a-long-random-secret +EMAIL_ENCRYPTION_KEY= + +# External/public URL used by emails and Kobo endpoints +APP_URL=https://projectx.example.com + +# Optional client URL override +CLIENT_URL=https://projectx.example.com + +# Optional SMTP config +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +SMTP_FROM=noreply@projectx.local + +# Optional API keys +GOOGLE_BOOKS_API_KEY= diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..d7ad5b89a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +/.github/ @neonsolstice diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml new file mode 100644 index 000000000..98b4f049b --- /dev/null +++ b/.github/actions/setup-node-pnpm/action.yml @@ -0,0 +1,60 @@ +name: Setup Node and pnpm +description: Set up Node.js, activate pnpm via Corepack, and optionally install dependencies. + +inputs: + node-version: + description: Node.js version to install. + required: false + default: "24" + pnpm-version: + description: pnpm version to activate via Corepack. + required: false + default: "10" + install: + description: Whether to run pnpm install. + required: false + default: "true" + cache: + description: Whether to enable pnpm dependency caching. + required: false + default: "true" + cache-dependency-path: + description: Path(s) used to generate the cache key. + required: false + default: "pnpm-lock.yaml" + +runs: + using: composite + steps: + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: ${{ inputs.node-version }} + + - name: Enable Corepack and activate pnpm + shell: bash + run: | + corepack enable + corepack prepare "pnpm@${{ inputs.pnpm-version }}" --activate + + # pnpm must be on PATH before its store path can be resolved for cache keying, + # so caching is configured in a separate step after Corepack activates pnpm. + - name: Get pnpm store directory + if: ${{ inputs.cache == 'true' }} + id: pnpm-store + shell: bash + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + if: ${{ inputs.cache == 'true' }} + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-pnpm-${{ inputs.pnpm-version }}-store-${{ hashFiles(inputs.cache-dependency-path) }} + restore-keys: | + ${{ runner.os }}-pnpm-${{ inputs.pnpm-version }}-store- + + - name: Install dependencies + if: ${{ inputs.install == 'true' }} + shell: bash + run: pnpm install --frozen-lockfile diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2a6b6f100 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + versioning-strategy: "auto" + groups: + production-dependencies: + dependency-type: "production" + update-types: ["minor", "patch"] + development-dependencies: + dependency-type: "development" + update-types: ["minor", "patch"] + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + github-actions: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..2ade98dde --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,218 @@ +name: CI +run-name: >- + ${{ (github.event_name == 'push' && format('CI - {0} ({1})', github.event.head_commit.message, github.ref_name)) || (github.event_name == 'pull_request' && format('CI - PR #{0}: {1}', github.event.pull_request.number, github.event.pull_request.title)) || 'CI - manual' }} + +on: + workflow_dispatch: + pull_request: + branches: [main] + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + NODE_VERSION: "24" + PNPM_VERSION: "10" + +jobs: + dependency-review: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Dependency review + uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 + + detect-changes: + name: Detect Changed Paths + runs-on: ubuntu-latest + needs: [dependency-review] + timeout-minutes: 10 + if: always() && (needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') + outputs: + server: ${{ steps.manual.outputs.server || steps.changes.outputs.server }} + client: ${{ steps.manual.outputs.client || steps.changes.outputs.client }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Manual dispatch runs all scopes + id: manual + if: github.event_name == 'workflow_dispatch' + shell: bash + run: | + echo "server=true" >> "$GITHUB_OUTPUT" + echo "client=true" >> "$GITHUB_OUTPUT" + + # For push events dorny/paths-filter diffs github.event.before..github.sha. + # New-branch pushes (before = 0000...) are handled by treating all files as changed. + # Force-push edge cases (before SHA no longer reachable) may mark all files changed too. + - name: Compute changed scopes + id: changes + if: github.event_name != 'workflow_dispatch' + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + with: + filters: | + server: + - 'server/**' + - 'packages/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.github/actions/setup-node-pnpm/action.yml' + - '.github/workflows/ci.yml' + client: + - 'client/**' + - 'packages/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - '.github/actions/setup-node-pnpm/action.yml' + - '.github/workflows/ci.yml' + + - name: Print change scope + shell: bash + run: | + echo "server=${{ steps.manual.outputs.server || steps.changes.outputs.server }}" + echo "client=${{ steps.manual.outputs.client || steps.changes.outputs.client }}" + + checks: + name: ${{ matrix.check }} (${{ matrix.package }}) + runs-on: ubuntu-latest + needs: [dependency-review, detect-changes] + timeout-minutes: 20 + # matrix context is not available in job-level if; per-package change filtering + # is done via the has-changes gate step below. + if: > + always() && + (needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') && + needs.detect-changes.result == 'success' + strategy: + # All matrix cells run independently so all failures are visible in one pass. + fail-fast: false + matrix: + package: [server, client] + check: [lint, typecheck, test] + permissions: + contents: read + # checks: write is only used by publish-unit-test-result-action in test matrix cells. + # Lint and typecheck cells receive this permission too due to the shared job definition. + checks: write + steps: + - name: Check for package changes + id: has-changes + shell: bash + env: + PACKAGE: ${{ matrix.package }} + SERVER_CHANGED: ${{ needs.detect-changes.outputs.server }} + CLIENT_CHANGED: ${{ needs.detect-changes.outputs.client }} + run: | + if [[ "$PACKAGE" == "server" && "$SERVER_CHANGED" == "true" ]] || \ + [[ "$PACKAGE" == "client" && "$CLIENT_CHANGED" == "true" ]]; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + if: steps.has-changes.outputs.run == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Node.js and pnpm + if: steps.has-changes.outputs.run == 'true' + uses: ./.github/actions/setup-node-pnpm + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + + - name: Run lint + if: steps.has-changes.outputs.run == 'true' && matrix.check == 'lint' + shell: bash + run: pnpm --filter "${{ matrix.package }}" lint:check + + - name: Run typecheck + if: steps.has-changes.outputs.run == 'true' && matrix.check == 'typecheck' + shell: bash + env: + PACKAGE: ${{ matrix.package }} + run: | + if [[ "$PACKAGE" == "server" ]]; then + pnpm --filter server type-check + else + pnpm --filter @projectx/types build + pnpm run typecheck:client:baseline + fi + + - name: Prepare test report directory + if: steps.has-changes.outputs.run == 'true' && matrix.check == 'test' + shell: bash + run: mkdir -p "$GITHUB_WORKSPACE/test-results/${{ matrix.package }}" + + - name: Run tests + if: steps.has-changes.outputs.run == 'true' && matrix.check == 'test' + shell: bash + env: + PACKAGE: ${{ matrix.package }} + JUNIT_OUTPUT: ${{ github.workspace }}/test-results/${{ matrix.package }}/junit.xml + run: | + if [[ "$PACKAGE" == "server" ]]; then + JEST_JUNIT_OUTPUT_FILE="$JUNIT_OUTPUT" pnpm --filter server exec jest --ci --reporters=default --reporters=jest-junit + else + pnpm --filter client exec vitest --run --reporter=default --reporter=junit --outputFile="$JUNIT_OUTPUT" + fi + + - name: Publish test report annotations + if: > + always() && + steps.has-changes.outputs.run == 'true' && + matrix.check == 'test' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2 + with: + check_name: ${{ matrix.package == 'server' && 'Server Unit Tests' || 'Client Unit Tests' }} + files: ${{ format('test-results/{0}/junit.xml', matrix.package) }} + fail_on: nothing + comment_mode: off + + - name: Upload test report artifact + if: > + always() && + steps.has-changes.outputs.run == 'true' && + matrix.check == 'test' && + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: test-report-${{ matrix.package }} + path: test-results/${{ matrix.package }} + retention-days: 7 + + quality-gate: + name: Quality Gate + runs-on: ubuntu-latest + needs: [dependency-review, detect-changes, checks] + timeout-minutes: 5 + if: always() + steps: + - name: Fail if any quality job failed + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: | + echo "One or more quality jobs failed." + exit 1 + + - name: Quality checks complete + run: | + echo "dependency-review=${{ needs.dependency-review.result }}" + echo "detect-changes=${{ needs.detect-changes.result }}" + echo "checks=${{ needs.checks.result }}" diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml new file mode 100644 index 000000000..e7474da73 --- /dev/null +++ b/.github/workflows/container-image.yml @@ -0,0 +1,120 @@ +name: Container Image +run-name: ${{ github.event_name == 'workflow_run' && format('Image - {0} ({1})', github.event.workflow_run.head_commit.message, github.event.workflow_run.head_branch) || format('Image - {0}', github.ref_name) }} + +on: + workflow_dispatch: + workflow_run: + workflows: ["CI"] + types: [completed] + branches: [main] + push: + tags: + - "v*" + +concurrency: + # Use the commit SHA across all trigger types so a workflow_run completion and a + # simultaneous tag push for the same commit share one concurrency slot and cancel correctly. + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} + cancel-in-progress: true + +permissions: + actions: read + contents: read + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + image: + name: Build and Push Image + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + packages: write + steps: + - name: Resolve source and image tags + id: vars + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + GH_REF: ${{ github.ref }} + GH_REF_NAME: ${{ github.ref_name }} + GH_SHA: ${{ github.sha }} + WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + # SHA resolution mirrors the concurrency group logic at the top of this workflow. + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$GH_REF" != "refs/heads/main" ]]; then + echo "workflow_dispatch is only supported from main." + exit 1 + fi + + if [[ "$EVENT_NAME" == "workflow_run" ]]; then + SHA="$WORKFLOW_RUN_HEAD_SHA" + else + SHA="$GH_SHA" + fi + + SHORT_SHA="${SHA:0:12}" + IMAGE="$REGISTRY/$IMAGE_NAME" + + PRIMARY_TAG="$GH_REF_NAME" + + echo "checkout_ref=${SHA}" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "tags<> "$GITHUB_OUTPUT" + echo "${IMAGE}:${PRIMARY_TAG}" >> "$GITHUB_OUTPUT" + echo "${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ steps.vars.outputs.checkout_ref }} + + - name: Verify CI succeeded for tag commit + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + shell: bash + env: + GH_TOKEN: ${{ github.token }} + COMMIT_SHA: ${{ github.sha }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + echo "Checking CI status for commit ${COMMIT_SHA}..." + success_count=$(gh api --paginate \ + "repos/${GH_REPO}/actions/workflows/ci.yml/runs?head_sha=${COMMIT_SHA}&status=completed" \ + --jq '[.workflow_runs[] | select(.conclusion == "success")] | length' \ + | awk '{sum += $1} END {print sum}') + if [[ "${success_count:-0}" -lt 1 ]]; then + echo "No successful CI run found for tag commit ${COMMIT_SHA}. Refusing to publish image." + exit 1 + fi + echo "CI verification passed: ${success_count} successful run(s) found." + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image + id: build_push + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.vars.outputs.tags }} + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 0d447c61a..541cd6673 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ .env .env.local +.env.prod client/.env server/.env *.local diff --git a/Dockerfile b/Dockerfile index acaf01fd2..94af31ed4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ +ARG NODE_IMAGE=node:24.11-alpine + # Stage 1: Build client -FROM node:24-alpine AS client-builder +FROM ${NODE_IMAGE} AS client-builder RUN npm install -g pnpm@10 WORKDIR /app @@ -11,8 +13,8 @@ RUN pnpm install --filter client... --frozen-lockfile COPY client/ ./client/ RUN pnpm --filter client run build-only -# Stage 2: Build server + create prod deploy bundle -FROM node:24-alpine AS server-builder +# Stage 2: Build server + create deploy bundle +FROM ${NODE_IMAGE} AS server-builder RUN npm install -g pnpm@10 WORKDIR /app @@ -24,21 +26,29 @@ RUN pnpm install --filter server... --frozen-lockfile COPY server/ ./server/ RUN pnpm --filter server run build -# pnpm deploy prunes to prod deps; dist/ is gitignored so copy it in after +# pnpm deploy prunes to prod deps; dist/ is gitignored so copy it in after. RUN pnpm --filter server deploy --prod --legacy /deploy RUN cp -r /app/server/dist /deploy/dist +RUN mkdir -p /deploy/migrations && cp -r /app/server/src/db/migrations/. /deploy/migrations/ -# Stage 3: Final image -FROM node:24-alpine +# Stage 3: Runtime image +FROM ${NODE_IMAGE} AS runtime WORKDIR /app -COPY --from=server-builder /deploy ./ -COPY --from=client-builder /app/client/dist ./public +ENV NODE_ENV=production +ENV PORT=3000 + +COPY --from=server-builder --chown=node:node /deploy ./ +COPY --from=client-builder --chown=node:node /app/client/dist ./public +COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh ./entrypoint.sh + +RUN chmod +x /app/entrypoint.sh && mkdir -p /books /tmp && chown -R node:node /books /tmp + +USER node EXPOSE 3000 -ENV NODE_ENV=production -COPY --from=server-builder /app/server/entrypoint.sh ./entrypoint.sh -RUN chmod +x entrypoint.sh +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "const p=process.env.PORT||3000;fetch('http://127.0.0.1:'+p+'/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" CMD ["sh", "entrypoint.sh"] diff --git a/README.md b/README.md index 7fb229dd5..908179477 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,12 @@ projectx/ │ └── types/ Shared TypeScript types (@projectx/types) ├── docker/ │ └── postgres/ Postgres init scripts (extensions) +├── docs/ +│ └── production.md Production deploy/backup/restore runbook ├── local/ Local dev data (covers, docs) - gitignored ├── docker-compose.yml Dev: runs Postgres only (app runs natively) +├── docker-compose.prod.yml Prod: app + Postgres + migration job +├── .env.prod.example Template env file for production compose ├── Dockerfile Production multi-stage build └── Dockerfile.dev Runs entire stack in Docker (not used for normal dev) ``` @@ -133,6 +137,16 @@ All commands run from the **repo root** unless noted otherwise. | `pnpm --filter server db:generate ` | Generate a migration from schema changes | | `pnpm --filter server db:studio` | Open Drizzle Studio (DB browser) | +### Production operations + +| Command | Description | +| ------------------------------------------ | -------------------------------------------- | +| `pnpm prod:up` | Start/update production compose stack | +| `pnpm prod:down` | Stop production compose stack | +| `pnpm db:backup:prod` | Create Postgres backup (`local/backups/`) | +| `pnpm db:restore:prod -- ` | Restore backup into production database | +| `pnpm db:restore:test:prod -- ` | Restore into temp DB to validate backup file | + ### Linting & formatting | Command | Description | @@ -215,6 +229,24 @@ Server environment is configured in `server/.env` (created from `.env.example` d | `SMTP_*` | (empty) | Optional SMTP config for password-reset emails | | `APP_URL` | `http://localhost:5173` | Client URL (used in emails) | +## Production Deploy + +Production uses a separate compose file and env file: + +- Compose: `docker-compose.prod.yml` +- Env file: `.env.prod` (from `.env.prod.example`) +- Image source: immutable images built in CI (`.github/workflows/container-image.yml`) + +Quick start: + +```bash +cp .env.prod.example .env.prod +# edit .env.prod and set APP_IMAGE + secrets +pnpm prod:up +``` + +Detailed runbook (deploy, backup, restore, restore drills): [docs/production.md](docs/production.md) + --- ## Database Workflow @@ -325,8 +357,9 @@ Make sure you're accessing the client at `http://localhost:5173` (not port 3000) ## Further Reading -| Doc | What it covers | -| -------------------------------------- | ------------------------------------------------------------ | -| [`server/README.md`](server/README.md) | Backend module map, DB commands, NestJS conventions | -| [`client/README.md`](client/README.md) | Frontend project layout, IDE setup, Vue/Tailwind conventions | -| [`packages/types/`](packages/types/) | Shared type definitions between server and client | +| Doc | What it covers | +| ------------------------------------------ | ------------------------------------------------------------ | +| [`docs/production.md`](docs/production.md) | Production compose deployment, backups, restore testing | +| [`server/README.md`](server/README.md) | Backend module map, DB commands, NestJS conventions | +| [`client/README.md`](client/README.md) | Frontend project layout, IDE setup, Vue/Tailwind conventions | +| [`packages/types/`](packages/types/) | Shared type definitions between server and client | diff --git a/client/package.json b/client/package.json index 8e175954e..e40771b62 100644 --- a/client/package.json +++ b/client/package.json @@ -39,31 +39,31 @@ "vue": "^3.5.29", "vue-router": "^5.0.3", "vue-sonner": "^2.0.9", - "vue-virtual-scroller": "^2.0.0-beta.8" + "vue-virtual-scroller": "^1.1.2" }, "devDependencies": { "@tsconfig/node24": "^24.0.4", "@types/jsdom": "^28.0.0", - "@types/node": "^24.11.0", + "@types/node": "^25.3.5", "@vitejs/plugin-vue": "^6.0.4", "@vitest/coverage-v8": "^4.0.18", "@vitest/eslint-plugin": "^1.6.9", "@vue/eslint-config-typescript": "^14.7.0", "@vue/test-utils": "^2.4.6", - "@vue/tsconfig": "^0.8.1", - "eslint": "^10.0.2", + "@vue/tsconfig": "^0.9.0", + "eslint": "^10.0.3", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-oxlint": "~1.50.0", + "eslint-plugin-oxlint": "~1.51.0", "eslint-plugin-vue": "~10.8.0", "jiti": "^2.6.1", "jsdom": "^28.1.0", "npm-run-all2": "^8.0.4", - "oxlint": "~1.50.0", + "oxlint": "~1.51.0", "prettier": "3.8.1", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "vite": "^7.3.1", - "vite-plugin-vue-devtools": "^8.0.6", + "vite-plugin-vue-devtools": "^8.0.7", "vitest": "^4.0.18", "vue-tsc": "^3.2.5" }, diff --git a/client/src/features/book/components/__tests__/BookCoverCard.spec.ts b/client/src/features/book/components/__tests__/BookCoverCard.spec.ts index 2be1a8803..71afca4b7 100644 --- a/client/src/features/book/components/__tests__/BookCoverCard.spec.ts +++ b/client/src/features/book/components/__tests__/BookCoverCard.spec.ts @@ -5,10 +5,10 @@ import type { BookCard } from '@projectx/types' vi.mock('vue-router', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, useRouter: () => ({ push: vi.fn() }) } + return { ...actual, useRouter: () => ({ push: vi.fn<(...args: unknown[]) => unknown>() }) } }) vi.mock('@/features/book/composables/useCoverVersions', () => ({ - useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }), + useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn<(...args: unknown[]) => void>() }), })) vi.mock('@/features/book/lib/book-cover', () => ({ bookCoverStyle: () => ({ background: 'oklch(0.22 0.07 200)', color: 'oklch(0.92 0.03 200)' }), diff --git a/client/src/features/book/components/__tests__/BookListRow.spec.ts b/client/src/features/book/components/__tests__/BookListRow.spec.ts index 6d2963b3d..f07b855e3 100644 --- a/client/src/features/book/components/__tests__/BookListRow.spec.ts +++ b/client/src/features/book/components/__tests__/BookListRow.spec.ts @@ -5,10 +5,10 @@ import type { BookCard } from '@projectx/types' vi.mock('vue-router', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, useRouter: () => ({ push: vi.fn() }) } + return { ...actual, useRouter: () => ({ push: vi.fn<(...args: unknown[]) => unknown>() }) } }) vi.mock('@/features/book/composables/useCoverVersions', () => ({ - useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }), + useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn<(...args: unknown[]) => void>() }), })) vi.mock('@/features/book/lib/book-cover', () => ({ bookCoverStyle: () => ({ background: 'oklch(0.22 0.07 200)', color: 'oklch(0.92 0.03 200)' }), diff --git a/client/src/features/book/components/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/__tests__/DetailsTab.spec.ts index 803e3031c..094110eb4 100644 --- a/client/src/features/book/components/__tests__/DetailsTab.spec.ts +++ b/client/src/features/book/components/__tests__/DetailsTab.spec.ts @@ -5,16 +5,25 @@ import type { BookDetail } from '@projectx/types' vi.mock('vue-router', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, useRouter: () => ({ push: vi.fn(), replace: vi.fn() }) } + return { + ...actual, + useRouter: () => ({ + push: vi.fn<(...args: unknown[]) => unknown>(), + replace: vi.fn<(...args: unknown[]) => unknown>(), + }), + } }) vi.mock('@/features/auth/composables/usePermissions', () => ({ usePermissions: () => ({ hasPermission: () => false }), })) vi.mock('@/lib/api', () => ({ - api: vi.fn(async () => ({ ok: false, json: async () => ({}) })), + api: vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise> }>>(async () => ({ + ok: false, + json: async () => ({}), + })), })) vi.mock('@/features/book/composables/useCoverVersions', () => ({ - useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn() }), + useCoverVersions: () => ({ coverUrl: () => '/cover.jpg', bumpVersion: vi.fn<(...args: unknown[]) => void>() }), })) vi.mock('@/features/book/lib/book-cover', () => ({ bookCoverStyle: () => ({ background: 'oklch(0.22 0.07 200)', color: 'oklch(0.92 0.03 200)' }), diff --git a/client/src/features/book/composables/__tests__/useBookEvents.spec.ts b/client/src/features/book/composables/__tests__/useBookEvents.spec.ts index 2abb7023a..160eec69b 100644 --- a/client/src/features/book/composables/__tests__/useBookEvents.spec.ts +++ b/client/src/features/book/composables/__tests__/useBookEvents.spec.ts @@ -13,7 +13,7 @@ vi.mock('@/features/scanner/composables/useScanProgress', () => ({ })) vi.mock('vue-sonner', () => ({ - toast: { warning: vi.fn() }, + toast: { warning: vi.fn<(message: string) => void>() }, })) // Import the composable after mocks are in place. @@ -42,7 +42,7 @@ afterEach(() => { describe('onBookMissing', () => { it('fires the callback with the bookIds array on book:missing event', () => { const { onBookMissing } = useBookEvents() - const cb = vi.fn() + const cb = vi.fn<(bookIds: number[]) => void>() const cleanup = onBookMissing(cb) mockSocket.emit('book:missing', { libraryId: 1, bookIds: [10, 20, 30] }) @@ -54,7 +54,7 @@ describe('onBookMissing', () => { it('cleanup function deregisters the callback', () => { const { onBookMissing } = useBookEvents() - const cb = vi.fn() + const cb = vi.fn<(bookIds: number[]) => void>() const cleanup = onBookMissing(cb) cleanup() diff --git a/client/src/features/reader/composables/usePdfOutline.ts b/client/src/features/reader/composables/usePdfOutline.ts index 6cbafbbac..426e00ad0 100644 --- a/client/src/features/reader/composables/usePdfOutline.ts +++ b/client/src/features/reader/composables/usePdfOutline.ts @@ -10,6 +10,9 @@ export interface OutlineItem { expanded: boolean } +type PdfPageRef = Parameters[0] +type PdfOutline = NonNullable>> + export function usePdfOutline(pdfDoc: ShallowRef) { const outline = ref([]) const loading = ref(false) @@ -19,14 +22,14 @@ export function usePdfOutline(pdfDoc: ShallowRef { + async function buildTree(doc: pdfjsLib.PDFDocumentProxy, raw: PdfOutline): Promise { const items: OutlineItem[] = [] for (const node of raw) { const pageNum = await resolvePageNum(doc, node.dest) @@ -59,4 +62,3 @@ export function usePdfOutline(pdfDoc: ShallowRefprocess.exit(r.ok?0:1)).catch(()=>process.exit(1))"', + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + logging: *default-logging volumes: postgres_data: diff --git a/docs/production.md b/docs/production.md new file mode 100644 index 000000000..462972dff --- /dev/null +++ b/docs/production.md @@ -0,0 +1,86 @@ +# Production Deployment (Docker Compose) + +This project's production Compose stack is intentionally separate from local development. + +## 1) Configure production environment + +Create `.env.prod` from the example and set real values: + +```bash +cp .env.prod.example .env.prod +``` + +Required fields: + +- `APP_IMAGE` (immutable tag or digest from CI, for example `ghcr.io//:sha-`) +- `POSTGRES_USER` +- `POSTGRES_PASSWORD` +- `POSTGRES_DB` +- `JWT_SECRET` +- `APP_URL` + +## 2) Deploy + +```bash +docker compose --env-file .env.prod -f docker-compose.prod.yml pull +docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --wait +``` + +What happens: + +- `postgres` starts with `pgvector` enabled via init SQL. +- `migrate` runs once (`node dist/scripts/migrate.js`) and must complete successfully. +- `app` starts only after DB health is `healthy` and migration service exits successfully. + +## 3) Roll forward + +1. Update `APP_IMAGE` in `.env.prod` to the new immutable image. +2. Pull + restart: + +```bash +docker compose --env-file .env.prod -f docker-compose.prod.yml pull +docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --wait +``` + +## 4) Backup strategy + +Create a compressed custom-format Postgres backup: + +```bash +pnpm db:backup:prod +``` + +By default this writes to `local/backups/`. + +Recommended cadence: + +- Daily backups +- Keep at least 7 daily + 4 weekly + 3 monthly snapshots +- Store a copy off-host + +## 5) Restore strategy + +Restore into the live DB: + +```bash +pnpm db:restore:prod -- local/backups/.dump +``` + +Run a non-destructive restore test into a temporary DB: + +```bash +pnpm db:restore:test:prod -- local/backups/.dump +``` + +Run the restore test at least monthly so backup integrity is continuously verified. + +## 6) CI image pipeline + +Workflow: `.github/workflows/container-image.yml` + +On `main` and tag pushes it: + +- builds the Docker image +- pushes immutable tags to GHCR (`sha-*`) +- scans the image with Trivy (high/critical) +- generates an SPDX SBOM artifact diff --git a/package.json b/package.json index a589be4a5..cb63cc729 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,11 @@ "coverage:server": "pnpm --filter server test:cov", "coverage:client": "pnpm --filter client test:cov --run", "db:up": "docker compose up -d --wait", + "prod:up": "docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --wait", + "prod:down": "docker compose --env-file .env.prod -f docker-compose.prod.yml down", + "db:backup:prod": "bash ./scripts/db/backup-prod.sh .env.prod", + "db:restore:prod": "bash ./scripts/db/restore-prod.sh .env.prod", + "db:restore:test:prod": "bash ./scripts/db/restore-test-prod.sh .env.prod", "db:migrate": "pnpm --filter server db:migrate", "db:reset": "bash ./scripts/db/reset.sh", "db:seed": "bash ./scripts/db/seed.sh", @@ -46,8 +51,8 @@ "devDependencies": { "concurrently": "^9.2.1", "husky": "^9.1.7", - "lint-staged": "^16.1.0", - "prettier": "^3.6.2" + "lint-staged": "^16.3.2", + "prettier": "^3.8.1" }, "lint-staged": { "*.{md,json,yml,yaml}": [ diff --git a/packages/types/package.json b/packages/types/package.json index 3f5e138ef..0d068fbd8 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -5,7 +5,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "devDependencies": { - "typescript": "^5.0.0" + "typescript": "^5.9.3" }, "scripts": { "build": "tsc" diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json index 88f8b5490..cf60a9fd8 100644 --- a/packages/types/tsconfig.json +++ b/packages/types/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "node", "declaration": true, "declarationMap": true, + "rootDir": "./src", "outDir": "./dist", "strict": false, "skipLibCheck": true diff --git a/scripts/db/backup-prod.sh b/scripts/db/backup-prod.sh new file mode 100755 index 000000000..50ab8bf53 --- /dev/null +++ b/scripts/db/backup-prod.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +ENV_FILE="${1:-.env.prod}" +OUT_DIR="${2:-$ROOT_DIR/local/backups}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing env file: $ENV_FILE" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +: "${POSTGRES_USER:?POSTGRES_USER is required}" +: "${POSTGRES_DB:?POSTGRES_DB is required}" + +mkdir -p "$OUT_DIR" +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +backup_file="$OUT_DIR/projectx-${POSTGRES_DB}-${timestamp}.dump" + +docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --wait postgres +docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \ + pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc >"$backup_file" + +echo "Backup created: $backup_file" diff --git a/scripts/db/restore-prod.sh b/scripts/db/restore-prod.sh new file mode 100755 index 000000000..6024b074c --- /dev/null +++ b/scripts/db/restore-prod.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +ENV_FILE="${1:-.env.prod}" +BACKUP_FILE="${2:-}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing env file: $ENV_FILE" >&2 + exit 1 +fi + +if [[ -z "$BACKUP_FILE" || ! -f "$BACKUP_FILE" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +: "${POSTGRES_USER:?POSTGRES_USER is required}" +: "${POSTGRES_DB:?POSTGRES_DB is required}" + +docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --wait postgres +docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$POSTGRES_DB' AND pid <> pg_backend_pid();" >/dev/null + +cat "$BACKUP_FILE" | docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \ + pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges + +echo "Restore complete: $BACKUP_FILE -> $POSTGRES_DB" diff --git a/scripts/db/restore-test-prod.sh b/scripts/db/restore-test-prod.sh new file mode 100755 index 000000000..9848ec5ce --- /dev/null +++ b/scripts/db/restore-test-prod.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +ENV_FILE="${1:-.env.prod}" +BACKUP_FILE="${2:-}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing env file: $ENV_FILE" >&2 + exit 1 +fi + +if [[ -z "$BACKUP_FILE" || ! -f "$BACKUP_FILE" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +: "${POSTGRES_USER:?POSTGRES_USER is required}" +: "${POSTGRES_DB:?POSTGRES_DB is required}" + +test_db="${POSTGRES_DB}_restore_test_$(date -u +%Y%m%d%H%M%S)" + +docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml up -d --wait postgres +docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d postgres -c "CREATE DATABASE \"$test_db\";" >/dev/null + +cleanup() { + docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d postgres -c "DROP DATABASE IF EXISTS \"$test_db\";" >/dev/null || true +} +trap cleanup EXIT + +cat "$BACKUP_FILE" | docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \ + pg_restore -U "$POSTGRES_USER" -d "$test_db" --no-owner --no-privileges >/dev/null + +table_count="$( + docker compose --env-file "$ENV_FILE" -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d "$test_db" -Atc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';" +)" + +echo "Restore test succeeded: $BACKUP_FILE -> $test_db (${table_count} public tables)" diff --git a/server/package.json b/server/package.json index 0c83239d3..2120ccd5c 100644 --- a/server/package.json +++ b/server/package.json @@ -6,6 +6,7 @@ "private": true, "license": "UNLICENSED", "scripts": { + "prebuild": "pnpm --filter @projectx/types build", "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", @@ -15,10 +16,12 @@ "start:prod": "node dist/main", "db:generate": "drizzle-kit generate --name", "db:migrate": "drizzle-kit migrate", + "db:migrate:runtime": "node dist/scripts/migrate.js", "db:studio": "drizzle-kit studio", "db:seed": "tsx src/scripts/db-seed.ts", "scan:test": "ts-node -r tsconfig-paths/register src/scripts/scan-test.ts", "sim:missing": "ts-node -r tsconfig-paths/register src/scripts/missing-detection-sim.ts", + "pretype-check": "pnpm --filter @projectx/types build", "type-check": "tsc --noEmit -p tsconfig.build.json", "lint": "pnpm run lint:check", "lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\"", @@ -38,9 +41,9 @@ "@fastify/multipart": "^9.4.0", "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.0.0", - "@nestjs/common": "^11.0.1", + "@nestjs/common": "^11.1.16", "@nestjs/config": "^4.0.3", - "@nestjs/core": "^11.0.1", + "@nestjs/core": "^11.1.16", "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-fastify": "^11.1.16", @@ -58,7 +61,7 @@ "class-validator": "^0.15.1", "drizzle-orm": "^0.45.1", "fast-xml-parser": "^5.4.2", - "fastify": "^5.8.1", + "fastify": "^5.8.2", "jose": "^6.2.0", "ms": "^2.1.3", "nestjs-pino": "^4.6.0", @@ -67,50 +70,51 @@ "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pdf-lib": "^1.17.1", - "pg": "^8.20.0", + "pg": "^8.18.0", "pino-http": "^11.0.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1", + "rxjs": "^7.8.2", "sharp": "^0.34.5", "socket.io": "^4.8.3", "unzipper": "^0.12.3", "zod": "^4.3.6" }, "devDependencies": { - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "^9.18.0", - "@nestjs/cli": "^11.0.0", - "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^11.0.1", - "@swc/cli": "^0.7.10", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "^10.0.1", + "@nestjs/cli": "^11.0.16", + "@nestjs/schematics": "^11.0.9", + "@nestjs/testing": "^11.1.16", + "@swc/cli": "^0.8.0", "@swc/core": "^1.15.18", "@types/bcrypt": "^6.0.0", "@types/bcryptjs": "^3.0.0", - "@types/express": "^5.0.0", + "@types/express": "^5.0.6", "@types/jest": "^30.0.0", - "@types/node": "^22.10.7", + "@types/node": "^25.3.5", "@types/nodemailer": "^7.0.11", "@types/passport-jwt": "^4.0.1", - "@types/pg": "^8.16.0", - "@types/supertest": "^6.0.2", + "@types/pg": "^8.18.0", + "@types/supertest": "^7.2.0", "@types/unzipper": "^0.10.11", "drizzle-kit": "^0.31.9", - "eslint": "^9.18.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-prettier": "^5.2.2", - "globals": "^16.0.0", - "jest": "^30.0.0", + "eslint": "^10.0.3", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.5", + "globals": "^17.4.0", + "jest": "^30.2.0", + "jest-junit": "^16.0.0", "pino-pretty": "^13.1.3", - "prettier": "^3.4.2", + "prettier": "^3.8.1", "source-map-support": "^0.5.21", - "supertest": "^7.0.0", - "ts-jest": "^29.2.5", - "ts-loader": "^9.5.2", + "supertest": "^7.2.2", + "ts-jest": "^29.4.6", + "ts-loader": "^9.5.4", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "tsx": "^4.21.0", - "typescript": "^5.7.3", - "typescript-eslint": "^8.20.0" + "typescript": "^5.9.3", + "typescript-eslint": "^8.56.1" }, "jest": { "moduleFileExtensions": [ diff --git a/server/src/common/services/mailer.service.spec.ts b/server/src/common/services/mailer.service.spec.ts index e29459e30..8289d7c80 100644 --- a/server/src/common/services/mailer.service.spec.ts +++ b/server/src/common/services/mailer.service.spec.ts @@ -65,9 +65,7 @@ describe('MailerService', () => { await expect(service.sendPasswordReset('user@example.com', 'Alice', 'a+b&c/d==')).resolves.toBeUndefined(); expect(createTransportMock).not.toHaveBeenCalled(); - expect(logger.log).toHaveBeenCalledWith( - '[DEV] Password reset URL for Alice: http://app.local/reset-password?token=a%2Bb%26c%2Fd%3D%3D', - ); + expect(logger.log).toHaveBeenCalledWith('[DEV] Password reset URL for Alice: http://app.local/reset-password?token=a%2Bb%26c%2Fd%3D%3D'); }); it('sends password reset email with expected payload and no DEV log in production', async () => { diff --git a/server/src/db/db.module.ts b/server/src/db/db.module.ts index e87930602..84b60ae18 100644 --- a/server/src/db/db.module.ts +++ b/server/src/db/db.module.ts @@ -12,7 +12,7 @@ export const DB = Symbol('DB'); { provide: DB, inject: [ConfigService], - useFactory: async (config: ConfigService) => { + useFactory: (config: ConfigService) => { const pool = new Pool({ connectionString: config.get('db.url'), max: 20, @@ -20,7 +20,6 @@ export const DB = Symbol('DB'); connectionTimeoutMillis: 5_000, statement_timeout: 30_000, }); - await pool.query('CREATE EXTENSION IF NOT EXISTS pg_trgm'); return drizzle(pool, { schema }); }, }, diff --git a/server/src/db/migrations/meta/0000_snapshot.json b/server/src/db/migrations/meta/0000_snapshot.json index 9c90129d6..8dbefc945 100644 --- a/server/src/db/migrations/meta/0000_snapshot.json +++ b/server/src/db/migrations/meta/0000_snapshot.json @@ -41,9 +41,7 @@ "app_settings_key_unique": { "name": "app_settings_key_unique", "nullsNotDistinct": false, - "columns": [ - "key" - ] + "columns": ["key"] } }, "policies": {}, @@ -114,12 +112,8 @@ "name": "password_reset_tokens_user_id_users_id_fk", "tableFrom": "password_reset_tokens", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -129,9 +123,7 @@ "password_reset_tokens_token_hash_unique": { "name": "password_reset_tokens_token_hash_unique", "nullsNotDistinct": false, - "columns": [ - "token_hash" - ] + "columns": ["token_hash"] } }, "policies": {}, @@ -182,9 +174,7 @@ "permissions_name_unique": { "name": "permissions_name_unique", "nullsNotDistinct": false, - "columns": [ - "name" - ] + "columns": ["name"] } }, "policies": {}, @@ -255,12 +245,8 @@ "name": "refresh_tokens_user_id_users_id_fk", "tableFrom": "refresh_tokens", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -270,9 +256,7 @@ "refresh_tokens_token_hash_unique": { "name": "refresh_tokens_token_hash_unique", "nullsNotDistinct": false, - "columns": [ - "token_hash" - ] + "columns": ["token_hash"] } }, "policies": {}, @@ -302,12 +286,8 @@ "name": "role_permissions_role_id_roles_id_fk", "tableFrom": "role_permissions", "tableTo": "roles", - "columnsFrom": [ - "role_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["role_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -315,12 +295,8 @@ "name": "role_permissions_permission_id_permissions_id_fk", "tableFrom": "role_permissions", "tableTo": "permissions", - "columnsFrom": [ - "permission_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["permission_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -328,10 +304,7 @@ "compositePrimaryKeys": { "role_permissions_role_id_permission_id_pk": { "name": "role_permissions_role_id_permission_id_pk", - "columns": [ - "role_id", - "permission_id" - ] + "columns": ["role_id", "permission_id"] } }, "uniqueConstraints": {}, @@ -390,9 +363,7 @@ "roles_name_unique": { "name": "roles_name_unique", "nullsNotDistinct": false, - "columns": [ - "name" - ] + "columns": ["name"] } }, "policies": {}, @@ -430,12 +401,8 @@ "name": "user_library_access_user_id_users_id_fk", "tableFrom": "user_library_access", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -443,12 +410,8 @@ "name": "user_library_access_library_id_libraries_id_fk", "tableFrom": "user_library_access", "tableTo": "libraries", - "columnsFrom": [ - "library_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["library_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -456,10 +419,7 @@ "compositePrimaryKeys": { "user_library_access_user_id_library_id_pk": { "name": "user_library_access_user_id_library_id_pk", - "columns": [ - "user_id", - "library_id" - ] + "columns": ["user_id", "library_id"] } }, "uniqueConstraints": {}, @@ -490,12 +450,8 @@ "name": "user_roles_user_id_users_id_fk", "tableFrom": "user_roles", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -503,12 +459,8 @@ "name": "user_roles_role_id_roles_id_fk", "tableFrom": "user_roles", "tableTo": "roles", - "columnsFrom": [ - "role_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["role_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -516,10 +468,7 @@ "compositePrimaryKeys": { "user_roles_user_id_role_id_pk": { "name": "user_roles_user_id_role_id_pk", - "columns": [ - "user_id", - "role_id" - ] + "columns": ["user_id", "role_id"] } }, "uniqueConstraints": {}, @@ -636,16 +585,12 @@ "users_username_unique": { "name": "users_username_unique", "nullsNotDistinct": false, - "columns": [ - "username" - ] + "columns": ["username"] }, "users_email_unique": { "name": "users_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -744,12 +689,8 @@ "name": "book_files_book_id_books_id_fk", "tableFrom": "book_files", "tableTo": "books", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -757,12 +698,8 @@ "name": "book_files_library_folder_id_library_folders_id_fk", "tableFrom": "book_files", "tableTo": "library_folders", - "columnsFrom": [ - "library_folder_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["library_folder_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -829,12 +766,8 @@ "name": "books_library_id_libraries_id_fk", "tableFrom": "books", "tableTo": "libraries", - "columnsFrom": [ - "library_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["library_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -842,12 +775,8 @@ "name": "books_library_folder_id_library_folders_id_fk", "tableFrom": "books", "tableTo": "library_folders", - "columnsFrom": [ - "library_folder_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["library_folder_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -888,12 +817,8 @@ "name": "collection_books_collection_id_collections_id_fk", "tableFrom": "collection_books", "tableTo": "collections", - "columnsFrom": [ - "collection_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["collection_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -901,12 +826,8 @@ "name": "collection_books_book_id_books_id_fk", "tableFrom": "collection_books", "tableTo": "books", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -914,10 +835,7 @@ "compositePrimaryKeys": { "collection_books_collection_id_book_id_pk": { "name": "collection_books_collection_id_book_id_pk", - "columns": [ - "collection_id", - "book_id" - ] + "columns": ["collection_id", "book_id"] } }, "uniqueConstraints": {}, @@ -987,12 +905,8 @@ "name": "collections_user_id_users_id_fk", "tableFrom": "collections", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1058,12 +972,8 @@ "name": "email_preferences_user_id_users_id_fk", "tableFrom": "email_preferences", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1071,12 +981,8 @@ "name": "email_preferences_default_provider_id_email_providers_id_fk", "tableFrom": "email_preferences", "tableTo": "email_providers", - "columnsFrom": [ - "default_provider_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["default_provider_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -1084,12 +990,8 @@ "name": "email_preferences_default_recipient_id_email_recipients_id_fk", "tableFrom": "email_preferences", "tableTo": "email_recipients", - "columnsFrom": [ - "default_recipient_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["default_recipient_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -1097,12 +999,8 @@ "name": "email_preferences_default_template_id_email_templates_id_fk", "tableFrom": "email_preferences", "tableTo": "email_templates", - "columnsFrom": [ - "default_template_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["default_template_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1112,9 +1010,7 @@ "email_preferences_user_id_unique": { "name": "email_preferences_user_id_unique", "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "columns": ["user_id"] } }, "policies": {}, @@ -1235,12 +1131,8 @@ "name": "email_providers_user_id_users_id_fk", "tableFrom": "email_providers", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1250,10 +1142,7 @@ "email_providers_user_id_name_unique": { "name": "email_providers_user_id_name_unique", "nullsNotDistinct": false, - "columns": [ - "user_id", - "name" - ] + "columns": ["user_id", "name"] } }, "policies": {}, @@ -1283,12 +1172,8 @@ "name": "email_recipient_group_members_group_id_email_recipient_groups_id_fk", "tableFrom": "email_recipient_group_members", "tableTo": "email_recipient_groups", - "columnsFrom": [ - "group_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["group_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1296,12 +1181,8 @@ "name": "email_recipient_group_members_recipient_id_email_recipients_id_fk", "tableFrom": "email_recipient_group_members", "tableTo": "email_recipients", - "columnsFrom": [ - "recipient_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["recipient_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1311,10 +1192,7 @@ "email_recipient_group_members_group_id_recipient_id_unique": { "name": "email_recipient_group_members_group_id_recipient_id_unique", "nullsNotDistinct": false, - "columns": [ - "group_id", - "recipient_id" - ] + "columns": ["group_id", "recipient_id"] } }, "policies": {}, @@ -1370,12 +1248,8 @@ "name": "email_recipient_groups_user_id_users_id_fk", "tableFrom": "email_recipient_groups", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1383,12 +1257,8 @@ "name": "email_recipient_groups_default_template_id_email_templates_id_fk", "tableFrom": "email_recipient_groups", "tableTo": "email_templates", - "columnsFrom": [ - "default_template_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["default_template_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1398,10 +1268,7 @@ "email_recipient_groups_user_id_name_unique": { "name": "email_recipient_groups_user_id_name_unique", "nullsNotDistinct": false, - "columns": [ - "user_id", - "name" - ] + "columns": ["user_id", "name"] } }, "policies": {}, @@ -1475,12 +1342,8 @@ "name": "email_recipients_user_id_users_id_fk", "tableFrom": "email_recipients", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1488,12 +1351,8 @@ "name": "email_recipients_default_template_id_email_templates_id_fk", "tableFrom": "email_recipients", "tableTo": "email_templates", - "columnsFrom": [ - "default_template_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["default_template_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1503,10 +1362,7 @@ "email_recipients_user_id_email_unique": { "name": "email_recipients_user_id_email_unique", "nullsNotDistinct": false, - "columns": [ - "user_id", - "email" - ] + "columns": ["user_id", "email"] } }, "policies": {}, @@ -1624,12 +1480,8 @@ "name": "email_send_log_user_id_users_id_fk", "tableFrom": "email_send_log", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1637,12 +1489,8 @@ "name": "email_send_log_book_id_books_id_fk", "tableFrom": "email_send_log", "tableTo": "books", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -1650,12 +1498,8 @@ "name": "email_send_log_book_file_id_book_files_id_fk", "tableFrom": "email_send_log", "tableTo": "book_files", - "columnsFrom": [ - "book_file_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -1663,12 +1507,8 @@ "name": "email_send_log_provider_id_email_providers_id_fk", "tableFrom": "email_send_log", "tableTo": "email_providers", - "columnsFrom": [ - "provider_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -1676,12 +1516,8 @@ "name": "email_send_log_template_id_email_templates_id_fk", "tableFrom": "email_send_log", "tableTo": "email_templates", - "columnsFrom": [ - "template_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["template_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1778,12 +1614,8 @@ "name": "email_templates_user_id_users_id_fk", "tableFrom": "email_templates", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1793,10 +1625,7 @@ "email_templates_user_id_name_unique": { "name": "email_templates_user_id_name_unique", "nullsNotDistinct": false, - "columns": [ - "user_id", - "name" - ] + "columns": ["user_id", "name"] } }, "policies": {}, @@ -1897,12 +1726,8 @@ "name": "file_write_log_book_id_books_id_fk", "tableFrom": "file_write_log", "tableTo": "books", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1910,12 +1735,8 @@ "name": "file_write_log_book_file_id_book_files_id_fk", "tableFrom": "file_write_log", "tableTo": "book_files", - "columnsFrom": [ - "book_file_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -1923,12 +1744,8 @@ "name": "file_write_log_user_id_users_id_fk", "tableFrom": "file_write_log", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -2113,12 +1930,8 @@ "name": "library_folders_library_id_libraries_id_fk", "tableFrom": "library_folders", "tableTo": "libraries", - "columnsFrom": [ - "library_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["library_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2198,12 +2011,8 @@ "name": "lenses_user_id_users_id_fk", "tableFrom": "lenses", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2213,10 +2022,7 @@ "lenses_user_id_name_unique": { "name": "lenses_user_id_name_unique", "nullsNotDistinct": false, - "columns": [ - "user_id", - "name" - ] + "columns": ["user_id", "name"] } }, "policies": {}, @@ -2301,12 +2107,8 @@ "name": "book_authors_book_id_books_id_fk", "tableFrom": "book_authors", "tableTo": "books", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2314,12 +2116,8 @@ "name": "book_authors_author_id_authors_id_fk", "tableFrom": "book_authors", "tableTo": "authors", - "columnsFrom": [ - "author_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["author_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -2327,10 +2125,7 @@ "compositePrimaryKeys": { "book_authors_book_id_author_id_pk": { "name": "book_authors_book_id_author_id_pk", - "columns": [ - "book_id", - "author_id" - ] + "columns": ["book_id", "author_id"] } }, "uniqueConstraints": {}, @@ -2361,12 +2156,8 @@ "name": "book_genres_book_id_books_id_fk", "tableFrom": "book_genres", "tableTo": "books", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2374,12 +2165,8 @@ "name": "book_genres_genre_id_genres_id_fk", "tableFrom": "book_genres", "tableTo": "genres", - "columnsFrom": [ - "genre_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["genre_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2387,10 +2174,7 @@ "compositePrimaryKeys": { "book_genres_book_id_genre_id_pk": { "name": "book_genres_book_id_genre_id_pk", - "columns": [ - "book_id", - "genre_id" - ] + "columns": ["book_id", "genre_id"] } }, "uniqueConstraints": {}, @@ -2606,12 +2390,8 @@ "name": "book_metadata_book_id_books_id_fk", "tableFrom": "book_metadata", "tableTo": "books", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2645,12 +2425,8 @@ "name": "book_tags_book_id_books_id_fk", "tableFrom": "book_tags", "tableTo": "books", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2658,12 +2434,8 @@ "name": "book_tags_tag_id_tags_id_fk", "tableFrom": "book_tags", "tableTo": "tags", - "columnsFrom": [ - "tag_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2671,10 +2443,7 @@ "compositePrimaryKeys": { "book_tags_book_id_tag_id_pk": { "name": "book_tags_book_id_tag_id_pk", - "columns": [ - "book_id", - "tag_id" - ] + "columns": ["book_id", "tag_id"] } }, "uniqueConstraints": {}, @@ -2706,9 +2475,7 @@ "genres_name_unique": { "name": "genres_name_unique", "nullsNotDistinct": false, - "columns": [ - "name" - ] + "columns": ["name"] } }, "policies": {}, @@ -2739,9 +2506,7 @@ "tags_name_unique": { "name": "tags_name_unique", "nullsNotDistinct": false, - "columns": [ - "name" - ] + "columns": ["name"] } }, "policies": {}, @@ -2824,12 +2589,8 @@ "name": "scan_jobs_library_id_libraries_id_fk", "tableFrom": "scan_jobs", "tableTo": "libraries", - "columnsFrom": [ - "library_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["library_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2937,12 +2698,8 @@ "name": "annotations_user_id_users_id_fk", "tableFrom": "annotations", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2950,12 +2707,8 @@ "name": "annotations_book_id_books_id_fk", "tableFrom": "annotations", "tableTo": "books", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3030,12 +2783,8 @@ "name": "bookmarks_user_id_users_id_fk", "tableFrom": "bookmarks", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3043,12 +2792,8 @@ "name": "bookmarks_book_id_books_id_fk", "tableFrom": "bookmarks", "tableTo": "books", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3123,12 +2868,8 @@ "name": "reader_default_preferences_user_id_users_id_fk", "tableFrom": "reader_default_preferences", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3203,12 +2944,8 @@ "name": "reader_preferences_user_id_users_id_fk", "tableFrom": "reader_preferences", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3216,12 +2953,8 @@ "name": "reader_preferences_book_file_id_book_files_id_fk", "tableFrom": "reader_preferences", "tableTo": "book_files", - "columnsFrom": [ - "book_file_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3297,12 +3030,8 @@ "name": "reading_progress_book_file_id_book_files_id_fk", "tableFrom": "reading_progress", "tableTo": "book_files", - "columnsFrom": [ - "book_file_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_file_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3310,12 +3039,8 @@ "name": "reading_progress_user_id_users_id_fk", "tableFrom": "reading_progress", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3323,10 +3048,7 @@ "compositePrimaryKeys": { "reading_progress_book_file_id_user_id_pk": { "name": "reading_progress_book_file_id_user_id_pk", - "columns": [ - "book_file_id", - "user_id" - ] + "columns": ["book_file_id", "user_id"] } }, "uniqueConstraints": {}, @@ -3370,12 +3092,8 @@ "name": "oidc_group_mappings_role_id_roles_id_fk", "tableFrom": "oidc_group_mappings", "tableTo": "roles", - "columnsFrom": [ - "role_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["role_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -3385,9 +3103,7 @@ "oidc_group_mappings_oidc_group_claim_unique": { "name": "oidc_group_mappings_oidc_group_claim_unique", "nullsNotDistinct": false, - "columns": [ - "oidc_group_claim" - ] + "columns": ["oidc_group_claim"] } }, "policies": {}, @@ -3507,12 +3223,8 @@ "name": "oidc_sessions_user_id_users_id_fk", "tableFrom": "oidc_sessions", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3580,12 +3292,8 @@ "name": "opds_users_user_id_users_id_fk", "tableFrom": "opds_users", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3595,9 +3303,7 @@ "opds_users_username_unique": { "name": "opds_users_username_unique", "nullsNotDistinct": false, - "columns": [ - "username" - ] + "columns": ["username"] } }, "policies": {}, @@ -3652,12 +3358,8 @@ "name": "kobo_devices_user_id_users_id_fk", "tableFrom": "kobo_devices", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3667,9 +3369,7 @@ "kobo_devices_token_unique": { "name": "kobo_devices_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -3713,12 +3413,8 @@ "name": "kobo_library_snapshots_user_id_users_id_fk", "tableFrom": "kobo_library_snapshots", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3728,9 +3424,7 @@ "kobo_library_snapshots_user_id_unique": { "name": "kobo_library_snapshots_user_id_unique", "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "columns": ["user_id"] } }, "policies": {}, @@ -3821,12 +3515,8 @@ "name": "kobo_reading_states_user_id_users_id_fk", "tableFrom": "kobo_reading_states", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3834,12 +3524,8 @@ "name": "kobo_reading_states_book_id_books_id_fk", "tableFrom": "kobo_reading_states", "tableTo": "books", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3849,10 +3535,7 @@ "kobo_reading_states_user_id_book_id_unique": { "name": "kobo_reading_states_user_id_book_id_unique", "nullsNotDistinct": false, - "columns": [ - "user_id", - "book_id" - ] + "columns": ["user_id", "book_id"] } }, "policies": {}, @@ -3922,12 +3605,8 @@ "name": "kobo_snapshot_books_snapshot_id_kobo_library_snapshots_id_fk", "tableFrom": "kobo_snapshot_books", "tableTo": "kobo_library_snapshots", - "columnsFrom": [ - "snapshot_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["snapshot_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3935,12 +3614,8 @@ "name": "kobo_snapshot_books_book_id_books_id_fk", "tableFrom": "kobo_snapshot_books", "tableTo": "books", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["book_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3948,10 +3623,7 @@ "compositePrimaryKeys": { "kobo_snapshot_books_snapshot_id_book_id_pk": { "name": "kobo_snapshot_books_snapshot_id_book_id_pk", - "columns": [ - "snapshot_id", - "book_id" - ] + "columns": ["snapshot_id", "book_id"] } }, "uniqueConstraints": {}, @@ -4038,12 +3710,8 @@ "name": "kobo_sync_settings_user_id_users_id_fk", "tableFrom": "kobo_sync_settings", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -4053,9 +3721,7 @@ "kobo_sync_settings_user_id_unique": { "name": "kobo_sync_settings_user_id_unique", "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "columns": ["user_id"] } }, "policies": {}, @@ -4200,12 +3866,8 @@ "name": "staging_files_target_library_id_libraries_id_fk", "tableFrom": "staging_files", "tableTo": "libraries", - "columnsFrom": [ - "target_library_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["target_library_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -4213,12 +3875,8 @@ "name": "staging_files_target_folder_id_library_folders_id_fk", "tableFrom": "staging_files", "tableTo": "library_folders", - "columnsFrom": [ - "target_folder_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["target_folder_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -4228,9 +3886,7 @@ "staging_files_absolute_path_unique": { "name": "staging_files_absolute_path_unique", "nullsNotDistinct": false, - "columns": [ - "absolute_path" - ] + "columns": ["absolute_path"] } }, "policies": {}, @@ -4242,24 +3898,12 @@ "public.library_access_level": { "name": "library_access_level", "schema": "public", - "values": [ - "viewer", - "editor", - "owner" - ] + "values": ["viewer", "editor", "owner"] }, "public.opds_sort_order": { "name": "opds_sort_order", "schema": "public", - "values": [ - "recent", - "title_asc", - "title_desc", - "author_asc", - "author_desc", - "series_asc", - "series_desc" - ] + "values": ["recent", "title_asc", "title_desc", "author_asc", "author_desc", "series_asc", "series_desc"] } }, "schemas": {}, @@ -4272,4 +3916,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index 2da93d996..75fde7c35 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -10,4 +10,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/server/src/db/schema/email-recipients.spec.ts b/server/src/db/schema/email-recipients.spec.ts index 4322fe579..b178b55e4 100644 --- a/server/src/db/schema/email-recipients.spec.ts +++ b/server/src/db/schema/email-recipients.spec.ts @@ -4,7 +4,15 @@ import { emailRecipientGroupMembers, emailRecipientGroups, emailRecipients } fro const fkByColumn = (table: unknown) => { const config = getTableConfig(table as never); - return new Map(config.foreignKeys.map((fk) => [fk.reference().columns.map((col) => col.name).join(','), fk])); + return new Map( + config.foreignKeys.map((fk) => [ + fk + .reference() + .columns.map((col) => col.name) + .join(','), + fk, + ]), + ); }; describe('email-recipients schema', () => { diff --git a/server/src/db/schema/kobo.ts b/server/src/db/schema/kobo.ts index a3854a924..a741e7224 100644 --- a/server/src/db/schema/kobo.ts +++ b/server/src/db/schema/kobo.ts @@ -84,9 +84,9 @@ export const koboReadingStates = pgTable( statusInfo: jsonb('status_info'), progressSyncedAt: timestamp('progress_synced_at'), updatedAt: timestamp('updated_at') - .defaultNow() - .notNull() - .$onUpdateFn(() => new Date()), + .defaultNow() + .notNull() + .$onUpdateFn(() => new Date()), }, (t) => [unique().on(t.userId, t.bookId)], ); diff --git a/server/src/db/schema/reader.spec.ts b/server/src/db/schema/reader.spec.ts index 41fd1eb55..f02168ef6 100644 --- a/server/src/db/schema/reader.spec.ts +++ b/server/src/db/schema/reader.spec.ts @@ -4,7 +4,15 @@ import { annotations, bookmarks, readerDefaultPreferences, readerPreferences, re const fkByColumn = (table: unknown) => { const config = getTableConfig(table as never); - return new Map(config.foreignKeys.map((fk) => [fk.reference().columns.map((col) => col.name).join(','), fk])); + return new Map( + config.foreignKeys.map((fk) => [ + fk + .reference() + .columns.map((col) => col.name) + .join(','), + fk, + ]), + ); }; describe('reader schema', () => { diff --git a/server/src/db/schema/schema.spec.ts b/server/src/db/schema/schema.spec.ts index afb44e01b..dcb018bbf 100644 --- a/server/src/db/schema/schema.spec.ts +++ b/server/src/db/schema/schema.spec.ts @@ -45,16 +45,16 @@ describe('Database Schema Logic', () => { }); it('fromDriver should handle null/undefined safely', () => { - // @ts-ignore + // @ts-expect-error: testing invalid null input expect(embeddingColumn.mapFromDriverValue(null)).toEqual([]); - // @ts-ignore + // @ts-expect-error: testing invalid undefined input expect(embeddingColumn.mapFromDriverValue(undefined)).toEqual([]); }); it('fromDriver should handle non-string inputs safely', () => { - // @ts-ignore + // @ts-expect-error: testing invalid number input expect(embeddingColumn.mapFromDriverValue(123)).toEqual([]); - // @ts-ignore + // @ts-expect-error: testing invalid object input expect(embeddingColumn.mapFromDriverValue({})).toEqual([]); }); }); diff --git a/server/src/main.ts b/server/src/main.ts index c59e4b00e..e78fd89c3 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -7,9 +7,6 @@ import { AppModule } from './app.module'; import { GlobalExceptionFilter } from './common/filters/http-exception.filter'; import { join } from 'path'; import { Readable } from 'stream'; -import { drizzle } from 'drizzle-orm/node-postgres'; -import { migrate } from 'drizzle-orm/node-postgres/migrator'; -import { Pool } from 'pg'; import fastifyCookie from '@fastify/cookie'; import fastifyMultipart from '@fastify/multipart'; import fastifyRateLimit from '@fastify/rate-limit'; @@ -20,15 +17,6 @@ import fastifyCompress from '@fastify/compress'; const MAX_COVER_BYTES = 20 * 1024 * 1024; async function bootstrap() { - const pool = new Pool({ - connectionString: process.env.DATABASE_URL, - max: 3, - idleTimeoutMillis: 10_000, - connectionTimeoutMillis: 5_000, - }); - await migrate(drizzle(pool), { migrationsFolder: join(__dirname, '..', 'src', 'db', 'migrations') }); - await pool.end(); - const adapter = new FastifyAdapter({ logger: false }); const app = await NestFactory.create(AppModule, adapter, { bufferLogs: true }); app.useLogger(app.get(Logger)); @@ -74,7 +62,7 @@ async function bootstrap() { app.useGlobalFilters(new GlobalExceptionFilter()); - await app.register(fastifyHelmet, { + await app.register(fastifyHelmet as never, { contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], @@ -89,15 +77,15 @@ async function bootstrap() { }, }); - await app.register(fastifyCompress, { encodings: ['gzip', 'br'] }); + await app.register(fastifyCompress as never, { encodings: ['gzip', 'br'] }); - await app.register(fastifyCookie); - await app.register(fastifyMultipart, { limits: { fileSize: MAX_COVER_BYTES } }); + await app.register(fastifyCookie as never); + await app.register(fastifyMultipart as never, { limits: { fileSize: MAX_COVER_BYTES } }); // Rate limit unauthenticated requests only (brute-force protection on public endpoints). // Bypass for: JWT cookie (web app), Authorization header (OPDS Basic Auth), // and Kobo device endpoints (token-authenticated via URL). - await app.register(fastifyRateLimit, { + await app.register(fastifyRateLimit as never, { max: 100, timeWindow: '1 minute', allowList: (req: { cookies?: { access_token?: string }; headers?: { authorization?: string }; url?: string }) => @@ -115,7 +103,7 @@ async function bootstrap() { } if (process.env.NODE_ENV === 'production') { - await app.register(fastifyStatic, { + await app.register(fastifyStatic as never, { root: join(__dirname, '..', 'public'), prefix: '/', decorateReply: false, diff --git a/server/src/modules/auth/auth.service.spec.ts b/server/src/modules/auth/auth.service.spec.ts index c7b5f6a89..3e3fe799e 100644 --- a/server/src/modules/auth/auth.service.spec.ts +++ b/server/src/modules/auth/auth.service.spec.ts @@ -355,9 +355,7 @@ describe('AuthService', () => { passwordHash: 'hash', }); - await expect(service.changePassword(1, { currentPassword: 'old', newPassword: 'New@1234' }, makeReply())).rejects.toThrow( - BadRequestException, - ); + await expect(service.changePassword(1, { currentPassword: 'old', newPassword: 'New@1234' }, makeReply())).rejects.toThrow(BadRequestException); }); }); diff --git a/server/src/modules/book/book.controller.spec.ts b/server/src/modules/book/book.controller.spec.ts index 96cfb00de..5cbec4894 100644 --- a/server/src/modules/book/book.controller.spec.ts +++ b/server/src/modules/book/book.controller.spec.ts @@ -216,9 +216,9 @@ describe('BookController', () => { it('streams server-sent events for bulk metadata refresh progress', async () => { const { controller, bookService } = makeController(); const { reply, raw } = makeReply(); - bookService.bulkRefreshMetadata.mockImplementation(async (_bookIds: number[], _user: RequestUser, onProgress: (bookId: number) => void) => { + bookService.bulkRefreshMetadata.mockImplementation((_bookIds: number[], _user: RequestUser, onProgress: (bookId: number) => void) => { onProgress(9); - return { processed: 1, failed: 0 }; + return Promise.resolve({ processed: 1, failed: 0 }); }); await controller.bulkRefreshMetadata({ bookIds: [9] }, makeUser(), reply); diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index 74a82113c..ba5f401e5 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -502,7 +502,7 @@ export class BookService { processed++; onProgress?.(id); } catch (err) { - this.logger.warn(`Bulk metadata refresh failed for book ${id}: ${err}`); + this.logger.warn(`Bulk metadata refresh failed for book ${id}: ${err instanceof Error ? err.message : String(err)}`); failed++; onProgress?.(id); } diff --git a/server/src/modules/email/email-file-selector.spec.ts b/server/src/modules/email/email-file-selector.spec.ts index c99347716..3b34601f7 100644 --- a/server/src/modules/email/email-file-selector.spec.ts +++ b/server/src/modules/email/email-file-selector.spec.ts @@ -18,10 +18,7 @@ describe('EmailFileSelector', () => { }; const module: TestingModule = await Test.createTestingModule({ - providers: [ - EmailFileSelector, - { provide: DB, useValue: db }, - ], + providers: [EmailFileSelector, { provide: DB, useValue: db }], }).compile(); selector = module.get(EmailFileSelector); diff --git a/server/src/modules/email/email-recipient.service.spec.ts b/server/src/modules/email/email-recipient.service.spec.ts index d3bfedcc3..b2f7d6ad4 100644 --- a/server/src/modules/email/email-recipient.service.spec.ts +++ b/server/src/modules/email/email-recipient.service.spec.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EmailRecipientService } from './email-recipient.service'; import { EmailRecipientRepository } from './email-recipient.repository'; diff --git a/server/src/modules/email/email-send-orchestrator.service.spec.ts b/server/src/modules/email/email-send-orchestrator.service.spec.ts index 4a3f8a4c3..cfb66f544 100644 --- a/server/src/modules/email/email-send-orchestrator.service.spec.ts +++ b/server/src/modules/email/email-send-orchestrator.service.spec.ts @@ -19,11 +19,8 @@ jest.mock('fs'); describe('EmailSendOrchestrator', () => { let orchestrator: EmailSendOrchestrator; let providerResolver: EmailProviderResolver; - let fileSelector: EmailFileSelector; let recipientService: EmailRecipientService; let groupService: EmailRecipientGroupService; - let templateService: EmailTemplateService; - let templateContextService: EmailTemplateContextService; let preferencesService: EmailPreferencesService; let sendLogService: EmailSendLogService; let transportService: EmailTransportService; @@ -42,7 +39,14 @@ describe('EmailSendOrchestrator', () => { roles: [], }; - const mockRecipient = { id: 10, email: 'recipient@test.com', name: 'Recipient', deviceType: 'kindle', preferredFormat: 'mobi', defaultTemplateId: null }; + const mockRecipient = { + id: 10, + email: 'recipient@test.com', + name: 'Recipient', + deviceType: 'kindle', + preferredFormat: 'mobi', + defaultTemplateId: null, + }; const mockFile = { id: 100, absolutePath: '/path/to/book.mobi', format: 'MOBI', relPath: 'Books/book.mobi' }; const mockTemplate = { id: 200, subject: 'Subject {{title}}', bodyText: 'Body' }; const mockProvider = { config: { host: 'smtp.test.com' }, providerId: 300 }; @@ -101,11 +105,8 @@ describe('EmailSendOrchestrator', () => { orchestrator = module.get(EmailSendOrchestrator); providerResolver = module.get(EmailProviderResolver); - fileSelector = module.get(EmailFileSelector); recipientService = module.get(EmailRecipientService); groupService = module.get(EmailRecipientGroupService); - templateService = module.get(EmailTemplateService); - templateContextService = module.get(EmailTemplateContextService); preferencesService = module.get(EmailPreferencesService); sendLogService = module.get(EmailSendLogService); transportService = module.get(EmailTransportService); @@ -171,9 +172,11 @@ describe('EmailSendOrchestrator', () => { const result = await orchestrator.resend(400, mockUser); expect(result.queued).toBe(1); - expect(sendLogService.create).toHaveBeenCalledWith(expect.objectContaining({ - toEmail: 'resend@test.com', - })); + expect(sendLogService.create).toHaveBeenCalledWith( + expect.objectContaining({ + toEmail: 'resend@test.com', + }), + ); }); }); @@ -191,11 +194,13 @@ describe('EmailSendOrchestrator', () => { await (orchestrator as any).dispatchSend(400, {}, task, file, 'Subject', 'Body', 0); - expect(mockTransporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ - to: 'test@test.com', - subject: 'Subject', - text: 'Body', - })); + expect(mockTransporter.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'test@test.com', + subject: 'Subject', + text: 'Body', + }), + ); expect(sendLogService.markSent).toHaveBeenCalledWith(400); }); @@ -221,9 +226,11 @@ describe('EmailSendOrchestrator', () => { await (orchestrator as any).dispatchSend(400, {}, task, file, 'Original', 'Body', 0); - expect(mockTransporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ - subject: 'convert', - })); + expect(mockTransporter.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'convert', + }), + ); }); it('should use "convert" subject in resend if original was "convert"', async () => { @@ -241,9 +248,11 @@ describe('EmailSendOrchestrator', () => { await orchestrator.resend(400, mockUser); - expect(sendLogService.create).toHaveBeenCalledWith(expect.objectContaining({ - subject: 'convert', - })); + expect(sendLogService.create).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'convert', + }), + ); }); it('should not retry if isFinal is true', async () => { diff --git a/server/src/modules/email/email-template-context.service.spec.ts b/server/src/modules/email/email-template-context.service.spec.ts index cd19c0d76..44484d6ae 100644 --- a/server/src/modules/email/email-template-context.service.spec.ts +++ b/server/src/modules/email/email-template-context.service.spec.ts @@ -7,7 +7,6 @@ import { DB } from '../../db'; describe('EmailTemplateContextService', () => { let service: EmailTemplateContextService; let db: any; - let config: ConfigService; beforeEach(async () => { db = { @@ -28,7 +27,6 @@ describe('EmailTemplateContextService', () => { }).compile(); service = module.get(EmailTemplateContextService); - config = module.get(ConfigService); }); it('should build context for a book', async () => { @@ -47,24 +45,22 @@ describe('EmailTemplateContextService', () => { // Authors and tags use orderBy or innerJoin and don't have limit (db.orderBy as jest.Mock).mockResolvedValueOnce(mockAuthors); (db.where as jest.Mock).mockImplementation(function (this: any) { - if (db.where.mock.calls.length === 4) { // tags call - return Promise.resolve(mockTags); - } - return this; + if (db.where.mock.calls.length === 4) { + // tags call + return Promise.resolve(mockTags); + } + return this; }); // Actually, it's cleaner to mock each stage - db.limit = jest.fn() - .mockResolvedValueOnce([mockBook]) - .mockResolvedValueOnce([mockMeta]) - .mockResolvedValueOnce([mockFile]); + db.limit = jest.fn().mockResolvedValueOnce([mockBook]).mockResolvedValueOnce([mockMeta]).mockResolvedValueOnce([mockFile]); db.orderBy = jest.fn().mockResolvedValueOnce(mockAuthors); - + // For tags, it's select().from().innerJoin().where() -> no limit, no orderBy // We need to make where() return mockTags for the 4th call let callCount = 0; - db.where = jest.fn().mockImplementation(function(this: any) { + db.where = jest.fn().mockImplementation(function (this: any) { callCount++; if (callCount === 4) return Promise.resolve(mockTags); return this; diff --git a/server/src/modules/email/email-template.service.spec.ts b/server/src/modules/email/email-template.service.spec.ts index 49b3b3811..2e8d1abbe 100644 --- a/server/src/modules/email/email-template.service.spec.ts +++ b/server/src/modules/email/email-template.service.spec.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { ForbiddenException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { EmailTemplateService } from './email-template.service'; import { EmailTemplateRepository } from './email-template.repository'; diff --git a/server/src/modules/file-write/file-lock.service.spec.ts b/server/src/modules/file-write/file-lock.service.spec.ts index 728a21d9d..624272bfd 100644 --- a/server/src/modules/file-write/file-lock.service.spec.ts +++ b/server/src/modules/file-write/file-lock.service.spec.ts @@ -12,7 +12,7 @@ describe('FileLockService', () => { return 1; }); - const second = lock.withLock('/tmp/a', async () => { + const second = lock.withLock('/tmp/a', () => { sequence.push('second:start'); sequence.push('second:end'); return 2; @@ -27,10 +27,10 @@ describe('FileLockService', () => { const starts: string[] = []; await Promise.all([ - lock.withLock('/tmp/a', async () => { + lock.withLock('/tmp/a', () => { starts.push('a'); }), - lock.withLock('/tmp/b', async () => { + lock.withLock('/tmp/b', () => { starts.push('b'); }), ]); @@ -43,13 +43,13 @@ describe('FileLockService', () => { const lock = new FileLockService(); await expect( - lock.withLock('/tmp/fail', async () => { + lock.withLock('/tmp/fail', () => { throw new Error('boom'); }), ).rejects.toThrow('boom'); await expect( - lock.withLock('/tmp/fail', async () => { + lock.withLock('/tmp/fail', () => { return 'ok'; }), ).resolves.toBe('ok'); diff --git a/server/src/modules/file-write/file-write.repository.spec.ts b/server/src/modules/file-write/file-write.repository.spec.ts index e7bc138b7..3b07fbefb 100644 --- a/server/src/modules/file-write/file-write.repository.spec.ts +++ b/server/src/modules/file-write/file-write.repository.spec.ts @@ -17,10 +17,7 @@ describe('FileWriteRepository', () => { const c2 = chain([]); const db = { - select: jest - .fn() - .mockReturnValueOnce(c1) - .mockReturnValueOnce(c2), + select: jest.fn().mockReturnValueOnce(c1).mockReturnValueOnce(c2), }; const repo = new FileWriteRepository(db as never); @@ -78,12 +75,7 @@ describe('FileWriteRepository', () => { }; const db = { - select: jest - .fn() - .mockReturnValueOnce(metaChain) - .mockReturnValueOnce(authorChain) - .mockReturnValueOnce(genreChain) - .mockReturnValueOnce(tagChain), + select: jest.fn().mockReturnValueOnce(metaChain).mockReturnValueOnce(authorChain).mockReturnValueOnce(genreChain).mockReturnValueOnce(tagChain), }; const repo = new FileWriteRepository(db as never); diff --git a/server/src/modules/file-write/file-write.service.spec.ts b/server/src/modules/file-write/file-write.service.spec.ts index 1b198d804..bb44437c1 100644 --- a/server/src/modules/file-write/file-write.service.spec.ts +++ b/server/src/modules/file-write/file-write.service.spec.ts @@ -41,13 +41,7 @@ describe('FileWriteService', () => { get: jest.fn().mockImplementation((key: string) => (key === 'storage.booksPath' ? '/books' : undefined)), } as unknown as ConfigService; - const service = new FileWriteService( - fileWriteRepo as never, - settingsService as never, - registry as never, - lockService as never, - config, - ); + const service = new FileWriteService(fileWriteRepo as never, settingsService as never, registry as never, lockService as never, config); return { service, fileWriteRepo, settingsService, registry, writer, lockService }; } diff --git a/server/src/modules/file-write/formats/cbx/cb7-format-writer.spec.ts b/server/src/modules/file-write/formats/cbx/cb7-format-writer.spec.ts index 4842cd084..21895d931 100644 --- a/server/src/modules/file-write/formats/cbx/cb7-format-writer.spec.ts +++ b/server/src/modules/file-write/formats/cbx/cb7-format-writer.spec.ts @@ -106,9 +106,7 @@ describe('Cb7FormatWriter', () => { const writer = new Cb7FormatWriter(); - await expect( - writer.write('/book.cb7', { title: 'Dune' }, { fieldMask: new Set(['title']), dryRun: false }), - ).rejects.toThrow('rename denied'); + await expect(writer.write('/book.cb7', { title: 'Dune' }, { fieldMask: new Set(['title']), dryRun: false })).rejects.toThrow('rename denied'); expect(mockBuildComicInfoXml).toHaveBeenCalledWith(null, { title: 'Dune' }, new Set(['title'])); expect(mockUnlink).toHaveBeenCalledWith('/.cbx-write-abcdef123'); diff --git a/server/src/modules/file-write/formats/cbx/cbz-format-writer.spec.ts b/server/src/modules/file-write/formats/cbx/cbz-format-writer.spec.ts index 2430955fe..1168f645f 100644 --- a/server/src/modules/file-write/formats/cbx/cbz-format-writer.spec.ts +++ b/server/src/modules/file-write/formats/cbx/cbz-format-writer.spec.ts @@ -39,11 +39,7 @@ describe('CbzFormatWriter', () => { mockBuildComicInfoXml.mockReturnValue('Dune'); mockWriteComicInfoToZip.mockResolvedValue(undefined); - const result = await writer.write( - '/book.cbz', - { title: 'Dune', tags: ['classic'] }, - { fieldMask: new Set(['title', 'tags']), dryRun: false }, - ); + const result = await writer.write('/book.cbz', { title: 'Dune', tags: ['classic'] }, { fieldMask: new Set(['title', 'tags']), dryRun: false }); expect(mockReadComicInfoFromZip).toHaveBeenCalledWith('/book.cbz'); expect(mockBuildComicInfoXml).toHaveBeenCalledWith('', { title: 'Dune', tags: ['classic'] }, new Set(['title', 'tags'])); diff --git a/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.spec.ts b/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.spec.ts index 2b489de29..2e28bf93b 100644 --- a/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.spec.ts +++ b/server/src/modules/file-write/formats/cbx/cbz-zip-patcher.spec.ts @@ -81,10 +81,7 @@ describe('cbz-zip-patcher', () => { it('readComicInfoFromZip finds comicinfo case-insensitively in root or nested paths', async () => { mockOpenFile.mockResolvedValue({ - files: [ - { path: 'pages/001.jpg' }, - { path: 'META/ComicInfo.XML', buffer: jest.fn().mockResolvedValue(Buffer.from('')) }, - ], + files: [{ path: 'pages/001.jpg' }, { path: 'META/ComicInfo.XML', buffer: jest.fn().mockResolvedValue(Buffer.from('')) }], } as never); await expect(readComicInfoFromZip('/book.cbz')).resolves.toBe(''); diff --git a/server/src/modules/file-write/formats/epub/epub-format-writer.spec.ts b/server/src/modules/file-write/formats/epub/epub-format-writer.spec.ts index 166969bfe..dacb8b596 100644 --- a/server/src/modules/file-write/formats/epub/epub-format-writer.spec.ts +++ b/server/src/modules/file-write/formats/epub/epub-format-writer.spec.ts @@ -53,17 +53,10 @@ describe('EpubFormatWriter', () => { const coverBytes = Buffer.from('cover'); - const result = await writer.write( - '/book.epub', - { title: 'Dune', coverBytes }, - { fieldMask: new Set(['title', 'coverBytes']), dryRun: false }, - ); + const result = await writer.write('/book.epub', { title: 'Dune', coverBytes }, { fieldMask: new Set(['title', 'coverBytes']), dryRun: false }); expect(mockCoverLocate).toHaveBeenCalledWith('', 'OPS/'); - expect(mockPatch).toHaveBeenCalledWith( - '/book.epub', - expect.any(Map), - ); + expect(mockPatch).toHaveBeenCalledWith('/book.epub', expect.any(Map)); const patches = mockPatch.mock.calls[0][1]; expect(patches.get('OPS/content.opf')).toEqual(Buffer.from('new')); @@ -83,11 +76,7 @@ describe('EpubFormatWriter', () => { const coverBytes = Buffer.from([1, 2, 3]); - await writer.write( - '/book.epub', - { title: 'Dune', coverBytes }, - { fieldMask: new Set(['title', 'coverBytes']), dryRun: false }, - ); + await writer.write('/book.epub', { title: 'Dune', coverBytes }, { fieldMask: new Set(['title', 'coverBytes']), dryRun: false }); expect(mockCoverInject).toHaveBeenCalledWith('new', 'OPS/', coverBytes); diff --git a/server/src/modules/file-write/formats/epub/epub-opf-builder.spec.ts b/server/src/modules/file-write/formats/epub/epub-opf-builder.spec.ts index 77ff6390a..29f8bbb8f 100644 --- a/server/src/modules/file-write/formats/epub/epub-opf-builder.spec.ts +++ b/server/src/modules/file-write/formats/epub/epub-opf-builder.spec.ts @@ -24,9 +24,7 @@ describe('epub-opf-builder', () => { it('throws on unsupported package version', () => { const opf = ``; - expect(() => build(opf, { title: 'Book' }, { fieldMask: new Set(['title']), dryRun: false })).toThrow( - 'Unsupported EPUB version: "1.0"', - ); + expect(() => build(opf, { title: 'Book' }, { fieldMask: new Set(['title']), dryRun: false })).toThrow('Unsupported EPUB version: "1.0"'); }); it('rewrites metadata for EPUB3, preserves UID, and appends required namespace prefix', () => { diff --git a/server/src/modules/file-write/formats/epub/epub-opf-locator.spec.ts b/server/src/modules/file-write/formats/epub/epub-opf-locator.spec.ts index 669d11006..658e97377 100644 --- a/server/src/modules/file-write/formats/epub/epub-opf-locator.spec.ts +++ b/server/src/modules/file-write/formats/epub/epub-opf-locator.spec.ts @@ -20,11 +20,11 @@ describe('locateOpf', () => { files: [ { path: 'META-INF/container.xml', - buffer: jest.fn().mockResolvedValue( - Buffer.from( - ``, + buffer: jest + .fn() + .mockResolvedValue( + Buffer.from(``), ), - ), }, ], } as never); diff --git a/server/src/modules/file-write/formats/pdf/pdf-format-writer.spec.ts b/server/src/modules/file-write/formats/pdf/pdf-format-writer.spec.ts index 76a8b7429..f7f4469ae 100644 --- a/server/src/modules/file-write/formats/pdf/pdf-format-writer.spec.ts +++ b/server/src/modules/file-write/formats/pdf/pdf-format-writer.spec.ts @@ -139,9 +139,9 @@ describe('PdfFormatWriter', () => { const writer = new PdfFormatWriter(); - await expect( - writer.write('/books/dune.pdf', { title: 'Dune' }, { fieldMask: new Set(['title']), dryRun: false }), - ).rejects.toThrow('permission denied'); + await expect(writer.write('/books/dune.pdf', { title: 'Dune' }, { fieldMask: new Set(['title']), dryRun: false })).rejects.toThrow( + 'permission denied', + ); expect(mockUnlink).toHaveBeenCalledWith('/books/.tmp-abc-uuid.pdf'); }); diff --git a/server/src/modules/file-write/formats/pdf/pdf-xmp-builder.spec.ts b/server/src/modules/file-write/formats/pdf/pdf-xmp-builder.spec.ts index 3f52d8ec2..f6cce786f 100644 --- a/server/src/modules/file-write/formats/pdf/pdf-xmp-builder.spec.ts +++ b/server/src/modules/file-write/formats/pdf/pdf-xmp-builder.spec.ts @@ -43,17 +43,11 @@ describe('buildXmp', () => { }); it('writes series only when both seriesName and seriesIndex are selected and present', () => { - const withBoth = buildXmp( - { seriesName: 'Dune', seriesIndex: 1 }, - new Set(['seriesName', 'seriesIndex']), - ); + const withBoth = buildXmp({ seriesName: 'Dune', seriesIndex: 1 }, new Set(['seriesName', 'seriesIndex'])); expect(withBoth).toContain('Dune'); expect(withBoth).toContain('1'); - const missingMask = buildXmp( - { seriesName: 'Dune', seriesIndex: 1 }, - new Set(['seriesName']), - ); + const missingMask = buildXmp({ seriesName: 'Dune', seriesIndex: 1 }, new Set(['seriesName'])); expect(missingMask).not.toContain('projectx:seriesName'); expect(missingMask).not.toContain('projectx:seriesIndex'); }); diff --git a/server/src/modules/library/library.service.spec.ts b/server/src/modules/library/library.service.spec.ts index fada6f7b6..429f2ad2a 100644 --- a/server/src/modules/library/library.service.spec.ts +++ b/server/src/modules/library/library.service.spec.ts @@ -98,7 +98,10 @@ describe('LibraryService', () => { }), ); expect(scannerService.startScanAsync).toHaveBeenCalledWith(5); - expect(result.folders).toEqual([{ id: 11, path: '/a' }, { id: 12, path: '/b' }]); + expect(result.folders).toEqual([ + { id: 11, path: '/a' }, + { id: 12, path: '/b' }, + ]); }); it('create rejects duplicate library names', async () => { @@ -146,14 +149,14 @@ describe('LibraryService', () => { it('prescan counts primary files recursively and flags overlapping paths', async () => { libraryRepo.findAllFolderPaths.mockResolvedValue([{ path: '/books/existing', libraryName: 'Existing Library' }]); - mockReaddir.mockImplementation(async (path: Parameters[0]) => { + mockReaddir.mockImplementation((path: Parameters[0]) => { if (path === '/books/new') { - return [dirent('a.epub', 'file'), dirent('.hidden.epub', 'file'), dirent('sub', 'dir')] as any; + return Promise.resolve([dirent('a.epub', 'file'), dirent('.hidden.epub', 'file'), dirent('sub', 'dir')] as any); } if (path === '/books/new/sub') { - return [dirent('b.pdf', 'file'), dirent('note.txt', 'file')] as any; + return Promise.resolve([dirent('b.pdf', 'file'), dirent('note.txt', 'file')] as any); } - return [] as any; + return Promise.resolve([] as any); }); mockIsPrimaryFormat.mockImplementation((path: string) => path.endsWith('.epub') || path.endsWith('.pdf')); diff --git a/server/src/modules/library/library.service.ts b/server/src/modules/library/library.service.ts index aad5da57b..4137c4492 100644 --- a/server/src/modules/library/library.service.ts +++ b/server/src/modules/library/library.service.ts @@ -1,6 +1,6 @@ import { ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { access, readdir, rm, stat } from 'fs/promises'; +import { readdir, rm, stat } from 'fs/promises'; import { join } from 'path'; import type { RequestUser } from '../../common/types/request-user'; @@ -130,20 +130,23 @@ export class LibraryService { const results = await Promise.all( dto.paths.map(async (inputPath) => { - let accessible = false; + let accessible: boolean; let fileCount = 0; let overlapLibrary: string | undefined; - try { - await access(inputPath); - const s = await stat(inputPath); - if (!s.isDirectory()) { - return { path: inputPath, accessible: false, fileCount: 0, error: 'Not a directory' }; - } - accessible = true; - fileCount = await countPrimaryFiles(inputPath); - } catch { + const s = await stat(inputPath).catch(() => null); + if (s === null) { accessible = false; + } else if (!s.isDirectory()) { + return { path: inputPath, accessible: false, fileCount: 0, error: 'Not a directory' }; + } else { + accessible = true; + try { + fileCount = await countPrimaryFiles(inputPath); + } catch { + accessible = false; + fileCount = 0; + } } // Check if this path overlaps an existing library folder diff --git a/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts b/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts index cbda9e861..1369009bc 100644 --- a/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts +++ b/server/src/modules/metadata-fetch/providers/amazon/amazon.provider.ts @@ -96,7 +96,7 @@ export class AmazonProvider implements IdentifiableProvider { } return res.text(); } catch (err) { - this.logger.warn(`Amazon fetch failed for ${url}: ${err}`); + this.logger.warn(`Amazon fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`); return null; } } diff --git a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.ts b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.ts index 042fc3b47..7639616f8 100644 --- a/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.ts +++ b/server/src/modules/metadata-fetch/providers/goodreads/goodreads.provider.ts @@ -86,7 +86,7 @@ export class GoodreadsProvider implements IdentifiableProvider { } return res.text(); } catch (err) { - this.logger.warn(`Goodreads fetch failed for ${url}: ${err}`); + this.logger.warn(`Goodreads fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`); return null; } } diff --git a/server/src/modules/metadata/lib/cover-epub.spec.ts b/server/src/modules/metadata/lib/cover-epub.spec.ts index a73b3410b..443c0b0bb 100644 --- a/server/src/modules/metadata/lib/cover-epub.spec.ts +++ b/server/src/modules/metadata/lib/cover-epub.spec.ts @@ -10,7 +10,7 @@ function zipFile(path: string, content: string | Buffer) { const buf = typeof content === 'string' ? Buffer.from(content) : content; return { path, - buffer: async () => buf, + buffer: () => Promise.resolve(buf), }; } @@ -29,11 +29,7 @@ describe('extractEpubCover', () => { const cover = Buffer.from([1, 2, 3]); mockOpenFile.mockResolvedValue({ - files: [ - zipFile('META-INF/container.xml', containerXml), - zipFile('OPS/content.opf', opfXml), - zipFile('OPS/images/cover.jpg', cover), - ], + files: [zipFile('META-INF/container.xml', containerXml), zipFile('OPS/content.opf', opfXml), zipFile('OPS/images/cover.jpg', cover)], }); await expect(extractEpubCover('/book.epub')).resolves.toEqual(cover); diff --git a/server/src/modules/metadata/lib/epub.spec.ts b/server/src/modules/metadata/lib/epub.spec.ts index a16ce0040..60770782c 100644 --- a/server/src/modules/metadata/lib/epub.spec.ts +++ b/server/src/modules/metadata/lib/epub.spec.ts @@ -13,7 +13,7 @@ function zipFile(path: string, content: string | Buffer) { const buf = typeof content === 'string' ? Buffer.from(content) : content; return { path, - buffer: async () => buf, + buffer: () => Promise.resolve(buf), }; } @@ -33,7 +33,20 @@ describe('extractEpubMetadata', () => { files: [zipFile('META-INF/container.xml', containerXml), zipFile('OPS/content.opf', '')], }); - mockParseOpf.mockReturnValue({ title: 'Dune', subtitle: null, description: null, isbn10: null, isbn13: null, publisher: null, publishedYear: null, language: null, seriesName: null, seriesIndex: null, authors: [], tags: [] }); + mockParseOpf.mockReturnValue({ + title: 'Dune', + subtitle: null, + description: null, + isbn10: null, + isbn13: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: null, + seriesIndex: null, + authors: [], + tags: [], + }); await expect(extractEpubMetadata('/books/dune.epub')).resolves.toEqual(expect.objectContaining({ title: 'Dune' })); }); @@ -45,7 +58,20 @@ describe('extractEpubMetadata', () => { files: [zipFile('META-INF/container.xml', containerXml), zipFile('OPS/content.opf', '')], }); - mockParseOpf.mockReturnValue({ title: 'Leading Slash', subtitle: null, description: null, isbn10: null, isbn13: null, publisher: null, publishedYear: null, language: null, seriesName: null, seriesIndex: null, authors: [], tags: [] }); + mockParseOpf.mockReturnValue({ + title: 'Leading Slash', + subtitle: null, + description: null, + isbn10: null, + isbn13: null, + publisher: null, + publishedYear: null, + language: null, + seriesName: null, + seriesIndex: null, + authors: [], + tags: [], + }); await expect(extractEpubMetadata('/books/x.epub')).resolves.toEqual(expect.objectContaining({ title: 'Leading Slash' })); }); diff --git a/server/src/modules/metadata/metadata.service.spec.ts b/server/src/modules/metadata/metadata.service.spec.ts index 7efd436c3..f31900542 100644 --- a/server/src/modules/metadata/metadata.service.spec.ts +++ b/server/src/modules/metadata/metadata.service.spec.ts @@ -114,7 +114,7 @@ describe('MetadataService', () => { global.fetch = jest.fn().mockResolvedValue({ ok: true, - arrayBuffer: async () => Buffer.from('image-bytes'), + arrayBuffer: () => Promise.resolve(Buffer.from('image-bytes')), }) as never; await service.downloadAndSaveCover('https://img.example/cover.png', 9); @@ -132,7 +132,7 @@ describe('MetadataService', () => { global.fetch = jest.fn().mockResolvedValue({ ok: true, - arrayBuffer: async () => Buffer.alloc(0), + arrayBuffer: () => Promise.resolve(Buffer.alloc(0)), }) as never; await service.downloadAndSaveCover('https://img.example/empty.png', 4); @@ -238,7 +238,7 @@ describe('MetadataService', () => { db.select.mockImplementation(() => ({ from: () => ({ where: () => ({ - limit: async () => [], + limit: () => Promise.resolve([]), }), }), })); @@ -247,7 +247,7 @@ describe('MetadataService', () => { return { values: (row: { name: string; sortName: string | null }) => { insertedAuthors.push(row); - return { returning: async () => [{ id: 81 }] }; + return { returning: () => Promise.resolve([{ id: 81 }]) }; }, }; } @@ -255,7 +255,7 @@ describe('MetadataService', () => { return { values: (row: { bookId: number; authorId: number; displayOrder: number }) => { insertedBookAuthors.push(row); - return { onConflictDoNothing: async () => undefined }; + return { onConflictDoNothing: () => Promise.resolve(undefined) }; }, }; } @@ -283,7 +283,7 @@ describe('MetadataService', () => { db.select.mockImplementation(() => ({ from: () => ({ where: () => ({ - limit: async () => [{ id: 9 }], + limit: () => Promise.resolve([{ id: 9 }]), }), }), })); @@ -292,7 +292,7 @@ describe('MetadataService', () => { return { values: (row: { bookId: number; authorId: number; displayOrder: number }) => { insertedBookAuthors.push(row); - return { onConflictDoNothing: async () => undefined }; + return { onConflictDoNothing: () => Promise.resolve(undefined) }; }, }; } diff --git a/server/src/modules/metadata/metadata.service.ts b/server/src/modules/metadata/metadata.service.ts index 508618463..87602dfbb 100644 --- a/server/src/modules/metadata/metadata.service.ts +++ b/server/src/modules/metadata/metadata.service.ts @@ -55,7 +55,7 @@ export class MetadataService { this.logger.debug(`Online cover saved for book ${bookId}`); } catch (err) { - this.logger.warn(`Cover download failed for book ${bookId}: ${err}`); + this.logger.warn(`Cover download failed for book ${bookId}: ${err instanceof Error ? err.message : String(err)}`); } } @@ -233,7 +233,7 @@ export class MetadataService { this.logger.debug(`Cover saved for book ${bookId}: ${saved}`); } } catch (err) { - this.logger.warn(`Cover extraction failed for book ${bookId}: ${err}`); + this.logger.warn(`Cover extraction failed for book ${bookId}: ${err instanceof Error ? err.message : String(err)}`); } } @@ -249,7 +249,7 @@ export class MetadataService { await this.setCoverSourceIfUnset(bookId, 'extracted'); this.logger.debug(`PDF cover saved for book ${bookId}`); } catch (err) { - this.logger.warn(`PDF cover save failed for book ${bookId}: ${err}`); + this.logger.warn(`PDF cover save failed for book ${bookId}: ${err instanceof Error ? err.message : String(err)}`); } } diff --git a/server/src/modules/scanner/file-event-processor.service.ts b/server/src/modules/scanner/file-event-processor.service.ts index 722371fb6..eba8f99f0 100644 --- a/server/src/modules/scanner/file-event-processor.service.ts +++ b/server/src/modules/scanner/file-event-processor.service.ts @@ -154,7 +154,7 @@ export class FileEventProcessorService { for (const entry of entries) { if (!entry.isFile()) continue; - const fullPath = join(entry.parentPath ?? entry.path, entry.name); + const fullPath = join(entry.parentPath ?? dirPath, entry.name); const { role } = classifyFile(fullPath); if (role !== 'primary') continue; diff --git a/server/src/modules/scanner/scanner.service.ts b/server/src/modules/scanner/scanner.service.ts index 013ded69c..a88148c4d 100644 --- a/server/src/modules/scanner/scanner.service.ts +++ b/server/src/modules/scanner/scanner.service.ts @@ -119,7 +119,7 @@ export class ScannerService implements OnApplicationBootstrap { try { candidates = await findBookCandidates(folder.path); } catch (err) { - this.logger.warn(`Cannot walk ${folder.path}: ${err}`); + this.logger.warn(`Cannot walk ${folder.path}: ${err instanceof Error ? err.message : String(err)}`); } if (allowed) { @@ -251,7 +251,7 @@ export class ScannerService implements OnApplicationBootstrap { try { isNew = await this.processFile(fileStat, format, role, book.id, libraryFolderId, fileByPath, fileByIno, fileCount); } catch (err) { - this.logger.warn(`Failed to process file ${fileStat.absolutePath}: ${err}`); + this.logger.warn(`Failed to process file ${fileStat.absolutePath}: ${err instanceof Error ? err.message : String(err)}`); continue; } @@ -262,7 +262,7 @@ export class ScannerService implements OnApplicationBootstrap { try { await this.metadataService.extractAndSave(book.id, fileStat.absolutePath, format); } catch (err) { - this.logger.warn(`Metadata extraction failed for ${fileStat.absolutePath}: ${err}`); + this.logger.warn(`Metadata extraction failed for ${fileStat.absolutePath}: ${err instanceof Error ? err.message : String(err)}`); } } } diff --git a/server/src/modules/staging/staging-finalize.service.ts b/server/src/modules/staging/staging-finalize.service.ts index 6479ca1d1..254adde28 100644 --- a/server/src/modules/staging/staging-finalize.service.ts +++ b/server/src/modules/staging/staging-finalize.service.ts @@ -276,7 +276,7 @@ export class StagingFinalizeService implements OnModuleInit { const bytes = await readFile(row.coverPath); await this.metadataService.saveExtractedCoverBytes(bookId, bytes); } catch (err) { - this.logger.warn(`Failed to copy staging cover to book ${bookId}: ${err instanceof Error ? err.message : err}`); + this.logger.warn(`Failed to copy staging cover to book ${bookId}: ${err instanceof Error ? err.message : String(err)}`); } } diff --git a/server/src/modules/staging/staging-ingest.service.ts b/server/src/modules/staging/staging-ingest.service.ts index 9ca82fff1..f17d5a711 100644 --- a/server/src/modules/staging/staging-ingest.service.ts +++ b/server/src/modules/staging/staging-ingest.service.ts @@ -145,7 +145,7 @@ export class StagingIngestService { : { status: 'ready' as const }; await this.repo.update(fileId, updates); } catch (err) { - this.logger.warn(`Auto-fetch metadata failed for staging file ${fileId}: ${err instanceof Error ? err.message : err}`); + this.logger.warn(`Auto-fetch metadata failed for staging file ${fileId}: ${err instanceof Error ? err.message : String(err)}`); await this.repo.update(fileId, { status: 'ready' }); } } diff --git a/server/src/modules/staging/staging-metadata.service.ts b/server/src/modules/staging/staging-metadata.service.ts index ba5d422a6..d6f8dc229 100644 --- a/server/src/modules/staging/staging-metadata.service.ts +++ b/server/src/modules/staging/staging-metadata.service.ts @@ -33,7 +33,7 @@ export class StagingMetadataService { status: 'ready', }); } catch (err) { - this.logger.warn(`Metadata extraction failed for staging file ${fileId}: ${err}`); + this.logger.warn(`Metadata extraction failed for staging file ${fileId}: ${err instanceof Error ? err.message : String(err)}`); await this.repo.update(fileId, { status: 'error', errorMessage: err instanceof Error ? err.message : 'Metadata extraction failed', diff --git a/server/src/modules/upload/upload-validator.service.ts b/server/src/modules/upload/upload-validator.service.ts index 5ecb8bd29..5a12cb55c 100644 --- a/server/src/modules/upload/upload-validator.service.ts +++ b/server/src/modules/upload/upload-validator.service.ts @@ -29,9 +29,7 @@ export class UploadValidatorService { * Preserves the original extension. */ sanitizeFilename(raw: string): string { - const sanitized = raw - .replace(/[/\\:*?"<>|\0]/g, '_') - .trim(); + const sanitized = raw.replace(/[/\\:*?"<>|\0]/g, '_').trim(); if (!sanitized) return 'upload'; @@ -44,7 +42,10 @@ export class UploadValidatorService { if (!ext) return sanitized.slice(0, 255); if (ext.length >= 255) return sanitized.slice(0, 255); - const stem = sanitized.slice(0, -ext.length).slice(0, 255 - ext.length).trim(); + const stem = sanitized + .slice(0, -ext.length) + .slice(0, 255 - ext.length) + .trim(); return `${stem || 'upload'}${ext}`; } } diff --git a/server/src/modules/upload/upload.controller.spec.ts b/server/src/modules/upload/upload.controller.spec.ts index a5c807daf..62bc0fe04 100644 --- a/server/src/modules/upload/upload.controller.spec.ts +++ b/server/src/modules/upload/upload.controller.spec.ts @@ -25,7 +25,9 @@ describe('UploadController', () => { file: jest.fn().mockResolvedValue({ filename: 'a.epub', file: {} }), }; - await expect(controller.uploadBook(1, 'abc', { id: 1, roles: [] } as any, req as any)).rejects.toThrow(new BadRequestException('Invalid folderId')); + await expect(controller.uploadBook(1, 'abc', { id: 1, roles: [] } as any, req as any)).rejects.toThrow( + new BadRequestException('Invalid folderId'), + ); }); it('passes parsed arguments to upload service and overrides multipart size limit', async () => { diff --git a/server/src/modules/upload/upload.service.spec.ts b/server/src/modules/upload/upload.service.spec.ts index 8507e9e88..f3e90a72c 100644 --- a/server/src/modules/upload/upload.service.spec.ts +++ b/server/src/modules/upload/upload.service.spec.ts @@ -95,7 +95,15 @@ describe('UploadService', () => { expect(result).toEqual({ bookId: 99, filename: 'Dune.epub', format: 'epub', sizeBytes: 456 }); expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Frank Herbert/Dune.epub'); - expect(processor.createBookRecord).toHaveBeenCalledWith(1, 2, '/library/Frank Herbert', '/library/Frank Herbert/Dune.epub', 'Frank Herbert/Dune.epub', 'epub', 456); + expect(processor.createBookRecord).toHaveBeenCalledWith( + 1, + 2, + '/library/Frank Herbert', + '/library/Frank Herbert/Dune.epub', + 'Frank Herbert/Dune.epub', + 'epub', + 456, + ); expect(processor.extractMetadataAsync).toHaveBeenCalledWith(99, '/library/Frank Herbert/Dune.epub', 'epub'); }); @@ -141,9 +149,7 @@ describe('UploadService', () => { }); it('rejects uploads when no default folder exists for the library', async () => { - db.select - .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }])) - .mockReturnValueOnce(selectChain([])); + db.select.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }])).mockReturnValueOnce(selectChain([])); await expect(service.upload(1, undefined, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(BadRequestException); expect(storage.streamToTemp).not.toHaveBeenCalled(); diff --git a/server/src/modules/user/user.controller.spec.ts b/server/src/modules/user/user.controller.spec.ts index 4806f456c..90b1009a5 100644 --- a/server/src/modules/user/user.controller.spec.ts +++ b/server/src/modules/user/user.controller.spec.ts @@ -19,26 +19,26 @@ describe('UserController', () => { jest.resetAllMocks(); }); - it('passes optional pagination args to service', () => { - controller.findAll(undefined, 25); + it('passes optional pagination args to service', async () => { + await controller.findAll(undefined, 25); expect(userService.findAll).toHaveBeenCalledWith(undefined, 25); }); - it('routes updateMe to current user id', () => { + it('routes updateMe to current user id', async () => { const user = { id: 7 } as any; const dto = { name: 'Updated' }; - controller.updateMe(user, dto as any); + await controller.updateMe(user, dto as any); expect(userService.updateMe).toHaveBeenCalledWith(7, dto); }); - it('delegates role assignment/revocation and admin reset', () => { + it('delegates role assignment/revocation and admin reset', async () => { const requester = { id: 1 } as any; - controller.assignRole(8, { roleId: 4 } as any, requester); - controller.revokeRole(8, 4, requester); - controller.adminResetPassword(8, requester); + await controller.assignRole(8, { roleId: 4 } as any, requester); + await controller.revokeRole(8, 4, requester); + await controller.adminResetPassword(8, requester); expect(userService.assignRole).toHaveBeenCalledWith(8, 4, requester); expect(userService.revokeRole).toHaveBeenCalledWith(8, 4, requester); diff --git a/server/src/modules/user/user.service.spec.ts b/server/src/modules/user/user.service.spec.ts index 762f9443d..1dec9765b 100644 --- a/server/src/modules/user/user.service.spec.ts +++ b/server/src/modules/user/user.service.spec.ts @@ -113,11 +113,7 @@ describe('UserService', () => { it('assignRole allows superuser-role assignment for manage_roles users', async () => { (db.query.roles.findFirst as jest.Mock).mockResolvedValue({ id: 9, isSuperuser: true }); - await service.assignRole( - 2, - 9, - reqUser({ roles: [{ isSuperuser: false, permissions: [{ name: 'manage_roles' }] }] }), - ); + await service.assignRole(2, 9, reqUser({ roles: [{ isSuperuser: false, permissions: [{ name: 'manage_roles' }] }] })); expect(userRepo.assignRole).toHaveBeenCalledWith(2, 9); }); @@ -143,6 +139,8 @@ describe('UserService', () => { it('adminResetPassword throws when target user does not exist', async () => { userRepo.findByIdWithRolesAndPermissions.mockResolvedValue(null); - await expect(service.adminResetPassword(9, reqUser({ roles: [{ isSuperuser: true, permissions: [] }] }))).rejects.toBeInstanceOf(NotFoundException); + await expect(service.adminResetPassword(9, reqUser({ roles: [{ isSuperuser: true, permissions: [] }] }))).rejects.toBeInstanceOf( + NotFoundException, + ); }); }); diff --git a/server/src/scripts/migrate.ts b/server/src/scripts/migrate.ts new file mode 100644 index 000000000..e7891b39a --- /dev/null +++ b/server/src/scripts/migrate.ts @@ -0,0 +1,45 @@ +import { existsSync } from 'fs'; +import { join } from 'path'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { Pool } from 'pg'; + +function resolveMigrationsFolder(): string { + const candidates = [ + join(__dirname, '..', '..', 'migrations'), + join(__dirname, '..', 'db', 'migrations'), + join(process.cwd(), 'migrations'), + join(process.cwd(), 'src', 'db', 'migrations'), + ]; + + const match = candidates.find((path) => existsSync(path)); + if (!match) { + throw new Error(`Unable to locate migrations folder. Checked: ${candidates.join(', ')}`); + } + return match; +} + +async function runMigrations() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error('DATABASE_URL is required'); + } + + const pool = new Pool({ + connectionString, + max: 3, + idleTimeoutMillis: 10_000, + connectionTimeoutMillis: 5_000, + }); + + try { + const migrationsFolder = resolveMigrationsFolder(); + await migrate(drizzle(pool), { migrationsFolder }); + + console.log(`Migrations applied successfully from ${migrationsFolder}`); + } finally { + await pool.end(); + } +} + +void runMigrations();