diff --git a/.env.example b/.env.example index 4418f04..9de5c4d 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,10 @@ +# Logging +# pretty (default): human-readable logs +# json: structured logs for log platforms +# LOG_FORMAT=pretty +# LOG_LEVEL=info + # Local / OpenAI TTS API Configuration (default) # Suggest using https://github.com/remsky/Kokoro-FastAPI # @@ -77,8 +83,39 @@ RUN_FS_MIGRATIONS= IMPORT_LIBRARY_DIR= IMPORT_LIBRARY_DIRS= -# (Required without Docker) Path to your local whisper.cpp CLI binary for STT timestamp generation -WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli +# Compute +# Embedded/local (default): leave COMPUTE_WORKER_URL empty. +# External worker: set COMPUTE_WORKER_URL + COMPUTE_WORKER_TOKEN. +# External worker split: +# - App side (this root `.env`): set only routing/auth + shared timeout/stale overrides. +# - Worker side (`compute/worker/.env*` or worker platform env): set NATS_*, S3_*, model base URLs, and worker tuning. +# Details: docs/deploy/compute-worker +# COMPUTE_WORKER_URL=http://localhost:8081 +# COMPUTE_WORKER_TOKEN=local-compute-token +# Optional embedded startup controls: +# EMBEDDED_COMPUTE_WORKER_PORT=8081 +# EMBEDDED_NATS_PORT=4222 +# EMBEDDED_NATS_MONITOR_PORT=8222 +# EMBEDDED_NATS_STORE_DIR=docstore/nats/jetstream +# `NATS_URL` here is for embedded startup only. In external worker mode, set `NATS_URL` on the worker service env. +# NATS_URL=nats://127.0.0.1:4222 +# Optional worker log level: +# COMPUTE_LOG_LEVEL=info +# Optional shared compute tuning: +# COMPUTE_JOB_CONCURRENCY=1 +# COMPUTE_WHISPER_TIMEOUT_MS=30000 +# COMPUTE_PDF_TIMEOUT_MS=300000 +# COMPUTE_OP_STALE_MS=1800000 + +# Optional Whisper ONNX base URL override (must contain all expected files) +# Default expected Whisper variant is q4: +# - onnx/encoder_model_q4.onnx +# - onnx/decoder_model_merged_q4.onnx +# - onnx/decoder_with_past_model_q4.onnx +# WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main + +# Optional PDF layout ONNX base URL override (must contain all expected files) +# PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main # (Optional) Override ffmpeg binary path used for audiobook processing FFMPEG_BIN= @@ -86,13 +123,12 @@ FFMPEG_BIN= # (Optional) Client feature flags — seeded into the admin-managed runtime # config on first boot, then ignored. Edit values from Settings → Admin → # Site features instead of redeploying. SSR-injected so they take effect -# without rebuilding (unlike the old NEXT_PUBLIC_* build-time pattern). -# NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=true -# NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true -# NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB=true -# NEXT_PUBLIC_ENABLE_USER_SIGNUPS=true -# NEXT_PUBLIC_RESTRICT_USER_API_KEYS=true -# NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=custom-openai -# NEXT_PUBLIC_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json -# NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true -# NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=true +# without rebuilding (unlike the old build-time public-env pattern). +# RUNTIME_SEED_ENABLE_DOCX_CONVERSION=true +# RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true +# RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB=true +# RUNTIME_SEED_ENABLE_USER_SIGNUPS=true +# RUNTIME_SEED_RESTRICT_USER_API_KEYS=true +# RUNTIME_SEED_DEFAULT_TTS_PROVIDER=custom-openai +# RUNTIME_SEED_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json +# RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT=true diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index cae0b8f..2c50173 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -22,30 +22,17 @@ jobs: prepare: runs-on: ubuntu-24.04 outputs: - image_name: ${{ steps.image-name.outputs.image_name }} - legacy_image_name: ${{ steps.image-name.outputs.legacy_image_name }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + web_image_name: ${{ steps.image-name.outputs.web_image_name }} + web_legacy_image_name: ${{ steps.image-name.outputs.web_legacy_image_name }} + compute_worker_image_name: ${{ steps.image-name.outputs.compute_worker_image_name }} steps: - name: Compute Docker image names id: image-name run: | owner="${GITHUB_REPOSITORY_OWNER,,}" - echo "image_name=${owner}/openreader" >> "$GITHUB_OUTPUT" - echo "legacy_image_name=${owner}/openreader-webui" >> "$GITHUB_OUTPUT" - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v6 - with: - images: | - ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }} - ${{ env.REGISTRY }}/${{ steps.image-name.outputs.legacy_image_name }} - tags: | - type=raw,value=latest,enable=${{ (github.event_name == 'push' && !contains(github.ref, '-pre')) || (github.event_name == 'workflow_dispatch' && inputs.use_latest_tag == true) }},priority=1000 - type=semver,pattern={{version}} - type=ref,event=tag - type=ref,event=branch,enable=${{ github.event_name == 'workflow_dispatch' && inputs.use_latest_tag != true }} + echo "web_image_name=${owner}/openreader" >> "$GITHUB_OUTPUT" + echo "web_legacy_image_name=${owner}/openreader-webui" >> "$GITHUB_OUTPUT" + echo "compute_worker_image_name=${owner}/openreader-compute-worker" >> "$GITHUB_OUTPUT" build: needs: prepare @@ -58,12 +45,30 @@ jobs: fail-fast: false matrix: include: - - arch: amd64 + - image_target: web + arch: amd64 platform: linux/amd64 runner: ubuntu-24.04 - - arch: arm64 + context: . + dockerfile: ./Dockerfile + - image_target: web + arch: arm64 platform: linux/arm64 runner: ubuntu-24.04-arm + context: . + dockerfile: ./Dockerfile + - image_target: compute-worker + arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + context: . + dockerfile: ./compute/worker/Dockerfile + - image_target: compute-worker + arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + context: . + dockerfile: ./compute/worker/Dockerfile steps: - name: Checkout repository @@ -82,46 +87,63 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push Docker image (${{ matrix.arch }}) + - name: Select image name + id: image + run: | + if [ "${{ matrix.image_target }}" = "web" ]; then + echo "image_name=${{ needs.prepare.outputs.web_image_name }}" >> "$GITHUB_OUTPUT" + else + echo "image_name=${{ needs.prepare.outputs.compute_worker_image_name }}" >> "$GITHUB_OUTPUT" + fi + + - name: Build and push Docker image (${{ matrix.image_target }} / ${{ matrix.arch }}) id: build uses: docker/build-push-action@v7 with: - context: . + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} platforms: ${{ matrix.platform }} - labels: ${{ needs.prepare.outputs.labels }} - cache-from: type=gha,scope=${{ matrix.arch }} - cache-to: type=gha,mode=max,scope=${{ matrix.arch }} + cache-from: type=gha,scope=${{ matrix.image_target }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.image_target }}-${{ matrix.arch }} provenance: false - outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true + outputs: type=image,name=${{ env.REGISTRY }}/${{ steps.image.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true - name: Export digest run: | - mkdir -p /tmp/digests + mkdir -p /tmp/digests/${{ matrix.image_target }} digest="${{ steps.build.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" + touch "/tmp/digests/${{ matrix.image_target }}/${digest#sha256:}" - name: Upload digest uses: actions/upload-artifact@v7 with: - name: digests-${{ matrix.arch }} - path: /tmp/digests + name: digests-${{ matrix.image_target }}-${{ matrix.arch }} + path: /tmp/digests/${{ matrix.image_target }} if-no-files-found: error retention-days: 1 merge: needs: [prepare, build] - runs-on: ubuntu-24.04 + runs-on: ${{ matrix.runner }} permissions: actions: read contents: read packages: write + strategy: + fail-fast: false + matrix: + include: + - image_target: web + runner: ubuntu-24.04 + - image_target: compute-worker + runner: ubuntu-24.04 steps: - name: Download digests uses: actions/download-artifact@v8 with: - path: /tmp/digests - pattern: digests-* + path: /tmp/digests/${{ matrix.image_target }} + pattern: digests-${{ matrix.image_target }}-* merge-multiple: true - name: Set up Docker Buildx @@ -134,20 +156,46 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Select image names + id: image + run: | + if [ "${{ matrix.image_target }}" = "web" ]; then + echo "image_name=${{ needs.prepare.outputs.web_image_name }}" >> "$GITHUB_OUTPUT" + echo "metadata_images<> "$GITHUB_OUTPUT" + echo "${{ env.REGISTRY }}/${{ needs.prepare.outputs.web_image_name }}" >> "$GITHUB_OUTPUT" + echo "${{ env.REGISTRY }}/${{ needs.prepare.outputs.web_legacy_image_name }}" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + else + echo "image_name=${{ needs.prepare.outputs.compute_worker_image_name }}" >> "$GITHUB_OUTPUT" + echo "metadata_images=${{ env.REGISTRY }}/${{ needs.prepare.outputs.compute_worker_image_name }}" >> "$GITHUB_OUTPUT" + fi + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ steps.image.outputs.metadata_images }} + tags: | + type=raw,value=latest,enable=${{ (github.event_name == 'push' && !contains(github.ref, '-pre')) || (github.event_name == 'workflow_dispatch' && inputs.use_latest_tag == true) }},priority=1000 + type=semver,pattern={{version}} + type=ref,event=tag + type=ref,event=branch,enable=${{ github.event_name == 'workflow_dispatch' && inputs.use_latest_tag != true }} + - name: Create manifest list and push - working-directory: /tmp/digests + working-directory: /tmp/digests/${{ matrix.image_target }} run: | docker buildx imagetools create \ $(echo "$TAGS" | xargs -I {} echo -t {}) \ - $(printf '${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }}@sha256:%s ' *) + $(printf '${{ env.REGISTRY }}/${{ steps.image.outputs.image_name }}@sha256:%s ' *) env: - TAGS: ${{ needs.prepare.outputs.tags }} + TAGS: ${{ steps.meta.outputs.tags }} - name: Output build information run: | - echo "✅ Docker images built and pushed successfully!" + echo "✅ Docker image built and pushed successfully!" + echo "🐋 Target: ${{ matrix.image_target }}" echo "🐋 Images:" - echo '${{ needs.prepare.outputs.tags }}' | sed 's/^/ - /' + echo '${{ steps.meta.outputs.tags }}' | sed 's/^/ - /' echo "📝 Event: ${{ github.event_name }}" if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then echo "📝 Triggered by manual workflow dispatch on branch: ${{ github.ref_name }}" diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index bd28673..dfe0e8d 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -34,8 +34,19 @@ jobs: sudo install -m 0755 ./weed /usr/local/bin/weed rm -f ./weed weed version + - name: Install NATS server binary + run: | + curl -fsSL -o /tmp/nats-server.tar.gz \ + https://github.com/nats-io/nats-server/releases/download/v2.12.1/nats-server-v2.12.1-linux-amd64.tar.gz + tar -xzf /tmp/nats-server.tar.gz -C /tmp + sudo install -m 0755 /tmp/nats-server-v2.12.1-linux-amd64/nats-server /usr/local/bin/nats-server + nats-server -v - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Build app and enforce bundle guard + run: | + pnpm build + pnpm build:bundle-guard - name: Verify ffprobe run: ffprobe -version - name: Install Playwright Browsers diff --git a/.gitignore b/.gitignore index 6d5cc68..4014f63 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,9 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env +.env.prod .dockerignore +*.creds # vercel .vercel diff --git a/Dockerfile b/Dockerfile index 30c4ace..ab8d381 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,14 @@ -# Stage 1: build whisper.cpp (no model download – the app handles that) -FROM alpine:3.23 AS whisper-builder - -RUN apk add --no-cache git cmake build-base - -WORKDIR /opt - -ARG TARGETARCH - -RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git && \ - cd whisper.cpp && \ - cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF $( [ "$TARGETARCH" = "arm64" ] && echo "-DGGML_CPU_ARM_ARCH=armv8-a" || true ) && \ - cmake --build build -j - -# Stage 1b: extract seaweedfs weed binary (for optional embedded weed mini) +# Stage 1: extract seaweedfs weed binary (for optional embedded weed mini) # Pin to 4.18 because CI observed upload regressions on 4.19. FROM chrislusf/seaweedfs:4.18 AS seaweedfs-builder RUN cp "$(command -v weed)" /tmp/weed && \ (wget -qO /tmp/SeaweedFS-LICENSE.txt "https://raw.githubusercontent.com/seaweedfs/seaweedfs/master/LICENSE" || \ wget -qO /tmp/SeaweedFS-LICENSE.txt "https://raw.githubusercontent.com/seaweedfs/seaweedfs/main/LICENSE") +# Stage 1b: extract nats-server binary for embedded single-container worker mode. +FROM nats:2.11-alpine AS nats-builder +RUN cp "$(command -v nats-server)" /tmp/nats-server + # Stage 2: build the Next.js app FROM node:lts-alpine AS app-builder @@ -29,8 +19,10 @@ RUN npm install -g pnpm@11.1.2 # Create app directory WORKDIR /app -# Copy package files +# Copy workspace manifests needed for dependency installation COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY compute/core/package.json ./compute/core/package.json +COPY compute/worker/package.json ./compute/worker/package.json # Install dependencies RUN pnpm install --frozen-lockfile @@ -59,29 +51,30 @@ FROM node:lts-alpine AS runner # ffmpeg is provided by ffmpeg-static from node_modules. RUN apk add --no-cache ca-certificates libreoffice-writer -# Install pnpm globally for running the app -RUN npm install -g pnpm@11.1.2 +# Install pnpm for runtime process commands. +RUN npm install -g pnpm@10.33.4 # App runtime directory WORKDIR /app -# Copy built app and dependencies from the builder stage +# Copy built app and runtime files from the builder stage (non-standalone runtime). COPY --from=app-builder /app ./ # Include third-party license report and copied license texts at a stable path in the image. COPY --from=app-builder /app/THIRD_PARTY_LICENSES /licenses # Include SeaweedFS license text for the copied weed binary. COPY --from=seaweedfs-builder /tmp/SeaweedFS-LICENSE.txt /licenses/SeaweedFS-LICENSE.txt +# Include static model notices for runtime-downloaded assets. +COPY --from=app-builder /app/compute/core/src/pdf/assets/LICENSE.txt /licenses/pp-doclayoutv3-LICENSE.txt -# Copy the compiled whisper.cpp build output into the runtime image -# (includes whisper-cli and its shared libraries, e.g. libwhisper.so, libggml.so) -COPY --from=whisper-builder /opt/whisper.cpp/build /opt/whisper.cpp/build # Copy seaweedfs weed binary for optional embedded local S3. COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed RUN chmod +x /usr/local/bin/weed +# Copy nats-server binary for embedded local JetStream. +COPY --from=nats-builder /tmp/nats-server /usr/local/bin/nats-server +RUN chmod +x /usr/local/bin/nats-server -# Point the app at the compiled whisper-cli binary and ensure its libs are discoverable -ENV WHISPER_CPP_BIN=/opt/whisper.cpp/build/bin/whisper-cli -ENV LD_LIBRARY_PATH=/opt/whisper.cpp/build +# Include OpenAI Whisper license text for runtime-downloaded ONNX artifacts. +COPY --from=app-builder /app/compute/core/src/whisper/assets/LICENSE.txt /licenses/openai-whisper-LICENSE.txt # Expose the port the app runs on EXPOSE 3003 diff --git a/README.md b/README.md index f233826..44ab3b7 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,13 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader ## ✨ Highlights -- 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI or OpenAI, Replicate, DeepInfra). -- 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration. -- ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps. -- 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders. -- 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends. +- 🧱 **Layout-aware PDF parsing** with PP-DocLayoutV3 (ONNX) — structured block detection, cross-page stitching, and geometry-based highlighting for precise read-along sync. +- ⏱️ **Word-by-word highlighting** via ONNX Whisper alignment through the compute worker control plane (NATS JetStream-backed). +- ⚡ **Segment-based read-along** for EPUB, PDF, TXT, MD, and DOCX — sentence-aware TTS with cached audio segments, background preloading, and resumable playback. +- 🎯 **Multi-provider TTS** — self-hosted OpenAI-compatible servers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI) or cloud APIs (OpenAI, Replicate, DeepInfra). - 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing. -- 🐳 **Self-host friendly** with Docker, optional auth, and automatic startup migrations. +- 🗂️ **Flexible backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync. +- 🐳 **Self-host friendly** — Docker (amd64/arm64), optional auth, and automatic startup migrations. ## 🚀 Start Here @@ -32,6 +32,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader | --- | --- | | Run with Docker | [Docker Quick Start](https://docs.openreader.richardr.dev/docker-quick-start) | | Deploy on Vercel | [Vercel Deployment](https://docs.openreader.richardr.dev/deploy/vercel-deployment) | +| Deploy external compute worker | [Compute Worker (NATS JetStream)](https://docs.openreader.richardr.dev/deploy/compute-worker) | | Develop locally | [Local Development](https://docs.openreader.richardr.dev/deploy/local-development) | | Configure auth | [Auth](https://docs.openreader.richardr.dev/configure/auth) | | Configure SQL database | [Database and Migrations](https://docs.openreader.richardr.dev/configure/database) | diff --git a/compute/core/package.json b/compute/core/package.json new file mode 100644 index 0000000..4f27980 --- /dev/null +++ b/compute/core/package.json @@ -0,0 +1,21 @@ +{ + "name": "@openreader/compute-core", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@huggingface/tokenizers": "^0.1.3", + "@napi-rs/canvas": "^0.1.100", + "ffmpeg-static": "^5.3.0", + "jszip": "^3.10.1", + "onnxruntime-node": "^1.26.0", + "pdfjs-dist": "4.8.69" + }, + "exports": { + ".": "./src/index.ts", + "./local-runtime": "./src/local-runtime.ts", + "./api-contracts": "./src/api-contracts/index.ts", + "./control-plane": "./src/control-plane/index.ts", + "./types": "./src/types/index.ts" + } +} diff --git a/compute/core/src/api-contracts/index.ts b/compute/core/src/api-contracts/index.ts new file mode 100644 index 0000000..18faa08 --- /dev/null +++ b/compute/core/src/api-contracts/index.ts @@ -0,0 +1,120 @@ +import type { TTSSentenceAlignment } from '../types/tts'; +import type { ParsedPdfDocument } from '../types/parsed-pdf'; + +export type { + TTSAudioBuffer, + TTSAudioBytes, + TTSSentenceAlignment, + TTSSentenceWord, +} from '../types/tts'; +export type { + ParsedPdfBlockKind, + ParsedPdfBlockFragment, + ParsedPdfBlock, + ParsedPdfPage, + ParsedPdfDocument, +} from '../types/parsed-pdf'; + +export const ALIGN_QUEUE_NAME = 'whisper-align'; +export const PDF_LAYOUT_QUEUE_NAME = 'pdf-layout'; + +export interface WhisperAlignJobBase { + text: string; + lang?: string; + cacheKey?: string; +} + +export interface WhisperAlignJobRequest extends WhisperAlignJobBase { + audioObjectKey: string; +} + +export interface WhisperAlignJobResult { + alignments: TTSSentenceAlignment[]; + timing?: WorkerJobTiming; +} + +export interface PdfLayoutJobBase { + documentId: string; + namespace: string | null; +} + +export interface PdfLayoutJobRequest extends PdfLayoutJobBase { + documentObjectKey: string; +} + +export type PdfLayoutJobResult = + | { + parsed: ParsedPdfDocument; + parsedObjectKey?: never; + timing?: WorkerJobTiming; + } + | { + parsed?: never; + parsedObjectKey: string; + timing?: WorkerJobTiming; + }; + +export type WorkerJobState = 'queued' | 'running' | 'succeeded' | 'failed'; + +export interface WorkerJobErrorShape { + message: string; + code?: string; +} + +export interface WorkerJobTiming { + queueWaitMs?: number; + s3FetchMs?: number; + computeMs?: number; +} + +export type PdfLayoutProgressPhase = 'infer' | 'merge'; + +export interface PdfLayoutProgress { + totalPages: number; + pagesParsed: number; + currentPage?: number; + phase: PdfLayoutProgressPhase; +} + +export interface WorkerJobStatusResponse { + status: WorkerJobState; + result?: Result; + error?: WorkerJobErrorShape; + timing?: WorkerJobTiming; +} + +export type WorkerOperationKind = 'whisper_align' | 'pdf_layout'; + +export interface WhisperAlignOperationRequest { + kind: 'whisper_align'; + opKey: string; + payload: WhisperAlignJobRequest; +} + +export interface PdfLayoutOperationRequest { + kind: 'pdf_layout'; + opKey: string; + payload: PdfLayoutJobRequest; +} + +export type WorkerOperationRequest = WhisperAlignOperationRequest | PdfLayoutOperationRequest; + +export interface WorkerOperationState { + opId: string; + opKey: string; + kind: WorkerOperationKind; + jobId: string; + status: WorkerJobState; + queuedAt: number; + updatedAt: number; + startedAt?: number; + result?: Result; + error?: WorkerJobErrorShape; + timing?: WorkerJobTiming; + progress?: PdfLayoutProgress; +} + +export interface WorkerOperationEvent { + eventId: number; + snapshot: WorkerOperationState; +} diff --git a/compute/core/src/config/cpu-budget.ts b/compute/core/src/config/cpu-budget.ts new file mode 100644 index 0000000..50d161b --- /dev/null +++ b/compute/core/src/config/cpu-budget.ts @@ -0,0 +1,28 @@ +import os from 'node:os'; + +function readPositiveInt(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.floor(parsed); +} + +export function getComputeJobConcurrency(): number { + return readPositiveInt('COMPUTE_JOB_CONCURRENCY', 1); +} + +export function getAvailableCpuCores(): number { + if (typeof os.availableParallelism === 'function') { + const value = os.availableParallelism(); + if (Number.isFinite(value) && value >= 1) return Math.floor(value); + } + const fallback = os.cpus().length; + return Number.isFinite(fallback) && fallback >= 1 ? Math.floor(fallback) : 1; +} + +export function getOnnxThreadsPerJob(): number { + const concurrency = getComputeJobConcurrency(); + const usableCores = Math.max(1, getAvailableCpuCores() - 1); + return Math.max(1, Math.floor(usableCores / concurrency)); +} diff --git a/compute/core/src/config/timeout.ts b/compute/core/src/config/timeout.ts new file mode 100644 index 0000000..40fd406 --- /dev/null +++ b/compute/core/src/config/timeout.ts @@ -0,0 +1,119 @@ +const DEFAULT_COMPUTE_WHISPER_TIMEOUT_MS = 30_000; +const DEFAULT_COMPUTE_PDF_TIMEOUT_MS = 300_000; +const DEFAULT_COMPUTE_PDF_HARD_CAP_MS = 24 * 60 * 60 * 1000; +const DEFAULT_COMPUTE_OP_STALE_MIN_MS = 30 * 60_000; +const DEFAULT_WORKER_WAIT_BUFFER_MS = 15_000; +const DEFAULT_WORKER_WAIT_MIN_MS = 60_000; + +export type ComputeTimeoutConfig = { + whisperTimeoutMs: number; + pdfTimeoutMs: number; + pdfHardCapMs: number; +}; + +export type ComputeOperationKind = 'whisper_align' | 'pdf_layout'; +export type IdleTimeoutAndHardCapInput = { + run: (touchProgress: () => void) => Promise; + idleTimeoutMs: number; + hardCapMs: number; + label: string; +}; + +function readPositiveIntEnv(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.floor(parsed); +} + +let timeoutConfigCache: ComputeTimeoutConfig | null = null; +let opStaleMsCache: number | null = null; + +export function getComputeTimeoutConfig(): ComputeTimeoutConfig { + if (timeoutConfigCache) return timeoutConfigCache; + timeoutConfigCache = { + whisperTimeoutMs: readPositiveIntEnv('COMPUTE_WHISPER_TIMEOUT_MS', DEFAULT_COMPUTE_WHISPER_TIMEOUT_MS), + pdfTimeoutMs: readPositiveIntEnv('COMPUTE_PDF_TIMEOUT_MS', DEFAULT_COMPUTE_PDF_TIMEOUT_MS), + pdfHardCapMs: DEFAULT_COMPUTE_PDF_HARD_CAP_MS, + }; + return timeoutConfigCache; +} + +export function getComputeOpStaleMs(): number { + if (typeof opStaleMsCache === 'number') return opStaleMsCache; + const config = getComputeTimeoutConfig(); + opStaleMsCache = readPositiveIntEnv( + 'COMPUTE_OP_STALE_MS', + Math.max(DEFAULT_COMPUTE_OP_STALE_MIN_MS, Math.max(config.whisperTimeoutMs, config.pdfTimeoutMs) * 4), + ); + return opStaleMsCache; +} + +export function getWorkerClientWaitTimeoutMs(kind: ComputeOperationKind): number { + const config = getComputeTimeoutConfig(); + const timeoutMs = kind === 'pdf_layout' ? config.pdfTimeoutMs : config.whisperTimeoutMs; + return Math.max(DEFAULT_WORKER_WAIT_MIN_MS, timeoutMs + DEFAULT_WORKER_WAIT_BUFFER_MS); +} + +export async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + let timer: NodeJS.Timeout | null = null; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +export async function withIdleTimeoutAndHardCap(input: IdleTimeoutAndHardCapInput): Promise { + let idleTimer: NodeJS.Timeout | null = null; + let hardCapTimer: NodeJS.Timeout | null = null; + let settled = false; + let rejectTimeout!: (reason: unknown) => void; + + const clearTimers = () => { + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + if (hardCapTimer) { + clearTimeout(hardCapTimer); + hardCapTimer = null; + } + }; + + const failTimeout = (kind: 'idle' | 'hard cap', timeoutMs: number) => { + if (settled) return; + settled = true; + clearTimers(); + rejectTimeout(new Error(`${input.label} ${kind} timed out after ${timeoutMs}ms`)); + }; + + const touchProgress = () => { + if (settled) return; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => failTimeout('idle', input.idleTimeoutMs), input.idleTimeoutMs); + }; + + const timeoutPromise = new Promise((_, reject) => { + rejectTimeout = reject; + hardCapTimer = setTimeout(() => failTimeout('hard cap', input.hardCapMs), input.hardCapMs); + touchProgress(); + }); + + try { + const result = await Promise.race([input.run(touchProgress), timeoutPromise]); + settled = true; + clearTimers(); + return result as T; + } catch (error) { + settled = true; + clearTimers(); + throw error; + } +} diff --git a/compute/core/src/control-plane/in-memory.ts b/compute/core/src/control-plane/in-memory.ts new file mode 100644 index 0000000..fc77c0e --- /dev/null +++ b/compute/core/src/control-plane/in-memory.ts @@ -0,0 +1,139 @@ +import { EventEmitter } from 'node:events'; +import type { + OperationEvent, + OperationEventStream, + OperationIndexEntry, + OperationQueue, + OperationState, + OperationStateStore, + QueuedOperation, +} from './types'; +import type { WorkerOperationKind } from '../api-contracts'; + +function topicFor(opId: string): string { + return `op.${opId}`; +} + +function normalizeSinceEventId(value: number | undefined): number { + if (!Number.isFinite(value ?? 0)) return 0; + return Math.max(0, Math.floor(value ?? 0)); +} + +export class InMemoryOperationQueue implements OperationQueue { + private readonly byKind = new Map(); + + constructor() { + this.byKind.set('whisper_align', []); + this.byKind.set('pdf_layout', []); + } + + async enqueue(job: QueuedOperation): Promise { + const list = this.byKind.get(job.kind); + if (!list) throw new Error(`Unsupported operation kind: ${job.kind}`); + list.push(job); + } + + async claimNext(kind: WorkerOperationKind): Promise { + const list = this.byKind.get(kind); + if (!list || list.length === 0) return null; + return list.shift() ?? null; + } + + size(kind?: WorkerOperationKind): number { + if (kind) return this.byKind.get(kind)?.length ?? 0; + let total = 0; + for (const list of this.byKind.values()) total += list.length; + return total; + } +} + +export class InMemoryOperationStateStore implements OperationStateStore { + private readonly stateByOpId = new Map(); + private readonly opIndexByKey = new Map(); + + async getOpState(opId: string): Promise { + return this.stateByOpId.get(opId) ?? null; + } + + async putOpState(state: OperationState): Promise { + this.stateByOpId.set(state.opId, state); + } + + async getOpIndex(opKey: string): Promise { + const opId = this.opIndexByKey.get(opKey); + return opId ? { opId } : null; + } + + async compareAndSetOpIndex(input: { + opKey: string; + newOpId: string; + expectedOpId: string | null; + }): Promise { + const current = this.opIndexByKey.get(input.opKey) ?? null; + if (current !== input.expectedOpId) return false; + this.opIndexByKey.set(input.opKey, input.newOpId); + return true; + } +} + +export class InMemoryOperationEventStream implements OperationEventStream { + private readonly emitter = new EventEmitter(); + private readonly lastIdByOpId = new Map(); + private readonly eventsByOpId = new Map(); + + constructor() { + this.emitter.setMaxListeners(0); + } + + async append(opId: string, snapshot: OperationState): Promise { + const nextEventId = (this.lastIdByOpId.get(opId) ?? 0) + 1; + this.lastIdByOpId.set(opId, nextEventId); + + const event: OperationEvent = { + eventId: nextEventId, + snapshot, + }; + + const list = this.eventsByOpId.get(opId) ?? []; + list.push(event); + this.eventsByOpId.set(opId, list); + this.emitter.emit(topicFor(opId), event); + return event; + } + + async listSince(opId: string, sinceEventId: number, limit?: number): Promise { + const list = this.eventsByOpId.get(opId) ?? []; + const normalizedSince = normalizeSinceEventId(sinceEventId); + const filtered = list.filter((event) => event.eventId > normalizedSince); + if (!Number.isFinite(limit ?? 0) || !limit || limit <= 0) return filtered; + return filtered.slice(0, Math.floor(limit)); + } + + async subscribe(input: { + opId: string; + sinceEventId?: number; + onEvent: (event: OperationEvent) => void | Promise; + onError?: (error: unknown) => void; + }): Promise<() => void> { + const replay = await this.listSince(input.opId, normalizeSinceEventId(input.sinceEventId)); + for (const event of replay) { + try { + await input.onEvent(event); + } catch (error) { + input.onError?.(error); + } + } + + const listener = (event: OperationEvent): void => { + Promise.resolve(input.onEvent(event)).catch((error) => { + input.onError?.(error); + }); + }; + + const topic = topicFor(input.opId); + this.emitter.on(topic, listener); + return () => { + this.emitter.off(topic, listener); + }; + } +} diff --git a/compute/core/src/control-plane/index.ts b/compute/core/src/control-plane/index.ts new file mode 100644 index 0000000..8e6b18c --- /dev/null +++ b/compute/core/src/control-plane/index.ts @@ -0,0 +1,5 @@ +export * from './types'; +export * from './state-machine'; +export * from './orchestrator'; +export * from './in-memory'; +export * from './sse'; diff --git a/compute/core/src/control-plane/orchestrator.ts b/compute/core/src/control-plane/orchestrator.ts new file mode 100644 index 0000000..950fe71 --- /dev/null +++ b/compute/core/src/control-plane/orchestrator.ts @@ -0,0 +1,288 @@ +import { randomUUID } from 'node:crypto'; +import type { + WorkerJobErrorShape, + WorkerJobTiming, + WorkerOperationKind, + WorkerOperationRequest, + WorkerOperationState, +} from '../api-contracts'; +import { + buildQueuedState, + createErrorShape, + explainReplacementReason, + isTerminalStatus, + shouldReuseExistingOperation, +} from './state-machine'; +import type { + OperationClock, + OperationEventStream, + OperationIdFactory, + OperationLifecycleConfig, + OperationQueue, + OperationStateStore, + QueuedOperation, +} from './types'; + +const RETRY_DELAY_MS = 25; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export interface OperationOrchestratorDeps { + queue: OperationQueue; + stateStore: OperationStateStore; + eventStream: OperationEventStream; + config: OperationLifecycleConfig; + clock?: OperationClock; + idFactory?: OperationIdFactory; +} + +export class OperationOrchestrator { + private readonly queue: OperationQueue; + private readonly stateStore: OperationStateStore; + private readonly eventStream: OperationEventStream; + private readonly opStaleMs: number; + private readonly maxCasRetries: number; + private readonly clock: OperationClock; + private readonly ids: OperationIdFactory; + + constructor(deps: OperationOrchestratorDeps) { + this.queue = deps.queue; + this.stateStore = deps.stateStore; + this.eventStream = deps.eventStream; + this.opStaleMs = deps.config.opStaleMs; + this.maxCasRetries = Math.max(1, Math.floor(deps.config.maxCasRetries ?? 10)); + this.clock = deps.clock ?? { now: () => Date.now() }; + this.ids = deps.idFactory ?? { + opId: () => randomUUID(), + jobId: () => randomUUID(), + }; + } + + private async persistState(state: WorkerOperationState): Promise { + await this.stateStore.putOpState(state); + await this.eventStream.append(state.opId, state); + } + + private buildQueuedJob(state: WorkerOperationState, request: WorkerOperationRequest): QueuedOperation { + return { + jobId: state.jobId, + opId: state.opId, + opKey: state.opKey, + kind: state.kind, + queuedAt: state.queuedAt, + payload: request.payload, + }; + } + + async enqueueOrReuse(request: WorkerOperationRequest): Promise { + const opKey = request.opKey.trim(); + + for (let attempt = 0; attempt < this.maxCasRetries; attempt += 1) { + const indexEntry = await this.stateStore.getOpIndex(opKey); + if (indexEntry?.opId) { + const current = await this.stateStore.getOpState(indexEntry.opId); + if (!current) { + await sleep(RETRY_DELAY_MS); + continue; + } + + const now = this.clock.now(); + if (shouldReuseExistingOperation({ + current, + requestKind: request.kind, + now, + opStaleMs: this.opStaleMs, + })) { + return current; + } + + const replacement = buildQueuedState({ + request, + opId: this.ids.opId(), + jobId: this.ids.jobId(), + queuedAt: now, + }); + + const replaced = await this.stateStore.compareAndSetOpIndex({ + opKey, + newOpId: replacement.opId, + expectedOpId: indexEntry.opId, + }); + if (!replaced) { + await sleep(RETRY_DELAY_MS); + continue; + } + + await this.persistState(replacement); + + try { + await this.queue.enqueue(this.buildQueuedJob(replacement, request)); + return replacement; + } catch (error) { + const failed: WorkerOperationState = { + ...replacement, + status: 'failed', + updatedAt: this.clock.now(), + error: createErrorShape(error), + }; + await this.persistState(failed); + return failed; + } + } + + const now = this.clock.now(); + const created = buildQueuedState({ + request, + opId: this.ids.opId(), + jobId: this.ids.jobId(), + queuedAt: now, + }); + + const createdIndex = await this.stateStore.compareAndSetOpIndex({ + opKey, + newOpId: created.opId, + expectedOpId: null, + }); + if (!createdIndex) { + await sleep(RETRY_DELAY_MS); + continue; + } + + await this.persistState(created); + + try { + await this.queue.enqueue(this.buildQueuedJob(created, request)); + return created; + } catch (error) { + const failed: WorkerOperationState = { + ...created, + status: 'failed', + updatedAt: this.clock.now(), + error: createErrorShape(error), + }; + await this.persistState(failed); + return failed; + } + } + + throw new Error('Unable to reserve operation after repeated CAS conflicts'); + } + + async getState(opId: string): Promise { + return this.stateStore.getOpState(opId); + } + + async markRunning(input: { + opId: string; + startedAt?: number; + updatedAt?: number; + timing?: WorkerJobTiming; + }): Promise { + const current = await this.requireState(input.opId); + const now = input.updatedAt ?? this.clock.now(); + + const next: WorkerOperationState = { + ...current, + status: 'running', + startedAt: input.startedAt ?? current.startedAt ?? now, + updatedAt: now, + ...(input.timing ? { timing: input.timing } : {}), + }; + + await this.persistState(next); + return next; + } + + async markProgress(input: { + opId: string; + progress: WorkerOperationState['progress']; + updatedAt?: number; + timing?: WorkerJobTiming; + }): Promise { + const current = await this.requireState(input.opId); + const now = input.updatedAt ?? this.clock.now(); + + const next: WorkerOperationState = { + ...current, + status: 'running', + startedAt: current.startedAt ?? now, + updatedAt: now, + progress: input.progress, + ...(input.timing ? { timing: input.timing } : {}), + }; + + await this.persistState(next); + return next; + } + + async markSucceeded(input: { + opId: string; + result: unknown; + updatedAt?: number; + timing?: WorkerJobTiming; + }): Promise { + const current = await this.requireState(input.opId); + const now = input.updatedAt ?? this.clock.now(); + + const next: WorkerOperationState = { + ...current, + status: 'succeeded', + startedAt: current.startedAt ?? now, + updatedAt: now, + result: input.result, + ...(input.timing ? { timing: input.timing } : {}), + }; + + await this.persistState(next); + return next; + } + + async markFailed(input: { + opId: string; + error: WorkerJobErrorShape | string; + updatedAt?: number; + timing?: WorkerJobTiming; + }): Promise { + const current = await this.requireState(input.opId); + const now = input.updatedAt ?? this.clock.now(); + + const shape = typeof input.error === 'string' ? { message: input.error } : input.error; + + const next: WorkerOperationState = { + ...current, + status: 'failed', + startedAt: current.startedAt ?? now, + updatedAt: now, + error: shape, + ...(input.timing ? { timing: input.timing } : {}), + }; + + await this.persistState(next); + return next; + } + + async explainReuseDecision(input: { + current: WorkerOperationState; + requestKind: WorkerOperationKind; + }): Promise { + return explainReplacementReason({ + current: input.current, + requestKind: input.requestKind, + now: this.clock.now(), + opStaleMs: this.opStaleMs, + }); + } + + private async requireState(opId: string): Promise { + const current = await this.stateStore.getOpState(opId); + if (!current) { + throw new Error(`Operation not found: ${opId}`); + } + if (isTerminalStatus(current.status)) { + return current; + } + return current; + } +} diff --git a/compute/core/src/control-plane/sse.ts b/compute/core/src/control-plane/sse.ts new file mode 100644 index 0000000..712f6af --- /dev/null +++ b/compute/core/src/control-plane/sse.ts @@ -0,0 +1,49 @@ +export interface SseFrameInput { + event?: string; + id?: string | number; + data?: T; + comment?: string; +} + +export function encodeSseFrame(input: SseFrameInput): string { + const lines: string[] = []; + if (typeof input.comment === 'string') { + lines.push(`: ${input.comment}`); + } + if (typeof input.id !== 'undefined') { + lines.push(`id: ${String(input.id)}`); + } + if (typeof input.event === 'string' && input.event.trim()) { + lines.push(`event: ${input.event}`); + } + if (typeof input.data !== 'undefined') { + const serialized = typeof input.data === 'string' ? input.data : JSON.stringify(input.data); + for (const line of serialized.replace(/\r\n/g, '\n').split('\n')) { + lines.push(`data: ${line}`); + } + } + return `${lines.join('\n')}\n\n`; +} + +export function parseSsePayload(frame: string): string | null { + const lines = frame.replace(/\r\n/g, '\n').split('\n'); + const dataLines: string[] = []; + for (const line of lines) { + if (!line.startsWith('data:')) continue; + dataLines.push(line.slice('data:'.length).trimStart()); + } + return dataLines.length > 0 ? dataLines.join('\n') : null; +} + +export function parseSseEventId(frame: string): number | null { + const lines = frame.replace(/\r\n/g, '\n').split('\n'); + for (const line of lines) { + if (!line.startsWith('id:')) continue; + const value = Number(line.slice('id:'.length).trim()); + if (Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + } + return null; +} + diff --git a/compute/core/src/control-plane/state-machine.ts b/compute/core/src/control-plane/state-machine.ts new file mode 100644 index 0000000..d280215 --- /dev/null +++ b/compute/core/src/control-plane/state-machine.ts @@ -0,0 +1,65 @@ +import type { + WorkerJobErrorShape, + WorkerJobState, + WorkerOperationKind, + WorkerOperationRequest, + WorkerOperationState, +} from '../api-contracts'; + +export function isTerminalStatus(status: WorkerJobState): boolean { + return status === 'succeeded' || status === 'failed'; +} + +export function isInflightStatus(status: WorkerJobState): boolean { + return status === 'queued' || status === 'running'; +} + +export function createErrorShape(error: unknown): WorkerJobErrorShape { + if (error && typeof error === 'object' && 'message' in error && typeof (error as { message: unknown }).message === 'string') { + return { message: (error as { message: string }).message }; + } + return { message: String(error) }; +} + +export function buildQueuedState(input: { + request: WorkerOperationRequest; + opId: string; + jobId: string; + queuedAt: number; +}): WorkerOperationState { + return { + opId: input.opId, + opKey: input.request.opKey, + kind: input.request.kind, + jobId: input.jobId, + status: 'queued', + queuedAt: input.queuedAt, + updatedAt: input.queuedAt, + }; +} + +export function explainReplacementReason(input: { + current: WorkerOperationState; + requestKind: WorkerOperationKind; + now: number; + opStaleMs: number; +}): string { + if (input.current.kind !== input.requestKind) return 'kind_mismatch'; + const ageMs = input.now - input.current.updatedAt; + if (isInflightStatus(input.current.status) && ageMs > input.opStaleMs) return 'stale_running'; + if (input.current.status === 'failed') return 'failed_prior'; + return `status_${input.current.status}`; +} + +export function shouldReuseExistingOperation(input: { + current: WorkerOperationState; + requestKind: WorkerOperationKind; + now: number; + opStaleMs: number; +}): boolean { + if (input.current.kind !== input.requestKind) return false; + if (input.current.status === 'succeeded') return true; + if (!isInflightStatus(input.current.status)) return false; + const ageMs = input.now - input.current.updatedAt; + return ageMs <= input.opStaleMs; +} diff --git a/compute/core/src/control-plane/types.ts b/compute/core/src/control-plane/types.ts new file mode 100644 index 0000000..79fd16c --- /dev/null +++ b/compute/core/src/control-plane/types.ts @@ -0,0 +1,63 @@ +import type { + WorkerOperationEvent, + WorkerOperationKind, + WorkerOperationState, +} from '../api-contracts'; + +export type OperationState = WorkerOperationState; +export type OperationEvent = WorkerOperationEvent; + +export interface QueuedOperation { + jobId: string; + opId: string; + opKey: string; + kind: WorkerOperationKind; + queuedAt: number; + payload: TPayload; +} + +export interface OperationQueue { + enqueue(job: QueuedOperation): Promise; + claimNext(kind: WorkerOperationKind): Promise | null>; + size(kind?: WorkerOperationKind): number; +} + +export interface OperationIndexEntry { + opId: string; +} + +export interface OperationStateStore { + getOpState(opId: string): Promise | null>; + putOpState(state: OperationState): Promise; + getOpIndex(opKey: string): Promise; + compareAndSetOpIndex(input: { + opKey: string; + newOpId: string; + expectedOpId: string | null; + }): Promise; +} + +export interface OperationEventStream { + append(opId: string, snapshot: OperationState): Promise>; + listSince(opId: string, sinceEventId: number, limit?: number): Promise[]>; + subscribe(input: { + opId: string; + sinceEventId?: number; + onEvent: (event: OperationEvent) => void | Promise; + onError?: (error: unknown) => void; + }): Promise<() => void>; +} + +export interface OperationClock { + now(): number; +} + +export interface OperationIdFactory { + opId(): string; + jobId(): string; +} + +export interface OperationLifecycleConfig { + opStaleMs: number; + maxCasRetries?: number; +} diff --git a/compute/core/src/index.ts b/compute/core/src/index.ts new file mode 100644 index 0000000..4ba1e89 --- /dev/null +++ b/compute/core/src/index.ts @@ -0,0 +1,24 @@ +export * from './api-contracts'; +export { + getComputeJobConcurrency, + getAvailableCpuCores, + getOnnxThreadsPerJob, +} from './config/cpu-budget'; +export { + getComputeTimeoutConfig, + getComputeOpStaleMs, + getWorkerClientWaitTimeoutMs, + withTimeout, + withIdleTimeoutAndHardCap, + type ComputeTimeoutConfig, + type ComputeOperationKind, + type IdleTimeoutAndHardCapInput, +} from './config/timeout'; +export { renderPage } from './pdf/render'; +export { mergeTextWithRegions } from './pdf/merge'; +export { stitchCrossPageBlocks } from './pdf/stitch'; +export { normalizeTextItemsForLayout } from './pdf/normalize-text'; +export { mapWordsToSentenceOffsets, type WhisperWord } from './whisper/alignment-map'; +export { buildGoertzelCoefficients, goertzelPower } from './whisper/spectral'; +export { buildWordsFromTimestampedTokens, extractTokenStartTimestamps } from './whisper/token-timestamps'; +export * from './control-plane'; diff --git a/compute/core/src/local-runtime.ts b/compute/core/src/local-runtime.ts new file mode 100644 index 0000000..ad57513 --- /dev/null +++ b/compute/core/src/local-runtime.ts @@ -0,0 +1,40 @@ +import { ensureWhisperModel } from './whisper/model'; +import { alignAudioWithText } from './whisper/align'; +import { ensureModel as ensurePdfLayoutModel } from './pdf/model'; +import { parsePdf } from './pdf/parse'; + +export async function ensureComputeModels(): Promise { + await Promise.all([ensureWhisperModel(), ensurePdfLayoutModel()]); +} + +export async function runWhisperAlignmentFromAudioBuffer(input: { + audioBuffer: ArrayBuffer; + text: string; + cacheKey?: string; + lang?: string; +}) { + const alignments = await alignAudioWithText( + input.audioBuffer, + input.text, + input.cacheKey, + { lang: input.lang }, + ); + return { alignments }; +} + +export async function runPdfLayoutFromPdfBuffer(input: { + documentId: string; + pdfBytes: ArrayBuffer; + onPageParsed?: (input: { + pageNumber: number; + totalPages: number; + pageMs: number; + }) => void | Promise; +}) { + const parsed = await parsePdf({ + documentId: input.documentId, + pdfBytes: input.pdfBytes, + onPageParsed: input.onPageParsed, + }); + return { parsed }; +} diff --git a/compute/core/src/pdf/assets/LICENSE.txt b/compute/core/src/pdf/assets/LICENSE.txt new file mode 100644 index 0000000..cb60fe1 --- /dev/null +++ b/compute/core/src/pdf/assets/LICENSE.txt @@ -0,0 +1,3 @@ +PP-DocLayoutV3 ONNX model assets are distributed by the model publisher under Apache-2.0. +See upstream for the authoritative license and terms: +https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX diff --git a/compute/core/src/pdf/assets/manifest.json b/compute/core/src/pdf/assets/manifest.json new file mode 100644 index 0000000..9f4807d --- /dev/null +++ b/compute/core/src/pdf/assets/manifest.json @@ -0,0 +1,31 @@ +{ + "name": "pp-doclayoutv3", + "version": "Bei0001/PP-DocLayoutV3-ONNX@main", + "files": [ + { + "path": "model.onnx", + "sha256": "c0721928ff08741bb208ebed539c77170db5234a68cb7e546e6cc9bc172a695b", + "size": 5088167 + }, + { + "path": "model.onnx.data", + "sha256": "34df3e4b79d7bbbf82abce1b4f3cde3d540fa57ad42ec8905c352b97c408d437", + "size": 136774480 + }, + { + "path": "config.json", + "sha256": "3cf834b91d23a756b1519bce4db42c09e852f3e35c35092dd5a3e253a50c071a", + "size": 2460 + }, + { + "path": "preprocessor_config.json", + "sha256": "519fe0187a43a1ca429e3ad8317bab8700f0d5e8fb3a6e3a0a413ffac078ba42", + "size": 575 + }, + { + "path": "LICENSE.txt", + "sha256": "578a6ba6f86b0692a8f719843f575a3eebf4705768ac5c37d149f441208f601f", + "size": 195 + } + ] +} diff --git a/compute/core/src/pdf/merge.ts b/compute/core/src/pdf/merge.ts new file mode 100644 index 0000000..aeff9ab --- /dev/null +++ b/compute/core/src/pdf/merge.ts @@ -0,0 +1,153 @@ +import type { LayoutRegion, PdfTextItem } from './types'; + +const NON_TEXT_REGION_LABELS = new Set(['chart', 'image', 'table', 'seal']); +const TEXT_ASSIGNABLE_LABELS = new Set([ + 'abstract', + 'algorithm', + 'aside_text', + 'content', + 'doc_title', + 'figure_title', + 'footer', + 'footnote', + 'formula_number', + 'header', + 'number', + 'paragraph_title', + 'reference', + 'reference_content', + 'text', + 'vision_footnote', + 'formula', +]); + +function centroid(item: PdfTextItem): { x: number; y: number } { + return { + x: item.x + item.width / 2, + y: item.y + item.height / 2, + }; +} + +function contains(region: LayoutRegion, item: PdfTextItem): boolean { + const c = centroid(item); + return c.x >= region.bbox[0] && c.x <= region.bbox[2] && c.y >= region.bbox[1] && c.y <= region.bbox[3]; +} + +function sortReadingOrder(items: PdfTextItem[]): PdfTextItem[] { + const tolerance = 2; + return [...items].sort((a, b) => { + if (Math.abs(a.y - b.y) <= tolerance) return a.x - b.x; + return a.y - b.y; + }); +} + +function joinText(items: PdfTextItem[]): string { + let out = ''; + let prev: PdfTextItem | null = null; + for (const item of items) { + if (!prev) { + out += item.text; + prev = item; + continue; + } + const prevEndX = prev.x + prev.width; + const gap = item.x - prevEndX; + const lineJump = item.y - prev.y; + const lineBreak = lineJump > Math.max(2, Math.min(prev.height, item.height) * 0.6); + const avgCharWidth = item.width / Math.max(1, item.text.length); + const needsSpace = lineBreak || gap > Math.max(avgCharWidth * 0.3, 2); + out += needsSpace ? ` ${item.text}` : item.text; + prev = item; + } + return out.replace(/\s+/g, ' ').trim(); +} + +function regionArea(region: LayoutRegion): number { + return Math.max(1, (region.bbox[2] - region.bbox[0]) * (region.bbox[3] - region.bbox[1])); +} + +function regionScore(region: LayoutRegion): number { + return Number.isFinite(region.confidence) ? Number(region.confidence) : 0; +} + +export interface RegionTextBlock { + region: LayoutRegion; + text: string; + items: PdfTextItem[]; + sourceOrder: number; +} + +export function mergeTextWithRegions(regions: LayoutRegion[], textItems: PdfTextItem[]): RegionTextBlock[] { + const sourceIndex = new Map(); + for (let i = 0; i < textItems.length; i += 1) { + sourceIndex.set(textItems[i]!, i); + } + + const chunkSourceOrder = (items: PdfTextItem[]): number => { + let min = Number.POSITIVE_INFINITY; + for (const item of items) { + const index = sourceIndex.get(item); + if (typeof index === 'number' && index < min) min = index; + } + return Number.isFinite(min) ? min : Number.MAX_SAFE_INTEGER; + }; + + const assignableRegions = regions + .map((region, index) => ({ region, index })) + .filter(({ region }) => TEXT_ASSIGNABLE_LABELS.has(region.label)); + const assignedByRegion = new Map(); + + for (const item of textItems) { + const candidates = assignableRegions.filter(({ region }) => contains(region, item)); + if (candidates.length === 0) continue; + + candidates.sort((a, b) => { + const scoreDelta = regionScore(b.region) - regionScore(a.region); + if (Math.abs(scoreDelta) > 1e-6) return scoreDelta; + return regionArea(a.region) - regionArea(b.region); + }); + + const winner = candidates[0]; + const list = assignedByRegion.get(winner.index) ?? []; + list.push(item); + assignedByRegion.set(winner.index, list); + } + + const out: RegionTextBlock[] = []; + + for (const [regionIndex, assignedItems] of assignedByRegion.entries()) { + const region = regions[regionIndex]; + if (!region) continue; + if (assignedItems.length === 0) continue; + const ordered = sortReadingOrder(assignedItems); + const text = joinText(ordered); + if (!text) continue; + + out.push({ + region, + text, + items: ordered, + sourceOrder: chunkSourceOrder(ordered), + }); + } + + for (const region of regions) { + if (!NON_TEXT_REGION_LABELS.has(region.label)) continue; + out.push({ + region, + text: '', + items: [], + sourceOrder: Number.MAX_SAFE_INTEGER, + }); + } + + out.sort((a, b) => { + if (a.sourceOrder !== b.sourceOrder) return a.sourceOrder - b.sourceOrder; + const ay = a.region.bbox[1]; + const by = b.region.bbox[1]; + if (Math.abs(ay - by) <= 2) return a.region.bbox[0] - b.region.bbox[0]; + return ay - by; + }); + + return out; +} diff --git a/compute/core/src/pdf/model.ts b/compute/core/src/pdf/model.ts new file mode 100644 index 0000000..ca09540 --- /dev/null +++ b/compute/core/src/pdf/model.ts @@ -0,0 +1,147 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createHash } from 'crypto'; +import { readFileSync } from 'fs'; +import { access, mkdir, rename, writeFile, readFile, unlink, copyFile } from 'fs/promises'; +import { DOCSTORE_DIR } from '../platform/docstore'; + +const DEFAULT_MODEL_BASE_URL = 'https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main'; +const PDF_LAYOUT_MODEL_BASE_URL_ENV = 'PDF_LAYOUT_MODEL_BASE_URL'; +const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); +const MODEL_DIR = path.join(DOCSTORE_DIR, 'model'); +const MANIFEST_PATH = path.join(MODULE_DIR, 'assets', 'manifest.json'); +export const MODEL_PATH = path.join(MODEL_DIR, 'PP-DocLayoutV3.onnx'); +export const MODEL_DATA_PATH = path.join(MODEL_DIR, 'PP-DocLayoutV3.onnx.data'); +export const MODEL_CONFIG_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.config.json'); +export const MODEL_PREPROCESSOR_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.preprocessor_config.json'); +const LICENSE_PATH = path.join(MODEL_DIR, 'pp-doclayoutv3.LICENSE.txt'); +const STATIC_LICENSE_PATH = path.join(MODULE_DIR, 'assets', 'LICENSE.txt'); + +type ManifestEntry = { + path: string; + sha256?: string; + size?: number; +}; + +function loadManifest(): { files: ManifestEntry[] } { + const manifestText = readFileSync(MANIFEST_PATH, 'utf8'); + const parsed = JSON.parse(manifestText) as { files?: ManifestEntry[] }; + return { files: Array.isArray(parsed.files) ? parsed.files : [] }; +} + +const manifest = loadManifest(); + +let inflight: Promise | null = null; + +async function sha256Hex(filePath: string): Promise { + const bytes = await readFile(filePath); + return createHash('sha256').update(bytes).digest('hex'); +} + +async function downloadToFile(url: string, outPath: string): Promise { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Download failed for ${url}: ${res.status} ${res.statusText}`); + } + const bytes = new Uint8Array(await res.arrayBuffer()); + await writeFile(outPath, bytes); +} + +function joinModelUrl(baseUrl: string, relativePath: string): string { + return `${baseUrl.replace(/\/+$/, '')}/${relativePath}`; +} + +function manifestEntry(filePath: string): { sha256: string; size: number } | null { + const found = manifest.files.find((entry) => entry.path === filePath); + if (!found || !found.sha256) return null; + return { + sha256: found.sha256.toLowerCase(), + size: Number(found.size), + }; +} + +async function verifyFile(pathToFile: string, manifestPath: string): Promise { + const expected = manifestEntry(manifestPath); + if (!expected) return true; + const bytes = await readFile(pathToFile); + if (Number.isFinite(expected.size) && expected.size > 0 && bytes.byteLength !== expected.size) { + return false; + } + const actual = await sha256Hex(pathToFile); + return actual === expected.sha256; +} + +async function ensureLicense(): Promise { + await copyFile(STATIC_LICENSE_PATH, LICENSE_PATH); + if (!(await verifyFile(LICENSE_PATH, 'LICENSE.txt'))) { + throw new Error('PDF layout model license checksum verification failed'); + } +} + +async function ensureModelInternal(): Promise { + try { + await access(MODEL_PATH); + await access(MODEL_DATA_PATH); + await access(MODEL_CONFIG_PATH); + await access(MODEL_PREPROCESSOR_PATH); + if ( + await verifyFile(MODEL_PATH, 'model.onnx') + && await verifyFile(MODEL_DATA_PATH, 'model.onnx.data') + && await verifyFile(MODEL_CONFIG_PATH, 'config.json') + && await verifyFile(MODEL_PREPROCESSOR_PATH, 'preprocessor_config.json') + ) { + await ensureLicense(); + return MODEL_PATH; + } + } catch { + // continue + } + + await mkdir(MODEL_DIR, { recursive: true }); + const modelTmpPath = `${MODEL_PATH}.tmp`; + const modelDataTmpPath = `${MODEL_DATA_PATH}.tmp`; + const configTmpPath = `${MODEL_CONFIG_PATH}.tmp`; + const preprocessorTmpPath = `${MODEL_PREPROCESSOR_PATH}.tmp`; + const modelBaseUrl = process.env[PDF_LAYOUT_MODEL_BASE_URL_ENV]?.trim() + || DEFAULT_MODEL_BASE_URL; + const modelUrl = joinModelUrl(modelBaseUrl, 'PP-DocLayoutV3.onnx'); + const modelDataUrl = joinModelUrl(modelBaseUrl, 'PP-DocLayoutV3.onnx.data'); + const configUrl = joinModelUrl(modelBaseUrl, 'config.json'); + const preprocessorUrl = joinModelUrl(modelBaseUrl, 'preprocessor_config.json'); + + await downloadToFile(modelUrl, modelTmpPath); + if (!(await verifyFile(modelTmpPath, 'model.onnx'))) { + await unlink(modelTmpPath).catch(() => undefined); + throw new Error('PDF layout model checksum verification failed'); + } + await downloadToFile(modelDataUrl, modelDataTmpPath); + if (!(await verifyFile(modelDataTmpPath, 'model.onnx.data'))) { + await unlink(modelDataTmpPath).catch(() => undefined); + throw new Error('PDF layout model external data checksum verification failed'); + } + await downloadToFile(configUrl, configTmpPath); + if (!(await verifyFile(configTmpPath, 'config.json'))) { + await unlink(configTmpPath).catch(() => undefined); + throw new Error('PDF layout model config checksum verification failed'); + } + await downloadToFile(preprocessorUrl, preprocessorTmpPath); + if (!(await verifyFile(preprocessorTmpPath, 'preprocessor_config.json'))) { + await unlink(preprocessorTmpPath).catch(() => undefined); + throw new Error('PDF layout model preprocessor checksum verification failed'); + } + + await rename(modelTmpPath, MODEL_PATH); + await rename(modelDataTmpPath, MODEL_DATA_PATH); + await rename(configTmpPath, MODEL_CONFIG_PATH); + await rename(preprocessorTmpPath, MODEL_PREPROCESSOR_PATH); + await ensureLicense(); + return MODEL_PATH; +} + +export async function ensureModel(): Promise { + if (inflight) return inflight; + inflight = ensureModelInternal().finally(() => { + inflight = null; + }); + return inflight; +} diff --git a/compute/core/src/pdf/normalize-text.ts b/compute/core/src/pdf/normalize-text.ts new file mode 100644 index 0000000..2c2eca7 --- /dev/null +++ b/compute/core/src/pdf/normalize-text.ts @@ -0,0 +1,35 @@ +import type { TextItem } from 'pdfjs-dist/types/src/display/api'; +import type { PdfTextItem } from './types'; + +export function normalizeTextItemsForLayout(items: TextItem[], pageHeight: number): PdfTextItem[] { + return items + .filter((item) => { + if (!(typeof item.str === 'string' && item.str.trim().length > 0)) return false; + const transform = item.transform; + if (!Array.isArray(transform) || transform.length < 6) return false; + + // Reject heavily skewed/rotated text runs (e.g. vertical margin labels + // such as arXiv metadata) so they do not get merged into body blocks. + const skewX = Number(transform[1] ?? 0); + const skewY = Number(transform[2] ?? 0); + if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false; + + return true; + }) + .map((item) => { + const x = Number(item.transform[4] ?? 0); + const width = Math.max(0, Number(item.width ?? 0)); + const height = Math.max(1, Math.abs(Number(item.transform[3] ?? 1))); + const baselineY = Number(item.transform[5] ?? 0); + // pdf.js text transforms are in PDF user-space (origin bottom-left). + // Normalize into top-left page coordinates to match rendered image/model boxes. + const y = Math.max(0, pageHeight - baselineY - height); + return { + text: item.str, + x, + y, + width, + height, + }; + }); +} diff --git a/compute/core/src/pdf/parse.ts b/compute/core/src/pdf/parse.ts new file mode 100644 index 0000000..6aef27d --- /dev/null +++ b/compute/core/src/pdf/parse.ts @@ -0,0 +1,150 @@ +import path from 'path'; +import type { TextItem } from 'pdfjs-dist/types/src/display/api'; +import type { ParsedPdfDocument, ParsedPdfPage } from '../types/parsed-pdf'; +import { ensureModel } from './model'; +import { runLayoutModel } from './runLayoutModel'; +import { mergeTextWithRegions } from './merge'; +import { stitchCrossPageBlocks } from './stitch'; +import { renderPage } from './render'; +import { normalizeTextItemsForLayout } from './normalize-text'; + +interface ParsePdfInput { + documentId: string; + pdfBytes: ArrayBuffer; + onPageParsed?: (input: { + pageNumber: number; + totalPages: number; + pageMs: number; + }) => void | Promise; +} + +const LAYOUT_RENDER_SCALE = 1.5; + +function resolvePdfjsStandardFontDataUrl(): string { + const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts'); + return `${standardFontDir.replace(/\/?$/, '/')}`; +} + +export async function parsePdf(input: ParsePdfInput): Promise { + await ensureModel(); + + // Keep independent byte copies for text extraction and page rendering. pdf.js + // can detach buffers passed to getDocument(). + const pdfBytesForText = new Uint8Array(input.pdfBytes).slice(); + const pdfBytesForRender = new Uint8Array(input.pdfBytes).slice(); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + if (pdfjs.GlobalWorkerOptions) { + pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs'; + pdfjs.GlobalWorkerOptions.workerPort = null; + } + const standardFontDataUrl = resolvePdfjsStandardFontDataUrl(); + + const loadingTask = pdfjs.getDocument({ + data: pdfBytesForText, + useWorkerFetch: false, + standardFontDataUrl, + isEvalSupported: false, + }); + const pdf = await loadingTask.promise; + + try { + const pages: ParsedPdfPage[] = []; + let nextBlockId = 1; + let sawText = false; + + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + const pageStartedAt = Date.now(); + const page = await pdf.getPage(pageNumber); + const viewport = page.getViewport({ scale: 1.0 }); + const textContent = await page.getTextContent(); + const textItems = normalizeTextItemsForLayout( + textContent.items.filter((item): item is TextItem => 'str' in item && 'transform' in item), + viewport.height, + ); + + if (textItems.length > 0) sawText = true; + + const rendered = await renderPage({ + pdfBytes: pdfBytesForRender.buffer.slice( + pdfBytesForRender.byteOffset, + pdfBytesForRender.byteOffset + pdfBytesForRender.byteLength, + ), + pageNumber, + scale: LAYOUT_RENDER_SCALE, + }); + const scaleX = rendered.width / Math.max(1, viewport.width); + const scaleY = rendered.height / Math.max(1, viewport.height); + const layoutTextItems = textItems.map((item) => ({ + ...item, + x: item.x * scaleX, + y: item.y * scaleY, + width: item.width * scaleX, + height: item.height * scaleY, + })); + const regions = await runLayoutModel({ + pageWidth: rendered.width, + pageHeight: rendered.height, + textItems: layoutTextItems, + pageImage: rendered.image, + }); + const merged = mergeTextWithRegions(regions, layoutTextItems); + if (textItems.length > 0 && merged.length === 0) { + throw new Error(`layout-merge-empty: page=${pageNumber} regions=${regions.length}`); + } + + const blocks = merged + .map((entry, readingOrder) => ({ + id: `b${String(nextBlockId++).padStart(4, '0')}`, + kind: entry.region.label, + fragments: [{ + page: pageNumber, + bbox: [ + entry.region.bbox[0] / scaleX, + entry.region.bbox[1] / scaleY, + entry.region.bbox[2] / scaleX, + entry.region.bbox[3] / scaleY, + ] as [number, number, number, number], + text: entry.text, + readingOrder, + ...(typeof entry.region.confidence === 'number' ? { modelConfidence: entry.region.confidence } : {}), + }], + text: entry.text, + })); + + pages.push({ + pageNumber, + width: viewport.width, + height: viewport.height, + blocks, + }); + + if (input.onPageParsed) { + await input.onPageParsed({ + pageNumber, + totalPages: pdf.numPages, + pageMs: Date.now() - pageStartedAt, + }); + } + } + + if (!sawText) { + throw new Error('no-text-layer'); + } + + const doc: ParsedPdfDocument = { + schemaVersion: 1, + documentId: input.documentId, + parserVersion: 'pp-doclayoutv3-onnx@800+pdfjs@4.8.69', + parsedAt: Date.now(), + pages, + }; + + return stitchCrossPageBlocks(doc); + } finally { + await pdf.destroy().catch(() => undefined); + await loadingTask.destroy().catch(() => undefined); + } +} diff --git a/compute/core/src/pdf/render.ts b/compute/core/src/pdf/render.ts new file mode 100644 index 0000000..833b5c5 --- /dev/null +++ b/compute/core/src/pdf/render.ts @@ -0,0 +1,166 @@ +import path from 'path'; +import type { Canvas } from '@napi-rs/canvas'; + +type CanvasRuntime = { + DOMMatrixCtor: unknown; + Path2DCtor: unknown; + createCanvasFn: (width: number, height: number) => Canvas; +}; + +let canvasRuntimePromise: Promise | null = null; + +function resolvePdfjsStandardFontDataUrl(): string { + const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts'); + return `${standardFontDir.replace(/\/?$/, '/')}`; +} + +async function loadCanvasRuntime(): Promise { + if (!canvasRuntimePromise) { + canvasRuntimePromise = (async () => { + const mod = await import('@napi-rs/canvas'); + const namespace = mod as Record; + const fallback = (namespace.default ?? {}) as Record; + + const createCanvasFn = (namespace.createCanvas ?? fallback.createCanvas) as + | ((width: number, height: number) => Canvas) + | undefined; + const DOMMatrixCtor = namespace.DOMMatrix ?? fallback.DOMMatrix; + const Path2DCtor = namespace.Path2D ?? fallback.Path2D; + + if (typeof createCanvasFn !== 'function') { + throw new Error( + `Canvas runtime missing createCanvas export (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`, + ); + } + if (!DOMMatrixCtor || !Path2DCtor) { + throw new Error( + `Canvas runtime missing DOMMatrix/Path2D exports (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`, + ); + } + + return { + DOMMatrixCtor, + Path2DCtor, + createCanvasFn, + }; + })(); + } + return canvasRuntimePromise; +} + +function ensureNodeCanvasGlobals(runtime: CanvasRuntime): void { + const g = globalThis as Record; + if (typeof g.DOMMatrix === 'undefined') g.DOMMatrix = runtime.DOMMatrixCtor; + if (typeof g.Path2D === 'undefined') g.Path2D = runtime.Path2DCtor; +} + +interface RenderInput { + pdfBytes: ArrayBuffer; + pageNumber: number; + scale?: number; + targetWidth?: number; + format?: 'png' | 'jpeg'; + jpegQuality?: number; +} + +function createPdfjsCanvasFactory(runtime: CanvasRuntime) { + return class OpenReaderCanvasFactory { + create(width: number, height: number) { + const canvas = runtime.createCanvasFn(Math.max(1, Math.floor(width)), Math.max(1, Math.floor(height))); + return { + canvas, + context: canvas.getContext('2d') as unknown as CanvasRenderingContext2D, + }; + } + + reset(target: { canvas: Canvas; context: CanvasRenderingContext2D }, width: number, height: number): void { + target.canvas.width = Math.max(1, Math.floor(width)); + target.canvas.height = Math.max(1, Math.floor(height)); + } + + destroy(target: { canvas: Canvas; context: CanvasRenderingContext2D }): void { + target.canvas.width = 0; + target.canvas.height = 0; + // @ts-expect-error pdf.js expects these nulled on destroy + target.canvas = null; + // @ts-expect-error pdf.js expects these nulled on destroy + target.context = null; + } + }; +} + +export async function renderPage({ + pdfBytes, + pageNumber, + scale = 1.5, + targetWidth, + format = 'png', + jpegQuality = 82, +}: RenderInput): Promise<{ + width: number; + height: number; + image: Buffer; + contentType: 'image/png' | 'image/jpeg'; +}> { + // pdf.js may detach the provided ArrayBuffer. Work with an isolated copy so + // callers can safely reuse their original bytes across pages/calls. + const isolatedBytes = new Uint8Array(pdfBytes).slice(); + + const canvasRuntime = await loadCanvasRuntime(); + ensureNodeCanvasGlobals(canvasRuntime); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + + if (pdfjs.GlobalWorkerOptions) { + pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs'; + pdfjs.GlobalWorkerOptions.workerPort = null; + } + + const standardFontDataUrl = resolvePdfjsStandardFontDataUrl(); + + const loadingTask = pdfjs.getDocument({ + data: isolatedBytes, + useWorkerFetch: false, + standardFontDataUrl, + isEvalSupported: false, + // Ensure pdf.js transport uses our canvas backend in Node/Next runtime. + CanvasFactory: createPdfjsCanvasFactory(canvasRuntime), + }); + + const pdf = await loadingTask.promise; + try { + const page = await pdf.getPage(pageNumber); + const baseViewport = page.getViewport({ scale: 1.0 }); + const effectiveScale = typeof targetWidth === 'number' && Number.isFinite(targetWidth) && targetWidth > 0 + ? (Math.max(1, Math.round(targetWidth)) / Math.max(1, baseViewport.width)) + : scale; + const viewport = page.getViewport({ scale: effectiveScale }); + const width = Math.max(1, Math.floor(viewport.width)); + const height = Math.max(1, Math.floor(viewport.height)); + const canvas = canvasRuntime.createCanvasFn(width, height); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, width, height); + + const renderTask = page.render({ + canvasContext: ctx as unknown as CanvasRenderingContext2D, + viewport, + intent: 'display', + }); + await renderTask.promise; + const contentType = format === 'jpeg' ? 'image/jpeg' : 'image/png'; + const image = format === 'jpeg' + ? canvas.toBuffer('image/jpeg', jpegQuality) + : canvas.toBuffer('image/png'); + return { + width, + height, + image, + contentType, + }; + } finally { + await pdf.destroy().catch(() => undefined); + await loadingTask.destroy().catch(() => undefined); + } +} diff --git a/compute/core/src/pdf/runLayoutModel.ts b/compute/core/src/pdf/runLayoutModel.ts new file mode 100644 index 0000000..5b8fb43 --- /dev/null +++ b/compute/core/src/pdf/runLayoutModel.ts @@ -0,0 +1,339 @@ +import * as ort from 'onnxruntime-node'; +import { readFile } from 'fs/promises'; +import type { LayoutRegion, PdfTextItem } from './types'; +import { ensureModel, MODEL_CONFIG_PATH, MODEL_PREPROCESSOR_PATH } from './model'; +import { getOnnxThreadsPerJob } from '../config/cpu-budget'; + +interface RunLayoutInput { + pageWidth: number; + pageHeight: number; + textItems: PdfTextItem[]; + pageImage: Buffer; +} + +const DEFAULT_INPUT_SIZE = 800; +const MIN_SCORE = 0.5; +const CLASS_MIN_SCORE: Partial> = { + header: 0.4, + footer: 0.4, + figure_title: 0.45, + footnote: 0.45, + vision_footnote: 0.45, +}; + +const LABEL_MAP: Record = { + // PP-DocLayoutV3 labels + abstract: 'abstract', + algorithm: 'algorithm', + aside_text: 'aside_text', + chart: 'chart', + content: 'content', + display_formula: 'formula', + doc_title: 'doc_title', + figure_title: 'figure_title', + footer: 'footer', + footer_image: 'footer', + footnote: 'footnote', + formula_number: 'formula_number', + header: 'header', + header_image: 'header', + image: 'image', + inline_formula: 'formula', + number: 'number', + paragraph_title: 'paragraph_title', + reference: 'reference', + reference_content: 'reference_content', + seal: 'seal', + table: 'table', + text: 'text', + vertical_text: 'text', + vision_footnote: 'vision_footnote', +}; + +const MIN_REGION_SIZE: Partial> = { + abstract: { minWidth: 24, minHeight: 14 }, + algorithm: { minWidth: 24, minHeight: 14 }, + aside_text: { minWidth: 24, minHeight: 14 }, + content: { minWidth: 24, minHeight: 14 }, + text: { minWidth: 24, minHeight: 14 }, + reference: { minWidth: 24, minHeight: 14 }, + reference_content: { minWidth: 24, minHeight: 14 }, + paragraph_title: { minWidth: 24, minHeight: 14 }, + doc_title: { minWidth: 24, minHeight: 14 }, + number: { minWidth: 18, minHeight: 12 }, + figure_title: { minWidth: 18, minHeight: 10 }, + footnote: { minWidth: 18, minHeight: 10 }, + vision_footnote: { minWidth: 18, minHeight: 10 }, + header: { minWidth: 18, minHeight: 10 }, + footer: { minWidth: 18, minHeight: 10 }, +}; + +interface ModelPreprocessor { + inputWidth: number; + inputHeight: number; + rescaleFactor: number; + mean: [number, number, number]; + std: [number, number, number]; +} + +let sessionPromise: Promise | null = null; +let idToLabelPromise: Promise> | null = null; +let preprocessorPromise: Promise | null = null; +let canvasFnsPromise: Promise<{ + createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }; + loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>; +}> | null = null; + +async function getCanvasFns(): Promise<{ + createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }; + loadImageFn: (src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>; +}> { + if (!canvasFnsPromise) { + canvasFnsPromise = (async () => { + const mod = await import('@napi-rs/canvas'); + const namespace = mod as Record; + const fallback = (namespace.default ?? {}) as Record; + const createCanvasFn = (namespace.createCanvas ?? fallback.createCanvas) as + | ((width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }) + | undefined; + const loadImageFn = (namespace.loadImage ?? fallback.loadImage) as + | ((src: Buffer) => Promise<{ width: number; height: number } & CanvasImageSource>) + | undefined; + + if (typeof createCanvasFn !== 'function' || typeof loadImageFn !== 'function') { + throw new Error( + `Canvas runtime missing createCanvas/loadImage exports (keys=${Object.keys(namespace).join(',')}; defaultKeys=${Object.keys(fallback).join(',')})`, + ); + } + + return { createCanvasFn, loadImageFn }; + })(); + } + return canvasFnsPromise; +} + +async function getSession(): Promise { + if (!sessionPromise) { + sessionPromise = (async () => { + const modelPath = await ensureModel(); + const onnxThreadsPerJob = getOnnxThreadsPerJob(); + const stableSessionOptions: ort.InferenceSession.SessionOptions = { + executionProviders: ['cpu'], + graphOptimizationLevel: 'all', + intraOpNumThreads: onnxThreadsPerJob, + interOpNumThreads: 1, + executionMode: 'sequential', + }; + return ort.InferenceSession.create(modelPath, { + ...stableSessionOptions, + }); + })(); + } + return sessionPromise; +} + +async function getIdToLabel(): Promise> { + if (!idToLabelPromise) { + idToLabelPromise = (async () => { + await ensureModel(); + const raw = await readFile(MODEL_CONFIG_PATH, 'utf8'); + const parsed = JSON.parse(raw) as { id2label?: Record }; + const out: Record = {}; + for (const [key, value] of Object.entries(parsed.id2label ?? {})) { + const n = Number(key); + if (Number.isFinite(n)) out[n] = String(value ?? '').trim(); + } + return out; + })(); + } + return idToLabelPromise; +} + +async function getPreprocessor(): Promise { + if (!preprocessorPromise) { + preprocessorPromise = (async () => { + await ensureModel(); + const raw = await readFile(MODEL_PREPROCESSOR_PATH, 'utf8'); + const parsed = JSON.parse(raw) as { + size?: { width?: number; height?: number }; + rescale_factor?: number; + image_mean?: number[]; + image_std?: number[]; + }; + + const inputWidth = Math.max(1, Number(parsed.size?.width ?? DEFAULT_INPUT_SIZE)); + const inputHeight = Math.max(1, Number(parsed.size?.height ?? DEFAULT_INPUT_SIZE)); + const rescaleFactor = Number.isFinite(parsed.rescale_factor) ? Number(parsed.rescale_factor) : (1 / 255); + const mean = [ + Number(parsed.image_mean?.[0] ?? 0), + Number(parsed.image_mean?.[1] ?? 0), + Number(parsed.image_mean?.[2] ?? 0), + ] as [number, number, number]; + const std = [ + Number(parsed.image_std?.[0] ?? 1), + Number(parsed.image_std?.[1] ?? 1), + Number(parsed.image_std?.[2] ?? 1), + ] as [number, number, number]; + + return { + inputWidth, + inputHeight, + rescaleFactor, + mean, + std, + }; + })(); + } + return preprocessorPromise; +} + +function preprocessResized( + image: CanvasImageSource, + preprocessor: ModelPreprocessor, + createCanvasFn: (width: number, height: number) => { getContext: (kind: '2d') => CanvasRenderingContext2D }, +): ort.Tensor { + const canvas = createCanvasFn(preprocessor.inputWidth, preprocessor.inputHeight); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, preprocessor.inputWidth, preprocessor.inputHeight); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(image, 0, 0, preprocessor.inputWidth, preprocessor.inputHeight); + + const imageData = ctx.getImageData(0, 0, preprocessor.inputWidth, preprocessor.inputHeight); + const chw = new Float32Array(1 * 3 * preprocessor.inputWidth * preprocessor.inputHeight); + const channelSize = preprocessor.inputWidth * preprocessor.inputHeight; + for (let y = 0; y < preprocessor.inputHeight; y += 1) { + for (let x = 0; x < preprocessor.inputWidth; x += 1) { + const pixelIndex = (y * preprocessor.inputWidth + x) * 4; + const idx = y * preprocessor.inputWidth + x; + const r = imageData.data[pixelIndex] * preprocessor.rescaleFactor; + const g = imageData.data[pixelIndex + 1] * preprocessor.rescaleFactor; + const b = imageData.data[pixelIndex + 2] * preprocessor.rescaleFactor; + + chw[idx] = (r - preprocessor.mean[0]) / Math.max(1e-8, preprocessor.std[0]); + chw[channelSize + idx] = (g - preprocessor.mean[1]) / Math.max(1e-8, preprocessor.std[1]); + chw[channelSize * 2 + idx] = (b - preprocessor.mean[2]) / Math.max(1e-8, preprocessor.std[2]); + } + } + return new ort.Tensor('float32', chw, [1, 3, preprocessor.inputHeight, preprocessor.inputWidth]); +} + +function clampBox( + bbox: [number, number, number, number], + pageWidth: number, + pageHeight: number, +): [number, number, number, number] | null { + const x0 = Math.max(0, Math.min(pageWidth, bbox[0])); + const y0 = Math.max(0, Math.min(pageHeight, bbox[1])); + const x1 = Math.max(0, Math.min(pageWidth, bbox[2])); + const y1 = Math.max(0, Math.min(pageHeight, bbox[3])); + if (x1 <= x0 || y1 <= y0) return null; + return [x0, y0, x1, y1]; +} + +function softmaxMax(logits: Float32Array, offset: number, count: number): { index: number; score: number } { + let maxLogit = Number.NEGATIVE_INFINITY; + let maxIndex = 0; + for (let i = 0; i < count; i += 1) { + const value = logits[offset + i]; + if (value > maxLogit) { + maxLogit = value; + maxIndex = i; + } + } + + let sum = 0; + for (let i = 0; i < count; i += 1) { + sum += Math.exp(logits[offset + i] - maxLogit); + } + + const score = sum > 0 ? (1 / sum) : 0; + return { index: maxIndex, score }; +} + +function normalizeModelLabel(rawLabel: string): string { + const normalized = rawLabel.trim().toLowerCase().replace(/[\s-]+/g, '_'); + if (normalized.endsWith('_image')) { + const base = normalized.slice(0, -'_image'.length); + if (base === 'header' || base === 'footer') return normalized; + } + // Some exports suffix duplicate classes (e.g. header_1, footer_1, text_1). + return normalized.replace(/_\d+$/g, ''); +} + +export async function runLayoutModel(input: RunLayoutInput): Promise { + const { pageWidth, pageHeight, textItems, pageImage } = input; + if (!textItems.length) return []; + if (!pageImage || pageImage.byteLength === 0) { + throw new Error('layout-render-missing-page-image'); + } + + try { + const [session, idToLabel, preprocessor, canvasFns] = await Promise.all([ + getSession(), + getIdToLabel(), + getPreprocessor(), + getCanvasFns(), + ]); + + const decodedPageImage = await canvasFns.loadImageFn(pageImage); + const pixelValues = preprocessResized(decodedPageImage, preprocessor, canvasFns.createCanvasFn); + const output = await session.run({ pixel_values: pixelValues }); + + const logits = output.logits?.data as Float32Array | undefined; + const predBoxes = output.pred_boxes?.data as Float32Array | undefined; + if (!logits || !predBoxes) return []; + + const numQueries = Math.floor(predBoxes.length / 4); + if (numQueries <= 0) return []; + const classCount = Math.floor(logits.length / numQueries); + if (classCount <= 0) return []; + + const regions: LayoutRegion[] = []; + + for (let queryIdx = 0; queryIdx < numQueries; queryIdx += 1) { + const cls = softmaxMax(logits, queryIdx * classCount, classCount); + const rawLabel = idToLabel[cls.index]; + if (!rawLabel) continue; + const mapped = LABEL_MAP[normalizeModelLabel(rawLabel)]; + if (!mapped) continue; + + const minScore = CLASS_MIN_SCORE[mapped] ?? MIN_SCORE; + if (!Number.isFinite(cls.score) || cls.score < minScore) continue; + + const cx = predBoxes[queryIdx * 4 + 0] * pageWidth; + const cy = predBoxes[queryIdx * 4 + 1] * pageHeight; + const w = predBoxes[queryIdx * 4 + 2] * pageWidth; + const h = predBoxes[queryIdx * 4 + 3] * pageHeight; + const rawBox: [number, number, number, number] = [ + cx - w / 2, + cy - h / 2, + cx + w / 2, + cy + h / 2, + ]; + + const clamped = clampBox(rawBox, pageWidth, pageHeight); + if (!clamped) continue; + + const sizeRule = MIN_REGION_SIZE[mapped]; + if (sizeRule) { + const width = clamped[2] - clamped[0]; + const height = clamped[3] - clamped[1]; + if (width < sizeRule.minWidth || height < sizeRule.minHeight) continue; + } + + regions.push({ + bbox: clamped, + label: mapped, + confidence: cls.score, + }); + } + + return regions.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0)); + } catch (error) { + throw new Error( + `layout-model-inference-failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/compute/core/src/pdf/stitch.ts b/compute/core/src/pdf/stitch.ts new file mode 100644 index 0000000..98a3d77 --- /dev/null +++ b/compute/core/src/pdf/stitch.ts @@ -0,0 +1,144 @@ +import type { ParsedPdfDocument, ParsedPdfBlock } from '../types/parsed-pdf'; + +const STITCHABLE_KINDS = new Set([ + 'text', + 'content', + 'reference_content', + 'aside_text', + 'abstract', + 'algorithm', + 'reference', +]); + +function stripTrailingClosers(text: string): string { + return text.trim().replace(/[\"'”’\]\)]+$/g, ''); +} + +function isSentenceTerminal(text: string): boolean { + return /[.!?]$/.test(stripTrailingClosers(text)); +} + +function canStitch(a: ParsedPdfBlock, b: ParsedPdfBlock): boolean { + if (!STITCHABLE_KINDS.has(a.kind)) return false; + if (a.kind !== b.kind) return false; + if (isSentenceTerminal(a.text)) return false; + const next = b.text.trim(); + if (!next) return false; + if (/^[A-Z]/.test(next)) return false; + return true; +} + +function splitHeadContinuation(text: string): { continuation: string; remainder: string } { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (!normalized) return { continuation: '', remainder: '' }; + + const CLOSERS = new Set(['"', "'", '”', '’', ')', ']', '}']); + const isTerminal = (ch: string): boolean => ch === '.' || ch === '!' || ch === '?'; + + for (let i = 0; i < normalized.length; i += 1) { + const ch = normalized[i]; + if (!isTerminal(ch)) continue; + + const prev = i > 0 ? normalized[i - 1] : ''; + const next = i + 1 < normalized.length ? normalized[i + 1] : ''; + if (ch === '.' && /\d/.test(prev) && /\d/.test(next)) continue; + + let cut = i + 1; + while (cut < normalized.length && CLOSERS.has(normalized[cut])) cut += 1; + + const after = cut < normalized.length ? normalized[cut] : ''; + if (!after || /\s/.test(after) || /[A-Z]/.test(after)) { + return { + continuation: normalized.slice(0, cut).trim(), + remainder: normalized.slice(cut).trim(), + }; + } + } + + return { + continuation: normalized, + remainder: '', + }; +} + +const HARD_BOUNDARY_KINDS = new Set([ + 'paragraph_title', + 'doc_title', +]); + +function findTailCandidateIndex(blocks: ParsedPdfBlock[]): number { + for (let i = blocks.length - 1; i >= 0; i -= 1) { + const block = blocks[i]; + if (!block || !block.text.trim()) continue; + if (STITCHABLE_KINDS.has(block.kind)) return i; + } + return -1; +} + +function findHeadCandidateIndex(blocks: ParsedPdfBlock[]): number { + for (let i = 0; i < blocks.length; i += 1) { + const block = blocks[i]; + if (!block || !block.text.trim()) continue; + if (STITCHABLE_KINDS.has(block.kind)) return i; + } + return -1; +} + +function hasHardBoundaryBetween( + pageBlocks: ParsedPdfBlock[], + startInclusive: number, + endExclusive: number, +): boolean { + for (let i = startInclusive; i < endExclusive; i += 1) { + const block = pageBlocks[i]; + if (block && HARD_BOUNDARY_KINDS.has(block.kind)) return true; + } + return false; +} + +export function stitchCrossPageBlocks(doc: ParsedPdfDocument): ParsedPdfDocument { + const pages = doc.pages.map((page) => ({ ...page, blocks: page.blocks.map((b) => ({ ...b, fragments: b.fragments.map((f) => ({ ...f })) })) })); + + for (let i = 0; i < pages.length - 1; i += 1) { + const page = pages[i]; + const next = pages[i + 1]; + const tailIndex = findTailCandidateIndex(page.blocks); + const headIndex = findHeadCandidateIndex(next.blocks); + if (tailIndex < 0 || headIndex < 0) continue; + + if (hasHardBoundaryBetween(page.blocks, tailIndex + 1, page.blocks.length)) continue; + if (hasHardBoundaryBetween(next.blocks, 0, headIndex)) continue; + + const tail = page.blocks[tailIndex]; + const head = next.blocks[headIndex]; + if (!tail || !head) continue; + if (!canStitch(tail, head)) continue; + + const { continuation, remainder } = splitHeadContinuation(head.text); + if (!continuation) continue; + + const continuationFragment = head.fragments[0] + ? { ...head.fragments[0], text: continuation } + : null; + + if (continuationFragment) { + tail.fragments.push(continuationFragment); + } + tail.text = `${tail.text} ${continuation}`.replace(/\s+/g, ' ').trim(); + + if (!remainder) { + next.blocks.splice(headIndex, 1); + continue; + } + + head.text = remainder; + if (head.fragments[0]) { + head.fragments[0].text = remainder; + } + } + + return { + ...doc, + pages, + }; +} diff --git a/compute/core/src/pdf/types.ts b/compute/core/src/pdf/types.ts new file mode 100644 index 0000000..38089a6 --- /dev/null +++ b/compute/core/src/pdf/types.ts @@ -0,0 +1,15 @@ +import type { ParsedPdfBlockKind } from '../types/parsed-pdf'; + +export interface PdfTextItem { + text: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface LayoutRegion { + bbox: [number, number, number, number]; + label: ParsedPdfBlockKind; + confidence?: number; +} diff --git a/compute/core/src/platform/docstore.ts b/compute/core/src/platform/docstore.ts new file mode 100644 index 0000000..0b62401 --- /dev/null +++ b/compute/core/src/platform/docstore.ts @@ -0,0 +1,21 @@ +import fs from 'fs'; +import path from 'path'; + +function findMonorepoRoot(startDir: string): string | null { + let current = path.resolve(startDir); + for (;;) { + const marker = path.join(current, 'pnpm-workspace.yaml'); + if (fs.existsSync(marker)) return current; + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + +function resolveDocstoreDir(): string { + const repoRoot = findMonorepoRoot(process.cwd()); + if (repoRoot) return path.join(repoRoot, 'docstore'); + return path.join(process.cwd(), 'docstore'); +} + +export const DOCSTORE_DIR = resolveDocstoreDir(); diff --git a/compute/core/src/platform/ffmpeg.ts b/compute/core/src/platform/ffmpeg.ts new file mode 100644 index 0000000..b4ed669 --- /dev/null +++ b/compute/core/src/platform/ffmpeg.ts @@ -0,0 +1,36 @@ +import { existsSync } from 'fs'; +import ffmpegStatic from 'ffmpeg-static'; + +function normalizePath(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function resolveBinary(envValue: string | null, bundledValue: string | null, envVarName: string, packageName: string): string { + if (envValue) { + if ((envValue.includes('/') || envValue.includes('\\')) && !existsSync(envValue)) { + throw new Error(`${envVarName} points to a missing binary: ${envValue}`); + } + return envValue; + } + + if (!bundledValue) { + throw new Error(`${packageName} binary is unavailable on this platform. Set ${envVarName} to an installed binary path.`); + } + + if ((bundledValue.includes('/') || bundledValue.includes('\\')) && !existsSync(bundledValue)) { + throw new Error(`${packageName} resolved to a missing binary path: ${bundledValue}`); + } + + return bundledValue; +} + +export function getFFmpegPath(): string { + return resolveBinary( + normalizePath(process.env.FFMPEG_BIN), + normalizePath(ffmpegStatic), + 'FFMPEG_BIN', + 'ffmpeg-static', + ); +} diff --git a/compute/core/src/types/index.ts b/compute/core/src/types/index.ts new file mode 100644 index 0000000..7408914 --- /dev/null +++ b/compute/core/src/types/index.ts @@ -0,0 +1,17 @@ +export type { + TTSAudioBuffer, + TTSAudioBytes, + TTSSentenceAlignment, + TTSSentenceWord, +} from './tts'; + +export type { + ParsedPdfBlock, + ParsedPdfBlockFragment, + ParsedPdfBlockKind, + ParsedPdfDocument, + ParsedPdfPage, + PdfParsePhase, + PdfParseProgress, + PdfParseStatus, +} from './parsed-pdf'; diff --git a/compute/core/src/types/parsed-pdf.ts b/compute/core/src/types/parsed-pdf.ts new file mode 100644 index 0000000..01d7067 --- /dev/null +++ b/compute/core/src/types/parsed-pdf.ts @@ -0,0 +1,63 @@ +export type ParsedPdfBlockKind = + | 'abstract' + | 'algorithm' + | 'aside_text' + | 'chart' + | 'content' + | 'formula' + | 'doc_title' + | 'figure_title' + | 'footer' + | 'footnote' + | 'formula_number' + | 'header' + | 'image' + | 'number' + | 'paragraph_title' + | 'reference' + | 'reference_content' + | 'seal' + | 'table' + | 'text' + | 'vision_footnote'; + +export interface ParsedPdfBlockFragment { + page: number; + bbox: [number, number, number, number]; + text: string; + readingOrder: number; + modelConfidence?: number; +} + +export interface ParsedPdfBlock { + id: string; + kind: ParsedPdfBlockKind; + fragments: ParsedPdfBlockFragment[]; + text: string; + parentSectionId?: string; +} + +export interface ParsedPdfPage { + pageNumber: number; + width: number; + height: number; + blocks: ParsedPdfBlock[]; +} + +export interface ParsedPdfDocument { + schemaVersion: 1; + documentId: string; + parserVersion: string; + parsedAt: number; + pages: ParsedPdfPage[]; +} + +export type PdfParseStatus = 'pending' | 'running' | 'ready' | 'failed'; +export type PdfParsePhase = 'infer' | 'merge'; + +export interface PdfParseProgress { + totalPages: number; + pagesParsed: number; + currentPage?: number; + phase: PdfParsePhase; +} diff --git a/compute/core/src/types/tts.ts b/compute/core/src/types/tts.ts new file mode 100644 index 0000000..73db759 --- /dev/null +++ b/compute/core/src/types/tts.ts @@ -0,0 +1,16 @@ +export type TTSAudioBuffer = ArrayBuffer; +export type TTSAudioBytes = number[]; + +export interface TTSSentenceWord { + text: string; + startSec: number; + endSec: number; + charStart: number; + charEnd: number; +} + +export interface TTSSentenceAlignment { + sentence: string; + sentenceIndex: number; + words: TTSSentenceWord[]; +} diff --git a/compute/core/src/whisper/align.ts b/compute/core/src/whisper/align.ts new file mode 100644 index 0000000..d18fa07 --- /dev/null +++ b/compute/core/src/whisper/align.ts @@ -0,0 +1,1014 @@ +import { createHash, randomUUID } from 'crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import * as ort from 'onnxruntime-node'; +import { Tokenizer } from '@huggingface/tokenizers'; +import JSZip from 'jszip'; +import type { TTSAudioBuffer, TTSAudioBytes, TTSSentenceAlignment } from '../types/tts'; +import { getFFmpegPath } from '../platform/ffmpeg'; +import { getOnnxThreadsPerJob } from '../config/cpu-budget'; +import { getComputeTimeoutConfig } from '../config/timeout'; +import { + mapWordsToSentenceOffsets, + type WhisperWord, +} from './alignment-map'; +import { buildGoertzelCoefficients, goertzelPower } from './spectral'; +import { + buildWordsFromTimestampedTokens, + extractTokenStartTimestamps, +} from './token-timestamps'; +import { + ensureWhisperModel, + WHISPER_CONFIG_PATH, + WHISPER_GENERATION_CONFIG_PATH, + WHISPER_TOKENIZER_CONFIG_PATH, + WHISPER_TOKENIZER_PATH, + WHISPER_ENCODER_MODEL_PATH, + WHISPER_DECODER_MERGED_MODEL_PATH, + WHISPER_DECODER_WITH_PAST_MODEL_PATH, +} from './model'; + +interface WhisperAlignmentOptions { + lang?: string; + textHint?: string; +} + +export interface WhisperRequestBody { + text: string; + audio: TTSAudioBytes; + lang?: string; +} + +interface WhisperRuntime { + encoder: ort.InferenceSession; + decoderMerged: ort.InferenceSession; + decoderWithPast: ort.InferenceSession; + tokenizer: Tokenizer; + promptStartToken: number; + defaultLanguageToken: number; + transcribeToken: number; + eosTokenId: number; + noTimestampsTokenId: number; + timestampBeginTokenId: number; + maxInitialTimestampIndex: number; + maxDecodeSteps: number; + suppressTokens: Set; + beginSuppressTokens: Set; + alignmentHeads: Array<[number, number]>; + prefillFetches: string[]; + stepFetches: string[]; +} + +type WhisperAlignmentState = { + alignmentCache: Map; + alignmentInFlight: Map>; + runtimePromise: Promise | null; + pendingAlignments: number; + officialMelFilters: Float32Array[] | null; + emptyPastFeedsTemplate: Record | null; +}; + +const WHISPER_ALIGNMENT_STATE_KEY = '__openreaderWhisperAlignmentStateV1'; +const g = globalThis as typeof globalThis & Record; +const state = (() => { + const existing = g[WHISPER_ALIGNMENT_STATE_KEY] as WhisperAlignmentState | undefined; + if (existing) return existing; + const created: WhisperAlignmentState = { + alignmentCache: new Map(), + alignmentInFlight: new Map>(), + runtimePromise: null, + pendingAlignments: 0, + officialMelFilters: null, + emptyPastFeedsTemplate: null, + }; + g[WHISPER_ALIGNMENT_STATE_KEY] = created; + return created; +})(); +const alignmentCache = state.alignmentCache; +const alignmentInFlight = state.alignmentInFlight; +const ALIGNMENT_CACHE_MAX_ENTRIES = 256; +const MAX_DECODE_STEPS_CAP = 128; +const ALIGNMENT_TIMEOUT_BUFFER_MS = 2_000; +const MIN_ALIGNMENT_TIMEOUT_MS = 5_000; +const MIN_FFMPEG_DECODE_TIMEOUT_MS = 10_000; +const MAX_FFMPEG_DECODE_TIMEOUT_MS = 60_000; + +const SAMPLE_RATE = 16000; +const N_FFT = 400; +const HOP_LENGTH = 160; +const CHUNK_LENGTH_SECONDS = 30; +const N_SAMPLES = CHUNK_LENGTH_SECONDS * SAMPLE_RATE; +const N_FRAMES = N_SAMPLES / HOP_LENGTH; +const N_MELS = 80; +const WHISPER_NUM_HEADS = 8; +const WHISPER_HEAD_DIM = 64; +const WHISPER_NUM_LAYERS = 6; +const MEL_FILTER_BINS = (N_FFT / 2) + 1; + +const hannWindow = buildHannWindow(N_FFT); +const goertzelCoefficients = buildGoertzelCoefficients(MEL_FILTER_BINS, N_FFT); + +const MODULE_DIR = dirname(fileURLToPath(import.meta.url)); +const MEL_FILTERS_NPZ_PATH = join(MODULE_DIR, 'assets', 'mel_filters.npz'); + +function buildHannWindow(length: number): Float32Array { + const window = new Float32Array(length); + for (let i = 0; i < length; i += 1) { + window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / length); + } + return window; +} + +function parseNpyFloat32(bytes: Uint8Array): { shape: number[]; data: Float32Array } { + if (bytes.length < 12) { + throw new Error('Invalid NPY payload: too short'); + } + const magic = String.fromCharCode(...bytes.slice(0, 6)); + if (magic !== '\u0093NUMPY') { + throw new Error('Invalid NPY payload: missing magic header'); + } + + const major = bytes[6]; + const headerLength = major <= 1 + ? new DataView(bytes.buffer, bytes.byteOffset + 8, 2).getUint16(0, true) + : new DataView(bytes.buffer, bytes.byteOffset + 8, 4).getUint32(0, true); + const headerOffset = major <= 1 ? 10 : 12; + const header = Buffer.from(bytes.slice(headerOffset, headerOffset + headerLength)).toString('latin1'); + + const descrMatch = header.match(/'descr':\s*'([^']+)'/); + if (!descrMatch || descrMatch[1] !== ' token.trim()) + .filter(Boolean) + .map((token) => Number(token)) + .filter((n) => Number.isFinite(n) && n > 0); + + const dataOffset = headerOffset + headerLength; + const dataBytes = bytes.slice(dataOffset); + const totalFloats = Math.floor(dataBytes.byteLength / 4); + const data = new Float32Array(totalFloats); + const view = new DataView(dataBytes.buffer, dataBytes.byteOffset, dataBytes.byteLength); + for (let i = 0; i < totalFloats; i += 1) { + data[i] = view.getFloat32(i * 4, true); + } + + return { shape, data }; +} + +async function loadOfficialMelFilters(): Promise { + if (state.officialMelFilters) return state.officialMelFilters; + + const npzBytes = await readFile(MEL_FILTERS_NPZ_PATH); + const zip = await JSZip.loadAsync(npzBytes); + const mel80 = zip.file('mel_80.npy'); + if (!mel80) { + throw new Error('OpenAI mel filter asset is missing mel_80.npy'); + } + + const raw = await mel80.async('uint8array'); + const parsed = parseNpyFloat32(raw); + const [rows, cols] = parsed.shape; + if (rows !== N_MELS || cols !== MEL_FILTER_BINS) { + throw new Error(`Unexpected mel filter shape: [${rows}, ${cols}]`); + } + + const filters: Float32Array[] = []; + for (let row = 0; row < rows; row += 1) { + const start = row * cols; + filters.push(parsed.data.slice(start, start + cols)); + } + + state.officialMelFilters = filters; + return filters; +} + +function pcm16ToFloat32(buffer: Buffer): Float32Array { + const view = new Int16Array(buffer.buffer, buffer.byteOffset, Math.floor(buffer.byteLength / 2)); + const out = new Float32Array(view.length); + for (let i = 0; i < view.length; i += 1) { + out[i] = view[i] / 32768; + } + return out; +} + +function padOrTrimAudio(samples: Float32Array): Float32Array { + if (samples.length === N_SAMPLES) return samples; + if (samples.length > N_SAMPLES) return samples.subarray(0, N_SAMPLES); + + const padded = new Float32Array(N_SAMPLES); + padded.set(samples, 0); + return padded; +} + +function reflectPad(audio: Float32Array, pad: number): Float32Array { + const out = new Float32Array(audio.length + (2 * pad)); + out.set(audio, pad); + + // Match PyTorch reflect padding (exclude edge sample). + for (let i = 0; i < pad; i += 1) { + out[pad - 1 - i] = audio[Math.min(audio.length - 1, i + 1)]; + out[pad + audio.length + i] = audio[Math.max(0, audio.length - 2 - i)]; + } + + return out; +} + +function computeLogMelSpectrogram(audioSamples: Float32Array): ort.Tensor { + if (!state.officialMelFilters) { + throw new Error('Whisper mel filters not loaded'); + } + + const paddedAudio = reflectPad(audioSamples, N_FFT / 2); + const stftFrames = N_FRAMES + 1; + const frameCount = N_FRAMES; + const freqBins = MEL_FILTER_BINS; + + const melSpec = Array.from({ length: N_MELS }, () => new Float32Array(frameCount)); + const frame = new Float32Array(N_FFT); + const power = new Float32Array(freqBins); + + for (let frameIndex = 0; frameIndex < stftFrames; frameIndex += 1) { + const offset = frameIndex * HOP_LENGTH; + + for (let i = 0; i < N_FFT; i += 1) { + frame[i] = (paddedAudio[offset + i] ?? 0) * hannWindow[i]; + } + + for (let k = 0; k < freqBins; k += 1) { + power[k] = goertzelPower(frame, goertzelCoefficients[k]); + } + + if (frameIndex === stftFrames - 1) { + continue; + } + + for (let melIndex = 0; melIndex < N_MELS; melIndex += 1) { + const filter = state.officialMelFilters[melIndex]; + let total = 0; + for (let k = 0; k < freqBins; k += 1) { + total += filter[k] * power[k]; + } + melSpec[melIndex][frameIndex] = total; + } + } + + // Whisper normalization from openai/whisper/audio.py + let globalMaxLog = Number.NEGATIVE_INFINITY; + for (let i = 0; i < N_MELS; i += 1) { + for (let j = 0; j < frameCount; j += 1) { + const logVal = Math.log10(Math.max(1e-10, melSpec[i][j])); + if (logVal > globalMaxLog) globalMaxLog = logVal; + melSpec[i][j] = logVal; + } + } + + const floorVal = globalMaxLog - 8.0; + const flattened = new Float32Array(1 * N_MELS * frameCount); + for (let i = 0; i < N_MELS; i += 1) { + for (let j = 0; j < frameCount; j += 1) { + const clamped = Math.max(melSpec[i][j], floorVal); + flattened[(i * frameCount) + j] = (clamped + 4.0) / 4.0; + } + } + + return new ort.Tensor('float32', flattened, [1, N_MELS, frameCount]); +} + +function getAlignmentTimeoutMs(): number { + const whisperTimeoutMs = getComputeTimeoutConfig().whisperTimeoutMs; + return Math.max(MIN_ALIGNMENT_TIMEOUT_MS, whisperTimeoutMs - ALIGNMENT_TIMEOUT_BUFFER_MS); +} + +function getFfmpegDecodeTimeoutMs(): number { + const whisperTimeoutMs = getComputeTimeoutConfig().whisperTimeoutMs; + const halfBudgetMs = Math.floor(whisperTimeoutMs * 0.5); + return Math.max( + MIN_FFMPEG_DECODE_TIMEOUT_MS, + Math.min(MAX_FFMPEG_DECODE_TIMEOUT_MS, halfBudgetMs), + ); +} + +async function decodeToPcm16(inputPath: string, outputPath: string): Promise { + const ffmpegDecodeTimeoutMs = getFfmpegDecodeTimeoutMs(); + await new Promise((resolve, reject) => { + const ffmpeg = spawn(getFFmpegPath(), [ + '-y', + '-i', + inputPath, + '-f', + 's16le', + '-ar', + String(SAMPLE_RATE), + '-ac', + '1', + outputPath, + ]); + + let stderr = ''; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + ffmpeg.kill('SIGKILL'); + }, ffmpegDecodeTimeoutMs); + ffmpeg.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + ffmpeg.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + + ffmpeg.on('close', (code) => { + clearTimeout(timer); + if (timedOut) { + reject(new Error(`ffmpeg decode timed out after ${ffmpegDecodeTimeoutMs}ms`)); + return; + } + if (code === 0) { + resolve(); + } else { + reject(new Error(`ffmpeg decode failed with code ${code}: ${stderr}`)); + } + }); + }); +} + +function parseLanguageCode(lang?: string): string | null { + if (!lang) return null; + const trimmed = lang.trim().toLowerCase(); + if (!trimmed) return null; + if (trimmed.includes('-')) return trimmed.split('-')[0] || null; + if (trimmed.includes('_')) return trimmed.split('_')[0] || null; + return trimmed; +} + +function tensorFromInt64(values: number[]): ort.Tensor { + return new ort.Tensor('int64', BigInt64Array.from(values.map((v) => BigInt(v))), [1, values.length]); +} + +function disposeTensor(tensor: ort.Tensor | undefined | null): void { + if (!tensor) return; + try { + tensor.dispose(); + } catch { + // Best-effort cleanup: ignore disposal errors during fallback path. + } +} + +function disposeTensorMap(tensors: Record): void { + for (const tensor of Object.values(tensors)) { + disposeTensor(tensor); + } +} + +function computeAdaptiveDecodeStepLimit(maxDecodeSteps: number, textHint?: string): number { + const normalized = (textHint ?? '').trim(); + if (!normalized) return Math.min(maxDecodeSteps, 96); + + const chars = normalized.length; + const words = normalized.split(/\s+/).filter(Boolean).length; + const estTokens = Math.max(words * 3, Math.ceil(chars / 2)); + const adaptive = Math.max(64, Math.min(maxDecodeSteps, estTokens + 24)); + return adaptive; +} + +function assertWithinDeadline(deadlineMs: number, timeoutMs: number): void { + if (Date.now() > deadlineMs) { + throw new Error(`Whisper alignment timed out after ${timeoutMs}ms`); + } +} + +function makeInFlightCoalesceKey(audioBuffer: TTSAudioBuffer, text: string, lang?: string): string { + const bytes = new Uint8Array(audioBuffer); + const span = 4096; + const head = bytes.subarray(0, Math.min(span, bytes.length)); + const tailStart = Math.max(0, bytes.length - span); + const tail = bytes.subarray(tailStart); + return createHash('sha256') + .update(text) + .update('\0') + .update(lang ?? '') + .update('\0') + .update(String(bytes.length)) + .update('\0') + .update(head) + .update('\0') + .update(tail) + .digest('hex'); +} + +function buildEmptyPastFeeds() { + if (state.emptyPastFeedsTemplate) return state.emptyPastFeedsTemplate; + + const feeds: Record = {}; + const emptyDecoderPast = new Float32Array(0); + const emptyEncoderPast = new Float32Array(1 * WHISPER_NUM_HEADS * 1500 * WHISPER_HEAD_DIM); + + for (let i = 0; i < WHISPER_NUM_LAYERS; i += 1) { + feeds[`past_key_values.${i}.decoder.key`] = new ort.Tensor('float32', emptyDecoderPast, [1, WHISPER_NUM_HEADS, 0, WHISPER_HEAD_DIM]); + feeds[`past_key_values.${i}.decoder.value`] = new ort.Tensor('float32', emptyDecoderPast, [1, WHISPER_NUM_HEADS, 0, WHISPER_HEAD_DIM]); + + // First pass still expects encoder KV inputs in the merged decoder graph. + feeds[`past_key_values.${i}.encoder.key`] = new ort.Tensor('float32', emptyEncoderPast, [1, WHISPER_NUM_HEADS, 1500, WHISPER_HEAD_DIM]); + feeds[`past_key_values.${i}.encoder.value`] = new ort.Tensor('float32', emptyEncoderPast, [1, WHISPER_NUM_HEADS, 1500, WHISPER_HEAD_DIM]); + } + + state.emptyPastFeedsTemplate = feeds; + return state.emptyPastFeedsTemplate; +} + +function argmax(values: Float32Array): number | null { + let bestIdx = 0; + let bestScore = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < values.length; i += 1) { + const score = values[i]; + if (score > bestScore) { + bestScore = score; + bestIdx = i; + } + } + + return Number.isFinite(bestScore) ? bestIdx : null; +} + +function applyTokenSuppression(logits: Float32Array, tokens: Set) { + for (const tokenId of tokens) { + if (tokenId >= 0 && tokenId < logits.length) { + logits[tokenId] = Number.NEGATIVE_INFINITY; + } + } +} + +function logSoftmax(input: Float32Array): Float32Array { + let max = Number.NEGATIVE_INFINITY; + for (let i = 0; i < input.length; i += 1) { + if (input[i] > max) max = input[i]; + } + if (!Number.isFinite(max)) { + return new Float32Array(input.length).fill(Number.NEGATIVE_INFINITY); + } + + let sum = 0; + for (let i = 0; i < input.length; i += 1) { + sum += Math.exp(input[i] - max); + } + const logSum = Math.log(sum); + + const out = new Float32Array(input.length); + for (let i = 0; i < input.length; i += 1) { + out[i] = input[i] - max - logSum; + } + return out; +} + +function applyWhisperTimestampLogitsRules(input: { + logits: Float32Array; + generated: number[]; + beginIndex: number; + eosTokenId: number; + noTimestampsTokenId: number; + timestampBeginTokenId: number; + maxInitialTimestampIndex: number; +}) { + const { + logits, + generated, + beginIndex, + eosTokenId, + noTimestampsTokenId, + timestampBeginTokenId, + maxInitialTimestampIndex, + } = input; + + if (noTimestampsTokenId >= 0 && noTimestampsTokenId < logits.length) { + logits[noTimestampsTokenId] = Number.NEGATIVE_INFINITY; + } + + if (generated.length === beginIndex) { + const upper = Math.min(timestampBeginTokenId, logits.length); + for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } + + const seq = generated.slice(beginIndex); + const lastWasTimestamp = seq.length >= 1 && seq[seq.length - 1] >= timestampBeginTokenId; + const penultimateWasTimestamp = seq.length < 2 || seq[seq.length - 2] >= timestampBeginTokenId; + + if (lastWasTimestamp) { + if (penultimateWasTimestamp) { + for (let i = timestampBeginTokenId; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } else { + const upper = Math.min(eosTokenId, logits.length); + for (let i = 0; i < upper; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } + } + + if (generated.length === beginIndex && Number.isFinite(maxInitialTimestampIndex)) { + const lastAllowed = timestampBeginTokenId + maxInitialTimestampIndex; + for (let i = lastAllowed + 1; i < logits.length; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } + + const textUpper = Math.min(timestampBeginTokenId, logits.length); + if (textUpper <= 0 || textUpper >= logits.length) return; + + const logprobs = logSoftmax(logits); + + let maxTextTokenLogprob = Number.NEGATIVE_INFINITY; + for (let i = 0; i < textUpper; i += 1) { + if (logprobs[i] > maxTextTokenLogprob) maxTextTokenLogprob = logprobs[i]; + } + + let timestampProbMass = 0; + for (let i = textUpper; i < logprobs.length; i += 1) { + timestampProbMass += Math.exp(logprobs[i]); + } + const timestampLogprob = timestampProbMass > 0 ? Math.log(timestampProbMass) : Number.NEGATIVE_INFINITY; + + if (timestampLogprob > maxTextTokenLogprob) { + for (let i = 0; i < textUpper; i += 1) logits[i] = Number.NEGATIVE_INFINITY; + } +} + +async function getRuntime(): Promise { + if (state.runtimePromise) return state.runtimePromise; + + state.runtimePromise = (async () => { + await ensureWhisperModel(); + await loadOfficialMelFilters(); + + const [configRaw, generationRaw, tokenizerJsonRaw, tokenizerConfigRaw] = await Promise.all([ + readFile(WHISPER_CONFIG_PATH, 'utf8'), + readFile(WHISPER_GENERATION_CONFIG_PATH, 'utf8'), + readFile(WHISPER_TOKENIZER_PATH, 'utf8'), + readFile(WHISPER_TOKENIZER_CONFIG_PATH, 'utf8'), + ]); + + const config = JSON.parse(configRaw) as { + decoder_start_token_id?: number; + eos_token_id?: number; + forced_decoder_ids?: Array<[number, number | null]>; + }; + + const generationConfig = JSON.parse(generationRaw) as { + no_timestamps_token_id?: number; + max_initial_timestamp_index?: number; + suppress_tokens?: number[]; + begin_suppress_tokens?: number[]; + max_length?: number; + alignment_heads?: Array<[number, number]>; + }; + + const tokenizer = new Tokenizer(JSON.parse(tokenizerJsonRaw), JSON.parse(tokenizerConfigRaw)); + + const promptStartToken = Number(config.decoder_start_token_id ?? 50258); + const eosTokenId = Number(config.eos_token_id ?? 50257); + const noTimestampsTokenId = Number(generationConfig.no_timestamps_token_id ?? 50363); + const timestampBeginTokenId = noTimestampsTokenId + 1; + const maxInitialTimestampIndex = Number(generationConfig.max_initial_timestamp_index ?? 50); + const configuredMaxDecodeSteps = Number(generationConfig.max_length ?? 448); + const maxDecodeSteps = Math.min(configuredMaxDecodeSteps, MAX_DECODE_STEPS_CAP); + const alignmentHeads = Array.isArray(generationConfig.alignment_heads) + ? generationConfig.alignment_heads + .filter((head): head is [number, number] => Array.isArray(head) && head.length === 2) + .map(([layer, head]) => [Number(layer), Number(head)] as [number, number]) + : []; + + const forcedDecoder = Array.isArray(config.forced_decoder_ids) ? config.forced_decoder_ids : []; + const defaultLanguageFromForced = forcedDecoder.find(([index, id]) => index === 1 && typeof id === 'number')?.[1] ?? null; + const transcribeFromForced = forcedDecoder.find(([index, id]) => index === 2 && typeof id === 'number')?.[1] ?? null; + + const defaultLanguageToken = Number(defaultLanguageFromForced ?? tokenizer.token_to_id('<|en|>') ?? 50259); + const transcribeToken = Number(transcribeFromForced ?? tokenizer.token_to_id('<|transcribe|>') ?? 50359); + + const onnxThreadsPerJob = getOnnxThreadsPerJob(); + const stableSessionOptions: ort.InferenceSession.SessionOptions = { + executionProviders: ['cpu'], + // Keep Whisper graph opts disabled: this quantized timestamped model can + // fail session init under ORT QDQ transform passes (missing *_scale). + graphOptimizationLevel: 'disabled', + intraOpNumThreads: onnxThreadsPerJob, + interOpNumThreads: 1, + executionMode: 'sequential', + }; + + const encoder = await ort.InferenceSession.create(WHISPER_ENCODER_MODEL_PATH, stableSessionOptions); + const decoderMerged = await ort.InferenceSession.create(WHISPER_DECODER_MERGED_MODEL_PATH, stableSessionOptions); + const decoderWithPast = await ort.InferenceSession.create(WHISPER_DECODER_WITH_PAST_MODEL_PATH, stableSessionOptions); + + const alignmentLayers = [...new Set(alignmentHeads.map(([layer]) => layer))]; + const prefillFetches: string[] = ['logits']; + const stepFetches: string[] = ['logits']; + const mergedOutputNames = new Set(decoderMerged.outputNames); + const withPastOutputNames = new Set(decoderWithPast.outputNames); + + for (let i = 0; i < WHISPER_NUM_LAYERS; i += 1) { + const decoderKey = `present.${i}.decoder.key`; + const decoderValue = `present.${i}.decoder.value`; + if (mergedOutputNames.has(decoderKey)) prefillFetches.push(decoderKey); + if (mergedOutputNames.has(decoderValue)) prefillFetches.push(decoderValue); + if (withPastOutputNames.has(decoderKey)) stepFetches.push(decoderKey); + if (withPastOutputNames.has(decoderValue)) stepFetches.push(decoderValue); + + const encoderKey = `present.${i}.encoder.key`; + const encoderValue = `present.${i}.encoder.value`; + if (mergedOutputNames.has(encoderKey)) prefillFetches.push(encoderKey); + if (mergedOutputNames.has(encoderValue)) prefillFetches.push(encoderValue); + } + + for (const layer of alignmentLayers) { + const key = `cross_attentions.${layer}`; + if (mergedOutputNames.has(key)) prefillFetches.push(key); + if (withPastOutputNames.has(key)) stepFetches.push(key); + } + + return { + encoder, + decoderMerged, + decoderWithPast, + tokenizer, + promptStartToken, + defaultLanguageToken, + transcribeToken, + eosTokenId, + noTimestampsTokenId, + timestampBeginTokenId, + maxInitialTimestampIndex, + maxDecodeSteps, + suppressTokens: new Set((generationConfig.suppress_tokens ?? []).map((v) => Number(v))), + beginSuppressTokens: new Set((generationConfig.begin_suppress_tokens ?? []).map((v) => Number(v))), + alignmentHeads, + prefillFetches, + stepFetches, + }; + })().catch((error) => { + state.runtimePromise = null; + throw error; + }); + + return state.runtimePromise; +} + +function resolveLanguageToken(runtime: WhisperRuntime, lang?: string): number { + const parsed = parseLanguageCode(lang); + if (!parsed) return runtime.defaultLanguageToken; + + const candidate = runtime.tokenizer.token_to_id(`<|${parsed}|>`); + return typeof candidate === 'number' ? candidate : runtime.defaultLanguageToken; +} + +async function runWhisperOnnx( + audioSamples: Float32Array, + opts: WhisperAlignmentOptions, + numFrames: number, + deadlineMs: number, + timeoutMs: number, +): Promise { + assertWithinDeadline(deadlineMs, timeoutMs); + const runtime = await getRuntime(); + const decodeStepLimit = computeAdaptiveDecodeStepLimit(runtime.maxDecodeSteps, opts.textHint); + const mel = computeLogMelSpectrogram(audioSamples); + const encoderPast: Record = {}; + const decoderPast: Record = {}; + const crossAttentions: Record = {}; + let encoderHidden: ort.Tensor | null = null; + let outputs: Record | null = null; + + try { + const encoderOutputs = await runtime.encoder.run({ + input_features: mel, + }, ['last_hidden_state']); + encoderHidden = encoderOutputs.last_hidden_state; + + const languageToken = resolveLanguageToken(runtime, opts.lang); + const promptTokens = [ + runtime.promptStartToken, + languageToken, + runtime.transcribeToken, + ]; + + const generated: number[] = [...promptTokens]; + const emptyPastFeeds = buildEmptyPastFeeds(); + type LayerChunk = { + data: Float32Array; + heads: number; + seqLen: number; + frames: number; + }; + const selectedHeadsByLayer = new Map(); + for (const [layer, head] of runtime.alignmentHeads) { + const existing = selectedHeadsByLayer.get(layer) ?? []; + if (!existing.includes(head)) existing.push(head); + selectedHeadsByLayer.set(layer, existing); + } + for (const [layer, heads] of selectedHeadsByLayer) { + heads.sort((a, b) => a - b); + selectedHeadsByLayer.set(layer, heads); + } + const crossAttentionChunks = new Map(); + + const captureCrossAttentions = (stepOutputs: Record, prefill = false) => { + for (const [layer, selectedHeads] of selectedHeadsByLayer) { + const key = `cross_attentions.${layer}`; + const tensor = stepOutputs[key]; + if (!tensor) continue; + const [, , seqLen, frames] = tensor.dims; + const data = tensor.data as Float32Array; + const rowsToKeep = prefill ? seqLen : 1; + const seqStart = prefill ? 0 : Math.max(0, seqLen - 1); + const copied = new Float32Array(selectedHeads.length * rowsToKeep * frames); + for (let h = 0; h < selectedHeads.length; h += 1) { + const sourceHead = selectedHeads[h]!; + for (let s = 0; s < rowsToKeep; s += 1) { + const sourceSeq = seqStart + s; + for (let f = 0; f < frames; f += 1) { + const src = (((sourceHead * seqLen) + sourceSeq) * frames) + f; + const dst = (((h * rowsToKeep) + s) * frames) + f; + copied[dst] = data[src] ?? 0; + } + } + } + const list = crossAttentionChunks.get(layer) ?? []; + list.push({ data: copied, heads: selectedHeads.length, seqLen: rowsToKeep, frames }); + crossAttentionChunks.set(layer, list); + } + }; + const beginIndex = promptTokens.length; + + // Prefill: run prompt in merged decoder (non-cache branch), identical to first + // forward pass in transformers.js/transformers generation. + const prefillInputIds = tensorFromInt64(generated); + const prefillUseCacheBranch = new ort.Tensor('bool', Uint8Array.from([0]), [1]); + const prefillFeeds: Record = { + input_ids: prefillInputIds, + encoder_hidden_states: encoderHidden, + use_cache_branch: prefillUseCacheBranch, + ...emptyPastFeeds, + }; + try { + assertWithinDeadline(deadlineMs, timeoutMs); + outputs = await runtime.decoderMerged.run(prefillFeeds, runtime.prefillFetches); + } finally { + disposeTensor(prefillInputIds); + disposeTensor(prefillUseCacheBranch); + } + captureCrossAttentions(outputs, true); + + for (let i = 0; i < WHISPER_NUM_LAYERS; i += 1) { + encoderPast[`past_key_values.${i}.encoder.key`] = outputs[`present.${i}.encoder.key`]; + encoderPast[`past_key_values.${i}.encoder.value`] = outputs[`present.${i}.encoder.value`]; + decoderPast[`past_key_values.${i}.decoder.key`] = outputs[`present.${i}.decoder.key`]; + decoderPast[`past_key_values.${i}.decoder.value`] = outputs[`present.${i}.decoder.value`]; + } + + for (let step = 0; step < decodeStepLimit; step += 1) { + assertWithinDeadline(deadlineMs, timeoutMs); + if (!outputs) break; + const logits = outputs.logits; + const logitsData = logits.data as Float32Array; + const vocabSize = logits.dims[2] ?? 0; + const offset = logitsData.length - vocabSize; + const lastLogits = logitsData.subarray(offset); + + applyTokenSuppression(lastLogits, runtime.suppressTokens); + if (generated.length === beginIndex) { + applyTokenSuppression(lastLogits, runtime.beginSuppressTokens); + } + applyWhisperTimestampLogitsRules({ + logits: lastLogits, + generated, + beginIndex, + eosTokenId: runtime.eosTokenId, + noTimestampsTokenId: runtime.noTimestampsTokenId, + timestampBeginTokenId: runtime.timestampBeginTokenId, + maxInitialTimestampIndex: runtime.maxInitialTimestampIndex, + }); + + const nextToken = argmax(lastLogits) ?? runtime.eosTokenId; + generated.push(nextToken); + if (nextToken === runtime.eosTokenId) break; + + const previousDecoderPast = { ...decoderPast }; + const stepInputIds = tensorFromInt64([nextToken]); + const stepFeeds: Record = { + input_ids: stepInputIds, + ...previousDecoderPast, + ...encoderPast, + }; + let nextOutputs: Record; + try { + assertWithinDeadline(deadlineMs, timeoutMs); + nextOutputs = await runtime.decoderWithPast.run(stepFeeds, runtime.stepFetches); + } finally { + disposeTensor(stepInputIds); + } + captureCrossAttentions(nextOutputs, false); + + for (let i = 0; i < WHISPER_NUM_LAYERS; i += 1) { + decoderPast[`past_key_values.${i}.decoder.key`] = nextOutputs[`present.${i}.decoder.key`]; + decoderPast[`past_key_values.${i}.decoder.value`] = nextOutputs[`present.${i}.decoder.value`]; + } + + disposeTensorMap(previousDecoderPast); + disposeTensor(outputs.logits); + for (const [name, tensor] of Object.entries(outputs)) { + if (name.startsWith('cross_attentions.')) { + disposeTensor(tensor); + } + } + outputs = nextOutputs; + } + + if (crossAttentionChunks.size === 0) { + return []; + } + + const remappedAlignmentHeads: Array<[number, number]> = runtime.alignmentHeads + .map(([layer, head]) => { + const selectedHeads = selectedHeadsByLayer.get(layer) ?? []; + const remappedHead = selectedHeads.indexOf(head); + if (remappedHead < 0) return null; + return [layer, remappedHead] as [number, number]; + }) + .filter((pair): pair is [number, number] => pair !== null); + + for (let layer = 0; layer < WHISPER_NUM_LAYERS; layer += 1) { + const chunks = crossAttentionChunks.get(layer); + if (!chunks || !chunks.length) continue; + + const heads = chunks[0].heads; + const frames = chunks[0].frames; + const concatSeqLen = chunks.reduce((sum, chunk) => sum + chunk.seqLen, 0); + const merged = new Float32Array(1 * heads * concatSeqLen * frames); + let seqOffset = 0; + + for (const chunk of chunks) { + const { data, seqLen, frames: tensorFrames } = chunk; + const copyFrames = Math.min(frames, tensorFrames); + + for (let h = 0; h < heads; h += 1) { + for (let s = 0; s < seqLen; s += 1) { + for (let f = 0; f < copyFrames; f += 1) { + const src = (((h * seqLen) + s) * tensorFrames) + f; + const dst = (((h * concatSeqLen) + (seqOffset + s)) * frames) + f; + merged[dst] = data[src] ?? 0; + } + } + } + seqOffset += seqLen; + } + + crossAttentions[`cross_attentions.${layer}`] = new ort.Tensor('float32', merged, [1, heads, concatSeqLen, frames]); + } + + const tokenStartTimestamps = extractTokenStartTimestamps({ + crossAttentions, + decoderLayers: WHISPER_NUM_LAYERS, + alignmentHeads: remappedAlignmentHeads, + numFrames, + numInputIds: promptTokens.length, + timePrecision: 0.02, + sequenceLength: generated.length, + }); + + const timedWords = buildWordsFromTimestampedTokens({ + tokens: generated, + tokenStartTimestamps, + tokenizer: runtime.tokenizer, + eosTokenId: runtime.eosTokenId, + promptLength: promptTokens.length, + timestampBeginTokenId: runtime.timestampBeginTokenId, + timePrecision: 0.02, + language: parseLanguageCode(opts.lang) ?? 'english', + }); + + const maxSec = Math.max(0, numFrames * 0.02); + return timedWords.map((word) => ({ + word: word.word, + start: Math.min(maxSec, Math.max(0, word.startSec)), + end: Math.min(maxSec, Math.max(0, word.endSec)), + })); + } finally { + disposeTensor(mel); + if (outputs?.logits) disposeTensor(outputs.logits); + if (outputs) { + for (const [name, tensor] of Object.entries(outputs)) { + if (name.startsWith('cross_attentions.')) { + disposeTensor(tensor); + } + } + } + disposeTensorMap(crossAttentions); + disposeTensorMap(decoderPast); + disposeTensorMap(encoderPast); + disposeTensor(encoderHidden); + } +} + +export async function alignAudioWithText( + audioBuffer: TTSAudioBuffer, + text: string, + cacheKey?: string, + opts: WhisperAlignmentOptions = {}, +): Promise { + if (!text.trim()) return []; + + if (cacheKey && alignmentCache.has(cacheKey)) { + const cached = alignmentCache.get(cacheKey)!; + alignmentCache.delete(cacheKey); + alignmentCache.set(cacheKey, cached); + return cached; + } + + if (cacheKey) { + const inFlight = alignmentInFlight.get(cacheKey); + if (inFlight) return inFlight; + } + const inFlightKey = cacheKey ?? makeInFlightCoalesceKey(audioBuffer, text, opts.lang); + const shared = alignmentInFlight.get(inFlightKey); + if (shared) return shared; + + state.pendingAlignments += 1; + const run = (async (): Promise => { + const alignmentTimeoutMs = getAlignmentTimeoutMs(); + const deadlineMs = Date.now() + alignmentTimeoutMs; + let tmpBase = ''; + let inputPath = ''; + let pcmPath = ''; + + try { + tmpBase = await mkdtemp(join(tmpdir(), 'openreader-whisper-')); + inputPath = join(tmpBase, `${randomUUID()}-input.bin`); + pcmPath = join(tmpBase, `${randomUUID()}-input.pcm16`); + + await writeFile(inputPath, Buffer.from(new Uint8Array(audioBuffer))); + await decodeToPcm16(inputPath, pcmPath); + + const pcmBytes = await readFile(pcmPath); + const decodedSamples = pcm16ToFloat32(pcmBytes); + const effectiveSampleLength = Math.min(decodedSamples.length, N_SAMPLES); + const effectiveFrameCount = Math.max(1, Math.floor((effectiveSampleLength / HOP_LENGTH) / 2)); + const normalizedAudio = padOrTrimAudio(decodedSamples); + + const words = await runWhisperOnnx( + normalizedAudio, + { ...opts, textHint: text }, + effectiveFrameCount, + deadlineMs, + alignmentTimeoutMs, + ); + const alignment = mapWordsToSentenceOffsets(text, words); + const result: TTSSentenceAlignment[] = [alignment]; + + if (cacheKey) { + if (alignmentCache.has(cacheKey)) { + alignmentCache.delete(cacheKey); + } + alignmentCache.set(cacheKey, result); + while (alignmentCache.size > ALIGNMENT_CACHE_MAX_ENTRIES) { + const oldest = alignmentCache.keys().next().value; + if (!oldest) break; + alignmentCache.delete(oldest); + } + } + + return result; + } finally { + if (tmpBase) { + await rm(tmpBase, { recursive: true, force: true }).catch(() => {}); + } + state.pendingAlignments = Math.max(0, state.pendingAlignments - 1); + } + })(); + + alignmentInFlight.set(inFlightKey, run); + run.finally(() => { + if (alignmentInFlight.get(inFlightKey) === run) { + alignmentInFlight.delete(inFlightKey); + } + }); + return run; +} + +export function makeWhisperCacheKey(input: WhisperRequestBody): string { + return createHash('sha256') + .update( + JSON.stringify({ + text: input.text, + lang: input.lang || '', + audioLen: input.audio?.length || 0, + }), + ) + .digest('hex'); +} diff --git a/compute/core/src/whisper/alignment-map.ts b/compute/core/src/whisper/alignment-map.ts new file mode 100644 index 0000000..b26ecc8 --- /dev/null +++ b/compute/core/src/whisper/alignment-map.ts @@ -0,0 +1,54 @@ +import type { TTSSentenceAlignment, TTSSentenceWord } from '../types/tts'; + +function preprocessSentenceForAudio(text: string): string { + return text + .replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -') + .replace(/(\w+)-\s+(\w+)/g, '$1$2') + .replace(/\*/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export interface WhisperWord { + start: number; + end: number; + word: string; +} + +export function mapWordsToSentenceOffsets(sentence: string, words: WhisperWord[]): TTSSentenceAlignment { + const normalizedSentence = preprocessSentenceForAudio(sentence); + const lowerSentence = normalizedSentence.toLowerCase(); + let cursor = 0; + + const alignedWords: TTSSentenceWord[] = words.map((w) => { + const token = w.word.trim(); + if (!token) { + return { + text: '', + startSec: w.start, + endSec: w.end, + charStart: cursor, + charEnd: cursor, + }; + } + + const idx = lowerSentence.indexOf(token.toLowerCase(), cursor); + const start = idx >= 0 ? idx : cursor; + const end = Math.min(normalizedSentence.length, start + token.length); + cursor = Math.max(cursor, end); + + return { + text: token, + startSec: w.start, + endSec: w.end, + charStart: start, + charEnd: end, + }; + }).filter((word) => word.text.length > 0); + + return { + sentence, + sentenceIndex: 0, + words: alignedWords, + }; +} diff --git a/compute/core/src/whisper/assets/LICENSE.txt b/compute/core/src/whisper/assets/LICENSE.txt new file mode 100644 index 0000000..d255525 --- /dev/null +++ b/compute/core/src/whisper/assets/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 OpenAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/compute/core/src/whisper/assets/manifest.json b/compute/core/src/whisper/assets/manifest.json new file mode 100644 index 0000000..b1adda1 --- /dev/null +++ b/compute/core/src/whisper/assets/manifest.json @@ -0,0 +1,76 @@ +{ + "name": "whisper-base_timestamped-q4", + "version": "onnx-community/whisper-base_timestamped@608c49e61301901684bc36cac8f74b95ff6b5a8e", + "files": [ + { + "path": "config.json", + "sha256": "f4d0608f7d918166da7edb3e188de5ef1bfe70d9802e785d271fd88111e9cf4b", + "size": 2243 + }, + { + "path": "generation_config.json", + "sha256": "61070cf8de25b1e9256e8e102ded49d8d24a8369ed36ef84fdf21549e68125a0", + "size": 3832 + }, + { + "path": "tokenizer.json", + "sha256": "27fc476bfe7f17299480be2273fc0608e4d5a99aba2ab5dec5374b4482d1a566", + "size": 2480466 + }, + { + "path": "tokenizer_config.json", + "sha256": "2e036e4dbacfdeb7242c7d4ec4149f4a16e86026048f94d1637e3a8ee9c6a573", + "size": 282682 + }, + { + "path": "merges.txt", + "sha256": "2df2990a395e35e8dfbc7511e08c12d56018d8d04691e0133e5d63b21e154dc6", + "size": 493869 + }, + { + "path": "vocab.json", + "sha256": "50d6a919f0a0601d56a04eb583c780d18553aa388254ba3158eb6a00f13e2c1a", + "size": 1036584 + }, + { + "path": "normalizer.json", + "sha256": "bf1c507dc8724ca9cf9903640dacfb69dae2f00edee4f21ceba106a7392f26dd", + "size": 52666 + }, + { + "path": "added_tokens.json", + "sha256": "9715fd2243b6f06a5858b5e32950d2853f73dd5bc201aafcf76f5082a2d8acd1", + "size": 34604 + }, + { + "path": "preprocessor_config.json", + "sha256": "a6a76d28c93edb273669eb9e0b0636a2bddbb1272c3261e47b7ca6dfdbac1b8d", + "size": 339 + }, + { + "path": "special_tokens_map.json", + "sha256": "e67ae3a0aaa99abcd9f187138e12db1f65c16a14761c50ef10eef2c174a7a691", + "size": 2194 + }, + { + "path": "onnx/encoder_model_q4.onnx", + "sha256": "0df94e35822653ba16e23c3f19f05b9b7fe7aae97884d1b277e02044f33c4880", + "size": 18771046 + }, + { + "path": "onnx/decoder_model_merged_q4.onnx", + "sha256": "fc1902ce2e42c69b2346d8e2a98898c60c01da1e6a64ae90f41d22350ac7db13", + "size": 123738327 + }, + { + "path": "onnx/decoder_with_past_model_q4.onnx", + "sha256": "d864ca26509968b00d92e6823c4db7ac460c106f9228a21bc5199e9893fe4126", + "size": 121378741 + }, + { + "path": "LICENSE.txt", + "sha256": "b5d65a59060e68c4ff940e1eddfa6f94b2d68fdf58ed7f4dd57721c997e35e9d", + "size": 1063 + } + ] +} diff --git a/compute/core/src/whisper/assets/mel_filters.npz b/compute/core/src/whisper/assets/mel_filters.npz new file mode 100644 index 0000000..28ea269 Binary files /dev/null and b/compute/core/src/whisper/assets/mel_filters.npz differ diff --git a/compute/core/src/whisper/model.ts b/compute/core/src/whisper/model.ts new file mode 100644 index 0000000..4c5a021 --- /dev/null +++ b/compute/core/src/whisper/model.ts @@ -0,0 +1,249 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createHash } from 'crypto'; +import { readFileSync } from 'fs'; +import { access, copyFile, mkdir, readFile, rename, unlink, writeFile } from 'fs/promises'; +import { DOCSTORE_DIR } from '../platform/docstore'; + +const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url)); +const MODEL_DIR = path.join(DOCSTORE_DIR, 'model', 'whisper-base_timestamped'); +const STATIC_LICENSE_PATH = path.join(MODULE_DIR, 'assets', 'LICENSE.txt'); +const MANIFEST_PATH = path.join(MODULE_DIR, 'assets', 'manifest.json'); + +export const WHISPER_CONFIG_PATH = path.join(MODEL_DIR, 'config.json'); +export const WHISPER_GENERATION_CONFIG_PATH = path.join(MODEL_DIR, 'generation_config.json'); +export const WHISPER_TOKENIZER_PATH = path.join(MODEL_DIR, 'tokenizer.json'); +export const WHISPER_TOKENIZER_CONFIG_PATH = path.join(MODEL_DIR, 'tokenizer_config.json'); +export const WHISPER_ENCODER_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'encoder_model_q4.onnx'); +export const WHISPER_DECODER_MERGED_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'decoder_model_merged_q4.onnx'); +export const WHISPER_DECODER_WITH_PAST_MODEL_PATH = path.join(MODEL_DIR, 'onnx', 'decoder_with_past_model_q4.onnx'); + +const BASE_MODEL_URL = 'https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main'; +const WHISPER_MODEL_BASE_URL_ENV = 'WHISPER_MODEL_BASE_URL'; + +const MODEL_RELATIVE_PATHS: string[] = [ + 'config.json', + 'generation_config.json', + 'tokenizer.json', + 'tokenizer_config.json', + 'merges.txt', + 'vocab.json', + 'normalizer.json', + 'added_tokens.json', + 'preprocessor_config.json', + 'special_tokens_map.json', + 'onnx/encoder_model_q4.onnx', + 'onnx/decoder_model_merged_q4.onnx', + 'onnx/decoder_with_past_model_q4.onnx', +]; + +const DEFAULT_URLS: Record = { + 'config.json': `${BASE_MODEL_URL}/config.json`, + 'generation_config.json': `${BASE_MODEL_URL}/generation_config.json`, + 'tokenizer.json': `${BASE_MODEL_URL}/tokenizer.json`, + 'tokenizer_config.json': `${BASE_MODEL_URL}/tokenizer_config.json`, + 'merges.txt': `${BASE_MODEL_URL}/merges.txt`, + 'vocab.json': `${BASE_MODEL_URL}/vocab.json`, + 'normalizer.json': `${BASE_MODEL_URL}/normalizer.json`, + 'added_tokens.json': `${BASE_MODEL_URL}/added_tokens.json`, + 'preprocessor_config.json': `${BASE_MODEL_URL}/preprocessor_config.json`, + 'special_tokens_map.json': `${BASE_MODEL_URL}/special_tokens_map.json`, + 'onnx/encoder_model_q4.onnx': `${BASE_MODEL_URL}/onnx/encoder_model_q4.onnx`, + 'onnx/decoder_model_merged_q4.onnx': `${BASE_MODEL_URL}/onnx/decoder_model_merged_q4.onnx`, + 'onnx/decoder_with_past_model_q4.onnx': `${BASE_MODEL_URL}/onnx/decoder_with_past_model_q4.onnx`, +}; + +type ManifestEntry = { path: string; sha256?: string; size?: number }; + +export interface WhisperArtifactSpec { + path: string; + sha256?: string; + size?: number; + url: string; +} + +export interface WhisperStaticArtifactSpec { + path: string; + sha256?: string; + size?: number; + sourcePath: string; +} + +export type WhisperFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +function loadManifestFiles(): ManifestEntry[] { + const manifestText = readFileSync(MANIFEST_PATH, 'utf8'); + const parsed = JSON.parse(manifestText) as { files?: ManifestEntry[] }; + return Array.isArray(parsed.files) ? parsed.files : []; +} + +const MANIFEST_FILES = loadManifestFiles(); +const MODEL_FILES = MANIFEST_FILES.filter((entry) => entry.path !== 'LICENSE.txt'); +const LICENSE_FILE = MANIFEST_FILES.find((entry) => entry.path === 'LICENSE.txt'); + +function normalizeExpected(entry: { sha256?: string; size?: number }): { sha256: string | null; size: number } { + return { + sha256: typeof entry.sha256 === 'string' ? entry.sha256.toLowerCase() : null, + size: Number(entry.size ?? 0), + }; +} + +function resolvePath(relativePath: string, modelDir: string): string { + return path.join(modelDir, relativePath); +} + +function joinModelUrl(baseUrl: string, relativePath: string): string { + return `${baseUrl.replace(/\/+$/, '')}/${relativePath}`; +} + +function resolveUrl(relativePath: string): string { + const overrideBase = process.env[WHISPER_MODEL_BASE_URL_ENV]?.trim(); + if (overrideBase) { + return joinModelUrl(overrideBase, relativePath); + } + const fallback = DEFAULT_URLS[relativePath]; + if (!fallback) { + throw new Error(`No default URL configured for Whisper model artifact: ${relativePath}`); + } + return fallback; +} + +function sha256OfBytes(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function verifyBytes(bytes: Uint8Array, expected: { sha256?: string; size?: number }): boolean { + const normalized = normalizeExpected(expected); + if (Number.isFinite(normalized.size) && normalized.size > 0 && bytes.byteLength !== normalized.size) { + return false; + } + if (!normalized.sha256) return true; + return sha256OfBytes(bytes) === normalized.sha256; +} + +async function verifyFile(filePath: string, expected: { sha256?: string; size?: number }): Promise { + const bytes = await readFile(filePath); + return verifyBytes(bytes, expected); +} + +async function downloadToFile(fetchImpl: WhisperFetch, url: string, outPath: string): Promise { + const res = await fetchImpl(url); + if (!res.ok) { + throw new Error(`Download failed for ${url}: ${res.status} ${res.statusText}`); + } + const bytes = new Uint8Array(await res.arrayBuffer()); + await writeFile(outPath, bytes); +} + +export async function ensureWhisperArtifacts(options: { + modelDir: string; + artifacts: WhisperArtifactSpec[]; + staticArtifacts?: WhisperStaticArtifactSpec[]; + fetchImpl?: WhisperFetch; +}): Promise { + const { + modelDir, + artifacts, + staticArtifacts = [], + fetchImpl = fetch, + } = options; + + try { + await Promise.all(artifacts.map(async (artifact) => { + const target = resolvePath(artifact.path, modelDir); + await access(target); + const valid = await verifyFile(target, artifact); + if (!valid) { + throw new Error(`Checksum mismatch for existing Whisper artifact: ${artifact.path}`); + } + })); + + await Promise.all(staticArtifacts.map(async (artifact) => { + const target = resolvePath(artifact.path, modelDir); + await access(target); + const valid = await verifyFile(target, artifact); + if (!valid) { + throw new Error(`Checksum mismatch for existing Whisper static artifact: ${artifact.path}`); + } + })); + + return; + } catch { + // Continue to repair/download. + } + + for (const artifact of artifacts) { + const target = resolvePath(artifact.path, modelDir); + const targetDir = path.dirname(target); + const tmp = `${target}.tmp`; + + await mkdir(targetDir, { recursive: true }); + await downloadToFile(fetchImpl, artifact.url, tmp); + if (!(await verifyFile(tmp, artifact))) { + await unlink(tmp).catch(() => undefined); + throw new Error(`Whisper artifact checksum verification failed: ${artifact.path}`); + } + await rename(tmp, target); + } + + for (const artifact of staticArtifacts) { + const target = resolvePath(artifact.path, modelDir); + const targetDir = path.dirname(target); + await mkdir(targetDir, { recursive: true }); + await copyFile(artifact.sourcePath, target); + if (!(await verifyFile(target, artifact))) { + throw new Error(`Whisper static artifact checksum verification failed: ${artifact.path}`); + } + } +} + +export function createSingleflightRunner(work: () => Promise): () => Promise { + let inflight: Promise | null = null; + return async () => { + if (inflight) return inflight; + inflight = work().finally(() => { + inflight = null; + }); + return inflight; + }; +} + +async function ensureModelInternal(): Promise { + if (process.env[WHISPER_MODEL_BASE_URL_ENV]?.trim()) { + for (const relativePath of MODEL_RELATIVE_PATHS) { + if (!(relativePath in DEFAULT_URLS)) { + throw new Error(`Missing default URL path mapping for Whisper artifact: ${relativePath}`); + } + } + } + + const artifacts: WhisperArtifactSpec[] = MODEL_FILES.map((entry) => ({ + path: entry.path, + sha256: entry.sha256, + size: entry.size, + url: resolveUrl(entry.path), + })); + + const staticArtifacts: WhisperStaticArtifactSpec[] = LICENSE_FILE + ? [{ + path: LICENSE_FILE.path, + sha256: LICENSE_FILE.sha256, + size: LICENSE_FILE.size, + sourcePath: STATIC_LICENSE_PATH, + }] + : []; + + await ensureWhisperArtifacts({ + modelDir: MODEL_DIR, + artifacts, + staticArtifacts, + }); + + return WHISPER_ENCODER_MODEL_PATH; +} + +const ensureWhisperModelSingleflight = createSingleflightRunner(ensureModelInternal); + +export async function ensureWhisperModel(): Promise { + return ensureWhisperModelSingleflight(); +} diff --git a/compute/core/src/whisper/spectral.ts b/compute/core/src/whisper/spectral.ts new file mode 100644 index 0000000..b7223cc --- /dev/null +++ b/compute/core/src/whisper/spectral.ts @@ -0,0 +1,21 @@ +export function buildGoertzelCoefficients(freqBins: number, fftSize: number): Float64Array { + const coeffs = new Float64Array(freqBins); + for (let k = 0; k < freqBins; k += 1) { + coeffs[k] = 2 * Math.cos((2 * Math.PI * k) / fftSize); + } + return coeffs; +} + +export function goertzelPower(samples: Float32Array, coeff: number): number { + let s1 = 0; + let s2 = 0; + for (let i = 0; i < samples.length; i += 1) { + const s0 = samples[i] + (coeff * s1) - s2; + s2 = s1; + s1 = s0; + } + + const power = (s1 * s1) + (s2 * s2) - (coeff * s1 * s2); + if (!Number.isFinite(power) || power < 0) return 0; + return power; +} diff --git a/compute/core/src/whisper/token-timestamps.ts b/compute/core/src/whisper/token-timestamps.ts new file mode 100644 index 0000000..47edc1d --- /dev/null +++ b/compute/core/src/whisper/token-timestamps.ts @@ -0,0 +1,449 @@ +import type { Tokenizer } from '@huggingface/tokenizers'; +import type * as ort from 'onnxruntime-node'; + +const PUNCTUATION_REGEX = '\\p{P}\\u0021-\\u002F\\u003A-\\u0040\\u005B-\\u0060\\u007B-\\u007E'; +const PUNCTUATION_ONLY_REGEX = new RegExp(`^[${PUNCTUATION_REGEX}]+$`, 'gu'); + +type TokenTimestamp = [start: number, end: number]; + +export interface WhisperWordTiming { + word: string; + startSec: number; + endSec: number; +} + +function medianFilter(data: Float32Array, windowSize: number): Float32Array { + if (windowSize % 2 === 0 || windowSize <= 0) { + throw new Error('Window size must be a positive odd number'); + } + + const output = new Float32Array(data.length); + const buffer = new Float32Array(windowSize); + const halfWindow = Math.floor(windowSize / 2); + + for (let i = 0; i < data.length; i += 1) { + let valuesIndex = 0; + for (let j = -halfWindow; j <= halfWindow; j += 1) { + let index = i + j; + if (index < 0) { + index = Math.abs(index); + } else if (index >= data.length) { + index = (2 * (data.length - 1)) - index; + } + buffer[valuesIndex] = data[index]; + valuesIndex += 1; + } + + const sortable = Array.from(buffer); + sortable.sort((a, b) => a - b); + output[i] = sortable[halfWindow] ?? 0; + } + + return output; +} + +function dynamicTimeWarping(matrix: Float32Array[], rows: number, cols: number): [number[], number[]] { + const cost: number[][] = Array.from({ length: rows + 1 }, () => Array(cols + 1).fill(Number.POSITIVE_INFINITY)); + const trace: number[][] = Array.from({ length: rows + 1 }, () => Array(cols + 1).fill(-1)); + cost[0][0] = 0; + + for (let j = 1; j <= cols; j += 1) { + for (let i = 1; i <= rows; i += 1) { + const c0 = cost[i - 1][j - 1]; + const c1 = cost[i - 1][j]; + const c2 = cost[i][j - 1]; + let c: number; + let t: number; + if (c0 < c1 && c0 < c2) { + c = c0; + t = 0; + } else if (c1 < c0 && c1 < c2) { + c = c1; + t = 1; + } else { + c = c2; + t = 2; + } + cost[i][j] = matrix[i - 1][j - 1] + c; + trace[i][j] = t; + } + } + + for (let i = 0; i <= cols; i += 1) trace[0][i] = 2; + for (let i = 0; i <= rows; i += 1) trace[i][0] = 1; + + let i = rows; + let j = cols; + const textIndices: number[] = []; + const timeIndices: number[] = []; + while (i > 0 || j > 0) { + textIndices.push(i - 1); + timeIndices.push(j - 1); + const step = trace[i][j]; + if (step === 0) { + i -= 1; + j -= 1; + } else if (step === 1) { + i -= 1; + } else if (step === 2) { + j -= 1; + } else { + throw new Error(`Unexpected DTW trace state at [${i}, ${j}]`); + } + } + + textIndices.reverse(); + timeIndices.reverse(); + return [textIndices, timeIndices]; +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function decodeTokens(tokenizer: Pick, tokens: number[]): string { + return tokenizer.decode(tokens, { skip_special_tokens: false }); +} + +function splitTokensOnUnicode( + tokenizer: Pick, + tokens: number[], +): [string[], number[][], number[][]] { + const decodedFull = decodeTokens(tokenizer, tokens); + const replacementChar = '\uFFFD'; + const words: string[] = []; + const wordTokens: number[][] = []; + const tokenIndices: number[][] = []; + let currentTokens: number[] = []; + let currentIndices: number[] = []; + let unicodeOffset = 0; + + for (let i = 0; i < tokens.length; i += 1) { + currentTokens.push(tokens[i]); + currentIndices.push(i); + + const decoded = decodeTokens(tokenizer, currentTokens); + if ( + !decoded.includes(replacementChar) + || decodedFull[unicodeOffset + decoded.indexOf(replacementChar)] === replacementChar + ) { + words.push(decoded); + wordTokens.push(currentTokens); + tokenIndices.push(currentIndices); + currentTokens = []; + currentIndices = []; + unicodeOffset += decoded.length; + } + } + + return [words, wordTokens, tokenIndices]; +} + +function splitTokensOnSpaces( + tokenizer: Pick, + tokens: number[], + eosTokenId: number, +): [string[], number[][], number[][]] { + const [subwords, subwordTokens, subwordIndices] = splitTokensOnUnicode(tokenizer, tokens); + const words: string[] = []; + const wordTokens: number[][] = []; + const tokenIndices: number[][] = []; + + for (let i = 0; i < subwords.length; i += 1) { + const subword = subwords[i]; + const tokenList = subwordTokens[i]; + const indices = subwordIndices[i]; + const special = tokenList[0] >= eosTokenId; + const withSpace = subword.startsWith(' '); + const trimmed = subword.trim(); + const punctuation = PUNCTUATION_ONLY_REGEX.test(trimmed); + + if (special || withSpace || punctuation || words.length === 0) { + words.push(subword); + wordTokens.push([...tokenList]); + tokenIndices.push([...indices]); + } else { + const ix = words.length - 1; + words[ix] += subword; + wordTokens[ix].push(...tokenList); + tokenIndices[ix].push(...indices); + } + } + + return [words, wordTokens, tokenIndices]; +} + +function mergePunctuations( + words: string[], + tokens: number[][], + indices: number[][], + prependPunctuations = '"\'“¡¿([{-', + appendPunctuations = '"\'.。,,!!??::”)]}、', +): [string[], number[][], number[][]] { + const newWords = words.map((w) => `${w}`); + const newTokens = tokens.map((t) => [...t]); + const newIndices = indices.map((idx) => [...idx]); + + let i = newWords.length - 2; + let j = newWords.length - 1; + while (i >= 0) { + if (newWords[i].startsWith(' ') && prependPunctuations.includes(newWords[i].trim())) { + newWords[j] = newWords[i] + newWords[j]; + newTokens[j] = [...newTokens[i], ...newTokens[j]]; + newIndices[j] = [...newIndices[i], ...newIndices[j]]; + newWords[i] = ''; + newTokens[i] = []; + newIndices[i] = []; + } else { + j = i; + } + i -= 1; + } + + i = 0; + j = 1; + while (j < newWords.length) { + if (!newWords[i].endsWith(' ') && appendPunctuations.includes(newWords[j])) { + newWords[i] += newWords[j]; + newTokens[i] = [...newTokens[i], ...newTokens[j]]; + newIndices[i] = [...newIndices[i], ...newIndices[j]]; + newWords[j] = ''; + newTokens[j] = []; + newIndices[j] = []; + } else { + i = j; + } + j += 1; + } + + return [ + newWords.filter((w) => w.length > 0), + newTokens.filter((t) => t.length > 0), + newIndices.filter((t) => t.length > 0), + ]; +} + +function combineTokensIntoWords( + tokenizer: Pick, + tokens: number[], + eosTokenId: number, + language = 'english', +): [string[], number[][], number[][]] { + let words: string[]; + let wordTokens: number[][]; + let tokenIndices: number[][]; + + if (['chinese', 'japanese', 'thai', 'lao', 'myanmar', 'zh', 'ja', 'th', 'lo', 'my'].includes(language)) { + [words, wordTokens, tokenIndices] = splitTokensOnUnicode(tokenizer, tokens); + } else { + [words, wordTokens, tokenIndices] = splitTokensOnSpaces(tokenizer, tokens, eosTokenId); + } + + return mergePunctuations(words, wordTokens, tokenIndices); +} + +export function extractTokenStartTimestamps(input: { + crossAttentions: Record; + decoderLayers: number; + alignmentHeads: Array<[number, number]>; + numFrames: number; + numInputIds: number; + timePrecision?: number; + sequenceLength: number; +}): number[] { + const { + crossAttentions, + decoderLayers, + alignmentHeads, + numFrames, + numInputIds, + timePrecision = 0.02, + sequenceLength, + } = input; + + const frameCount = Math.max(1, numFrames); + const perLayer: Float32Array[] = []; + for (let layer = 0; layer < decoderLayers; layer += 1) { + const key = `cross_attentions.${layer}`; + const tensor = crossAttentions[key]; + if (!tensor) continue; + perLayer[layer] = tensor.data as Float32Array; + } + + const selected: Float32Array[] = []; + let seqLen = 0; + let attnFrames = 0; + for (const [layer, head] of alignmentHeads) { + const flat = perLayer[layer]; + if (!flat) continue; + const layerTensor = crossAttentions[`cross_attentions.${layer}`]; + if (!layerTensor || layerTensor.dims.length < 4) continue; + const [, numHeads, currentSeqLen, currentFrames] = layerTensor.dims; + if (head >= numHeads) continue; + seqLen = currentSeqLen; + attnFrames = Math.min(currentFrames, frameCount); + const headSlice = new Float32Array(seqLen * attnFrames); + for (let s = 0; s < seqLen; s += 1) { + for (let f = 0; f < attnFrames; f += 1) { + const flatIndex = (((head * currentSeqLen) + s) * currentFrames) + f; + headSlice[(s * attnFrames) + f] = flat[flatIndex] ?? 0; + } + } + selected.push(headSlice); + } + + if (!selected.length || seqLen === 0 || attnFrames === 0) { + return new Array(sequenceLength).fill(0); + } + + const normalizedHeads = selected.map((headData) => { + const means = new Float32Array(attnFrames); + const stds = new Float32Array(attnFrames); + + for (let f = 0; f < attnFrames; f += 1) { + let sum = 0; + for (let s = 0; s < seqLen; s += 1) sum += headData[(s * attnFrames) + f]; + const mean = sum / seqLen; + means[f] = mean; + let varSum = 0; + for (let s = 0; s < seqLen; s += 1) { + const d = headData[(s * attnFrames) + f] - mean; + varSum += d * d; + } + stds[f] = Math.sqrt(varSum / seqLen) || 1; + } + + const out = new Float32Array(headData.length); + for (let s = 0; s < seqLen; s += 1) { + const row = new Float32Array(attnFrames); + for (let f = 0; f < attnFrames; f += 1) { + row[f] = (headData[(s * attnFrames) + f] - means[f]) / stds[f]; + } + const filtered = medianFilter(row, 7); + out.set(filtered, s * attnFrames); + } + return out; + }); + + const croppedRows = Math.max(0, seqLen - numInputIds); + if (croppedRows === 0) return new Array(sequenceLength).fill(0); + + const matrix: Float32Array[] = Array.from({ length: croppedRows }, () => new Float32Array(attnFrames)); + for (const headData of normalizedHeads) { + for (let r = 0; r < croppedRows; r += 1) { + const srcRow = r + numInputIds; + for (let f = 0; f < attnFrames; f += 1) { + matrix[r][f] += headData[(srcRow * attnFrames) + f]; + } + } + } + + const scale = 1 / normalizedHeads.length; + for (let r = 0; r < croppedRows; r += 1) { + for (let f = 0; f < attnFrames; f += 1) { + matrix[r][f] = -matrix[r][f] * scale; + } + } + + const [textIndices, timeIndices] = dynamicTimeWarping(matrix, croppedRows, attnFrames); + const jumps = new Array(textIndices.length).fill(false); + for (let i = 0; i < textIndices.length; i += 1) { + jumps[i] = i === 0 ? true : textIndices[i] !== textIndices[i - 1]; + } + + const jumpTimes: number[] = []; + for (let i = 0; i < jumps.length; i += 1) { + if (jumps[i]) jumpTimes.push(timeIndices[i] * timePrecision); + } + + const timestamps = new Array(sequenceLength).fill(0); + for (let i = 0; i < numInputIds && i < timestamps.length; i += 1) timestamps[i] = 0; + for (let i = 0; i < jumpTimes.length && (numInputIds + i) < timestamps.length; i += 1) { + timestamps[numInputIds + i] = jumpTimes[i]; + } + if (timestamps.length > 0 && jumpTimes.length > 0) { + timestamps[timestamps.length - 1] = jumpTimes[jumpTimes.length - 1]; + } + return timestamps; +} + +export function buildWordsFromTimestampedTokens(input: { + tokens: number[]; + tokenStartTimestamps: number[]; + tokenizer: Pick; + eosTokenId: number; + promptLength: number; + timestampBeginTokenId: number; + timePrecision?: number; + language?: string; +}): WhisperWordTiming[] { + const { + tokens, + tokenStartTimestamps, + tokenizer, + eosTokenId, + promptLength, + timestampBeginTokenId, + timePrecision = 0.02, + language = 'english', + } = input; + + const limit = Math.min(tokens.length, tokenStartTimestamps.length); + const tokenRanges: TokenTimestamp[] = []; + for (let i = 0; i < limit; i += 1) { + const start = tokenStartTimestamps[i] ?? 0; + const end = i + 1 < limit ? (tokenStartTimestamps[i + 1] ?? (start + timePrecision)) : (start + timePrecision); + tokenRanges.push([start, Math.max(start, end)]); + } + + const words: WhisperWordTiming[] = []; + let segmentStart: number | null = null; + let textTokens: number[] = []; + let textRanges: TokenTimestamp[] = []; + + const flushSegment = (segmentEnd: number | null) => { + if (!textTokens.length) return; + const [wordTexts, , tokenIndices] = combineTokensIntoWords(tokenizer, textTokens, eosTokenId, language); + for (let i = 0; i < wordTexts.length; i += 1) { + const indices = tokenIndices[i]; + if (!indices.length) continue; + const start = textRanges[indices[0]]?.[0] ?? segmentStart ?? 0; + const end = textRanges[indices[indices.length - 1]]?.[1] ?? segmentEnd ?? start; + const clampedStart = segmentStart == null ? start : Math.max(segmentStart, start); + const clampedEndBase = segmentEnd == null ? end : Math.min(segmentEnd, end); + const clampedEnd = Math.max( + clampedStart + (clampedEndBase <= clampedStart ? timePrecision : 0), + clampedEndBase, + ); + words.push({ + word: wordTexts[i].trim(), + startSec: round2(clampedStart), + endSec: round2(clampedEnd), + }); + } + textTokens = []; + textRanges = []; + }; + + for (let i = promptLength; i < limit; i += 1) { + const token = tokens[i]; + if (token === eosTokenId) break; + + if (token >= timestampBeginTokenId) { + const ts = (token - timestampBeginTokenId) * timePrecision; + if (segmentStart == null) { + segmentStart = ts; + } else { + flushSegment(ts); + segmentStart = ts; + } + continue; + } + + textTokens.push(token); + textRanges.push(tokenRanges[i]); + } + + flushSegment(null); + return words.filter((w) => w.word.length > 0); +} diff --git a/compute/core/tsconfig.json b/compute/core/tsconfig.json new file mode 100644 index 0000000..3e68ae5 --- /dev/null +++ b/compute/core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/compute/worker/.env.example b/compute/worker/.env.example new file mode 100644 index 0000000..15472fa --- /dev/null +++ b/compute/worker/.env.example @@ -0,0 +1,58 @@ +# External compute-worker service env only. +# Keep COMPUTE_WORKER_TOKEN and S3_* aligned with app env. +# In external worker mode, worker runtime vars live here (or worker platform env), +# not in the app/root `.env`. +# App/root `.env` should only set: +# - COMPUTE_WORKER_URL +# - COMPUTE_WORKER_TOKEN +# - optional shared timeout/stale overrides +# Details: docs/deploy/compute-worker + +# Compute worker bind +# Platform note: +# - Local/manual: keep PORT=8081 +# - Railway/Render/Fly/etc: platform injects PORT +# COMPUTE_WORKER_HOST=0.0.0.0 +# PORT=8081 +# LOG_FORMAT=pretty +# COMPUTE_LOG_LEVEL=info + +# Must match app env when app uses COMPUTE_WORKER_URL +COMPUTE_WORKER_TOKEN=local-compute-token + +# NATS/JetStream +NATS_URL=nats://nats:4222 +# Optional: NATS authentication credentials (e.g. for Synadia Cloud / NGS) +# You can specify the file path: +# NATS_CREDS_FILE=/path/to/NGS-Default-compute-worker.creds +# Or specify the raw credentials string content (excellent for container/cloud platforms): +# NATS_CREDS="-----BEGIN NATS USER JWT-----\n...\n-----BEGIN USER NKEY SEED-----\n..." + +# Shared object storage (must be reachable from worker) +S3_BUCKET=openreader-documents +S3_REGION=us-east-1 +S3_ACCESS_KEY_ID=devkey +S3_SECRET_ACCESS_KEY=devsecret +# S3_PREFIX=openreader +# Optional for non-AWS S3-compatible endpoints: +S3_ENDPOINT=http://host.docker.internal:8333 +S3_FORCE_PATH_STYLE=true + +# Optional tuning +# COMPUTE_PREWARM_MODELS=true +# COMPUTE_JOB_CONCURRENCY=1 +# COMPUTE_WHISPER_TIMEOUT_MS=30000 +# COMPUTE_PDF_TIMEOUT_MS=300000 +# COMPUTE_PDF_JOB_ATTEMPTS=1 +# COMPUTE_JOBS_STREAM_MAX_BYTES=268435456 +# COMPUTE_EVENTS_STREAM_MAX_BYTES=134217728 +# COMPUTE_JOB_STATES_MAX_BYTES=67108864 +# COMPUTE_NATS_REPLICAS=1 +# COMPUTE_OP_STALE_MS=1800000 +# Optional model mirrors +# Default expected Whisper variant is q4: +# - onnx/encoder_model_q4.onnx +# - onnx/decoder_model_merged_q4.onnx +# - onnx/decoder_with_past_model_q4.onnx +# WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main +# PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main diff --git a/compute/worker/Dockerfile b/compute/worker/Dockerfile new file mode 100644 index 0000000..7ee779f --- /dev/null +++ b/compute/worker/Dockerfile @@ -0,0 +1,29 @@ +FROM node:lts AS deploy-stage + +RUN npm install -g pnpm@11.1.2 + +WORKDIR /workspace + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY compute/worker/package.json compute/worker/package.json +COPY compute/core/package.json compute/core/package.json + +COPY compute/worker compute/worker +COPY compute/core compute/core + +RUN pnpm --config.inject-workspace-packages=true --filter @openreader/compute-worker deploy /opt/compute-worker + +FROM node:lts + +RUN npm install -g pnpm@11.1.2 + +WORKDIR /workspace + +COPY --from=deploy-stage /opt/compute-worker ./ + +ENV COMPUTE_WORKER_HOST=0.0.0.0 +ENV PORT=8081 + +EXPOSE 8081 + +CMD ["node", "--import", "tsx", "src/server.ts"] diff --git a/compute/worker/docker-compose.yml b/compute/worker/docker-compose.yml new file mode 100644 index 0000000..dde5a73 --- /dev/null +++ b/compute/worker/docker-compose.yml @@ -0,0 +1,47 @@ +services: + nats: + image: nats:2.14-alpine + container_name: openreader-compute-nats + command: ["-js", "-sd", "/data"] + ports: + - "4222:4222" + - "8222:8222" + volumes: + - nats-data:/data + + compute-worker: + build: + context: ../.. + dockerfile: compute/worker/Dockerfile + container_name: openreader-compute-worker + depends_on: + - nats + env_file: + - ./.env + environment: + NATS_URL: ${NATS_URL:-nats://nats:4222} + COMPUTE_WORKER_HOST: ${COMPUTE_WORKER_HOST:-0.0.0.0} + PORT: ${PORT:-8081} + COMPUTE_WORKER_TOKEN: ${COMPUTE_WORKER_TOKEN:-local-compute-token} + S3_PREFIX: ${S3_PREFIX:-openreader} + COMPUTE_PREWARM_MODELS: ${COMPUTE_PREWARM_MODELS:-true} + ports: + - "8081:8081" + develop: + watch: + - action: sync+restart + path: . + target: /workspace + - action: rebuild + path: ../../compute/core + - action: rebuild + path: ./package.json + - action: rebuild + path: ../../compute/core/package.json + - action: rebuild + path: ../../pnpm-lock.yaml + - action: rebuild + path: ../../pnpm-workspace.yaml + +volumes: + nats-data: diff --git a/compute/worker/package.json b/compute/worker/package.json new file mode 100644 index 0000000..4b3b1c7 --- /dev/null +++ b/compute/worker/package.json @@ -0,0 +1,22 @@ +{ + "name": "@openreader/compute-worker", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "start": "tsx src/server.ts" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1050.0", + "@nats-io/jetstream": "^3.4.0", + "@nats-io/kv": "^3.4.0", + "@nats-io/transport-node": "^3.4.0", + "@openreader/compute-core": "workspace:*", + "fastify": "^5.6.2", + "pino": "^10.3.1", + "pino-pretty": "^13.1.2", + "tsx": "^4.22.3", + "zod": "^4.1.12" + } +} diff --git a/compute/worker/src/control-plane/jetstream.ts b/compute/worker/src/control-plane/jetstream.ts new file mode 100644 index 0000000..642906d --- /dev/null +++ b/compute/worker/src/control-plane/jetstream.ts @@ -0,0 +1,315 @@ +import { createHash } from 'node:crypto'; +import { AckPolicy, DeliverPolicy, ReplayPolicy, type JetStreamClient, type JetStreamManager } from '@nats-io/jetstream'; +import { nanos } from '@nats-io/transport-node'; +import type { + OperationEvent, + OperationEventStream, + OperationQueue, + OperationState, + OperationStateStore, + QueuedOperation, +} from '@openreader/compute-core/control-plane'; +import type { + PdfLayoutJobRequest, + WhisperAlignJobRequest, + WorkerOperationKind, +} from '@openreader/compute-core/api-contracts'; +import { createJsonCodec } from './json-codec'; + +export interface KvEntryLike { + operation?: string; + value: Uint8Array; + revision: number; +} + +export interface KvStoreLike { + get(key: string): Promise; + put(key: string, data: Uint8Array): Promise; + create(key: string, data: Uint8Array): Promise; + update(key: string, data: Uint8Array, version: number): Promise; +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return String(error); +} + +function isCasConflictError(error: unknown): boolean { + const message = toErrorMessage(error).toLowerCase(); + return message.includes('wrong last sequence') || message.includes('key exists') || message.includes('wrong last'); +} + +function isPut(entry: KvEntryLike | null): entry is KvEntryLike { + return Boolean(entry && entry.operation === 'PUT'); +} + +interface OpIndexEntry { + opId: string; +} + +export const OP_EVENTS_SUBJECT_PREFIX = 'ops.events'; +export const OP_EVENTS_SUBJECT_WILDCARD = `${OP_EVENTS_SUBJECT_PREFIX}.*`; + +export function hashOpKey(opKey: string): string { + return createHash('sha256').update(opKey).digest('hex'); +} + +export function opIndexKvKey(opKey: string): string { + return `op_index.${hashOpKey(opKey)}`; +} + +export function opStateKvKey(opId: string): string { + return `op_state.${opId}`; +} + +export function opEventsSubject(opId: string): string { + return `${OP_EVENTS_SUBJECT_PREFIX}.${opId}`; +} + +export interface JetStreamOperationStateStoreDeps { + getKv: () => Promise; +} + +export class JetStreamOperationStateStore implements OperationStateStore { + private readonly getKv: () => Promise; + private readonly opStateCodec = createJsonCodec>(); + private readonly opIndexCodec = createJsonCodec(); + + constructor(deps: JetStreamOperationStateStoreDeps) { + this.getKv = deps.getKv; + } + + async getOpState(opId: string): Promise | null> { + const kv = await this.getKv(); + const entry = await kv.get(opStateKvKey(opId)); + if (!isPut(entry)) return null; + return this.opStateCodec.decode(entry.value); + } + + async putOpState(state: OperationState): Promise { + const kv = await this.getKv(); + await kv.put(opStateKvKey(state.opId), this.opStateCodec.encode(state)); + } + + async getOpIndex(opKey: string): Promise<{ opId: string } | null> { + const kv = await this.getKv(); + const entry = await kv.get(opIndexKvKey(opKey)); + if (!isPut(entry)) return null; + return this.opIndexCodec.decode(entry.value); + } + + async compareAndSetOpIndex(input: { + opKey: string; + newOpId: string; + expectedOpId: string | null; + }): Promise { + const kv = await this.getKv(); + const key = opIndexKvKey(input.opKey); + const value = this.opIndexCodec.encode({ opId: input.newOpId }); + + if (input.expectedOpId === null) { + try { + await kv.create(key, value); + return true; + } catch (error) { + if (isCasConflictError(error)) return false; + throw error; + } + } + + const current = await kv.get(key); + if (!isPut(current)) return false; + const decoded = this.opIndexCodec.decode(current.value); + if (decoded.opId !== input.expectedOpId) return false; + + try { + await kv.update(key, value, current.revision); + return true; + } catch (error) { + if (isCasConflictError(error)) return false; + throw error; + } + } +} + +export interface JetStreamOperationEventStreamDeps { + getJs: () => Promise>; + getJsm: () => Promise>; + eventsStreamName: string; + inactiveThresholdMs?: number; +} + +export class JetStreamOperationEventStream implements OperationEventStream { + private readonly getJs: () => Promise>; + private readonly getJsm: () => Promise>; + private readonly eventsStreamName: string; + private readonly inactiveThresholdNanos: number; + private readonly opStateCodec = createJsonCodec>(); + + constructor(deps: JetStreamOperationEventStreamDeps) { + this.getJs = deps.getJs; + this.getJsm = deps.getJsm; + this.eventsStreamName = deps.eventsStreamName; + this.inactiveThresholdNanos = nanos((deps.inactiveThresholdMs ?? 60_000)); + } + + async append(opId: string, snapshot: OperationState): Promise> { + const js = await this.getJs(); + const encoder = new TextEncoder(); + const ack = await js.publish(opEventsSubject(opId), encoder.encode(JSON.stringify(snapshot))); + return { + eventId: ack.seq, + snapshot, + }; + } + + private async createConsumer(input: { + opId: string; + sinceEventId?: number; + replayOnly: boolean; + }): Promise<{ name: string; js: Pick }> { + const js = await this.getJs(); + const jsm = await this.getJsm(); + const subject = opEventsSubject(input.opId); + const since = Math.max(0, Math.floor(input.sinceEventId ?? 0)); + const name = `op_events_${input.opId.slice(0, 12)}_${crypto.randomUUID().replaceAll('-', '').slice(0, 12)}`; + const config = { + name, + ack_policy: AckPolicy.None, + deliver_policy: since > 0 ? DeliverPolicy.StartSequence : (input.replayOnly ? DeliverPolicy.All : DeliverPolicy.New), + replay_policy: ReplayPolicy.Instant, + filter_subject: subject, + max_deliver: 1, + inactive_threshold: this.inactiveThresholdNanos, + ...(since > 0 ? { opt_start_seq: since + 1 } : {}), + }; + await jsm.consumers.add(this.eventsStreamName, config); + return { name, js }; + } + + private async deleteConsumer(name: string): Promise { + const jsm = await this.getJsm(); + await jsm.consumers.delete(this.eventsStreamName, name).catch(() => undefined); + } + + async listSince(opId: string, sinceEventId: number, limit = 200): Promise[]> { + const boundedLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 200; + const { name, js } = await this.createConsumer({ + opId, + sinceEventId, + replayOnly: true, + }); + try { + const consumer = await js.consumers.get(this.eventsStreamName, name); + const output: OperationEvent[] = []; + while (output.length < boundedLimit) { + const msg = await consumer.next({ expires: 250 }); + if (!msg) break; + output.push({ + eventId: msg.seq, + snapshot: this.opStateCodec.decode(msg.data), + }); + } + return output; + } finally { + await this.deleteConsumer(name); + } + } + + async subscribe(input: { + opId: string; + sinceEventId?: number; + onEvent: (event: OperationEvent) => void | Promise; + onError?: (error: unknown) => void; + }): Promise<() => void> { + const { name, js } = await this.createConsumer({ + opId: input.opId, + sinceEventId: input.sinceEventId, + replayOnly: false, + }); + const consumer = await js.consumers.get(this.eventsStreamName, name); + const messages = await consumer.consume(); + let closed = false; + + void (async () => { + try { + for await (const msg of messages) { + if (closed) break; + try { + await input.onEvent({ + eventId: msg.seq, + snapshot: this.opStateCodec.decode(msg.data), + }); + } catch (error) { + input.onError?.(error); + } + } + } catch (error) { + if (!closed) input.onError?.(error); + } finally { + if (!closed) { + closed = true; + await this.deleteConsumer(name); + } + } + })(); + + return () => { + if (closed) return; + closed = true; + void messages.close().catch(() => undefined); + void this.deleteConsumer(name); + }; + } +} + +export interface JetStreamOperationQueueDeps { + getJs: () => Promise>; + whisperSubject: string; + layoutSubject: string; + onEnqueued?: (job: QueuedOperation) => Promise | void; +} + +export class JetStreamOperationQueue implements OperationQueue { + private readonly getJs: () => Promise>; + private readonly whisperSubject: string; + private readonly layoutSubject: string; + private readonly onEnqueued?: (job: QueuedOperation) => Promise | void; + private readonly whisperCodec = createJsonCodec>(); + private readonly layoutCodec = createJsonCodec>(); + + constructor(deps: JetStreamOperationQueueDeps) { + this.getJs = deps.getJs; + this.whisperSubject = deps.whisperSubject; + this.layoutSubject = deps.layoutSubject; + this.onEnqueued = deps.onEnqueued; + } + + async enqueue(job: QueuedOperation): Promise { + const js = await this.getJs(); + if (job.kind === 'whisper_align') { + await js.publish( + this.whisperSubject, + this.whisperCodec.encode(job as QueuedOperation), + ); + } else if (job.kind === 'pdf_layout') { + await js.publish( + this.layoutSubject, + this.layoutCodec.encode(job as QueuedOperation), + ); + } else { + const exhaustive: never = job.kind; + throw new Error(`Unsupported operation kind: ${String(exhaustive)}`); + } + + await this.onEnqueued?.(job); + } + + async claimNext(_kind: WorkerOperationKind): Promise | null> { + throw new Error('JetStreamOperationQueue.claimNext is not used by the worker runtime'); + } + + size(): number { + return 0; + } +} diff --git a/compute/worker/src/control-plane/json-codec.ts b/compute/worker/src/control-plane/json-codec.ts new file mode 100644 index 0000000..27d3ea0 --- /dev/null +++ b/compute/worker/src/control-plane/json-codec.ts @@ -0,0 +1,17 @@ +export type JsonCodec = { + encode(value: T): Uint8Array; + decode(data: Uint8Array): T; +}; + +export function createJsonCodec(): JsonCodec { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + return { + encode(value: T): Uint8Array { + return encoder.encode(JSON.stringify(value)); + }, + decode(data: Uint8Array): T { + return JSON.parse(decoder.decode(data)) as T; + }, + }; +} diff --git a/compute/worker/src/server.ts b/compute/worker/src/server.ts new file mode 100644 index 0000000..2b9a04b --- /dev/null +++ b/compute/worker/src/server.ts @@ -0,0 +1,1322 @@ +import Fastify, { type FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { + connect, + nanos, + credsAuthenticator, + type NatsConnection, +} from '@nats-io/transport-node'; +import { + AckPolicy, + DeliverPolicy, + ReplayPolicy, + RetentionPolicy, + StorageType, + jetstream, + jetstreamManager, + type Consumer, + type JetStreamClient, + type JetStreamManager, + type JsMsg, +} from '@nats-io/jetstream'; +import { Kvm } from '@nats-io/kv'; +import { + ensureComputeModels, + runPdfLayoutFromPdfBuffer, + runWhisperAlignmentFromAudioBuffer, +} from '@openreader/compute-core/local-runtime'; +import { + getComputeTimeoutConfig, + getComputeOpStaleMs, + getAvailableCpuCores, + getOnnxThreadsPerJob, + withIdleTimeoutAndHardCap, + withTimeout, +} from '@openreader/compute-core'; +import { encodeSseFrame, OperationOrchestrator } from '@openreader/compute-core/control-plane'; +import type { + PdfLayoutJobRequest, + PdfLayoutJobResult, + WorkerOperationEvent, + WhisperAlignJobRequest, + WhisperAlignJobResult, + WorkerJobState, + WorkerJobTiming, + WorkerOperationKind, + WorkerOperationRequest, + WorkerOperationState, + PdfLayoutProgress, +} from '@openreader/compute-core/api-contracts'; +import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { + JetStreamOperationEventStream, + OP_EVENTS_SUBJECT_WILDCARD, + JetStreamOperationQueue, + JetStreamOperationStateStore, + hashOpKey, +} from './control-plane/jetstream'; +import { type JsonCodec, createJsonCodec } from './control-plane/json-codec'; + +const JOBS_STREAM_NAME = 'compute_jobs'; +const WHISPER_JOBS_SUBJECT = 'jobs.whisper'; +const LAYOUT_JOBS_SUBJECT = 'jobs.layout'; +const WHISPER_CONSUMER_NAME = 'compute_whisper'; +const LAYOUT_CONSUMER_NAME = 'compute_layout'; +const EVENTS_STREAM_NAME = 'compute_events'; +const COMPUTE_STATE_BUCKET = 'compute_state'; +const COMPUTE_STATE_TTL_MS = 24 * 60 * 60 * 1000; +const LOOP_ERROR_BACKOFF_MS = 500; +const RUNNING_HEARTBEAT_MS = 5000; +const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i; +const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; +const WHISPER_MAX_DELIVER = 1; +const NATS_API_TIMEOUT_MS = 60_000; +// Disconnect from NATS after this much continuous idle so the worker stops +// generating outbound traffic (pull polling + keepalive PINGs) and Railway can +// put it to sleep. Reconnect happens lazily on the next inbound request. +const IDLE_DISCONNECT_MS = 120_000; +const IDLE_CHECK_INTERVAL_MS = 5_000; +// Bounded pull window so consumer loops yield periodically and can be stopped +// cleanly when going idle, instead of blocking on a long-lived pull. +const PULL_EXPIRES_MS = 5_000; +const REQUEST_STARTED_AT_MS_KEY = Symbol('request-started-at-ms'); +const REQUEST_COUNTED_KEY = Symbol('request-activity-counted'); +const SLOW_JOB_LOG_THRESHOLD_MS_BY_KIND: Record = { + whisper_align: 15_000, + pdf_layout: 120_000, +}; + +interface QueuedJob { + jobId: string; + opId: string; + opKey: string; + kind: WorkerOperationKind; + queuedAt: number; + payload: TPayload; +} + +interface NatsSession { + nc: NatsConnection; + js: JetStreamClient; + jsm: JetStreamManager; + kv: Awaited>; + whisperConsumer: Consumer; + layoutConsumer: Consumer; +} + +type StreamedOperationState = WorkerOperationState; + +function requireEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} + +function readIntEnv(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.floor(parsed); +} + +function normalizeNatsReplicas(value: number): number { + if (value === 3 || value === 5) return value; + return 1; +} + +function parseBoolEnv(name: string, fallback: boolean): boolean { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const normalized = raw.toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + +function buildLoggerConfig(): boolean | Record { + const format = (process.env.LOG_FORMAT?.trim().toLowerCase() || 'pretty'); + const level = process.env.COMPUTE_LOG_LEVEL?.trim() || 'info'; + if (format === 'json') { + return { + level, + base: null, + }; + } + return { + level, + base: null, + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + }, + }; +} + +function normalizeS3Prefix(prefix: string | undefined): string { + const value = (prefix || 'openreader').trim(); + return value ? value.replace(/^\/+|\/+$/g, '') : 'openreader'; +} + +function sanitizeNamespace(namespace: string | null): string | null { + if (!namespace) return null; + return SAFE_NAMESPACE_REGEX.test(namespace) ? namespace : null; +} + +function documentParsedKey(id: string, namespace: string | null, prefix: string): string { + if (!DOCUMENT_ID_REGEX.test(id)) { + throw new Error(`Invalid document id: ${id}`); + } + const ns = sanitizeNamespace(namespace); + const nsSegment = ns ? `ns/${ns}/` : ''; + return `${prefix}/documents_v1/parsed_v1/${nsSegment}${id}.json`; +} + +function buildS3Client(): S3Client { + const bucket = requireEnv('S3_BUCKET'); + const region = requireEnv('S3_REGION'); + const accessKeyId = requireEnv('S3_ACCESS_KEY_ID'); + const secretAccessKey = requireEnv('S3_SECRET_ACCESS_KEY'); + const endpoint = process.env.S3_ENDPOINT?.trim() || undefined; + const forcePathStyle = parseBoolEnv('S3_FORCE_PATH_STYLE', false); + + void bucket; + + return new S3Client({ + region, + endpoint, + forcePathStyle, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', + credentials: { + accessKeyId, + secretAccessKey, + }, + }); +} + +async function bodyToBuffer(body: unknown): Promise { + if (!body) return Buffer.alloc(0); + if (body instanceof Uint8Array) return Buffer.from(body); + if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength); + if (body instanceof ArrayBuffer) return Buffer.from(body); + if (typeof body === 'object' && body !== null && 'transformToByteArray' in body) { + const maybe = body as { transformToByteArray?: () => Promise }; + if (typeof maybe.transformToByteArray === 'function') { + return Buffer.from(await maybe.transformToByteArray()); + } + } + if (typeof body === 'object' && body !== null && 'on' in body) { + const stream = body as NodeJS.ReadableStream; + const chunks: Buffer[] = []; + for await (const chunk of stream) { + if (Buffer.isBuffer(chunk)) chunks.push(chunk); + else if (typeof chunk === 'string') chunks.push(Buffer.from(chunk)); + else chunks.push(Buffer.from(chunk as Uint8Array)); + } + return Buffer.concat(chunks); + } + throw new Error('Unsupported S3 response body type'); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +function isAuthed(request: FastifyRequest, expectedToken: string): boolean { + const auth = request.headers.authorization; + if (!auth?.startsWith('Bearer ')) return false; + const token = auth.slice('Bearer '.length).trim(); + return token === expectedToken; +} + +function safeDurationMs(start: number | undefined, end: number | undefined): number | undefined { + if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined; + return Math.max(0, Math.floor((end as number) - (start as number))); +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return String(error); +} + +function requestPath(request: FastifyRequest): string { + return request.url.split('?')[0] ?? request.url; +} + +function isHealthPath(path: string): boolean { + return path === '/health/live' || path === '/health/ready'; +} + +function extractTraceId(request: FastifyRequest): string | null { + const header = request.headers['x-openreader-trace-id']; + if (Array.isArray(header)) return header[0] ?? null; + return typeof header === 'string' ? header : null; +} + +function extractOpId(request: FastifyRequest, path: string): string | null { + const params = request.params as { opId?: unknown } | undefined; + if (params && typeof params.opId === 'string' && params.opId.trim()) { + return params.opId.trim(); + } + const match = path.match(/^\/ops\/([^/]+)/); + if (!match?.[1]) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return match[1]; + } +} + +function isAlreadyExistsError(error: unknown): boolean { + const message = toErrorMessage(error).toLowerCase(); + return message.includes('already in use') || message.includes('already exists'); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +class ConcurrencyGate { + private readonly maxInFlight: number; + private inFlight = 0; + private readonly queue: Array<() => void> = []; + + constructor(limit: number) { + this.maxInFlight = Number.isFinite(limit) && limit >= 1 ? Math.floor(limit) : 1; + } + + async acquire(): Promise { + if (this.inFlight < this.maxInFlight) { + this.inFlight += 1; + return; + } + + await new Promise((resolve) => { + this.queue.push(() => { + this.inFlight += 1; + resolve(); + }); + }); + } + + release(): void { + this.inFlight = Math.max(0, this.inFlight - 1); + const next = this.queue.shift(); + if (next) next(); + } +} + +function isTerminalStatus(status: WorkerJobState): boolean { + return status === 'succeeded' || status === 'failed'; +} + +function extractResultRef(kind: WorkerOperationKind, result: unknown): string | undefined { + if (kind !== 'pdf_layout' || !result || typeof result !== 'object') return undefined; + const maybe = result as { parsedObjectKey?: unknown }; + return typeof maybe.parsedObjectKey === 'string' ? maybe.parsedObjectKey : undefined; +} + +const alignSchema = z.object({ + text: z.string().trim().min(1), + lang: z.string().trim().min(1).max(16).optional(), + cacheKey: z.string().trim().min(1).max(256).optional(), + audioObjectKey: z.string().trim().min(1).max(2048), +}); + +const layoutSchema = z.object({ + documentId: z.string().trim().min(1), + namespace: z.string().trim().min(1).max(128).nullable(), + documentObjectKey: z.string().trim().min(1).max(2048), +}); + +const operationCreateSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('whisper_align'), + opKey: z.string().trim().min(1).max(1024), + payload: alignSchema, + }), + z.object({ + kind: z.literal('pdf_layout'), + opKey: z.string().trim().min(1).max(1024), + payload: layoutSchema, + }), +]); + +async function ensureJetStreamResources( + jsm: JetStreamManager, + whisperTimeoutMs: number, + pdfTimeoutMs: number, + pdfAttempts: number, + jobsMaxBytes: number, + eventsMaxBytes: number, + natsReplicas: number, +): Promise { + const streamConfig = { + name: JOBS_STREAM_NAME, + subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT], + retention: RetentionPolicy.Workqueue, + storage: StorageType.File, + max_bytes: jobsMaxBytes, + num_replicas: natsReplicas, + }; + + try { + await jsm.streams.add(streamConfig); + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + await jsm.streams.update(JOBS_STREAM_NAME, { + subjects: [WHISPER_JOBS_SUBJECT, LAYOUT_JOBS_SUBJECT], + max_bytes: jobsMaxBytes, + num_replicas: natsReplicas, + }); + } + + const eventsStreamConfig = { + name: EVENTS_STREAM_NAME, + subjects: [OP_EVENTS_SUBJECT_WILDCARD], + retention: RetentionPolicy.Limits, + storage: StorageType.File, + max_bytes: eventsMaxBytes, + max_age: nanos(COMPUTE_STATE_TTL_MS), + num_replicas: natsReplicas, + }; + + try { + await jsm.streams.add(eventsStreamConfig); + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + await jsm.streams.update(EVENTS_STREAM_NAME, { + subjects: [OP_EVENTS_SUBJECT_WILDCARD], + max_bytes: eventsMaxBytes, + max_age: nanos(COMPUTE_STATE_TTL_MS), + num_replicas: natsReplicas, + }); + } + + const ensureConsumer = async ( + name: string, + subject: string, + ackWaitMs: number, + maxDeliver: number, + ): Promise => { + const config = { + durable_name: name, + ack_policy: AckPolicy.Explicit, + deliver_policy: DeliverPolicy.All, + replay_policy: ReplayPolicy.Instant, + filter_subject: subject, + ack_wait: nanos(Math.max(ackWaitMs, 1_000)), + max_deliver: maxDeliver, + }; + + try { + await jsm.consumers.add(JOBS_STREAM_NAME, config); + } catch (error) { + if (!isAlreadyExistsError(error)) throw error; + await jsm.consumers.update(JOBS_STREAM_NAME, name, { + filter_subject: subject, + ack_wait: nanos(Math.max(ackWaitMs, 1_000)), + max_deliver: maxDeliver, + }); + } + }; + + await Promise.all([ + ensureConsumer(WHISPER_CONSUMER_NAME, WHISPER_JOBS_SUBJECT, whisperTimeoutMs + 15_000, WHISPER_MAX_DELIVER), + ensureConsumer(LAYOUT_CONSUMER_NAME, LAYOUT_JOBS_SUBJECT, pdfTimeoutMs + 15_000, pdfAttempts), + ]); +} + +async function main(): Promise { + const port = readIntEnv('PORT', 8081); + const host = process.env.COMPUTE_WORKER_HOST?.trim() || '0.0.0.0'; + const workerToken = requireEnv('COMPUTE_WORKER_TOKEN'); + const natsUrl = requireEnv('NATS_URL'); + const timeoutConfig = getComputeTimeoutConfig(); + + const jobConcurrency = readIntEnv('COMPUTE_JOB_CONCURRENCY', 1); + const whisperTimeoutMs = timeoutConfig.whisperTimeoutMs; + const pdfTimeoutMs = timeoutConfig.pdfTimeoutMs; + const pdfHardCapMs = timeoutConfig.pdfHardCapMs; + const pdfAttempts = readIntEnv('COMPUTE_PDF_JOB_ATTEMPTS', 1); + const prewarmModels = parseBoolEnv('COMPUTE_PREWARM_MODELS', true); + const jobsStreamMaxBytes = readIntEnv('COMPUTE_JOBS_STREAM_MAX_BYTES', 256 * 1024 * 1024); + const eventsStreamMaxBytes = readIntEnv('COMPUTE_EVENTS_STREAM_MAX_BYTES', 128 * 1024 * 1024); + const jobStatesMaxBytes = readIntEnv('COMPUTE_JOB_STATES_MAX_BYTES', 64 * 1024 * 1024); + const natsReplicas = normalizeNatsReplicas(readIntEnv('COMPUTE_NATS_REPLICAS', 1)); + const opStaleMs = getComputeOpStaleMs(); + + const connectOpts: Parameters[0] = { servers: natsUrl }; + const natsCreds = process.env.NATS_CREDS?.trim(); + const natsCredsFile = process.env.NATS_CREDS_FILE?.trim(); + + if (natsCreds) { + console.log('[compute-worker] Connecting to NATS using credentials string from NATS_CREDS'); + connectOpts.authenticator = credsAuthenticator(new TextEncoder().encode(natsCreds)); + } else if (natsCredsFile) { + console.log(`[compute-worker] Connecting to NATS using credentials file: ${natsCredsFile}`); + const { readFileSync } = await import('node:fs'); + const credsData = readFileSync(natsCredsFile); + connectOpts.authenticator = credsAuthenticator(credsData); + } + + // Lazy NATS connection lifecycle. The worker connects on demand (first request + // that needs the queue/KV) and disconnects after IDLE_DISCONNECT_MS of full idle + // so it stops emitting outbound traffic and Railway can sleep it. Reconnect is + // transparent: any inbound /ops request both wakes the container and re-establishes + // the session via ensureConnected(). + let session: NatsSession | null = null; + let connecting: Promise | null = null; + let workerLoops: Promise[] = []; + let idleTimer: NodeJS.Timeout | null = null; + let stopping = false; + let loopStopRequested = false; + + // Activity accounting feeding the idle detector. The worker is considered idle + // only when no HTTP request is in flight, no SSE stream is open, no job is + // processing, and nothing has happened for IDLE_DISCONNECT_MS. + let inFlightHttp = 0; + let activeSse = 0; + let inFlightJobs = 0; + let lastActivityAt = Date.now(); + const jobGate = new ConcurrencyGate(jobConcurrency); + + const markActivity = (): void => { + lastActivityAt = Date.now(); + }; + + function startIdleTimer(): void { + if (idleTimer) return; + idleTimer = setInterval(() => { + if (!session || stopping) return; + if (inFlightHttp > 0 || activeSse > 0 || inFlightJobs > 0) return; + if (Date.now() - lastActivityAt < IDLE_DISCONNECT_MS) return; + void disconnect('idle'); + }, IDLE_CHECK_INTERVAL_MS); + // Don't let the idle checker keep the process alive on its own. + idleTimer.unref?.(); + } + + async function disconnect(reason: string): Promise { + const current = session; + if (!current) return; + // Clear synchronously (before any await) so concurrent requests reconnect a + // fresh session instead of using the connection we're about to close. + session = null; + loopStopRequested = true; + if (idleTimer) { + clearInterval(idleTimer); + idleTimer = null; + } + try { + await current.nc.close(); + } catch { + // ignore close errors + } + await Promise.allSettled(workerLoops); + workerLoops = []; + loopStopRequested = false; + app.log.info({ reason }, 'nats disconnected'); + } + + async function ensureConnected(): Promise { + if (session) return session; + if (connecting) return connecting; + connecting = (async () => { + const nc: NatsConnection = await connect(connectOpts); + const js: JetStreamClient = jetstream(nc, { timeout: NATS_API_TIMEOUT_MS }); + const jsm: JetStreamManager = await jetstreamManager(nc, { timeout: NATS_API_TIMEOUT_MS }); + await ensureJetStreamResources( + jsm, + whisperTimeoutMs, + pdfTimeoutMs, + pdfAttempts, + jobsStreamMaxBytes, + eventsStreamMaxBytes, + natsReplicas, + ); + const kv = await new Kvm(js).create(COMPUTE_STATE_BUCKET, { + replicas: natsReplicas, + history: 1, + ttl: COMPUTE_STATE_TTL_MS, + max_bytes: jobStatesMaxBytes, + }); + const whisperConsumer = await js.consumers.get(JOBS_STREAM_NAME, WHISPER_CONSUMER_NAME); + const layoutConsumer = await js.consumers.get(JOBS_STREAM_NAME, LAYOUT_CONSUMER_NAME); + const next: NatsSession = { nc, js, jsm, kv, whisperConsumer, layoutConsumer }; + session = next; + markActivity(); + startWorkerLoops(next); + startIdleTimer(); + // Safety net: if the connection closes for any reason (network drop after + // exhausting reconnects, or our own disconnect), drop the stale session so + // the next request reconnects cleanly. + void nc.closed().then(() => { + if (session?.nc === nc) session = null; + }); + app.log.info('nats connected'); + return next; + })(); + try { + return await connecting; + } finally { + connecting = null; + } + } + + const s3 = buildS3Client(); + const s3Bucket = requireEnv('S3_BUCKET'); + const s3Prefix = normalizeS3Prefix(process.env.S3_PREFIX); + + const ensureSafeKey = (key: string): string => { + const trimmed = key.trim(); + if (!trimmed.startsWith(`${s3Prefix}/`)) { + throw new Error('Object key prefix mismatch'); + } + return trimmed; + }; + + const readObjectByKey = async (key: string): Promise => { + const safeKey = ensureSafeKey(key); + const response = await s3.send(new GetObjectCommand({ + Bucket: s3Bucket, + Key: safeKey, + })); + const bytes = await bodyToBuffer(response.Body); + return toArrayBuffer(new Uint8Array(bytes)); + }; + + const putParsedObject = async (documentId: string, namespace: string | null, parsed: unknown): Promise => { + const key = documentParsedKey(documentId, namespace, s3Prefix); + const body = Buffer.from(JSON.stringify(parsed)); + await s3.send(new PutObjectCommand({ + Bucket: s3Bucket, + Key: key, + Body: body, + ContentType: 'application/json', + ServerSideEncryption: 'AES256', + })); + return key; + }; + + if (prewarmModels) { + await ensureComputeModels(); + } + + const app = Fastify({ + logger: buildLoggerConfig(), + disableRequestLogging: true, + }); + app.log.info({ + jobConcurrency, + whisperTimeoutMs, + pdfTimeoutMs, + pdfAttempts, + opStaleMs, + availableCpuCores: getAvailableCpuCores(), + onnxThreadsPerJob: getOnnxThreadsPerJob(), + natsApiTimeoutMs: NATS_API_TIMEOUT_MS, + natsReplicas, + eventsStreamMaxBytes, + pdfLayoutHardCapMs: pdfHardCapMs, + }, 'compute runtime config'); + + const whisperJobCodec = createJsonCodec>(); + const layoutJobCodec = createJsonCodec>(); + + const operationStateStore = new JetStreamOperationStateStore({ + getKv: async () => (await ensureConnected()).kv, + }); + + const operationEventStream = new JetStreamOperationEventStream({ + getJs: async () => (await ensureConnected()).js, + getJsm: async () => (await ensureConnected()).jsm, + eventsStreamName: EVENTS_STREAM_NAME, + }); + + const operationQueue = new JetStreamOperationQueue({ + getJs: async () => (await ensureConnected()).js, + whisperSubject: WHISPER_JOBS_SUBJECT, + layoutSubject: LAYOUT_JOBS_SUBJECT, + }); + + const orchestrator = new OperationOrchestrator({ + queue: operationQueue, + stateStore: operationStateStore, + eventStream: operationEventStream, + config: { + opStaleMs, + maxCasRetries: 10, + }, + }); + + const getOpState = async (opId: string): Promise => { + return await operationStateStore.getOpState(opId); + }; + + const releaseHttp = (request: FastifyRequest): void => { + const counted = request as FastifyRequest & { [REQUEST_COUNTED_KEY]?: boolean }; + if (!counted[REQUEST_COUNTED_KEY]) return; + counted[REQUEST_COUNTED_KEY] = false; + inFlightHttp = Math.max(0, inFlightHttp - 1); + markActivity(); + }; + + app.addHook('onRequest', async (request, reply) => { + const path = requestPath(request); + (request as FastifyRequest & { [REQUEST_STARTED_AT_MS_KEY]?: number })[REQUEST_STARTED_AT_MS_KEY] = Date.now(); + // Count every request as in-flight activity so the idle detector never + // disconnects mid-request. Released in onResponse, or manually after hijack + // for SSE streams (where onResponse does not fire). + (request as FastifyRequest & { [REQUEST_COUNTED_KEY]?: boolean })[REQUEST_COUNTED_KEY] = true; + inFlightHttp += 1; + markActivity(); + if (isHealthPath(path)) return; + if (!isAuthed(request, workerToken)) { + return reply.code(401).send({ error: 'Unauthorized' }); + } + return; + }); + + app.addHook('onResponse', async (request, reply) => { + releaseHttp(request); + const path = requestPath(request); + if (isHealthPath(path)) return; + if (reply.statusCode >= 500) { + const startedAt = (request as FastifyRequest & { [REQUEST_STARTED_AT_MS_KEY]?: number })[REQUEST_STARTED_AT_MS_KEY]; + const durationMs = Number.isFinite(startedAt) ? Math.max(0, Date.now() - (startedAt as number)) : -1; + app.log.error({ + reqId: request.id, + method: request.method, + path, + statusCode: reply.statusCode, + durationMs, + traceId: extractTraceId(request) ?? null, + opId: extractOpId(request, path), + }, 'http.error'); + } + }); + + app.get('/health/live', async () => ({ ok: true })); + + // Reports readiness without forcing a NATS round-trip. Probing NATS here would + // reconnect (and keep) the connection open, defeating idle sleep, so we only + // report the current connection state. The worker reconnects lazily on the next + // /ops request regardless of what this returns. + app.get('/health/ready', async () => ({ ok: true, natsConnected: session !== null })); + + app.post('/ops', async (request, reply) => { + const parsed = operationCreateSchema.safeParse(request.body); + if (!parsed.success) { + reply.code(400); + return { + error: 'Invalid request body', + issues: parsed.error.issues, + }; + } + + const requestOp = parsed.data as WorkerOperationRequest; + const op = await orchestrator.enqueueOrReuse(requestOp); + app.log.info({ + kind: requestOp.kind, + opId: op.opId, + jobId: op.jobId, + status: op.status, + opKeyHash: hashOpKey(requestOp.opKey.trim()).slice(0, 16), + }, 'op.accepted'); + reply.code(202); + return op; + }); + + app.get('/ops/:opId', async (request, reply) => { + const params = z.object({ opId: z.string().trim().min(1) }).safeParse(request.params); + if (!params.success) { + reply.code(400); + return { error: 'Invalid op id' }; + } + + const state = await getOpState(params.data.opId); + if (!state) { + reply.code(404); + return { error: 'Operation not found' }; + } + + return state; + }); + + app.get('/ops/:opId/events', async (request, reply) => { + const params = z.object({ opId: z.string().trim().min(1) }).safeParse(request.params); + if (!params.success) { + reply.code(400); + return { error: 'Invalid op id' }; + } + + const initial = await getOpState(params.data.opId); + if (!initial) { + reply.code(404); + return { error: 'Operation not found' }; + } + + const cursorQueryRaw = request.query as { sinceEventId?: string | number | null } | undefined; + const cursorFromQuery = Number(cursorQueryRaw?.sinceEventId ?? 0); + const lastEventIdHeader = request.headers['last-event-id']; + const cursorFromHeader = Number( + Array.isArray(lastEventIdHeader) ? (lastEventIdHeader[0] ?? 0) : (lastEventIdHeader ?? 0), + ); + const sinceEventId = Math.max( + 0, + Number.isFinite(cursorFromQuery) ? Math.floor(cursorFromQuery) : 0, + Number.isFinite(cursorFromHeader) ? Math.floor(cursorFromHeader) : 0, + ); + + reply.hijack(); + // onResponse will not fire for a hijacked reply, so release the HTTP in-flight + // count here and track the long-lived stream via activeSse instead. + releaseHttp(request); + activeSse += 1; + markActivity(); + reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); + reply.raw.setHeader('Connection', 'keep-alive'); + reply.raw.setHeader('X-Accel-Buffering', 'no'); + + let closed = false; + let unsubscribe: (() => void) | null = null; + + const writeSnapshot = (snapshot: StreamedOperationState, eventId: number): void => { + if (closed || reply.raw.writableEnded) return; + const frameEvent: WorkerOperationEvent = { + eventId, + snapshot, + }; + reply.raw.write(encodeSseFrame({ + id: eventId, + event: 'snapshot', + data: frameEvent, + })); + }; + + const closeStream = (): void => { + if (closed) return; + closed = true; + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + activeSse = Math.max(0, activeSse - 1); + markActivity(); + if (!reply.raw.writableEnded) { + reply.raw.end(); + } + }; + + request.raw.on('close', () => { + closeStream(); + }); + + try { + let current = initial; + let signature = JSON.stringify(current); + writeSnapshot(current, sinceEventId > 0 ? sinceEventId : 0); + if (isTerminalStatus(current.status)) { + return reply; + } + + unsubscribe = await operationEventStream.subscribe({ + opId: params.data.opId, + sinceEventId, + onEvent: (event) => { + if (closed) return; + if (event.snapshot.opId !== params.data.opId) return; + const nextSignature = JSON.stringify(event.snapshot); + if (nextSignature !== signature) { + current = event.snapshot; + signature = nextSignature; + writeSnapshot(current, event.eventId); + } + if (isTerminalStatus(event.snapshot.status)) { + closeStream(); + } + }, + onError: (error) => { + app.log.warn({ + opId: params.data.opId, + error: toErrorMessage(error), + }, 'op events stream loop error'); + closeStream(); + }, + }); + + await new Promise((resolve) => { + request.raw.once('close', () => resolve()); + }); + } catch (error) { + app.log.warn({ + opId: params.data.opId, + error: toErrorMessage(error), + }, 'op events stream loop error'); + } finally { + closeStream(); + } + + return reply; + }); + + const runWhisper = async ( + payload: WhisperAlignJobRequest, + queueWaitMs: number, + ): Promise => { + const parsed = alignSchema.parse(payload); + + const s3FetchStartedAt = Date.now(); + const audioBuffer = await readObjectByKey(parsed.audioObjectKey); + const s3FetchMs = Date.now() - s3FetchStartedAt; + + const computeStartedAt = Date.now(); + const result = await withTimeout( + runWhisperAlignmentFromAudioBuffer({ + audioBuffer, + text: parsed.text, + cacheKey: parsed.cacheKey, + lang: parsed.lang, + }), + whisperTimeoutMs, + 'whisper alignment job', + ); + + const computeMs = Date.now() - computeStartedAt; + return { + ...result, + timing: { + queueWaitMs, + s3FetchMs, + computeMs, + }, + }; + }; + + const runLayout = async ( + payload: PdfLayoutJobRequest, + queueWaitMs: number, + hooks?: { onProgress?: (progress: PdfLayoutProgress) => Promise }, + ): Promise => { + const parsed = layoutSchema.parse(payload); + + const s3FetchStartedAt = Date.now(); + const pdfBytes = await readObjectByKey(parsed.documentObjectKey); + const s3FetchMs = Date.now() - s3FetchStartedAt; + + let lastTotalPages = 0; + let lastPagesParsed = 0; + const computeStartedAt = Date.now(); + const result = await withIdleTimeoutAndHardCap({ + idleTimeoutMs: Math.max(pdfTimeoutMs, 1_000), + hardCapMs: pdfHardCapMs, + label: 'pdf layout job', + run: async (touchProgress) => runPdfLayoutFromPdfBuffer({ + documentId: parsed.documentId, + pdfBytes, + onPageParsed: async ({ pageNumber, totalPages }) => { + touchProgress(); + lastTotalPages = totalPages; + lastPagesParsed = pageNumber; + if (!hooks?.onProgress) return; + await hooks.onProgress({ + totalPages, + pagesParsed: pageNumber, + currentPage: pageNumber, + phase: 'infer', + }); + }, + }), + }); + + const computeMs = Date.now() - computeStartedAt; + if (hooks?.onProgress && lastTotalPages > 0) { + await hooks.onProgress({ + totalPages: lastTotalPages, + pagesParsed: lastPagesParsed, + currentPage: lastPagesParsed || undefined, + phase: 'merge', + }); + } + const parsedObjectKey = await putParsedObject(parsed.documentId, parsed.namespace, result.parsed); + return { + parsedObjectKey, + timing: { + queueWaitMs, + s3FetchMs, + computeMs, + }, + }; + }; + + type ProcessMessageInput = { + msg: JsMsg; + codec: JsonCodec>; + run: ( + payload: TPayload, + queueWaitMs: number, + hooks?: { onProgress?: (progress: PdfLayoutProgress) => Promise }, + ) => Promise; + workerLabel: string; + }; + + type ProcessMessageContext = { + decoded: QueuedJob; + workerLabel: string; + startedAt: number; + queueWaitTiming?: { queueWaitMs: number }; + latestProgress?: PdfLayoutProgress; + }; + + function buildQueueWaitTiming(queuedAt: number, now: number): { queueWaitMs: number } | undefined { + const queueWaitMs = safeDurationMs(queuedAt, now); + return typeof queueWaitMs === 'number' ? { queueWaitMs } : undefined; + } + + function extractTiming(result: unknown): WorkerJobTiming | undefined { + if (!result || typeof result !== 'object' || !('timing' in result)) return undefined; + return (result as { timing?: WorkerJobTiming }).timing; + } + + async function markRunning( + context: ProcessMessageContext, + updatedAt: number, + options?: { includeStartedAt?: boolean }, + ): Promise { + if (context.latestProgress) { + await orchestrator.markProgress({ + opId: context.decoded.opId, + progress: context.latestProgress, + updatedAt, + ...(context.queueWaitTiming ? { timing: context.queueWaitTiming } : {}), + }); + return; + } + + await orchestrator.markRunning({ + opId: context.decoded.opId, + ...(options?.includeStartedAt === false ? {} : { startedAt: context.startedAt }), + updatedAt, + ...(context.queueWaitTiming ? { timing: context.queueWaitTiming } : {}), + }); + } + + async function markProgress( + context: ProcessMessageContext, + progress: PdfLayoutProgress, + updatedAt: number, + ): Promise { + context.latestProgress = progress; + await markRunning(context, updatedAt); + } + + async function markTerminal(input: { + context: ProcessMessageContext; + status: 'succeeded' | 'failed'; + result?: TResult; + errorMessage?: string; + timing?: WorkerJobTiming; + updatedAt: number; + }): Promise { + if (input.status === 'succeeded') { + await orchestrator.markSucceeded({ + opId: input.context.decoded.opId, + result: input.result as WhisperAlignJobResult | PdfLayoutJobResult, + updatedAt: input.updatedAt, + ...(input.timing ? { timing: input.timing } : {}), + }); + return; + } + + await orchestrator.markFailed({ + opId: input.context.decoded.opId, + error: { message: input.errorMessage ?? 'unknown worker failure' }, + updatedAt: input.updatedAt, + ...(input.timing ? { timing: input.timing } : {}), + }); + } + + async function handleRetry(input: { + context: ProcessMessageContext | null; + msg: JsMsg; + errorMessage: string; + }): Promise { + const deliveryCount = input.msg.info.deliveryCount; + const isWhisperAlign = input.context?.decoded.kind === 'whisper_align'; + const maxAttempts = isWhisperAlign ? WHISPER_MAX_DELIVER : pdfAttempts; + const hasRetriesLeft = !isWhisperAlign && deliveryCount < maxAttempts; + + if (input.context) { + const now = Date.now(); + const retryTiming = buildQueueWaitTiming(input.context.decoded.queuedAt, now); + const persistOpUpdate = hasRetriesLeft + ? (input.context.latestProgress + ? orchestrator.markProgress({ + opId: input.context.decoded.opId, + progress: input.context.latestProgress, + updatedAt: now, + ...(retryTiming ? { timing: retryTiming } : {}), + }) + : orchestrator.markRunning({ + opId: input.context.decoded.opId, + updatedAt: now, + ...(retryTiming ? { timing: retryTiming } : {}), + })) + : markTerminal({ + context: input.context, + status: 'failed', + errorMessage: input.errorMessage, + updatedAt: now, + ...(retryTiming ? { timing: retryTiming } : {}), + }); + + await persistOpUpdate.catch((stateError) => { + app.log.error({ + worker: input.context?.workerLabel, + opId: input.context?.decoded.opId, + jobId: input.context?.decoded.jobId, + error: toErrorMessage(stateError), + }, 'failed to persist operation state'); + }); + } + + if (hasRetriesLeft) { + input.msg.nak(); + app.log.error({ + worker: input.context?.workerLabel, + kind: input.context?.decoded.kind, + opId: input.context?.decoded.opId, + jobId: input.context?.decoded.jobId, + status: 'running', + error: input.errorMessage, + deliveryCount, + maxAttempts, + retryAction: 'nack_retry', + }, 'job.terminal'); + return; + } + + input.msg.term(input.errorMessage); + app.log.error({ + worker: input.context?.workerLabel, + kind: input.context?.decoded.kind, + opId: input.context?.decoded.opId, + jobId: input.context?.decoded.jobId, + status: 'failed', + error: input.errorMessage, + deliveryCount, + maxAttempts, + retrySuppressed: isWhisperAlign ? 'whisper_align' : undefined, + retryAction: 'term', + }, 'job.terminal'); + } + + async function processMessage(input: ProcessMessageInput): Promise { + let context: ProcessMessageContext | null = null; + let heartbeat: NodeJS.Timeout | null = null; + try { + const decoded = input.codec.decode(input.msg.data); + const startedAt = Date.now(); + context = { + decoded, + workerLabel: input.workerLabel, + startedAt, + queueWaitTiming: buildQueueWaitTiming(decoded.queuedAt, startedAt), + }; + + await markRunning(context, startedAt); + app.log.info({ + worker: input.workerLabel, + kind: decoded.kind, + opId: decoded.opId, + jobId: decoded.jobId, + queueWaitMs: context.queueWaitTiming?.queueWaitMs ?? null, + deliveryCount: input.msg.info.deliveryCount, + }, 'job.started'); + + heartbeat = setInterval(() => { + const now = Date.now(); + void markRunning(context!, now).catch((stateError) => { + app.log.error({ + worker: input.workerLabel, + opId: context?.decoded.opId, + jobId: context?.decoded.jobId, + error: toErrorMessage(stateError), + }, 'failed to persist operation heartbeat state'); + }); + }, RUNNING_HEARTBEAT_MS); + + const result = await input.run(decoded.payload, context.queueWaitTiming?.queueWaitMs ?? 0, { + onProgress: async (progress) => { + await markProgress(context!, progress, Date.now()); + }, + }); + const resultTiming = extractTiming(result); + const now = Date.now(); + + await markTerminal({ + context, + status: 'succeeded', + result, + timing: resultTiming, + updatedAt: now, + }); + + input.msg.ack(); + const terminalDurationMs = safeDurationMs(context.startedAt, now); + const slowJobLogThresholdMs = SLOW_JOB_LOG_THRESHOLD_MS_BY_KIND[decoded.kind]; + if ((terminalDurationMs ?? 0) >= slowJobLogThresholdMs) { + app.log.info({ + worker: input.workerLabel, + kind: decoded.kind, + opId: decoded.opId, + jobId: decoded.jobId, + durationMs: terminalDurationMs ?? null, + timing: resultTiming ?? null, + }, 'job.stage'); + } + app.log.info({ + worker: input.workerLabel, + kind: decoded.kind, + opId: decoded.opId, + jobId: decoded.jobId, + status: 'succeeded', + durationMs: terminalDurationMs ?? null, + resultRef: extractResultRef(decoded.kind, result), + timing: resultTiming ?? null, + }, 'job.terminal'); + } catch (error) { + await handleRetry({ + context, + msg: input.msg, + errorMessage: toErrorMessage(error), + }); + } finally { + if (heartbeat) clearInterval(heartbeat); + } + } + + async function createWorkerLoop(input: { + owner: NatsSession; + consumer: Consumer; + codec: JsonCodec>; + run: ( + payload: TPayload, + queueWaitMs: number, + hooks?: { onProgress?: (progress: PdfLayoutProgress) => Promise }, + ) => Promise; + workerLabel: string; + }): Promise { + // Exit when the loop's connection is no longer the active session (idle + // disconnect, unexpected close, or replaced by a reconnect). + const detached = (): boolean => stopping || loopStopRequested || session !== input.owner; + while (!detached()) { + let msg: JsMsg | null = null; + try { + try { + // Bounded pull so the loop yields periodically and exits promptly when + // the session is torn down for idle (nc.close() rejects the pending pull). + msg = await input.consumer.next({ expires: PULL_EXPIRES_MS }); + } catch (error) { + if (detached()) return; + app.log.error({ error: toErrorMessage(error), worker: input.workerLabel }, 'worker pull failed'); + await sleep(LOOP_ERROR_BACKOFF_MS); + continue; + } + + // An empty pull is not activity; let the idle window advance. + if (!msg) continue; + markActivity(); + inFlightJobs += 1; + await jobGate.acquire(); + if (detached()) { + return; + } + await processMessage({ + msg, + codec: input.codec, + run: input.run, + workerLabel: input.workerLabel, + }); + } finally { + if (msg) { + jobGate.release(); + inFlightJobs = Math.max(0, inFlightJobs - 1); + markActivity(); + } + } + } + } + + function startWorkerLoops(active: NatsSession): void { + // Always starts a fresh set bound to the new session. Any loops from a prior + // session self-terminate once they observe session !== their owner. + workerLoops = []; + loopStopRequested = false; + for (let i = 0; i < jobConcurrency; i += 1) { + workerLoops.push(createWorkerLoop({ + owner: active, + consumer: active.whisperConsumer, + codec: whisperJobCodec, + run: runWhisper, + workerLabel: `whisper-${i + 1}`, + })); + } + for (let i = 0; i < jobConcurrency; i += 1) { + workerLoops.push(createWorkerLoop({ + owner: active, + consumer: active.layoutConsumer, + codec: layoutJobCodec, + run: runLayout, + workerLabel: `layout-${i + 1}`, + })); + } + } + + const close = async (): Promise => { + if (stopping) return; + stopping = true; + if (idleTimer) { + clearInterval(idleTimer); + idleTimer = null; + } + await app.close(); + await Promise.allSettled(workerLoops); + const current = session; + session = null; + if (current) { + try { + await current.nc.drain(); + } catch { + try { + await current.nc.close(); + } catch { + // ignore close errors + } + } + } + }; + + process.once('SIGINT', () => { + void close().finally(() => process.exit(0)); + }); + + process.once('SIGTERM', () => { + void close().finally(() => process.exit(0)); + }); + + await app.listen({ host, port }); + app.log.info({ host, port }, 'compute worker listening'); +} + +void main().catch((error) => { + console.error('[compute-worker] fatal startup error', error); + process.exit(1); +}); diff --git a/compute/worker/tsconfig.json b/compute/worker/tsconfig.json new file mode 100644 index 0000000..5912d1a --- /dev/null +++ b/compute/worker/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": [] +} diff --git a/docs-site/docs/about/acknowledgements.md b/docs-site/docs/about/acknowledgements.md index 0a6c5ec..b47cccb 100644 --- a/docs-site/docs/about/acknowledgements.md +++ b/docs-site/docs/about/acknowledgements.md @@ -10,7 +10,7 @@ This project is built with support from the following open-source projects and t - [SQLite](https://www.sqlite.org/) - [PostgreSQL](https://www.postgresql.org/) - [SeaweedFS](https://github.com/seaweedfs/seaweedfs) -- [whisper.cpp](https://github.com/ggerganov/whisper.cpp) +- [OpenAI Whisper](https://github.com/openai/whisper) - [ffmpeg](https://ffmpeg.org) - [react-pdf](https://github.com/wojtekmaj/react-pdf) - [react-reader](https://github.com/happyr/react-reader) diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index 533d500..3d36571 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -21,7 +21,7 @@ On every session resolution the server compares the user's email against this li When the logged-in user is an admin, an **Admin** tab appears in **Settings → sidebar** with two sub-tabs: - **Shared providers** — server-side TTS provider instances visible to all users. -- **Site features** — runtime-editable replacements for what were previously `NEXT_PUBLIC_*` build-time flags. +- **Site features** — runtime-editable replacements for what were previously build-time public env flags. ## Shared TTS providers @@ -74,14 +74,15 @@ Runtime-editable settings, one row per key: | `restrictUserApiKeys` | Restrict user-supplied API keys/base URLs; when `true`, only admin shared providers are allowed. | | `enableTtsProvidersTab` | Whether the user-facing TTS Provider tab in Settings is shown. | | `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). | -| `enableWordHighlight` | Enable whisper.cpp word-by-word highlighting during TTS playback. | | `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. | | `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). | | `enableDestructiveDeleteActions` | Show "Delete all data" buttons in the Documents tab (auth-disabled mode). | +Word-by-word highlighting and PDF layout parsing capability are controlled by compute-worker server env configuration, not an admin runtime flag. + Each row shows a source badge: -- **from env** — the value was migrated from the corresponding `NEXT_PUBLIC_*` env var on first boot. Editing it in the UI flips the source to **admin**. +- **from env** — the value was migrated from the corresponding `RUNTIME_SEED_*` env var on first boot. Editing it in the UI flips the source to **admin**. - **admin** — explicit admin override. Use **Reset** on the row to clear it back to the env-default state. - **default** — neither env nor admin set; uses the built-in default. @@ -91,11 +92,11 @@ Turning `restrictUserApiKeys` off allows user-supplied API keys to flow through ## Migrating off env vars -The future-direction goal is to remove `NEXT_PUBLIC_*` / `API_KEY` / `API_BASE` from your `.env` entirely. To do that safely: +The future-direction goal is to remove `RUNTIME_SEED_*` / `API_KEY` / `API_BASE` from your `.env` entirely. To do that safely: 1. Deploy this version with your existing env values in place. 2. Boot the app once. Open Settings → Admin and verify: - - Each `NEXT_PUBLIC_*` setting appears as **from env**. + - Each `RUNTIME_SEED_*` setting appears as **from env**. - A `default-openai` row exists in **Shared providers** (if you had `API_KEY` set). 3. Remove the env vars from your `.env`. 4. Redeploy. Behavior is unchanged — the DB is now the source of truth. diff --git a/docs-site/docs/configure/auth.md b/docs-site/docs/configure/auth.md index 630a29e..34e5053 100644 --- a/docs-site/docs/configure/auth.md +++ b/docs-site/docs/configure/auth.md @@ -31,7 +31,7 @@ ADMIN_EMAILS=alice@example.com,bob@example.com Admins see a new **Admin** tab in **Settings** with two sub-tabs: - **Shared TTS providers** — server-managed TTS provider instances with encrypted keys, visible to all users. -- **Site features** — runtime overrides for what were previously `NEXT_PUBLIC_*` build-time flags (including account signup availability, default TTS provider/model, word highlighting, audiobook export, etc.). +- **Site features** — runtime overrides for what were previously build-time public env flags (including account signup availability, default TTS provider, audiobook export, etc.). Admin assignment is reconciled on every session resolution, so removing an email from `ADMIN_EMAILS` demotes the user on next login without a restart. See [Admin Panel](./admin-panel) for the full reference. diff --git a/docs-site/docs/configure/tts-providers.md b/docs-site/docs/configure/tts-providers.md index 7a03e5d..1fa5961 100644 --- a/docs-site/docs/configure/tts-providers.md +++ b/docs-site/docs/configure/tts-providers.md @@ -11,7 +11,7 @@ OpenReader routes all TTS requests through the Next.js server to an OpenAI-compa **Environment variables**: `API_KEY` and `API_BASE` exist as a one-shot first-boot seed that auto-creates a `default-openai` admin shared provider. After the first boot they are no longer read by the running app. :::tip -If you're running a private/self-hosted instance and want per-user BYOK behavior, turn off **Settings → Admin → Site features → Restrict user API keys**. Legacy first-boot seed via `NEXT_PUBLIC_RESTRICT_USER_API_KEYS=false` is still supported for no-admin bootstrap flows. +If you're running a private/self-hosted instance and want per-user BYOK behavior, turn off **Settings → Admin → Site features → Restrict user API keys**. Legacy first-boot seed via `RUNTIME_SEED_RESTRICT_USER_API_KEYS=false` is still supported for no-admin bootstrap flows. ::: ## Providers diff --git a/docs-site/docs/deploy/compute-worker.md b/docs-site/docs/deploy/compute-worker.md new file mode 100644 index 0000000..4c82f4a --- /dev/null +++ b/docs-site/docs/deploy/compute-worker.md @@ -0,0 +1,221 @@ +title: Compute Worker (NATS JetStream) +--- + +Use this guide when compute-worker runs as a standalone service outside the Next.js app server. +For embedded/local startup (`pnpm dev` / `pnpm start` without `COMPUTE_WORKER_URL`), use root `.env` instead. + +## Overview + +The compute worker handles: + +- Whisper word alignment operations +- PDF layout parsing operations + +The app server submits operations to `POST /ops`, reuses in-flight work via required `opKey`, and consumes status updates via `GET /ops/:opId/events` (SSE). Queue durability and retries are backed by NATS JetStream WorkQueue consumers and NATS KV. + +## Published image + +- App server image: `ghcr.io/richardr1126/openreader` +- Compute worker image: `ghcr.io/richardr1126/openreader-compute-worker` +- Compute worker image (example pinned tag): `ghcr.io/richardr1126/openreader-compute-worker:refactor-ppdoclayoutv3-onnx-layout-parsing` + +## Worker environment variables + +Required: + +- `COMPUTE_WORKER_TOKEN`: bearer token expected by worker routes +- `NATS_URL`: NATS server connection string (JetStream enabled) +- `S3_BUCKET` +- `S3_REGION` +- `S3_ACCESS_KEY_ID` +- `S3_SECRET_ACCESS_KEY` + +> [!IMPORTANT] +> This file (`compute/worker/.env*`) is only for standalone worker deployments. +> In embedded/local startup, app entrypoint spawns worker with the already-resolved root `.env` values. +> In standalone external worker mode: +> - App server env (root `.env` or platform env): `COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`, optional shared timeout/stale overrides. +> - Worker service env (`compute/worker/.env*` or platform env): worker runtime values (`NATS_*`, `S3_*`, model base URLs, worker tuning). +> For standalone worker deployments, keep shared app/worker values aligned: +> - `COMPUTE_WORKER_TOKEN` +> - shared object storage settings (`S3_*`) +> - shared timeout/stale settings (`COMPUTE_WHISPER_TIMEOUT_MS`, `COMPUTE_PDF_TIMEOUT_MS`, `COMPUTE_OP_STALE_MS`) + +Common optional: + +- `NATS_CREDS`: raw user credentials file content (JWT + private key), ideal for cloud container environments where mounting files is difficult. +- `NATS_CREDS_FILE`: path to a `.creds` file on the server. +- `S3_ENDPOINT` (for non-AWS S3-compatible storage) +- `S3_FORCE_PATH_STYLE=true` (for many S3-compatible providers) +- `S3_PREFIX=openreader` +- `COMPUTE_WORKER_HOST=0.0.0.0` +- `PORT=8081` (local/manual; on Railway platform injects this) +- `LOG_FORMAT=pretty` (default) or `json` + +Advanced tuning (usually leave unset unless you need overrides): + +- `COMPUTE_PREWARM_MODELS=true` +- `COMPUTE_JOB_CONCURRENCY=1` (shared total compute jobs across whisper + PDF) +- `COMPUTE_WHISPER_TIMEOUT_MS=30000` +- `COMPUTE_PDF_TIMEOUT_MS=300000` +- `WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main` (optional override, q4 defaults) +- `PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main` (optional override) +- `COMPUTE_PDF_JOB_ATTEMPTS=1` (PDF layout retry attempts) +- `COMPUTE_JOBS_STREAM_MAX_BYTES=268435456` (256MB JetStream jobs stream cap) +- `COMPUTE_JOB_STATES_MAX_BYTES=67108864` (64MB JetStream KV bucket cap) +- `COMPUTE_NATS_REPLICAS=1` (JetStream stream + KV replicas; valid: `1`, `3`, `5`) +- `COMPUTE_OP_STALE_MS=1800000` (stale op replacement window) + +## App server environment variables + +Set on the Next.js app server: + +```env +# Local worker example: +# COMPUTE_WORKER_URL=http://localhost:8081 +# Cloud worker example (Railway): +COMPUTE_WORKER_URL=https:// +COMPUTE_WORKER_TOKEN= +# Optional shared timeout overrides (keep equal to worker service values): +# COMPUTE_WHISPER_TIMEOUT_MS=30000 +# COMPUTE_PDF_TIMEOUT_MS=300000 +# COMPUTE_OP_STALE_MS=1800000 +``` + +Model artifact overrides (`WHISPER_MODEL_BASE_URL`, `PDF_LAYOUT_MODEL_BASE_URL`) are worker runtime variables and should be set on the compute worker service environment. Current Whisper defaults expect q4 artifacts (`encoder_model_q4.onnx`, `decoder_model_merged_q4.onnx`, `decoder_with_past_model_q4.onnx`) under that base URL. + +`COMPUTE_OP_STALE_MS` is shared by both services in worker mode: + +- Worker: opKey stale replacement window in compute op state. +- App server: stale PDF parse-state healing window (`/api/documents/[id]/parsed*`). + +Set the same value on app + worker envs. + +There is no app-local compute fallback. If worker is unavailable, affected requests fail. + +## Config ownership summary + +- Embedded/local startup (`pnpm dev` / `pnpm start`, no `COMPUTE_WORKER_URL`): + - Configure root `.env` only. + - `compute/worker/.env*` is ignored. +- Standalone external worker service: + - Configure app root `.env` with `COMPUTE_WORKER_URL` + `COMPUTE_WORKER_TOKEN`. + - Configure worker service env (`compute/worker/.env*` or platform env). + - Keep shared values aligned (`COMPUTE_WORKER_TOKEN`, `S3_*`, timeout/stale values). + +## Production notes + +- Worker mode assumes shared object storage is reachable by both app server and worker. +- Non-exposed embedded `weed mini` is not supported with external worker mode. +- Protect `COMPUTE_WORKER_TOKEN` and avoid exposing worker routes publicly without auth. + +## Railway sleep & idle behavior + +The worker connects to NATS lazily (on the first request needing the queue/KV) and +disconnects after **120s** of full idle — no in-flight request, SSE stream, job, or +queued work. This stops outbound pull polling and keepalive PINGs so Railway can sleep +it; the next inbound request transparently reconnects, re-ensures the stream/consumers +and KV (idempotent), and drains anything pending. No separate mode, no extra env vars, +and the `/ops*` contract is unchanged. + +Caveats: inbound HTTP is the wake signal (in OpenReader the app server only enqueues via +`POST /ops`, so this is always satisfied); a continuous external `/health/*` probe keeps +it awake and prevents sleep; and the first request after a cold start re-runs model +prewarm, so it's slower. + +## Health endpoints + +- `GET /health/live` — liveness; always returns `{ ok: true }`. +- `GET /health/ready` — returns `{ ok: true, natsConnected }`. It does not probe NATS (that + would reconnect and prevent idle sleep); `natsConnected` just reflects the current session. + +## Synadia Cloud + Railway Setup (Complete Guide) + +Use this end-to-end guide when your queue backend is Synadia Cloud (NGS) and your worker runs on Railway. + +### 1. Create Synadia account and credentials + +1. Create a Synadia Cloud account and create/select your NGS environment. +2. Create a user or service account for OpenReader compute worker access. +3. Download the generated credentials file (usually `.creds`) and keep it secure. + +You will use: + +- `NATS_URL=tls://connect.ngs.global:4222` +- The full `.creds` file content + +### 2. Deploy compute worker on Railway + +Create a Railway service from: + +```text +ghcr.io/richardr1126/openreader-compute-worker:refactor-ppdoclayoutv3-onnx-layout-parsing +``` + +Railway injects a dynamic `PORT` env var and routes traffic there. +Do not hardcode Railway ingress to `8081`; keep service networking enabled and use the public Railway URL. + +### 3. Configure Railway worker environment variables + +Set these in the Railway worker service: + +```env +COMPUTE_WORKER_HOST=0.0.0.0 +# Local/manual only: +# PORT=8081 +# Railway: rely on injected PORT +COMPUTE_WORKER_TOKEN= +# Optional advanced tuning overrides (defaults shown): +# COMPUTE_PREWARM_MODELS=true +# COMPUTE_JOB_CONCURRENCY=1 +# COMPUTE_WHISPER_TIMEOUT_MS=30000 +# COMPUTE_PDF_TIMEOUT_MS=300000 +# WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main +# # Expects q4 files at that base: +# # - onnx/encoder_model_q4.onnx +# # - onnx/decoder_model_merged_q4.onnx +# # - onnx/decoder_with_past_model_q4.onnx +# PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main +# COMPUTE_PDF_JOB_ATTEMPTS=1 +# COMPUTE_JOBS_STREAM_MAX_BYTES=268435456 +# COMPUTE_JOB_STATES_MAX_BYTES=67108864 +# COMPUTE_NATS_REPLICAS=1 + +NATS_URL=tls://connect.ngs.global:4222 +NATS_CREDS="-----BEGIN NATS USER JWT----- +... +------END USER NKEY SEED------" + +S3_BUCKET= +S3_REGION= +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_ENDPOINT= +S3_FORCE_PATH_STYLE=true +S3_PREFIX=openreader +``` + +Notes: + +- `NATS_CREDS` should be the full Synadia `.creds` file content, including begin/end markers. +- Keep `COMPUTE_WORKER_TOKEN` identical between app server and worker. +- On Railway, leave `PORT` managed by the platform. +- If your platform supports mounted files, you can use `NATS_CREDS_FILE` instead of `NATS_CREDS`. +- `COMPUTE_JOBS_STREAM_MAX_BYTES` and `COMPUTE_JOB_STATES_MAX_BYTES` are optional; defaults are `268435456` (256MiB) and `67108864` (64MiB). +- `COMPUTE_NATS_REPLICAS` is optional; default is `1`. Valid values are `1`, `3`, `5`. + +### 4. Configure the OpenReader app server + +Set these env vars on the app server: + +```env +COMPUTE_WORKER_URL=https:// +COMPUTE_WORKER_TOKEN= +``` + +### 5. Verify health + +After deploy, check: + +- `GET https:///health/live` +- `GET https:///health/ready` diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 7fc484a..17830a9 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -89,6 +89,41 @@ OpenReader currently pins `4.18` in CI and Docker builds while `4.19` compatibil +
+NATS Server nats-server (required for embedded compute mode) + +If `COMPUTE_WORKER_URL` is unset, startup launches embedded compute worker + NATS, so `nats-server` must be available on host PATH. + +If you always use an external worker (`COMPUTE_WORKER_URL` set), this is not required. + + + + +```bash +brew install nats-server +nats-server -v +``` + + + + +```bash +# Linux amd64 example +mkdir -p "$HOME/.local/bin" +curl -fsSL -o /tmp/nats-server.zip \ + https://github.com/nats-io/nats-server/releases/latest/download/nats-server-v2.12.1-linux-amd64.zip +unzip -j /tmp/nats-server.zip '*/nats-server' -d /tmp +install -m 0755 /tmp/nats-server "$HOME/.local/bin/nats-server" +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +export PATH="$HOME/.local/bin:$PATH" +nats-server -v +``` + + + + +
+
LibreOffice (optional, for DOCX conversion) @@ -114,50 +149,51 @@ sudo apt install -y libreoffice
-whisper.cpp (optional, for word-by-word highlighting) +Word-by-word highlighting (optional) -Install build dependencies: +No extra native Whisper CLI build step is required. - - +Word-by-word highlighting and PDF layout parsing are worker-backed in current releases. + +If you need mirrors or pinned artifact locations, set `WHISPER_MODEL_BASE_URL` in `.env` (current defaults expect q4 Whisper files at that base URL). + +
+ +
+External compute worker dev stack (optional) + +Use this only when you intentionally run compute-worker as a separate service. +Default local flow does not need `compute/worker/.env`; embedded worker startup reads root `.env`. +Full worker deployment details are in [Compute Worker (NATS JetStream)](./compute-worker). + +Start only NATS + compute-worker via compose watch: ```bash -brew install cmake +docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch +# or: pnpm compute:dev:watch ``` - - +`compute/worker/.env.example` contains a starter config for standalone worker service deployments. + +Run the main app separately on the host: ```bash -# Debian/Ubuntu example -sudo apt update -sudo apt install -y git build-essential cmake +pnpm dev ``` - - +For app -> external worker routing, set in root `.env`: -Build whisper.cpp: - -```bash -# clone and build whisper.cpp (no model download needed – OpenReader handles that) -git clone https://github.com/ggml-org/whisper.cpp.git -cd whisper.cpp -cmake -B build -cmake --build build -j --config Release - -# point OpenReader to the compiled whisper-cli binary -echo WHISPER_CPP_BIN="$(pwd)/build/bin/whisper-cli" +```env +COMPUTE_WORKER_URL=http://localhost:8081 +COMPUTE_WORKER_TOKEN= ``` -If you are not on Debian/Ubuntu, install equivalent packages with your distro package manager: +Ownership in external worker mode: +- root `.env`: app routing/auth (`COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`) plus optional shared timeout/stale overrides +- `compute/worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning) -- Fedora/RHEL: use `dnf` (`gcc gcc-c++ make cmake curl git tar xz`) -- Arch: use `pacman` (`base-devel cmake curl git tar xz`) - -:::tip -Set `WHISPER_CPP_BIN` in your `.env` to enable word-by-word highlighting. -::: +Worker mode requires worker-reachable shared object storage (S3-compatible endpoint). +For external worker mode, object storage must be shared/reachable by both app and worker services.
@@ -186,6 +222,24 @@ cp .env.example .env Then edit `.env`. +Default embedded worker flow (no external worker URL): + +```env +# Leave COMPUTE_WORKER_URL unset. +# Entry point auto-starts embedded worker+NATS when available. +``` + +External worker flow: + +```env +COMPUTE_WORKER_URL=http://localhost:8081 +COMPUTE_WORKER_TOKEN= +``` + +Use the same ownership split: +- root `.env`: app routing/auth (`COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`) plus optional shared timeout/stale overrides +- `compute/worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning) + Use one of these `.env` mode templates: @@ -239,17 +293,35 @@ S3_SECRET_ACCESS_KEY=your-secret-key # Optional for non-AWS providers: # S3_ENDPOINT=https://your-s3-compatible-endpoint # S3_FORCE_PATH_STYLE=true +``` + + + + +```env +API_BASE=http://host.docker.internal:8880/v1 +API_KEY=none +COMPUTE_WORKER_URL=http://localhost:8081 +COMPUTE_WORKER_TOKEN= +USE_EMBEDDED_WEED_MINI=false +S3_BUCKET=your-bucket +S3_REGION=us-east-1 +S3_ACCESS_KEY_ID=your-access-key +S3_SECRET_ACCESS_KEY=your-secret-key +# Optional for non-AWS providers: +# S3_ENDPOINT=https://your-s3-compatible-endpoint +# S3_FORCE_PATH_STYLE=true ``` :::note Env vars vs. admin panel -On first boot, `API_KEY` / `API_BASE` and any `NEXT_PUBLIC_*` flags you've set get auto-seeded into the admin-managed runtime config (DB-backed, keys encrypted at rest). After that, the admin UI is authoritative and editing those env vars no longer changes app behavior. See [Admin Panel](../configure/admin-panel). +On first boot, `API_KEY` / `API_BASE` and any `RUNTIME_SEED_*` flags you've set get auto-seeded into the admin-managed runtime config (DB-backed, keys encrypted at rest). After that, the admin UI is authoritative and editing those env vars no longer changes app behavior. See [Admin Panel](../configure/admin-panel). ::: :::note User BYOK restriction default -If you want each user to enter personal provider credentials, set `restrictUserApiKeys=false` (from **Settings → Admin** when auth/admin is enabled, or via legacy first-boot seed `NEXT_PUBLIC_RESTRICT_USER_API_KEYS=false` for no-admin bootstrap flows). +If you want each user to enter personal provider credentials, set `restrictUserApiKeys=false` (from **Settings → Admin** when auth/admin is enabled, or via legacy first-boot seed `RUNTIME_SEED_RESTRICT_USER_API_KEYS=false` for no-admin bootstrap flows). ::: :::info @@ -271,6 +343,9 @@ Learn about migration behavior and commands in [Migrations](../configure/migrati pnpm dev ``` +If you use embedded worker startup (no `COMPUTE_WORKER_URL`) and the host is missing `nats-server`, +install `nats-server` locally or switch to external worker mode. + diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index 88adbee..86a9b3a 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -8,6 +8,8 @@ This guide covers deploying OpenReader to Vercel with external Postgres and S3-c - Documents (PDF/EPUB/TXT/MD) work with `POSTGRES_URL` + external S3 storage. - Audiobook routes work on Node.js serverless functions using `ffmpeg-static`. +- Heavy compute features (Whisper alignment + PDF layout parsing) run through an external compute worker service. +- For worker setup details and worker-specific env vars, see [Compute Worker (NATS JetStream)](./compute-worker). :::warning DOCX Conversion Limitation `docx` conversion requires `soffice` (LibreOffice), which is not available in a standard Vercel runtime. @@ -35,15 +37,40 @@ BASE_URL=https://your-app.vercel.app AUTH_SECRET=... ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in-app +# Heavy compute (required on Vercel in current releases) +COMPUTE_WORKER_URL=https:// +COMPUTE_WORKER_TOKEN=... + +# Logging (recommended for Vercel log ingestion) +LOG_FORMAT=json +LOG_LEVEL=info + # First-boot seed for the TTS shared provider (optional; manage in-app afterwards) -API_KEY=your_replicate_key +# API_KEY=your_replicate_key # API_BASE only needed for OpenAI-compatible self-hosted providers ``` +If you also run an external worker service (for example Railway), set these there too: + +- `LOG_FORMAT=json` +- `COMPUTE_LOG_LEVEL=info` + :::note Env vars vs. admin panel (important for Vercel) `API_KEY` / `API_BASE` are one-shot bootstrap seeds on first deploy. After boot, manage providers and site features in **Settings → Admin**. Changes there apply on refresh without a redeploy. See [Admin Panel](../configure/admin-panel). ::: +## 1a. Railway + Synadia quick start (worker mode) + +If your Vercel app uses an external compute worker on Railway with Synadia Cloud (NGS): + +1. Deploy a Railway service from: + - `ghcr.io/richardr1126/openreader-compute-worker:refactor-ppdoclayoutv3-onnx-layout-parsing` +2. Enable public networking on that Railway service and set: + - `COMPUTE_WORKER_URL=https://` (in Vercel) +3. Use the same `COMPUTE_WORKER_TOKEN` value in both Vercel and Railway worker env vars. + +For complete Railway worker env vars (`NATS_*`, `S3_*`, health checks, and Synadia `.creds` guidance), see [Compute Worker (NATS JetStream)](./compute-worker). + ## 2. First-run admin configuration (recommended) After the first successful deploy and admin login, open **Settings → Admin** and configure: @@ -58,11 +85,10 @@ After the first successful deploy and admin login, open **Settings → Admin** a - `defaultTtsProvider=replicate` (or your preferred shared slug). - `showAllProviderModels=false` if you want users locked to each provider's default model. - `enableAudiobookExport=true`. - - `enableWordHighlight=false` unless your timestamp stack is configured. ## 3. Legacy first-boot seed (optional) -If you must pre-seed site features via environment variables, the legacy `NEXT_PUBLIC_*` seeds are still supported on first boot only. Prefer the admin panel for ongoing management. +If you must pre-seed site features via environment variables, the legacy `RUNTIME_SEED_*` seeds are still supported on first boot only. Prefer the admin panel for ongoing management. See [Environment Variables](../reference/environment-variables#legacy-first-boot-runtime-seeds-optional) for the complete legacy seed list. @@ -91,8 +117,7 @@ Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so automatic - `/api/audiobook` - `/api/audiobook/chapter` -- `/api/audiobook/status` -- `/api/whisper` +- `/api/tts/segments/ensure` :::info `serverExternalPackages` should include `ffmpeg-static` so package paths resolve at runtime instead of being bundled into route output. @@ -109,7 +134,7 @@ FFmpeg workloads benefit from more memory/CPU. This repo includes: "$schema": "https://openapi.vercel.sh/vercel.json", "functions": { "app/api/audiobook/route.ts": { "memory": 3009 }, - "app/api/whisper/route.ts": { "memory": 3009 } + "app/api/tts/segments/ensure/route.ts": { "memory": 3009 } } } ``` @@ -126,4 +151,4 @@ Adjust memory per route if your files are larger or your plan differs. 1. Upload and read a PDF/EPUB document. 2. Confirm sync/blob fetch works across refreshes/devices. 3. Generate at least one audiobook chapter and play/download it. -4. If using word highlighting, verify timestamps are produced and rendered. +4. Verify worker-backed word highlighting and PDF parsing. diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index 6af01e8..f61ea07 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -22,6 +22,12 @@ OpenReader currently pins embedded SeaweedFS to `4.18` in CI and Docker builds. `4.19` introduced intermittent `InternalError` responses on S3 `PutObject` in our upload flow. ::: +## Published images + +- App server: `ghcr.io/richardr1126/openreader:latest` +- Compute worker (Optional): `ghcr.io/richardr1126/openreader-compute-worker:latest` +- Legacy app alias: `ghcr.io/richardr1126/openreader-webui:latest` + ## 1. Start the Docker container @@ -113,7 +119,7 @@ What this command enables: - Set `API_BASE` on first boot to a TTS endpoint the container can reach (`host.docker.internal` works for host-local services). After first boot, manage providers in **Settings → Admin → Shared providers**. - Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set. The admin panel requires auth. - Set `ADMIN_EMAILS` to your email if you want the **Admin** tab in Settings. -- `restrictUserApiKeys` controls shared-provider-only mode. For per-user BYOK in auth-enabled setups, toggle it off in **Settings → Admin → Site features**. Legacy first-boot seed via `NEXT_PUBLIC_RESTRICT_USER_API_KEYS=false` is still supported. +- `restrictUserApiKeys` controls shared-provider-only mode. For per-user BYOK in auth-enabled setups, toggle it off in **Settings → Admin → Site features**. Legacy first-boot seed via `RUNTIME_SEED_RESTRICT_USER_API_KEYS=false` is still supported. - Use a `/app/docstore` mount if you want data to survive container/image replacement. - Startup automatically runs DB/storage migrations via the shared entrypoint. ::: @@ -135,6 +141,7 @@ Visit [http://localhost:3003](http://localhost:3003) after startup. ## 3. Update Docker image Legacy image compatibility: `ghcr.io/richardr1126/openreader-webui:latest` remains available as an alias. +For external compute mode image details, see [Compute Worker (NATS JetStream)](./deploy/compute-worker). ```bash docker stop openreader || true && \ diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md index c4d09e0..6d00019 100644 --- a/docs-site/docs/introduction.md +++ b/docs-site/docs/introduction.md @@ -12,28 +12,19 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c ## ✨ Highlights +- 🧱 **Layout-aware PDF Parsing** + - PP-DocLayoutV3 (ONNX) detects structured blocks with cross-page stitching and geometry-based highlighting for precise read-along sync and clean TTS segmentation +- ⏱️ **Word-by-word Highlighting** via ONNX Whisper alignment + - Powered by the external compute worker control plane (NATS JetStream-backed) +- ⚡ **Segment-based TTS Playback** + - Sentence-aware generation with cached audio segments, background preloading, and resumable playback across EPUB, PDF, TXT, MD, and DOCX - 🎯 **Multi-Provider TTS Support** - - [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI): supports multi-voice combinations (for example `af_heart+af_bella`) - - [**KittenTTS-FastAPI**](https://github.com/richardr1126/KittenTTS-FastAPI): lightweight, CPU-friendly self-hosted TTS - - [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI) - - **Custom OpenAI-compatible**: any TTS API with `/v1/audio/voices` and `/v1/audio/speech` endpoints - - **Cloud TTS providers**: - - [**Replicate**](https://replicate.com/explore): includes a built-in catalog and supports any Replicate model ID via `Other` - - [**DeepInfra**](https://deepinfra.com/models/text-to-speech): Kokoro-82M and other hosted models - - [**OpenAI API**](https://platform.openai.com/docs/pricing#transcription-and-speech): `tts-1`, `tts-1-hd`, and `gpt-4o-mini-tts` -- 📖 **Read Along Experience** - - Real-time highlighting for PDF/EPUB, with optional word-level [whisper.cpp](https://github.com/ggml-org/whisper.cpp) timestamps -- 🛜 **Document Storage** - - Documents are persisted in server blob/object storage for consistent access -- ⚡ **Segment-based TTS Playback** for reusable generation + preloading - - Stores segment audio in object storage for fast replay/resume + - Self-hosted: [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI) (multi-voice combinations), [**KittenTTS-FastAPI**](https://github.com/richardr1126/KittenTTS-FastAPI), [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI), or any custom OpenAI-compatible endpoint + - Cloud: [**OpenAI**](https://platform.openai.com/docs/pricing#transcription-and-speech) (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`), [**Replicate**](https://replicate.com/explore) (built-in catalog + any model ID), [**DeepInfra**](https://deepinfra.com/models/text-to-speech) (Kokoro-82M and others) - 🎧 **Audiobook Export** in `m4b`/`mp3` with resumable chapter generation -- 🔐 **Auth Optional by Design** - - Run no-auth for local use, or enable auth with user isolation and claim flow -- 🗂️ **Flexible Storage and Database Modes** with embedded defaults or external S3/Postgres -- 🚀 **Production-ready Server Behavior** with TTS caching/retries/rate limits and startup migrations -- 🎨 **Customizable Experience** - - 13 built-in themes (light and dark palettes), TTS, and document handling controls +- 🗂️ **Flexible Backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync +- 🔐 **Auth Optional** — run no-auth for local use, or enable full user isolation with a claim flow for multi-user deployments +- 🎨 **Customizable** — 13 built-in themes (light and dark palettes), per-user TTS settings, and document handling controls ## 🧭 Key Docs diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index ef79788..b4a4078 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -6,19 +6,17 @@ toc_max_heading_level: 3 This is the single reference page for OpenReader environment variables. :::note Recommended configuration path -For auth-enabled deployments, use **Settings → Admin** as the primary source of truth for shared TTS providers and site features. Legacy env vars (`API_KEY`, `API_BASE`, and `NEXT_PUBLIC_*`) are optional first-boot seeds only. +For auth-enabled deployments, use **Settings → Admin** as the primary source of truth for shared TTS providers and site features. Legacy env vars (`API_KEY`, `API_BASE`, and `RUNTIME_SEED_*`) are optional first-boot seeds only. ::: ## Quick Reference Table | Variable | Area | Default | When to set | | --- | --- | --- | --- | -| `ADMIN_EMAILS` | Auth/Admin | empty | Comma-separated emails auto-promoted to admin (requires auth enabled) | +| `LOG_FORMAT` | Runtime logging | `pretty` | Set `json` for structured logs; shared by app server + compute worker | +| `LOG_LEVEL` | Runtime logging | `info` | Set app server log level | | `API_BASE` | Legacy bootstrap seed | none | Optional first-boot seed into `default-openai`; then manage in Settings → Admin → Shared providers | | `API_KEY` | Legacy bootstrap seed | none | Optional first-boot seed into `default-openai`; then manage in Settings → Admin → Shared providers | -| `NEXT_PUBLIC_*` runtime seeds | Legacy bootstrap seed | varies | Optional first-boot seeds for site features; then manage in Settings → Admin → Site features | -| `NEXT_PUBLIC_CHANGELOG_FEED_URL` | Legacy bootstrap seed | `https://docs.openreader.richardr.dev/changelog/manifest.json` | Optional first-boot seed for changelog feed URL; then manage in Settings → Admin → Site features | -| `NEXT_PUBLIC_ENABLE_USER_SIGNUPS` | Legacy bootstrap seed | `true` | Optional first-boot seed for whether new accounts can be created; then manage in Settings → Admin → Site features | | `TTS_CACHE_MAX_SIZE_BYTES` | TTS caching | `268435456` (256 MB) | Tune in-memory TTS cache size | | `TTS_CACHE_TTL_MS` | TTS caching | `1800000` (30 min) | Tune in-memory TTS cache TTL | | `TTS_MAX_RETRIES` | TTS retry | `2` | Tune retry attempts for upstream 429/5xx | @@ -38,6 +36,7 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in | | `GITHUB_CLIENT_SECRET` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_ID` to enable GitHub sign-in | | `DISABLE_AUTH_RATE_LIMIT` | Rate limiting | `false` | Set `true` to disable auth-layer rate limiting | +| `ADMIN_EMAILS` | Auth/Admin | empty | Comma-separated emails auto-promoted to admin (requires auth enabled) | | `POSTGRES_URL` | Database | unset (SQLite mode) | Set to switch metadata/auth DB to Postgres | | `USE_EMBEDDED_WEED_MINI` | Storage | `true` when unset | Set `false` to use external S3-compatible storage only | | `WEED_MINI_DIR` | Storage | `docstore/seaweedfs` | Override embedded SeaweedFS data directory | @@ -53,11 +52,50 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass | | `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root | | `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) | -| `WHISPER_CPP_BIN` | Word timing | unset | Set to enable `whisper.cpp` timestamps | +| `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Set only for standalone external compute worker; leave unset for embedded worker startup | +| `COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset (auto-generated in embedded startup) | Required for standalone external compute worker auth; must match worker | +| `EMBEDDED_COMPUTE_WORKER_PORT` | Heavy compute backend | `8081` | Override embedded worker bind port | +| `EMBEDDED_NATS_PORT` | Heavy compute backend | `4222` | Override embedded NATS client port | +| `EMBEDDED_NATS_MONITOR_PORT` | Heavy compute backend | `8222` | Override embedded NATS monitor port | +| `EMBEDDED_NATS_STORE_DIR` | Heavy compute backend | `docstore/nats/jetstream` | Override embedded JetStream storage directory | +| `NATS_URL` | Heavy compute backend | `nats://127.0.0.1:4222` in embedded startup | Optional override for embedded startup or required on standalone worker service | +| `COMPUTE_LOG_LEVEL` | Heavy compute backend | `info` | Compute worker log level | +| `COMPUTE_JOB_CONCURRENCY` | Heavy compute backend | `1` | Worker-side shared compute concurrency cap | +| `COMPUTE_WHISPER_TIMEOUT_MS` | Heavy compute backend | `30000` | Shared whisper alignment timeout budget (worker + worker client wait budget) | +| `COMPUTE_PDF_TIMEOUT_MS` | Heavy compute backend | `300000` | Shared PDF idle-timeout budget (worker + worker client wait budget) | +| `COMPUTE_OP_STALE_MS` | Heavy compute backend | `max(30m, 4x max compute timeout)` | Shared stale window for worker op replacement and app-side stale PDF parse-state healing | +| `PDF_LAYOUT_MODEL_BASE_URL` | PDF layout model | PP-DocLayoutV3 ONNX base URL | Optional base URL override for `ensureModel()` | +| `WHISPER_MODEL_BASE_URL` | Whisper ONNX model | onnx-community defaults | Optional base URL override for ONNX whisper-base_timestamped q4 downloads | | `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path | +| `RUNTIME_SEED_*` runtime seeds | Legacy bootstrap seed | varies | Optional first-boot seeds for site features; then manage in Settings → Admin → Site features | +| `RUNTIME_SEED_ENABLE_DOCX_CONVERSION` | Legacy bootstrap seed | `true` | Optional first-boot seed to enable/disable DOCX conversion UI | +| `RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS` | Legacy bootstrap seed | `true` | Optional first-boot seed to show/hide destructive delete actions | +| `RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB` | Legacy bootstrap seed | `true` | Optional first-boot seed to show/hide user TTS providers tab | +| `RUNTIME_SEED_CHANGELOG_FEED_URL` | Legacy bootstrap seed | `https://docs.openreader.richardr.dev/changelog/manifest.json` | Optional first-boot seed for changelog feed URL; then manage in Settings → Admin → Site features | +| `RUNTIME_SEED_ENABLE_USER_SIGNUPS` | Legacy bootstrap seed | `true` | Optional first-boot seed for whether new accounts can be created; then manage in Settings → Admin → Site features | +| `RUNTIME_SEED_RESTRICT_USER_API_KEYS` | Legacy bootstrap seed | runtime-dependent | Optional first-boot seed to restrict per-user BYOK | +| `RUNTIME_SEED_DEFAULT_TTS_PROVIDER` | Legacy bootstrap seed | `custom-openai` | Optional first-boot seed for default TTS provider slug | +| `RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT` | Legacy bootstrap seed | `true` | Optional first-boot seed to enable audiobook export UI | +## Runtime Logging + +### LOG_FORMAT + +Controls log output format for server-side Pino loggers. + +- Default: `pretty` +- Allowed values: `pretty`, `json` +- Applies to app server and compute worker +- Recommended in production (Vercel + external worker): `json` + +### LOG_LEVEL + +App server log level. + +- Default: `info` + ## TTS Provider and Request Behavior ### API_BASE @@ -230,6 +268,8 @@ Switches metadata/auth storage from SQLite to Postgres. - Set: Postgres mode - Related docs: [Database](../configure/database) +### Embedded SeaweedFS weed mini config + ### USE_EMBEDDED_WEED_MINI Controls embedded SeaweedFS startup. @@ -252,6 +292,8 @@ Maximum seconds to wait for embedded SeaweedFS startup. - Default: `20` - Related docs: [Object / Blob Storage](../configure/object-blob-storage) +### S3 storage config + ### S3_ACCESS_KEY_ID Access key for S3-compatible storage. @@ -346,12 +388,121 @@ Multiple library roots for server library import. ## Audio Tooling and Alignment -### WHISPER_CPP_BIN +### COMPUTE_WORKER_URL -Absolute path to compiled `whisper.cpp` binary for word-level timestamps. +Base URL for standalone external compute worker mode. -- Example: `/whisper.cpp/build/bin/whisper-cli` -- Required only for optional word-by-word highlighting +- Leave unset for embedded/local startup (`pnpm dev` / `pnpm start`) so entrypoint can start embedded worker+NATS. +- Embedded startup requires `nats-server` available on host PATH. +- Required only when using a standalone external worker service. +- App-side only: set on app server/root `.env` (routing target), not worker-only env files. +- Example: `http://localhost:8081` + +### COMPUTE_WORKER_TOKEN + +Bearer token for compute-worker auth. + +- Required for standalone external worker service mode. +- Must match worker service `COMPUTE_WORKER_TOKEN`. +- In embedded startup, entrypoint auto-generates one if unset. +- In external worker mode, set this on both app server/root `.env` and worker service env (`compute/worker/.env*` or platform env). + +### EMBEDDED_COMPUTE_WORKER_PORT + +Embedded compute-worker HTTP port. + +- Default: `8081` + +### EMBEDDED_NATS_PORT + +Embedded NATS client port. + +- Default: `4222` + +### EMBEDDED_NATS_MONITOR_PORT + +Embedded NATS monitor (`/healthz`) port. + +- Default: `8222` + +### EMBEDDED_NATS_STORE_DIR + +Embedded JetStream storage directory. + +- Default: `docstore/nats/jetstream` + +### NATS_URL + +NATS connection URL used by compute worker runtime. + +- Embedded startup default: `nats://127.0.0.1:4222` +- Standalone worker service: set in worker service env (`compute/worker/.env*` or platform env) +- For embedded startup, this is optional; startup supplies the default value. +- Worker-side only in external mode: set on worker service env, not app/root `.env`. + +### COMPUTE_LOG_LEVEL + +Compute worker log level. + +- Default: `info` +- In standalone mode, set this on the worker service env. + +### COMPUTE_JOB_CONCURRENCY + +Worker-side shared compute concurrency cap. + +- Default: `1` +- Set on the compute-worker service environment + +### COMPUTE_WHISPER_TIMEOUT_MS + +Shared whisper alignment timeout budget in milliseconds. + +- Default: `30000` +- Used by: + - Worker compute whisper runtime + - App server worker-client wait budget (SSE wait timeout) + +### COMPUTE_PDF_TIMEOUT_MS + +Shared PDF idle-timeout budget in milliseconds. + +- Default: `300000` (5 minutes) +- Used by: + - Worker compute PDF runtime (idle timeout) + - App server worker-client wait budget (SSE wait timeout) + +### COMPUTE_OP_STALE_MS + +Shared stale window in milliseconds. + +- Default: `max(30m, 4x max(COMPUTE_WHISPER_TIMEOUT_MS, COMPUTE_PDF_TIMEOUT_MS))` +- Used by: + - Worker op reuse/replacement guard (`/ops` opKey stale detection) + - App-server PDF parse-state stale healing in `/api/documents/[id]/parsed*` +- If a parse row is stuck in `pending`/`running` past this window, app routes mark it failed so retries/reparse can proceed. +- Keep this value aligned on both app-server and worker service envs. + +### PDF_LAYOUT_MODEL_BASE_URL + +Optional base URL override for PP-DocLayoutV3 artifacts downloaded by `ensureModel()`. + +- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main` +- Required files at that base: + - `PP-DocLayoutV3.onnx` + - `PP-DocLayoutV3.onnx.data` + - `config.json` + - `preprocessor_config.json` +- Configure this on the worker service env (not only the app server env) + +### WHISPER_MODEL_BASE_URL + +Optional base URL override for the built-in ONNX Whisper alignment model downloader. + +- Default: `https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main` +- Default model variant: q4 (`encoder_model_q4.onnx`, `decoder_model_merged_q4.onnx`, `decoder_with_past_model_q4.onnx`) +- The base URL must host all expected manifest files under the same relative paths. +- Configure this on the worker service env (not only the app server env) ### FFMPEG_BIN @@ -364,23 +515,23 @@ Absolute path or executable name for the ffmpeg binary used by audiobook/process These variables exist only as **first-boot seeds** for the admin-managed runtime config. Prefer changing site features from **Settings → Admin → Site features**. Keep these only when you need bootstrap defaults before the first admin login. See [Admin Panel](../configure/admin-panel) for migration behavior. -The values are SSR-injected via `window.__OPENREADER_RUNTIME_CONFIG__`, so admin edits take effect for all users on the next page load — no rebuild required (unlike the old `NEXT_PUBLIC_*` build-time pattern). +The values are SSR-injected via `window.__RUNTIME_CONFIG__`, so admin edits take effect for all users on the next page load — no rebuild required (unlike the old build-time public env pattern). -### NEXT_PUBLIC_ENABLE_DOCX_CONVERSION +### RUNTIME_SEED_ENABLE_DOCX_CONVERSION Controls whether the experimental DOCX-to-PDF conversion and upload feature is enabled. - Default: `true` (enabled) - Runtime key: `enableDocxConversion` -### NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS +### RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS Controls whether the "Delete all user docs" and other bulk-delete buttons are shown in Settings. - Default: `true` (enabled) - Runtime key: `enableDestructiveDeleteActions` -### NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB +### RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB Controls whether the **TTS Provider** section appears in the user-facing Settings modal. @@ -388,7 +539,7 @@ Controls whether the **TTS Provider** section appears in the user-facing Setting - Set `false` to hide provider/model/API controls in the per-user Settings modal (the admin panel is unaffected). - Runtime key: `enableTtsProvidersTab` -### NEXT_PUBLIC_ENABLE_USER_SIGNUPS +### RUNTIME_SEED_ENABLE_USER_SIGNUPS Controls whether new user accounts can be created. @@ -397,7 +548,7 @@ Controls whether new user accounts can be created. - Existing users can still sign in. - Runtime key: `enableUserSignups` -### NEXT_PUBLIC_RESTRICT_USER_API_KEYS +### RUNTIME_SEED_RESTRICT_USER_API_KEYS Controls whether users can supply personal API keys/base URLs for built-in providers. @@ -406,7 +557,7 @@ Controls whether users can supply personal API keys/base URLs for built-in provi - When `false`, users can use per-user BYOK credentials for built-in providers. - Runtime key: `restrictUserApiKeys` -### NEXT_PUBLIC_DEFAULT_TTS_PROVIDER +### RUNTIME_SEED_DEFAULT_TTS_PROVIDER Sets the default TTS provider for new users. @@ -416,7 +567,7 @@ Sets the default TTS provider for new users. `showAllProviderModels` is a runtime-only admin setting (no env seed). Configure it in **Settings → Admin → Site features**. -### NEXT_PUBLIC_CHANGELOG_FEED_URL +### RUNTIME_SEED_CHANGELOG_FEED_URL Sets the changelog manifest URL used by the Settings modal changelog viewer. @@ -425,21 +576,10 @@ Sets the changelog manifest URL used by the Settings modal changelog viewer. - Runtime key: `changelogFeedUrl` -### NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT +### RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT Controls whether audiobook export UI/actions are shown in the client. - Default: `true` (enabled) - Affects export entry points in PDF/EPUB pages and document settings UI - Runtime key: `enableAudiobookExport` - -### NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT - -Controls word-by-word highlighting UI and timestamp-alignment behavior. - -- Default: `true` (enabled) -- Requires working timestamp generation (for example `WHISPER_CPP_BIN`) -- Affects: - - Word-highlight toggles in document settings - - Alignment requests during TTS playback -- Runtime key: `enableWordHighlight` diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md index cc45fa2..ea847e6 100644 --- a/docs-site/docs/reference/stack.md +++ b/docs-site/docs/reference/stack.md @@ -4,9 +4,10 @@ title: Stack ## Framework -- [Next.js](https://nextjs.org/) 15 (App Router) +- [Next.js](https://nextjs.org/) 15 (App Router, Turbopack in dev) - [React](https://react.dev/) 19 - [TypeScript](https://www.typescriptlang.org/) +- [pnpm](https://pnpm.io/) workspaces monorepo ## Containerization and runtime @@ -17,24 +18,48 @@ title: Stack - UI: [Tailwind CSS](https://tailwindcss.com), [Headless UI](https://headlessui.com), [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin) - Interactions: `react-dnd`, `react-dropzone` +- Server state: [TanStack Query](https://tanstack.com/query) (React Query v5) - Authentication: [Better Auth](https://www.better-auth.com/) client SDK - Local storage/cache: [Dexie.js](https://dexie.org/) (IndexedDB) +- Audio playback: [Howler.js](https://howlerjs.com/) +- Notifications: `react-hot-toast` - Document rendering: - PDF: [react-pdf](https://github.com/wojtekmaj/react-pdf), [pdf.js](https://mozilla.github.io/pdf.js/) - EPUB: [react-reader](https://github.com/gerhardsletten/react-reader), [epubjs](https://github.com/futurepress/epub.js/) - Markdown/Text: [react-markdown](https://github.com/remarkjs/react-markdown), [remark-gfm](https://github.com/remarkjs/remark-gfm) - Text preprocessing/matching: [compromise](https://github.com/spencermountain/compromise), [cmpstr](https://github.com/remsky/cmpstr) +- Analytics: [Vercel Analytics](https://vercel.com/analytics) ## Next.js server - APIs: Route Handlers for sync, blob/content access, migrations, audiobook export, TTS/Whisper proxying - State sync: request-based today (not realtime push updates) -- Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters +- Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters with anonymous session support - Metadata DB: [Drizzle ORM](https://orm.drizzle.team/) with SQLite (`better-sqlite3`) by default and optional Postgres (`pg`) - App tables are manually maintained in Drizzle schema files - Auth tables are auto-generated by the [Better Auth](https://www.better-auth.com/) CLI and migrated alongside app tables via Drizzle - Blob storage: embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3 -- Audio/processing pipeline: OpenAI-compatible TTS providers, [ffmpeg](https://ffmpeg.org/) for audiobook assembly, optional [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for word timestamps +- TTS providers: OpenAI-compatible API (`openai` SDK), [Replicate](https://replicate.com/) (`replicate` client), DeepInfra, and custom OpenAI-compatible endpoints — credentials are encrypted at rest +- Audio pipeline: [ffmpeg](https://ffmpeg.org/) (`ffmpeg-static`) for audiobook assembly, `archiver` for export packaging +- Utilities: `lru-cache` for in-process caching, `fast-xml-parser` for EPUB/XML parsing, `uuid` for identifier generation, `zod` for schema validation + +## External compute worker (optional) + +Monorepo packages under `compute/`: + +- **`@openreader/compute-core`** — ONNX runtime lifecycle, model management, and inference logic shared by compute worker runtime + app/worker contracts + - ONNX runtime: `onnxruntime-node` with `@huggingface/tokenizers` + - Whisper alignment: `onnx-community/whisper-base_timestamped` (q4) for word-level timestamps + - PDF layout: `Bei0001/PP-DocLayoutV3-ONNX` for document block detection and layout parsing + - PDF rendering: `pdfjs-dist`, `@napi-rs/canvas` for server-side page rasterization + - Utilities: `jszip`, `ffmpeg-static` +- **`@openreader/compute-worker`** — standalone Node.js worker service + - HTTP server: [Fastify](https://fastify.dev/) v5 + - Job queue + state: [NATS](https://nats.io/) JetStream WorkQueue pull consumers + NATS KV (`jobs.whisper`, `jobs.layout`) + - Storage: AWS SDK v3 S3 client for reading/writing blobs + - Logging: [Pino](https://getpino.io/) + - Validation: [Zod](https://zod.dev/) +- Heavy compute is worker-backed via `COMPUTE_WORKER_URL` + `COMPUTE_WORKER_TOKEN` (remote queue via HTTP + NATS) ## Tooling and testing @@ -42,3 +67,4 @@ title: Stack - TypeScript - [Playwright](https://playwright.dev/) end-to-end tests - Drizzle migration/generation scripts +- [Docusaurus](https://docusaurus.io/) documentation site (`docs-site/`) diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index b433750..fe5d1a3 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -53,7 +53,7 @@ const sidebars: SidebarsConfig = { { type: 'category', label: '🚀 Deploy', - items: ['deploy/local-development', 'deploy/vercel-deployment'], + items: ['deploy/local-development', 'deploy/compute-worker', 'deploy/vercel-deployment'], }, { type: 'category', diff --git a/docs-site/versioned_docs/version-v3.0.0/reference/environment-variables.md b/docs-site/versioned_docs/version-v3.0.0/reference/environment-variables.md index ef79788..781afbb 100644 --- a/docs-site/versioned_docs/version-v3.0.0/reference/environment-variables.md +++ b/docs-site/versioned_docs/version-v3.0.0/reference/environment-variables.md @@ -364,7 +364,7 @@ Absolute path or executable name for the ffmpeg binary used by audiobook/process These variables exist only as **first-boot seeds** for the admin-managed runtime config. Prefer changing site features from **Settings → Admin → Site features**. Keep these only when you need bootstrap defaults before the first admin login. See [Admin Panel](../configure/admin-panel) for migration behavior. -The values are SSR-injected via `window.__OPENREADER_RUNTIME_CONFIG__`, so admin edits take effect for all users on the next page load — no rebuild required (unlike the old `NEXT_PUBLIC_*` build-time pattern). +The values are SSR-injected via `window.__RUNTIME_CONFIG__`, so admin edits take effect for all users on the next page load — no rebuild required (unlike the old `NEXT_PUBLIC_*` build-time pattern). ### NEXT_PUBLIC_ENABLE_DOCX_CONVERSION diff --git a/drizzle/postgres/0005_pdf_layout_and_compute.sql b/drizzle/postgres/0005_pdf_layout_and_compute.sql new file mode 100644 index 0000000..58654dd --- /dev/null +++ b/drizzle/postgres/0005_pdf_layout_and_compute.sql @@ -0,0 +1,14 @@ +CREATE TABLE "document_settings" ( + "document_id" text NOT NULL, + "user_id" text NOT NULL, + "data_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "client_updated_at_ms" bigint DEFAULT 0 NOT NULL, + "created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint, + "updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint, + CONSTRAINT "document_settings_document_id_user_id_pk" PRIMARY KEY("document_id","user_id") +); +--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN "parse_status" text;--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN "parsed_json_key" text;--> statement-breakpoint +ALTER TABLE "document_settings" ADD CONSTRAINT "document_settings_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_document_settings_user_id" ON "document_settings" USING btree ("user_id"); diff --git a/drizzle/postgres/0006_preview-defaults-cleanup.sql b/drizzle/postgres/0006_preview-defaults-cleanup.sql new file mode 100644 index 0000000..dddf603 --- /dev/null +++ b/drizzle/postgres/0006_preview-defaults-cleanup.sql @@ -0,0 +1,2 @@ +ALTER TABLE "document_previews" ALTER COLUMN "variant" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "document_previews" ALTER COLUMN "width" DROP DEFAULT; \ No newline at end of file diff --git a/drizzle/postgres/0007_pdf_parse_progress.sql b/drizzle/postgres/0007_pdf_parse_progress.sql new file mode 100644 index 0000000..9c2d188 --- /dev/null +++ b/drizzle/postgres/0007_pdf_parse_progress.sql @@ -0,0 +1,2 @@ +ALTER TABLE "documents" ADD COLUMN "parse_state" text;--> statement-breakpoint +ALTER TABLE "documents" DROP COLUMN "parse_status"; \ No newline at end of file diff --git a/drizzle/postgres/meta/0005_snapshot.json b/drizzle/postgres/meta/0005_snapshot.json new file mode 100644 index 0000000..9d083c1 --- /dev/null +++ b/drizzle/postgres/meta/0005_snapshot.json @@ -0,0 +1,1833 @@ +{ + "id": "a23bc220-ebf2-43e2-92b6-d765c23c07d4", + "prevId": "abda257a-d6bd-4a44-b069-4ca884890e9e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_providers": { + "name": "admin_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_settings": { + "name": "admin_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobook_chapters": { + "name": "audiobook_chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "name": "audiobook_chapters_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobooks": { + "name": "audiobooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "name": "audiobooks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_previews": { + "name": "document_previews", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'card-240-jpeg'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_until_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "name": "document_previews_document_id_namespace_variant_pk", + "columns": [ + "document_id", + "namespace", + "variant" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_settings": { + "name": "document_settings", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "name": "document_settings_document_id_user_id_pk", + "columns": [ + "document_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parse_status": { + "name": "parse_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_modified", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "name": "documents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_entries": { + "name": "tts_segment_entries", + "schema": "", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_version": { + "name": "document_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_reader_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_char_offset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_href", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_page", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_location", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "name": "tts_segment_entries_segment_entry_id_user_id_pk", + "columns": [ + "segment_entry_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_variants": { + "name": "tts_segment_variants", + "schema": "", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "settings_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "name": "tts_segment_variants_segment_id_user_id_pk", + "columns": [ + "segment_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_document_progress": { + "name": "user_document_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "name": "user_document_progress_user_id_document_id_pk", + "columns": [ + "user_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tts_chars": { + "name": "user_tts_chars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "char_count": { + "name": "char_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "name": "user_tts_chars_user_id_date_pk", + "columns": [ + "user_id", + "date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/0006_snapshot.json b/drizzle/postgres/meta/0006_snapshot.json new file mode 100644 index 0000000..f021826 --- /dev/null +++ b/drizzle/postgres/meta/0006_snapshot.json @@ -0,0 +1,1831 @@ +{ + "id": "4d83c2e9-61f5-4a0a-a49d-9b68e7912375", + "prevId": "a23bc220-ebf2-43e2-92b6-d765c23c07d4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_providers": { + "name": "admin_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_settings": { + "name": "admin_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobook_chapters": { + "name": "audiobook_chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "name": "audiobook_chapters_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobooks": { + "name": "audiobooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "name": "audiobooks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_previews": { + "name": "document_previews", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_until_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "name": "document_previews_document_id_namespace_variant_pk", + "columns": [ + "document_id", + "namespace", + "variant" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_settings": { + "name": "document_settings", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "name": "document_settings_document_id_user_id_pk", + "columns": [ + "document_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parse_status": { + "name": "parse_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_modified", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "name": "documents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_entries": { + "name": "tts_segment_entries", + "schema": "", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_version": { + "name": "document_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_reader_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_char_offset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_href", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_page", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_location", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "name": "tts_segment_entries_segment_entry_id_user_id_pk", + "columns": [ + "segment_entry_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_variants": { + "name": "tts_segment_variants", + "schema": "", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "settings_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "name": "tts_segment_variants_segment_id_user_id_pk", + "columns": [ + "segment_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_document_progress": { + "name": "user_document_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "name": "user_document_progress_user_id_document_id_pk", + "columns": [ + "user_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tts_chars": { + "name": "user_tts_chars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "char_count": { + "name": "char_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "name": "user_tts_chars_user_id_date_pk", + "columns": [ + "user_id", + "date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/0007_snapshot.json b/drizzle/postgres/meta/0007_snapshot.json new file mode 100644 index 0000000..ffc0c25 --- /dev/null +++ b/drizzle/postgres/meta/0007_snapshot.json @@ -0,0 +1,1831 @@ +{ + "id": "68baf959-cd1d-418b-a04e-11397aa05c39", + "prevId": "4d83c2e9-61f5-4a0a-a49d-9b68e7912375", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_providers": { + "name": "admin_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_settings": { + "name": "admin_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobook_chapters": { + "name": "audiobook_chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "name": "audiobook_chapters_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobooks": { + "name": "audiobooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "name": "audiobooks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_previews": { + "name": "document_previews", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_until_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "name": "document_previews_document_id_namespace_variant_pk", + "columns": [ + "document_id", + "namespace", + "variant" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_settings": { + "name": "document_settings", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "name": "document_settings_document_id_user_id_pk", + "columns": [ + "document_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parse_state": { + "name": "parse_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_modified", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "name": "documents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_entries": { + "name": "tts_segment_entries", + "schema": "", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_version": { + "name": "document_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_reader_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_char_offset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_href", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_page", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_location", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "name": "tts_segment_entries_segment_entry_id_user_id_pk", + "columns": [ + "segment_entry_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_variants": { + "name": "tts_segment_variants", + "schema": "", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "settings_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "name": "tts_segment_variants_segment_id_user_id_pk", + "columns": [ + "segment_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_document_progress": { + "name": "user_document_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "name": "user_document_progress_user_id_document_id_pk", + "columns": [ + "user_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tts_chars": { + "name": "user_tts_chars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "char_count": { + "name": "char_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "name": "user_tts_chars_user_id_date_pk", + "columns": [ + "user_id", + "date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 266bd66..eccd0d6 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -36,6 +36,27 @@ "when": 1778644075378, "tag": "0004_admin_panel", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1779041718214, + "tag": "0005_pdf_layout_and_compute", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1779102950715, + "tag": "0006_preview-defaults-cleanup", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1779378127846, + "tag": "0007_pdf_parse_progress", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0005_pdf_layout_and_compute.sql b/drizzle/sqlite/0005_pdf_layout_and_compute.sql new file mode 100644 index 0000000..8c6f338 --- /dev/null +++ b/drizzle/sqlite/0005_pdf_layout_and_compute.sql @@ -0,0 +1,14 @@ +CREATE TABLE `document_settings` ( + `document_id` text NOT NULL, + `user_id` text NOT NULL, + `data_json` text DEFAULT '{}' NOT NULL, + `client_updated_at_ms` integer DEFAULT 0 NOT NULL, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), + PRIMARY KEY(`document_id`, `user_id`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `idx_document_settings_user_id` ON `document_settings` (`user_id`);--> statement-breakpoint +ALTER TABLE `documents` ADD `parse_status` text;--> statement-breakpoint +ALTER TABLE `documents` ADD `parsed_json_key` text; diff --git a/drizzle/sqlite/0006_preview-defaults-cleanup.sql b/drizzle/sqlite/0006_preview-defaults-cleanup.sql new file mode 100644 index 0000000..4c92960 --- /dev/null +++ b/drizzle/sqlite/0006_preview-defaults-cleanup.sql @@ -0,0 +1,27 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_document_previews` ( + `document_id` text NOT NULL, + `namespace` text DEFAULT '' NOT NULL, + `variant` text NOT NULL, + `status` text DEFAULT 'queued' NOT NULL, + `source_last_modified_ms` integer NOT NULL, + `object_key` text NOT NULL, + `content_type` text DEFAULT 'image/jpeg' NOT NULL, + `width` integer NOT NULL, + `height` integer, + `byte_size` integer, + `etag` text, + `lease_owner` text, + `lease_until_ms` integer DEFAULT 0 NOT NULL, + `attempt_count` integer DEFAULT 0 NOT NULL, + `last_error` text, + `created_at_ms` integer DEFAULT 0 NOT NULL, + `updated_at_ms` integer DEFAULT 0 NOT NULL, + PRIMARY KEY(`document_id`, `namespace`, `variant`) +); +--> statement-breakpoint +INSERT INTO `__new_document_previews`("document_id", "namespace", "variant", "status", "source_last_modified_ms", "object_key", "content_type", "width", "height", "byte_size", "etag", "lease_owner", "lease_until_ms", "attempt_count", "last_error", "created_at_ms", "updated_at_ms") SELECT "document_id", "namespace", "variant", "status", "source_last_modified_ms", "object_key", "content_type", "width", "height", "byte_size", "etag", "lease_owner", "lease_until_ms", "attempt_count", "last_error", "created_at_ms", "updated_at_ms" FROM `document_previews`;--> statement-breakpoint +DROP TABLE `document_previews`;--> statement-breakpoint +ALTER TABLE `__new_document_previews` RENAME TO `document_previews`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `idx_document_previews_status_lease` ON `document_previews` (`status`,`lease_until_ms`); \ No newline at end of file diff --git a/drizzle/sqlite/0007_pdf_parse_progress.sql b/drizzle/sqlite/0007_pdf_parse_progress.sql new file mode 100644 index 0000000..b9a4631 --- /dev/null +++ b/drizzle/sqlite/0007_pdf_parse_progress.sql @@ -0,0 +1,2 @@ +ALTER TABLE `documents` ADD `parse_state` text;--> statement-breakpoint +ALTER TABLE `documents` DROP COLUMN `parse_status`; \ No newline at end of file diff --git a/drizzle/sqlite/meta/0005_snapshot.json b/drizzle/sqlite/meta/0005_snapshot.json new file mode 100644 index 0000000..01640f2 --- /dev/null +++ b/drizzle/sqlite/meta/0005_snapshot.json @@ -0,0 +1,1695 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bb7fa58d-9b33-46a5-81d1-d2cdfb88c8ee", + "prevId": "7465ec8f-ee2c-4bc3-a0ce-eb8c145d5c6c", + "tables": { + "admin_providers": { + "name": "admin_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_settings": { + "name": "admin_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobook_chapters": { + "name": "audiobook_chapters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobook_chapters_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobooks": { + "name": "audiobooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobooks_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_previews": { + "name": "document_previews", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'card-240-jpeg'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 240 + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + "status", + "lease_until_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "columns": [ + "document_id", + "namespace", + "variant" + ], + "name": "document_previews_document_id_namespace_variant_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_settings": { + "name": "document_settings", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "columns": [ + "document_id", + "user_id" + ], + "name": "document_settings_document_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parse_status": { + "name": "parse_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + "user_id", + "last_modified" + ], + "isUnique": false + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "documents_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_entries": { + "name": "tts_segment_entries", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_version": { + "name": "document_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + "user_id", + "document_id", + "document_version", + "locator_reader_rank", + "locator_spine_index", + "locator_char_offset", + "locator_spine_href", + "locator_page", + "locator_location", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + "user_id", + "document_id", + "document_version", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + "user_id", + "document_id", + "document_version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "columns": [ + "segment_entry_id", + "user_id" + ], + "name": "tts_segment_entries_segment_entry_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_variants": { + "name": "tts_segment_variants", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_json": { + "name": "settings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + "user_id", + "segment_entry_id", + "updated_at" + ], + "isUnique": false + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + "user_id", + "segment_entry_id", + "settings_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "columns": [ + "segment_id", + "user_id" + ], + "name": "tts_segment_variants_segment_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_document_progress": { + "name": "user_document_progress", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "columns": [ + "user_id", + "document_id" + ], + "name": "user_document_progress_user_id_document_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_tts_chars": { + "name": "user_tts_chars", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "char_count": { + "name": "char_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "columns": [ + "user_id", + "date" + ], + "name": "user_tts_chars_user_id_date_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/0006_snapshot.json b/drizzle/sqlite/meta/0006_snapshot.json new file mode 100644 index 0000000..28a338a --- /dev/null +++ b/drizzle/sqlite/meta/0006_snapshot.json @@ -0,0 +1,1693 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b4c531bb-7ddb-40e6-a946-815a6521653e", + "prevId": "bb7fa58d-9b33-46a5-81d1-d2cdfb88c8ee", + "tables": { + "admin_providers": { + "name": "admin_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_settings": { + "name": "admin_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobook_chapters": { + "name": "audiobook_chapters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobook_chapters_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobooks": { + "name": "audiobooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobooks_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_previews": { + "name": "document_previews", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + "status", + "lease_until_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "columns": [ + "document_id", + "namespace", + "variant" + ], + "name": "document_previews_document_id_namespace_variant_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_settings": { + "name": "document_settings", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "columns": [ + "document_id", + "user_id" + ], + "name": "document_settings_document_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parse_status": { + "name": "parse_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + "user_id", + "last_modified" + ], + "isUnique": false + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "documents_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_entries": { + "name": "tts_segment_entries", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_version": { + "name": "document_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + "user_id", + "document_id", + "document_version", + "locator_reader_rank", + "locator_spine_index", + "locator_char_offset", + "locator_spine_href", + "locator_page", + "locator_location", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + "user_id", + "document_id", + "document_version", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + "user_id", + "document_id", + "document_version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "columns": [ + "segment_entry_id", + "user_id" + ], + "name": "tts_segment_entries_segment_entry_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_variants": { + "name": "tts_segment_variants", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_json": { + "name": "settings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + "user_id", + "segment_entry_id", + "updated_at" + ], + "isUnique": false + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + "user_id", + "segment_entry_id", + "settings_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "columns": [ + "segment_id", + "user_id" + ], + "name": "tts_segment_variants_segment_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_document_progress": { + "name": "user_document_progress", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "columns": [ + "user_id", + "document_id" + ], + "name": "user_document_progress_user_id_document_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_tts_chars": { + "name": "user_tts_chars", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "char_count": { + "name": "char_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "columns": [ + "user_id", + "date" + ], + "name": "user_tts_chars_user_id_date_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/0007_snapshot.json b/drizzle/sqlite/meta/0007_snapshot.json new file mode 100644 index 0000000..37cc0da --- /dev/null +++ b/drizzle/sqlite/meta/0007_snapshot.json @@ -0,0 +1,1693 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "031a62ae-47a7-4476-a577-7b14ff787eba", + "prevId": "b4c531bb-7ddb-40e6-a946-815a6521653e", + "tables": { + "admin_providers": { + "name": "admin_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_settings": { + "name": "admin_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobook_chapters": { + "name": "audiobook_chapters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobook_chapters_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobooks": { + "name": "audiobooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobooks_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_previews": { + "name": "document_previews", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + "status", + "lease_until_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "columns": [ + "document_id", + "namespace", + "variant" + ], + "name": "document_previews_document_id_namespace_variant_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_settings": { + "name": "document_settings", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "columns": [ + "document_id", + "user_id" + ], + "name": "document_settings_document_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parse_state": { + "name": "parse_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + "user_id", + "last_modified" + ], + "isUnique": false + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "documents_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_entries": { + "name": "tts_segment_entries", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_version": { + "name": "document_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + "user_id", + "document_id", + "document_version", + "locator_reader_rank", + "locator_spine_index", + "locator_char_offset", + "locator_spine_href", + "locator_page", + "locator_location", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + "user_id", + "document_id", + "document_version", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + "user_id", + "document_id", + "document_version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "columns": [ + "segment_entry_id", + "user_id" + ], + "name": "tts_segment_entries_segment_entry_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_variants": { + "name": "tts_segment_variants", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_json": { + "name": "settings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + "user_id", + "segment_entry_id", + "updated_at" + ], + "isUnique": false + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + "user_id", + "segment_entry_id", + "settings_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "columns": [ + "segment_id", + "user_id" + ], + "name": "tts_segment_variants_segment_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_document_progress": { + "name": "user_document_progress", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "columns": [ + "user_id", + "document_id" + ], + "name": "user_document_progress_user_id_document_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_tts_chars": { + "name": "user_tts_chars", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "char_count": { + "name": "char_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "columns": [ + "user_id", + "date" + ], + "name": "user_tts_chars_user_id_date_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index fafa753..8db8d88 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -36,6 +36,27 @@ "when": 1778644075081, "tag": "0004_admin_panel", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1779041717890, + "tag": "0005_pdf_layout_and_compute", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1779102950385, + "tag": "0006_preview-defaults-cleanup", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1779378125905, + "tag": "0007_pdf_parse_progress", + "breakpoints": true } ] } \ No newline at end of file diff --git a/empty-module.ts b/empty-module.ts index 7c645e4..de5f48e 100644 --- a/empty-module.ts +++ b/empty-module.ts @@ -1 +1,3 @@ -export default {}; \ No newline at end of file +const emptyModule = {}; + +export default emptyModule; diff --git a/eslint.config.mjs b/eslint.config.mjs index c85fb67..2dac3b5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,8 +9,114 @@ const compat = new FlatCompat({ baseDirectory: __dirname, }); +const LOGGER_LEVEL_METHOD = "trace|debug|info|warn|error|fatal"; +const STATIC_LOGGER_CALL_SELECTOR = + `CallExpression[callee.type='MemberExpression'][callee.computed=false][callee.property.name=/^(${LOGGER_LEVEL_METHOD})$/]`; +const DYNAMIC_LOGGER_CALL_SELECTOR = + "CallExpression[callee.type='MemberExpression'][callee.computed=true]"; +const LOGGER_RECEIVER_SELECTOR = + ":matches([callee.object.name=/^(logger|serverLogger)$/],[callee.object.property.name='logger'])"; +const SERVER_LOGGER_CALL_SELECTOR = `:matches(${STATIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR},${DYNAMIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR})`; +const NEXT_RESPONSE_ERROR_JSON_SELECTOR = + "CallExpression[callee.type='MemberExpression'][callee.object.name='NextResponse'][callee.property.name='json'][arguments.0.type='ObjectExpression']:has(Property[key.name='error'])"; + const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), + { + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: [ + "@openreader/compute-core/*", + "!@openreader/compute-core/local-runtime", + "!@openreader/compute-core/types", + "!@openreader/compute-core/api-contracts", + ], + message: + "Use '@openreader/compute-core' root imports for light APIs. Allowed subpaths are '@openreader/compute-core/local-runtime', '@openreader/compute-core/types', and '@openreader/compute-core/api-contracts'.", + }, + ], + }, + ], + }, + }, + { + files: ["src/app/api/**/*.ts", "src/lib/server/**/*.ts"], + rules: { + "no-console": "error", + "no-restricted-syntax": [ + "error", + { + selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.length<2]`, + message: + "Server logger calls must pass context + message: logger.({ event, ...ctx }, 'message').", + }, + { + selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.0.type!='ObjectExpression']`, + message: + "Server logger first argument must be an object with an event field.", + }, + { + selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.0.type='Literal']`, + message: + "Server logger first argument must be an object with an event field, not a string literal.", + }, + { + selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.0.type='TemplateLiteral']`, + message: + "Server logger first argument must be an object with an event field, not a template string.", + }, + { + selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.0.type='ObjectExpression']:not(:has(Property[key.name='event']))`, + message: + "Server logger context object must include an event field.", + }, + { + selector: `${SERVER_LOGGER_CALL_SELECTOR}[arguments.1.type!='Literal'][arguments.1.type!='TemplateLiteral']`, + message: + "Server logger second argument must be a message string (literal or template literal).", + }, + { + selector: `${STATIC_LOGGER_CALL_SELECTOR}[callee.property.name='error']${LOGGER_RECEIVER_SELECTOR}[arguments.0.type='ObjectExpression']:not(:has(ObjectExpression > Property[key.name='error'])):not(:has(ObjectExpression > SpreadElement))`, + message: + "Error-level server logger calls must include nested `error` payload (prefer `error: errorToLog(...)`).", + }, + { + selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='detail']`, + message: + "Do not use top-level `detail` in server logger context; keep throwable text under `error.message`.", + }, + { + selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='errorCode']`, + message: + "Do not use top-level `errorCode` in server logger context; classify failures under nested `error.code`.", + }, + { + selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='err']`, + message: + "Use `error` (typically from errorToLog(...)) instead of `err` in server logs.", + }, + { + selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='error'][value.type='Literal']`, + message: + "Server logger `error` must be a structured object (prefer `errorToLog(...)`), not a literal.", + }, + { + selector: `${SERVER_LOGGER_CALL_SELECTOR} > ObjectExpression:first-child > Property[key.name='error'][value.type='TemplateLiteral']`, + message: + "Server logger `error` must be a structured object (prefer `errorToLog(...)`), not template text.", + }, + { + selector: `CatchClause ReturnStatement > ${NEXT_RESPONSE_ERROR_JSON_SELECTOR}[arguments.1.type='ObjectExpression']:has(Property[key.name='status'][value.value=500])`, + message: + "Use shared error response helpers (e.g. errorResponse(...)) for terminal 500 route failures instead of direct NextResponse.json({ error }).", + }, + ], + }, + }, ]; export default eslintConfig; diff --git a/next.config.ts b/next.config.ts index 5179aeb..b2952b3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -15,6 +15,14 @@ const securityHeaders = [ }, ]; +const bundleWorkerCompute = true; +const serverExternalPackages = [ + '@napi-rs/canvas', + 'better-sqlite3', + 'ffmpeg-static', + ...(!bundleWorkerCompute ? ['onnxruntime-node', '@huggingface/tokenizers'] : []), +]; + const nextConfig: NextConfig = { async headers() { return [ @@ -30,7 +38,8 @@ const nextConfig: NextConfig = { canvas: '@napi-rs/canvas', }, }, - serverExternalPackages: ["@napi-rs/canvas", "ffmpeg-static", "better-sqlite3"], + transpilePackages: [], + serverExternalPackages, outputFileTracingIncludes: { '/api/audiobook': [ './node_modules/ffmpeg-static/ffmpeg', @@ -38,7 +47,7 @@ const nextConfig: NextConfig = { '/api/audiobook/chapter': [ './node_modules/ffmpeg-static/ffmpeg', ], - '/api/whisper': [ + '/api/tts/segments/ensure': [ './node_modules/ffmpeg-static/ffmpeg', ], '/api/documents/blob/preview/ensure': [ @@ -51,6 +60,24 @@ const nextConfig: NextConfig = { './node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs', ], }, + outputFileTracingExcludes: { + '/*': [ + './docstore/**/*', + './node_modules/onnxruntime-node/**/*', + './node_modules/@huggingface/tokenizers/**/*', + ], + }, + webpack: (config, { isServer }) => { + if (isServer && bundleWorkerCompute) { + config.resolve.alias = { + ...(config.resolve.alias || {}), + '@openreader/compute-core/local-runtime$': false, + 'onnxruntime-node$': false, + '@huggingface/tokenizers$': false, + }; + } + return config; + }, }; export default nextConfig; diff --git a/package.json b/package.json index bd5aeaf..f509a63 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw", "dev:raw": "next dev --turbopack -p 3003", "build": "next build", + "build:bundle-guard": "node scripts/check-next-server-bundle.mjs", "start": "node scripts/openreader-entrypoint.mjs -- pnpm start:raw", "start:raw": "next start -p 3003", "lint": "next lint", @@ -20,12 +21,18 @@ "docs:build": "pnpm --dir docs-site build", "docs:serve": "pnpm --dir docs-site serve", "docs:version": "pnpm --dir docs-site docusaurus docs:version", - "docs:clear": "pnpm --dir docs-site clear" + "docs:clear": "pnpm --dir docs-site clear", + "compute:worker:dev": "pnpm --filter @openreader/compute-worker dev", + "compute:worker:start": "pnpm --filter @openreader/compute-worker start", + "compute:dev:compose": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --build", + "compute:dev:watch": "docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch", + "lint:route-errors": "node scripts/check-route-error-responses.mjs" }, "dependencies": { "@aws-sdk/client-s3": "^3.1045.0", "@aws-sdk/s3-request-presigner": "^3.1045.0", "@headlessui/react": "^2.2.10", + "@huggingface/tokenizers": "^0.1.3", "@napi-rs/canvas": "^0.1.100", "@tanstack/react-query": "^5.100.10", "@types/archiver": "^7.0.0", @@ -48,9 +55,12 @@ "jszip": "^3.10.1", "lru-cache": "^11.3.6", "next": "^15.5.18", + "onnxruntime-node": "^1.26.0", "openai": "^6.37.0", "pdfjs-dist": "4.8.69", "pg": "^8.20.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "react": "^19.2.6", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", diff --git a/playwright.config.ts b/playwright.config.ts index ae80706..3c080b9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,6 +6,7 @@ import 'dotenv/config'; */ export default defineConfig({ testDir: './tests', + tsconfig: './tsconfig.json', timeout: 30 * 1000, outputDir: './tests/results', globalTeardown: './tests/global-teardown.ts', @@ -29,7 +30,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { // Disable auth rate limiting for tests to support parallel workers creating sessions - command: `npm run build && DISABLE_AUTH_RATE_LIMIT=true npm run start`, + command: `pnpm build && DISABLE_AUTH_RATE_LIMIT=true pnpm start`, url: 'http://localhost:3003', reuseExistingServer: !process.env.CI, timeout: 120 * 1000, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f76116..1cebe16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@headlessui/react': specifier: ^2.2.10 version: 2.2.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@huggingface/tokenizers': + specifier: ^0.1.3 + version: 0.1.3 '@napi-rs/canvas': specifier: ^0.1.100 version: 0.1.100 @@ -88,6 +91,9 @@ importers: next: specifier: ^15.5.18 version: 15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + onnxruntime-node: + specifier: ^1.26.0 + version: 1.26.0 openai: specifier: ^6.37.0 version: 6.37.0(zod@4.4.3) @@ -97,6 +103,12 @@ importers: pg: specifier: ^8.20.0 version: 8.20.0 + pino: + specifier: ^10.3.1 + version: 10.3.1 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 react: specifier: ^19.2.6 version: 19.2.6 @@ -142,7 +154,7 @@ importers: version: 1.60.0 '@tailwindcss/typography': specifier: ^0.5.19 - version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)) + version: 0.5.19(tailwindcss@3.4.19(tsx@4.22.3)) '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 @@ -175,11 +187,65 @@ importers: version: 8.5.14 tailwindcss: specifier: ^3.4.19 - version: 3.4.19(tsx@4.21.0) + version: 3.4.19(tsx@4.22.3) typescript: specifier: ^5.9.3 version: 5.9.3 + compute/core: + dependencies: + '@huggingface/tokenizers': + specifier: ^0.1.3 + version: 0.1.3 + '@napi-rs/canvas': + specifier: ^0.1.100 + version: 0.1.100 + ffmpeg-static: + specifier: ^5.3.0 + version: 5.3.0 + jszip: + specifier: ^3.10.1 + version: 3.10.1 + onnxruntime-node: + specifier: ^1.26.0 + version: 1.26.0 + pdfjs-dist: + specifier: 4.8.69 + version: 4.8.69 + + compute/worker: + dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1050.0 + version: 3.1050.0 + '@nats-io/jetstream': + specifier: ^3.4.0 + version: 3.4.0 + '@nats-io/kv': + specifier: ^3.4.0 + version: 3.4.0 + '@nats-io/transport-node': + specifier: ^3.4.0 + version: 3.4.0 + '@openreader/compute-core': + specifier: workspace:* + version: link:../core + fastify: + specifier: ^5.6.2 + version: 5.8.5 + pino: + specifier: ^10.3.1 + version: 10.3.1 + pino-pretty: + specifier: ^13.1.2 + version: 13.1.3 + tsx: + specifier: ^4.22.3 + version: 4.22.3 + zod: + specifier: ^4.1.12 + version: 4.4.3 + packages: '@alloc/quick-lru@5.2.0': @@ -213,6 +279,14 @@ packages: resolution: {integrity: sha512-fsuO3Y6t+3Ro9Bsg41DKj4Sfy53CGSrhnMldNplWmG8Tx0UbYk+YDa4RD1hVlJpERw4JBmPkl0+J9qlxMh1pcA==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-s3@3.1050.0': + resolution: {integrity: sha512-9kgtv+bXZQrOIJT2INPPBCezrJu1FlgGrzEat/ut4A4V53IT00LynsBZgp12eFKbjJuNCeTo7iPSKjPsX8ub+A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.12': + resolution: {integrity: sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.8': resolution: {integrity: sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==} engines: {node: '>=20.0.0'} @@ -221,50 +295,98 @@ packages: resolution: {integrity: sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==} engines: {node: '>=20.0.0'} + '@aws-sdk/crc64-nvme@3.972.8': + resolution: {integrity: sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.34': resolution: {integrity: sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.38': + resolution: {integrity: sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.36': resolution: {integrity: sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.40': + resolution: {integrity: sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.38': resolution: {integrity: sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.42': + resolution: {integrity: sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.38': resolution: {integrity: sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.42': + resolution: {integrity: sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.39': resolution: {integrity: sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.43': + resolution: {integrity: sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.34': resolution: {integrity: sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.38': + resolution: {integrity: sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.38': resolution: {integrity: sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.42': + resolution: {integrity: sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.38': resolution: {integrity: sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.42': + resolution: {integrity: sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-bucket-endpoint@3.972.10': resolution: {integrity: sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-bucket-endpoint@3.972.14': + resolution: {integrity: sha512-Aaj0d+xbo1jJquBWJP0/9V/XZRYukO3LWIRp3dOLHmoFrYKb4YZ0aLefgVHfGcNOVBS2ZTq7L/n5JcrE7DaC+Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-expect-continue@3.972.10': resolution: {integrity: sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-expect-continue@3.972.12': + resolution: {integrity: sha512-dA5pKTom/Ls9mgeyeaRBNQrRIVOLVjv4AmKOB0/e4yaiXEUy0gSz2d3liP8JHtYoCAEWySU1jWnyzwLOREN+4g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-flexible-checksums@3.974.16': resolution: {integrity: sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-flexible-checksums@3.974.20': + resolution: {integrity: sha512-NdnMVQCR1YjIcqFAiNLdBiOwr2DyQDB2IiXQrBhzolKOv32ae4d4Ll7IzLMi04eMHiq/o/Y/GjFuVjF9HuG0QA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-host-header@3.972.10': resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} engines: {node: '>=20.0.0'} @@ -285,6 +407,10 @@ packages: resolution: {integrity: sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-s3@3.972.41': + resolution: {integrity: sha512-M4T2I2WPuH5WQpU8Tsp+u2bcO29zGRkU14ATzuqb9I4xh8tzsLqtp4hzaJM5aO2dhMZnHDzyQwSFVgc3XbnoGg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-ssec@3.972.10': resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==} engines: {node: '>=20.0.0'} @@ -293,6 +419,10 @@ packages: resolution: {integrity: sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.10': + resolution: {integrity: sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.6': resolution: {integrity: sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==} engines: {node: '>=20.0.0'} @@ -309,10 +439,18 @@ packages: resolution: {integrity: sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.27': + resolution: {integrity: sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1041.0': resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1049.0': + resolution: {integrity: sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.8': resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} @@ -349,6 +487,10 @@ packages: resolution: {integrity: sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.24': + resolution: {integrity: sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} @@ -472,6 +614,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.18.20': resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} @@ -490,6 +638,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.18.20': resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} @@ -508,6 +662,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.18.20': resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} engines: {node: '>=12'} @@ -526,6 +686,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.18.20': resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} engines: {node: '>=12'} @@ -544,6 +710,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.18.20': resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} engines: {node: '>=12'} @@ -562,6 +734,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} engines: {node: '>=12'} @@ -580,6 +758,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} engines: {node: '>=12'} @@ -598,6 +782,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.18.20': resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} engines: {node: '>=12'} @@ -616,6 +806,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.18.20': resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} engines: {node: '>=12'} @@ -634,6 +830,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.18.20': resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} engines: {node: '>=12'} @@ -652,6 +854,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.18.20': resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} @@ -670,6 +878,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.18.20': resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} engines: {node: '>=12'} @@ -688,6 +902,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.18.20': resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} engines: {node: '>=12'} @@ -706,6 +926,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.18.20': resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} engines: {node: '>=12'} @@ -724,6 +950,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.18.20': resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} engines: {node: '>=12'} @@ -742,6 +974,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.18.20': resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} engines: {node: '>=12'} @@ -760,6 +998,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -772,6 +1016,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} engines: {node: '>=12'} @@ -790,6 +1040,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -802,6 +1058,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} engines: {node: '>=12'} @@ -820,6 +1082,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -832,6 +1100,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.20': resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} @@ -850,6 +1124,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.18.20': resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} engines: {node: '>=12'} @@ -868,6 +1148,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.18.20': resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} engines: {node: '>=12'} @@ -886,6 +1172,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.18.20': resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} engines: {node: '>=12'} @@ -904,6 +1196,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -942,6 +1240,24 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -970,6 +1286,9 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + '@huggingface/tokenizers@0.1.3': + resolution: {integrity: sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1247,6 +1566,27 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nats-io/jetstream@3.4.0': + resolution: {integrity: sha512-GzHQodNJ942+R5LRb8PuZ5ugVWVWMRiufxUYLLVWkXKfwDXYN+Owo0d7L/b9O7BPyrbYD7jQWAC6+ZVuXa9Gyw==} + + '@nats-io/kv@3.4.0': + resolution: {integrity: sha512-168pcRJxWcIRHwdyczI3DaGk5r3CAj3CyKXuk4Nmx5KKj7N1znQOJPIxCoV0TPlAvoTidVb7eBv41J9imapMeQ==} + + '@nats-io/nats-core@3.4.0': + resolution: {integrity: sha512-QMDM86EUNm+wudlKC67HLar/KHHQUvJGLfb4jbahje3pUk3K9afeck9fwsxBZF0eUFox6T/TA6m/t+lQqf+QsQ==} + + '@nats-io/nkeys@2.0.3': + resolution: {integrity: sha512-JVt56GuE6Z89KUkI4TXUbSI9fmIfAmk6PMPknijmuL72GcD+UgIomTcRWiNvvJKxA01sBbmIPStqJs5cMRBC3A==} + engines: {node: '>=18.0.0'} + + '@nats-io/nuid@3.0.0': + resolution: {integrity: sha512-QbXZDrxmYlrn5AD06gYcUTmEHwxn96HBQMIk7XTjDlEcx6FPzVBBPjp4AMRh1lEv6B4iJ6Xb/Uz61JugPXFRQg==} + engines: {node: '>= 22.x'} + + '@nats-io/transport-node@3.4.0': + resolution: {integrity: sha512-hH7u7ejIBTFEJIZ8rIcMrHJI6wl+HhpO5sVFs1+ppmXa8RuB2+Lh1+UwTzZ5xTNNm1TKcRkYy+2qCV56qp8RxA==} + engines: {node: '>= 18.0.0'} + '@next/env@15.5.18': resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} @@ -1336,6 +1676,9 @@ packages: resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} engines: {node: '>=14'} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1384,11 +1727,20 @@ packages: '@smithy/core@3.24.1': resolution: {integrity: sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==} engines: {node: '>=18.0.0'} + deprecated: Deprecated due to bug in browser bundling instructions https://github.com/smithy-lang/smithy-typescript/issues/2025 + + '@smithy/core@3.24.3': + resolution: {integrity: sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==} + engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.3.1': resolution: {integrity: sha512-0S/acwHnqX4WrjXzhdiDRxsG2s9SC0cpPIK9nZ1R6UOHd+j7uL28+4bHu22urbLk2TVw3fkp6na/+fkUt/pLNQ==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.3.3': + resolution: {integrity: sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-serde-browser@4.3.1': resolution: {integrity: sha512-X7MyI1fu8M84IPKk49kO4kb27Mqp6un9/0o/MsA1ngZ5OxxWKGUxPS3S/AJ9q1cPVTSGmRcbaGNfGUSsflTJkg==} engines: {node: '>=18.0.0'} @@ -1405,6 +1757,10 @@ packages: resolution: {integrity: sha512-r7bN6spQ+caZC8AnyvSxkRUb57zt2jhhRw3Z+2Ez8hjq6coIikDBFUUI/+CQ1xx9K6eX1Gx6wUKo4ylU66TIqw==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.4.3': + resolution: {integrity: sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==} + engines: {node: '>=18.0.0'} + '@smithy/hash-blob-browser@4.3.1': resolution: {integrity: sha512-2fbltQVQYmGd0OzPv2oDMRF0pxkzeIx8cbpx2x6W3UJWGaEyUzVPxF4d0sDXZ/r2obg+RbTyhTidXWlPDsKRKw==} engines: {node: '>=18.0.0'} @@ -1461,6 +1817,10 @@ packages: resolution: {integrity: sha512-BdEYko85f/ldp68uH8XEyIvo810xFk6eyPH81SRggTOApYHWA+Xu7B2EzLuHbe37WVLaUA7F1fWR3/zBeme2WA==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.7.3': + resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} + engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.3.1': resolution: {integrity: sha512-3NHoqVBhzpY2b4YBx9AqyKC4C8nnEjl5FyKuxrCjvnjinG0ODj+yg1xX360nNahT6wghYjSw1SooCt3kIdnqIA==} engines: {node: '>=18.0.0'} @@ -1481,6 +1841,10 @@ packages: resolution: {integrity: sha512-728lZZEWYWubBESrfntNslZQYDKRlJDY4dcDnYbL50+gu35pGPLblu4S0/RH/RDLF6me1M87ECHsHELGL7dA/Q==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.4.3': + resolution: {integrity: sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==} + engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.13.1': resolution: {integrity: sha512-IcznNM8Qd9u1X3oflp12tkzyOB4HbT+sfYWlWiyEysgNzSHoWcHUUsTT4y1jjDjtVuuVVQbYks+g1kVd7u1eGQ==} engines: {node: '>=18.0.0'} @@ -1489,6 +1853,10 @@ packages: resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} engines: {node: '>=18.0.0'} + '@smithy/types@4.14.2': + resolution: {integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.3.1': resolution: {integrity: sha512-tuelFlF2PZR/wogFC58NIrPOv+Zna4N1+3kA161/33D1Gbwvl6Nh4WsAsW05ZyPp0O6CMGsdbb0S2b/qVjRMCw==} engines: {node: '>=18.0.0'} @@ -1844,6 +2212,9 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1854,13 +2225,28 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.17: + resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} + engines: {node: '>=12.0'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1948,6 +2334,10 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + attr-accept@2.2.5: resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} engines: {node: '>=4'} @@ -1956,6 +2346,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + axe-core@4.11.4: resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} engines: {node: '>=4'} @@ -2214,6 +2607,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2236,6 +2632,10 @@ packages: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} @@ -2282,6 +2682,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2545,6 +2948,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2704,6 +3112,12 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-copy@4.0.3: + resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2721,9 +3135,21 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -2731,10 +3157,17 @@ packages: resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} hasBin: true + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + fast-xml-parser@5.8.0: resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} hasBin: true + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2766,6 +3199,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2843,6 +3280,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + global-agent@4.1.3: + resolution: {integrity: sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==} + engines: {node: '>=10.0'} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2900,6 +3341,9 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -2951,6 +3395,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -3105,6 +3553,10 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3115,9 +3567,15 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3160,6 +3618,9 @@ packages: lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -3206,6 +3667,10 @@ packages: marks-pane@1.0.9: resolution: {integrity: sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==} + matcher@4.0.0: + resolution: {integrity: sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3485,9 +3950,20 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onnxruntime-common@1.26.0: + resolution: {integrity: sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==} + + onnxruntime-node@1.26.0: + resolution: {integrity: sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ==} + os: [win32, darwin, linux] + openai@6.37.0: resolution: {integrity: sha512-0H5dEGFmmLv6KSd0W1w2nyL8WsLkX6yoLeQpU+dZAOuGcany5qkYQMmj35ZrKgb6yiyYqpUzFOpR8mZQkgqeEQ==} hasBin: true @@ -3611,6 +4087,20 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -3713,6 +4203,12 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -3737,6 +4233,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -3840,6 +4339,13 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + redux@4.2.1: resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} @@ -3867,6 +4373,10 @@ packages: resolution: {integrity: sha512-1ufKejfUVz/azy+5TnzQP7U1+MHVWZ6psnQ06az8byUUnRhT+DZ/MvewzB1NQYBVMgNKR7xPDtTwlcP5nv/5+w==} engines: {git: '>=2.11.0', node: '>=18.0.0', npm: '>=7.19.0', yarn: '>=1.7.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3884,10 +4394,17 @@ packages: engines: {node: '>= 0.4'} hasBin: true + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} @@ -3912,9 +4429,20 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3924,6 +4452,13 @@ packages: engines: {node: '>=10'} hasBin: true + serialize-error@8.1.0: + resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==} + engines: {node: '>=10'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-cookie-parser@3.1.0: resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} @@ -3980,6 +4515,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4068,6 +4606,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + strnum@2.3.0: resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} @@ -4137,6 +4679,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4148,6 +4694,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.1: + resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4174,13 +4724,25 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.22.3: + resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + type@2.7.3: resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} @@ -4429,6 +4991,38 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-s3@3.1050.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/credential-provider-node': 3.972.43 + '@aws-sdk/middleware-bucket-endpoint': 3.972.14 + '@aws-sdk/middleware-expect-continue': 3.972.12 + '@aws-sdk/middleware-flexible-checksums': 3.974.20 + '@aws-sdk/middleware-location-constraint': 3.972.10 + '@aws-sdk/middleware-sdk-s3': 3.972.41 + '@aws-sdk/middleware-ssec': 3.972.10 + '@aws-sdk/signature-v4-multi-region': 3.996.27 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/fetch-http-handler': 5.4.3 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.12': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/xml-builder': 3.972.24 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.3 + '@smithy/signature-v4': 5.4.3 + '@smithy/types': 4.14.1 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/core@3.974.8': dependencies: '@aws-sdk/types': 3.973.8 @@ -4451,6 +5045,11 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/crc64-nvme@3.972.8': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.34': dependencies: '@aws-sdk/core': 3.974.8 @@ -4459,6 +5058,14 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.36': dependencies: '@aws-sdk/core': 3.974.8 @@ -4472,6 +5079,16 @@ snapshots: '@smithy/util-stream': 4.6.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.40': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/fetch-http-handler': 5.4.3 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.38': dependencies: '@aws-sdk/core': 3.974.8 @@ -4491,6 +5108,22 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-ini@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/credential-provider-env': 3.972.38 + '@aws-sdk/credential-provider-http': 3.972.40 + '@aws-sdk/credential-provider-login': 3.972.42 + '@aws-sdk/credential-provider-process': 3.972.38 + '@aws-sdk/credential-provider-sso': 3.972.42 + '@aws-sdk/credential-provider-web-identity': 3.972.42 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/credential-provider-imds': 4.3.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.38': dependencies: '@aws-sdk/core': 3.974.8 @@ -4504,6 +5137,15 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-login@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.39': dependencies: '@aws-sdk/credential-provider-env': 3.972.34 @@ -4521,6 +5163,20 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-node@3.972.43': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.38 + '@aws-sdk/credential-provider-http': 3.972.40 + '@aws-sdk/credential-provider-ini': 3.972.42 + '@aws-sdk/credential-provider-process': 3.972.38 + '@aws-sdk/credential-provider-sso': 3.972.42 + '@aws-sdk/credential-provider-web-identity': 3.972.42 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/credential-provider-imds': 4.3.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.34': dependencies: '@aws-sdk/core': 3.974.8 @@ -4530,6 +5186,14 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.38': dependencies: '@aws-sdk/core': 3.974.8 @@ -4543,6 +5207,16 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-sso@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/token-providers': 3.1049.0 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.38': dependencies: '@aws-sdk/core': 3.974.8 @@ -4555,6 +5229,15 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.42': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/middleware-bucket-endpoint@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 @@ -4565,6 +5248,14 @@ snapshots: '@smithy/util-config-provider': 4.3.1 tslib: 2.8.1 + '@aws-sdk/middleware-bucket-endpoint@3.972.14': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/middleware-expect-continue@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 @@ -4572,6 +5263,13 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/middleware-expect-continue@3.972.12': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/middleware-flexible-checksums@3.974.16': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -4589,6 +5287,18 @@ snapshots: '@smithy/util-utf8': 4.3.1 tslib: 2.8.1 + '@aws-sdk/middleware-flexible-checksums@3.974.20': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/crc64-nvme': 3.972.8 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 @@ -4633,6 +5343,16 @@ snapshots: '@smithy/util-utf8': 4.3.1 tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.972.41': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/signature-v4-multi-region': 3.996.27 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/signature-v4': 5.4.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/middleware-ssec@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 @@ -4650,6 +5370,19 @@ snapshots: '@smithy/util-retry': 4.4.1 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.10': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.12 + '@aws-sdk/signature-v4-multi-region': 3.996.27 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/fetch-http-handler': 5.4.3 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.6': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -4722,6 +5455,14 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.27': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/signature-v4': 5.4.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1041.0': dependencies: '@aws-sdk/core': 3.974.8 @@ -4734,6 +5475,15 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.1049.0': + dependencies: + '@aws-sdk/core': 3.974.12 + '@aws-sdk/nested-clients': 3.997.10 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/types@3.973.8': dependencies: '@smithy/types': 4.14.1 @@ -4785,6 +5535,13 @@ snapshots: fast-xml-parser: 5.7.2 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.24': + dependencies: + '@nodable/entities': 2.1.0 + '@smithy/types': 4.14.1 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} '@babel/runtime@7.29.2': {} @@ -4883,6 +5640,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.18.20': optional: true @@ -4892,6 +5652,9 @@ snapshots: '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.18.20': optional: true @@ -4901,6 +5664,9 @@ snapshots: '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.18.20': optional: true @@ -4910,6 +5676,9 @@ snapshots: '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.18.20': optional: true @@ -4919,6 +5688,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.18.20': optional: true @@ -4928,6 +5700,9 @@ snapshots: '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.18.20': optional: true @@ -4937,6 +5712,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.18.20': optional: true @@ -4946,6 +5724,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.18.20': optional: true @@ -4955,6 +5736,9 @@ snapshots: '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.18.20': optional: true @@ -4964,6 +5748,9 @@ snapshots: '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.18.20': optional: true @@ -4973,6 +5760,9 @@ snapshots: '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.18.20': optional: true @@ -4982,6 +5772,9 @@ snapshots: '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.18.20': optional: true @@ -4991,6 +5784,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.18.20': optional: true @@ -5000,6 +5796,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.18.20': optional: true @@ -5009,6 +5808,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.18.20': optional: true @@ -5018,6 +5820,9 @@ snapshots: '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.18.20': optional: true @@ -5027,12 +5832,18 @@ snapshots: '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.18.20': optional: true @@ -5042,12 +5853,18 @@ snapshots: '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.18.20': optional: true @@ -5057,12 +5874,18 @@ snapshots: '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.18.20': optional: true @@ -5072,6 +5895,9 @@ snapshots: '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.18.20': optional: true @@ -5081,6 +5907,9 @@ snapshots: '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.18.20': optional: true @@ -5090,6 +5919,9 @@ snapshots: '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.18.20': optional: true @@ -5099,6 +5931,9 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': dependencies: eslint: 9.39.4(jiti@1.21.7) @@ -5145,6 +5980,29 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -5180,6 +6038,8 @@ snapshots: react-dom: 19.2.6(react@19.2.6) use-sync-external-store: 1.6.0(react@19.2.6) + '@huggingface/tokenizers@0.1.3': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -5382,6 +6242,32 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@nats-io/jetstream@3.4.0': + dependencies: + '@nats-io/nats-core': 3.4.0 + + '@nats-io/kv@3.4.0': + dependencies: + '@nats-io/jetstream': 3.4.0 + '@nats-io/nats-core': 3.4.0 + + '@nats-io/nats-core@3.4.0': + dependencies: + '@nats-io/nkeys': 2.0.3 + '@nats-io/nuid': 3.0.0 + + '@nats-io/nkeys@2.0.3': + dependencies: + tweetnacl: 1.0.3 + + '@nats-io/nuid@3.0.0': {} + + '@nats-io/transport-node@3.4.0': + dependencies: + '@nats-io/nats-core': 3.4.0 + '@nats-io/nkeys': 2.0.3 + '@nats-io/nuid': 3.0.0 + '@next/env@15.5.18': {} '@next/eslint-plugin-next@15.5.18': @@ -5434,6 +6320,8 @@ snapshots: '@opentelemetry/semantic-conventions@1.41.1': {} + '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -5481,12 +6369,24 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@smithy/core@3.24.3': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.3.1': dependencies: '@smithy/core': 3.24.1 '@smithy/types': 4.14.1 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.3.3': + dependencies: + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/eventstream-serde-browser@4.3.1': dependencies: '@smithy/core': 3.24.1 @@ -5508,6 +6408,12 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.4.3': + dependencies: + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/hash-blob-browser@4.3.1': dependencies: '@smithy/core': 3.24.1 @@ -5578,6 +6484,12 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@smithy/node-http-handler@4.7.3': + dependencies: + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/property-provider@4.3.1': dependencies: '@smithy/core': 3.24.1 @@ -5604,6 +6516,12 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@smithy/signature-v4@5.4.3': + dependencies: + '@smithy/core': 3.24.3 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + '@smithy/smithy-client@4.13.1': dependencies: '@smithy/core': 3.24.1 @@ -5614,6 +6532,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.14.2': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.3.1': dependencies: '@smithy/core': 3.24.1 @@ -5699,10 +6621,10 @@ snapshots: dependencies: tslib: 2.8.1 - '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0))': + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.22.3))': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.19(tsx@4.21.0) + tailwindcss: 3.4.19(tsx@4.22.3) '@tanstack/query-core@5.100.10': {} @@ -5951,18 +6873,26 @@ snapshots: dependencies: event-target-shim: 5.0.1 + abstract-logging@2.0.1: {} + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 acorn@8.16.0: {} + adm-zip@0.5.17: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 transitivePeerDependencies: - supports-color + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -5970,6 +6900,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -6094,12 +7031,19 @@ snapshots: async@3.2.6: {} + atomic-sleep@1.0.0: {} + attr-accept@2.2.5: {} available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + axe-core@4.11.4: {} axobject-query@4.1.0: {} @@ -6308,6 +7252,8 @@ snapshots: color-name@1.1.4: {} + colorette@2.0.20: {} + comma-separated-tokens@2.0.3: {} commander@4.1.1: {} @@ -6335,6 +7281,8 @@ snapshots: readable-stream: 3.6.2 typedarray: 0.0.6 + cookie@1.1.1: {} + core-js@3.49.0: {} core-util-is@1.0.3: {} @@ -6381,6 +7329,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dateformat@4.6.3: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -6697,6 +7647,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -6931,6 +7910,10 @@ snapshots: extend@3.0.2: {} + fast-copy@4.0.3: {} + + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -6953,8 +7936,25 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + fast-levenshtein@2.0.6: {} + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.2: {} + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -6967,6 +7967,13 @@ snapshots: path-expression-matcher: 1.5.0 strnum: 2.3.0 + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + fast-xml-parser@5.8.0: dependencies: '@nodable/entities': 2.1.0 @@ -6975,6 +7982,24 @@ snapshots: strnum: 2.3.0 xml-naming: 0.1.0 + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.0 + toad-cache: 3.7.1 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -7006,6 +8031,12 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -7097,6 +8128,13 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + global-agent@4.1.3: + dependencies: + globalthis: 1.0.4 + matcher: 4.0.0 + semver: 7.8.0 + serialize-error: 8.1.0 + globals@14.0.0: {} globalthis@1.0.4: @@ -7160,6 +8198,8 @@ snapshots: dependencies: '@types/hast': 3.0.4 + help-me@5.0.0: {} + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -7206,6 +8246,8 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + ipaddr.js@2.4.0: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -7364,6 +8406,8 @@ snapshots: jose@6.2.3: {} + joycon@3.1.1: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -7372,8 +8416,14 @@ snapshots: json-buffer@3.0.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -7423,6 +8473,12 @@ snapshots: dependencies: immediate: 3.0.6 + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -7457,6 +8513,10 @@ snapshots: marks-pane@1.0.9: {} + matcher@4.0.0: + dependencies: + escape-string-regexp: 4.0.0 + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -7942,10 +9002,20 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + on-exit-leak-free@2.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 + onnxruntime-common@1.26.0: {} + + onnxruntime-node@1.26.0: + dependencies: + adm-zip: 0.5.17 + global-agent: 4.1.3 + onnxruntime-common: 1.26.0 + openai@6.37.0(zod@4.4.3): optionalDependencies: zod: 4.4.3 @@ -8059,6 +9129,42 @@ snapshots: pify@2.3.0: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.3 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + pirates@4.0.7: {} playwright-core@1.60.0: {} @@ -8083,13 +9189,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.14 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.14)(tsx@4.21.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.14)(tsx@4.22.3): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.14 - tsx: 4.21.0 + tsx: 4.22.3 postcss-nested@6.2.0(postcss@8.5.14): dependencies: @@ -8149,6 +9255,10 @@ snapshots: process-nextick-args@2.0.1: {} + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + process@0.11.10: {} progress@2.0.3: {} @@ -8170,6 +9280,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -8320,6 +9432,10 @@ snapshots: dependencies: picomatch: 2.3.2 + real-require@0.2.0: {} + + real-require@1.0.0: {} + redux@4.2.1: dependencies: '@babel/runtime': 7.29.2 @@ -8382,6 +9498,8 @@ snapshots: optionalDependencies: readable-stream: 4.7.0 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -8402,8 +9520,12 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + ret@0.5.0: {} + reusify@1.1.0: {} + rfdc@1.4.1: {} + rou3@0.7.12: {} run-parallel@1.2.0: @@ -8433,12 +9555,26 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + scheduler@0.27.0: {} + secure-json-parse@4.1.0: {} + semver@6.3.1: {} semver@7.8.0: {} + serialize-error@8.1.0: + dependencies: + type-fest: 0.20.2 + + set-cookie-parser@2.7.2: {} + set-cookie-parser@3.1.0: {} set-function-length@1.2.2: @@ -8541,6 +9677,10 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -8659,6 +9799,8 @@ snapshots: strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + strnum@2.3.0: {} style-to-js@1.1.21: @@ -8694,7 +9836,7 @@ snapshots: tabbable@6.4.0: {} - tailwindcss@3.4.19(tsx@4.21.0): + tailwindcss@3.4.19(tsx@4.22.3): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -8713,7 +9855,7 @@ snapshots: postcss: 8.5.14 postcss-import: 15.1.0(postcss@8.5.14) postcss-js: 4.1.0(postcss@8.5.14) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.14)(tsx@4.21.0) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.14)(tsx@4.22.3) postcss-nested: 6.2.0(postcss@8.5.14) postcss-selector-parser: 6.1.2 resolve: 1.22.12 @@ -8769,6 +9911,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tiny-invariant@1.3.3: {} tinyglobby@0.2.16: @@ -8780,6 +9926,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.1: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -8806,14 +9954,24 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.22.3: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 + tweetnacl@1.0.3: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 + type-fest@0.20.2: {} + type@2.7.3: {} typed-array-buffer@1.0.3: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 225f824..67ae185 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,6 @@ packages: - . - -strictDepBuilds: false + - compute/* allowBuilds: '@napi-rs/canvas': true @@ -11,10 +10,13 @@ allowBuilds: es5-ext: false esbuild: false ffmpeg-static: true + onnxruntime-node: true sharp: false unrs-resolver: false overrides: - lodash: ^4.17.23 - '@xmldom/xmldom': ^0.9.8 '@types/localforage': npm:empty-module@0.0.2 + '@xmldom/xmldom': ^0.9.8 + lodash: ^4.17.23 + +strictDepBuilds: false diff --git a/scripts/check-next-server-bundle.mjs b/scripts/check-next-server-bundle.mjs new file mode 100644 index 0000000..9621cb4 --- /dev/null +++ b/scripts/check-next-server-bundle.mjs @@ -0,0 +1,50 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const serverDir = path.join(root, '.next', 'server'); + +if (!fs.existsSync(serverDir)) { + console.error('[bundle-guard] Missing .next/server. Run `pnpm build` first.'); + process.exit(1); +} + +const forbidden = [ + 'onnxruntime-node', + '@huggingface/tokenizers', + '@openreader/compute-core/local-runtime', +]; + +const includeExt = new Set(['.js', '.mjs', '.cjs']); +const failures = []; + +function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name).toLowerCase(); + if (!includeExt.has(ext)) continue; + const text = fs.readFileSync(fullPath, 'utf8'); + for (const pattern of forbidden) { + if (text.includes(pattern)) { + failures.push({ file: fullPath, pattern }); + } + } + } +} + +walk(serverDir); + +if (failures.length > 0) { + console.error('[bundle-guard] Forbidden compute deps detected in Next server bundle:'); + for (const failure of failures) { + console.error(`- ${failure.pattern} in ${path.relative(root, failure.file)}`); + } + process.exit(1); +} + +console.info('[bundle-guard] OK: no forbidden compute deps in .next/server'); diff --git a/scripts/check-route-error-responses.mjs b/scripts/check-route-error-responses.mjs new file mode 100644 index 0000000..8770958 --- /dev/null +++ b/scripts/check-route-error-responses.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const ROOT = process.cwd(); +const API_DIR = path.join(ROOT, 'src', 'app', 'api'); + +async function walk(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await walk(full)); + continue; + } + if (entry.isFile() && full.endsWith('.ts')) files.push(full); + } + return files; +} + +function findViolations(source) { + const violations = []; + const catchWithDirectJson500 = + /catch\s*\(\s*error\s*\)\s*\{[\s\S]{0,1200}?NextResponse\.json\s*\(\s*\{\s*error:[\s\S]{0,240}status:\s*500/g; + let match; + while ((match = catchWithDirectJson500.exec(source)) !== null) { + const index = match.index; + const line = source.slice(0, index).split('\n').length; + violations.push(line); + } + return violations; +} + +const files = await walk(API_DIR); +const failures = []; +for (const file of files) { + const source = await fs.readFile(file, 'utf8'); + const lines = findViolations(source); + if (lines.length > 0) { + failures.push({ file, lines }); + } +} + +if (failures.length > 0) { + for (const failure of failures) { + for (const line of failure.lines) { + console.error(`${path.relative(ROOT, failure.file)}:${line} direct NextResponse.json({ error }) in catch`); + } + } + process.exit(1); +} + +console.log('Route error response check passed.'); diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index 4909709..97dcb5d 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -137,6 +137,12 @@ function hasWeedBinary() { return true; } +function hasNatsBinary() { + const probe = spawnSync('nats-server', ['-v'], { stdio: 'ignore' }); + if (probe.error) return false; + return true; +} + async function waitForEndpoint(url, timeoutSeconds) { const waitMs = Math.max(1, timeoutSeconds) * 1000; const deadline = Date.now() + waitMs; @@ -181,19 +187,20 @@ function spawnMainCommand(command, env) { const exitPromise = new Promise((resolve) => { child.on('error', (error) => { console.error('Failed to launch command:', error); - resolve(1); + resolve({ code: 1, signal: null, launchError: true }); }); child.on('exit', (code, signal) => { + console.error(`Main command exit event: code=${code ?? 'null'} signal=${signal ?? 'null'}.`); if (typeof code === 'number') { - resolve(code); + resolve({ code, signal: null, launchError: false }); return; } if (signal) { - resolve(1); + resolve({ code: 1, signal, launchError: false }); return; } - resolve(0); + resolve({ code: 0, signal: null, launchError: false }); }); }); @@ -311,12 +318,23 @@ async function main() { } const runtimeEnv = { ...process.env }; + runtimeEnv.LOG_FORMAT = withDefault(runtimeEnv.LOG_FORMAT, 'pretty'); let weedProc = null; let weedExitPromise = Promise.resolve(); + let natsProc = null; + let natsExitPromise = Promise.resolve(); + let workerProc = null; + let workerExitPromise = Promise.resolve(); let appProc = null; let shutdownPromise = null; + let isShuttingDown = false; + let fatalExitScheduled = false; let stopWeedStdoutForward = () => { }; let stopWeedStderrForward = () => { }; + let stopNatsStdoutForward = () => { }; + let stopNatsStderrForward = () => { }; + let stopWorkerStdoutForward = () => { }; + let stopWorkerStderrForward = () => { }; let didExit = false; const exitOnce = (code) => { @@ -325,16 +343,38 @@ async function main() { process.exit(code); }; + const scheduleFatalShutdown = (serviceName, code, signal, detail) => { + if (fatalExitScheduled || isShuttingDown || didExit) return; + fatalExitScheduled = true; + const codeLabel = typeof code === 'number' ? String(code) : 'null'; + const signalLabel = signal ?? 'null'; + const suffix = detail ? ` (${detail})` : ''; + console.error( + `Critical service "${serviceName}" exited unexpectedly: code=${codeLabel} signal=${signalLabel}${suffix}. ` + + 'Shutting down all services.', + ); + void shutdown('SIGTERM').finally(() => exitOnce(typeof code === 'number' && code !== 0 ? code : 1)); + }; + const shutdown = async (signal = 'SIGTERM') => { if (shutdownPromise) return shutdownPromise; + isShuttingDown = true; shutdownPromise = (async () => { await Promise.all([ terminateChild(appProc, signal, 4000), + terminateChild(workerProc, 'SIGTERM', 4000), + terminateChild(natsProc, 'SIGTERM', 4000), terminateChild(weedProc, 'SIGTERM', 4000), ]); await weedExitPromise; + await natsExitPromise; + await workerExitPromise; stopWeedStdoutForward(); stopWeedStderrForward(); + stopNatsStdoutForward(); + stopNatsStderrForward(); + stopWorkerStdoutForward(); + stopWorkerStderrForward(); })(); return shutdownPromise; }; @@ -374,7 +414,12 @@ async function main() { const waitTimeout = Number.isFinite(waitSec) ? waitSec : 20; const launchWeed = (endpointUrl) => { const parsedEndpoint = parseS3Endpoint(endpointUrl); - const weedArgs = ['mini', `-dir=${runtimeEnv.WEED_MINI_DIR}`]; + const weedArgs = [ + '-alsologtostderr=false', + '-stderrthreshold=WARNING', + 'mini', + `-dir=${runtimeEnv.WEED_MINI_DIR}`, + ]; weedArgs.push(`-s3.port=${parsedEndpoint.port}`); if (runningInDocker) { weedArgs.push('-ip.bind=0.0.0.0'); @@ -389,13 +434,13 @@ async function main() { weedExitPromise = once(weedProc, 'exit').then(() => undefined).catch(() => undefined); weedProc.on('exit', (code, signal) => { + if (isShuttingDown) return; if (typeof code === 'number' && code !== 0) { console.error(`Embedded weed mini exited with code ${code}.`); - return; - } - if (signal) { + } else if (signal) { console.error(`Embedded weed mini exited due to signal ${signal}.`); } + scheduleFatalShutdown('weed mini', code, signal, 'embedded storage service'); }); }; @@ -419,9 +464,108 @@ async function main() { } } + const embeddedWorkerPort = Number.parseInt(withDefault(runtimeEnv.EMBEDDED_COMPUTE_WORKER_PORT, '8081'), 10); + const embeddedNatsPort = Number.parseInt(withDefault(runtimeEnv.EMBEDDED_NATS_PORT, '4222'), 10); + const embeddedNatsMonitorPort = Number.parseInt(withDefault(runtimeEnv.EMBEDDED_NATS_MONITOR_PORT, '8222'), 10); + const shouldStartEmbeddedWorker = !Boolean(runtimeEnv.COMPUTE_WORKER_URL?.trim()); + + if (shouldStartEmbeddedWorker && !hasNatsBinary()) { + throw new Error( + '`nats-server` binary is required when COMPUTE_WORKER_URL is unset. ' + + 'Install nats-server or set COMPUTE_WORKER_URL and COMPUTE_WORKER_TOKEN for an external worker.', + ); + } + + if (shouldStartEmbeddedWorker) { + runtimeEnv.NATS_URL = withDefault(runtimeEnv.NATS_URL, `nats://127.0.0.1:${embeddedNatsPort}`); + runtimeEnv.COMPUTE_WORKER_URL = withDefault(runtimeEnv.COMPUTE_WORKER_URL, `http://127.0.0.1:${embeddedWorkerPort}`); + runtimeEnv.COMPUTE_WORKER_TOKEN = withDefault( + runtimeEnv.COMPUTE_WORKER_TOKEN, + randomBytes(24).toString('base64url'), + ); + runtimeEnv.COMPUTE_WORKER_HOST = withDefault(runtimeEnv.COMPUTE_WORKER_HOST, '127.0.0.1'); + runtimeEnv.COMPUTE_NATS_REPLICAS = withDefault(runtimeEnv.COMPUTE_NATS_REPLICAS, '1'); + + const natsStoreDir = withDefault(runtimeEnv.EMBEDDED_NATS_STORE_DIR, 'docstore/nats/jetstream'); + fs.mkdirSync(natsStoreDir, { recursive: true }); + + console.log(`Starting embedded nats-server on 127.0.0.1:${embeddedNatsPort}...`); + natsProc = spawn( + 'nats-server', + [ + '-js', + '-sd', natsStoreDir, + '-a', '127.0.0.1', + '-p', String(embeddedNatsPort), + '-m', String(embeddedNatsMonitorPort), + ], + { + env: runtimeEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + stopNatsStdoutForward = forwardChildStream(natsProc.stdout, process.stdout); + stopNatsStderrForward = forwardChildStream(natsProc.stderr, process.stderr); + natsExitPromise = once(natsProc, 'exit').then(() => undefined).catch(() => undefined); + natsProc.on('exit', (code, signal) => { + if (isShuttingDown) return; + if (typeof code === 'number' && code !== 0) { + console.error(`Embedded nats-server exited with code ${code}.`); + } else if (signal) { + console.error(`Embedded nats-server exited due to signal ${signal}.`); + } + scheduleFatalShutdown('nats-server', code, signal, 'embedded queue service'); + }); + natsProc.on('error', (error) => { + console.error(`Embedded nats-server failed to start: ${error instanceof Error ? error.message : String(error)}`); + scheduleFatalShutdown('nats-server', null, null, 'failed to start'); + }); + await waitForEndpoint(`http://127.0.0.1:${embeddedNatsMonitorPort}/healthz`, 20); + console.log(`Embedded nats-server is ready at nats://127.0.0.1:${embeddedNatsPort}`); + + console.log(`Starting embedded compute-worker on 127.0.0.1:${embeddedWorkerPort}...`); + const workerEnv = { + ...runtimeEnv, + PORT: String(embeddedWorkerPort), + }; + workerProc = spawn( + 'pnpm', + ['--filter', '@openreader/compute-worker', 'start'], + { + env: workerEnv, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + }, + ); + stopWorkerStdoutForward = forwardChildStream(workerProc.stdout, process.stdout); + stopWorkerStderrForward = forwardChildStream(workerProc.stderr, process.stderr); + workerExitPromise = once(workerProc, 'exit').then(() => undefined).catch(() => undefined); + workerProc.on('exit', (code, signal) => { + if (isShuttingDown) return; + if (typeof code === 'number' && code !== 0) { + console.error(`Embedded compute-worker exited with code ${code}.`); + } else if (signal) { + console.error(`Embedded compute-worker exited due to signal ${signal}.`); + } + scheduleFatalShutdown('compute-worker', code, signal, 'embedded compute service'); + }); + workerProc.on('error', (error) => { + console.error(`Embedded compute-worker failed to start: ${error instanceof Error ? error.message : String(error)}`); + scheduleFatalShutdown('compute-worker', null, null, 'failed to start'); + }); + await waitForEndpoint(`http://127.0.0.1:${embeddedWorkerPort}/health/ready`, 30); + console.log(`Embedded compute-worker is ready at http://127.0.0.1:${embeddedWorkerPort}`); + } else if (!runtimeEnv.COMPUTE_WORKER_URL?.trim() || !runtimeEnv.COMPUTE_WORKER_TOKEN?.trim()) { + throw new Error('COMPUTE_WORKER_URL and COMPUTE_WORKER_TOKEN are required when embedded compute worker startup is disabled.'); + } + const { child, exitPromise } = spawnMainCommand(command, runtimeEnv); appProc = child; - const exitCode = await exitPromise; + const exitInfo = await exitPromise; + const exitCode = typeof exitInfo?.code === 'number' ? exitInfo.code : 1; + console.error( + `Main command finished with code=${exitInfo?.code ?? 'null'} signal=${exitInfo?.signal ?? 'null'} launchError=${Boolean(exitInfo?.launchError)}.`, + ); await shutdown('SIGTERM'); exitOnce(exitCode); diff --git a/src/app/(app)/app/layout.tsx b/src/app/(app)/app/layout.tsx index f8abbbb..947ab4f 100644 --- a/src/app/(app)/app/layout.tsx +++ b/src/app/(app)/app/layout.tsx @@ -4,16 +4,13 @@ import type { ReactNode } from 'react'; import { ConfigProvider } from '@/contexts/ConfigContext'; import { DocumentProvider } from '@/contexts/DocumentContext'; -import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal'; +import { OnboardingFlowProvider } from '@/contexts/OnboardingFlowContext'; export default function AppHomeLayout({ children }: { children: ReactNode }) { return ( - <> - {children} - - + {children} ); diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index 5765461..57f4c0b 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -19,6 +19,7 @@ import { resolveDocumentId } from '@/lib/client/dexie'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; +import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; import { useEpubDocument } from './useEpubDocument'; export default function EPUBPage() { @@ -102,6 +103,8 @@ export default function EPUBPage() { loadDocument(); }, [loadDocument, isLoading]); + useUnmountCleanupRef(clearCurrDoc); + // Compute available height = viewport - (header height + tts bar height) useEffect(() => { const compute = () => { @@ -111,7 +114,9 @@ export default function EPUBPage() { const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0; const vh = window.innerHeight; const h = Math.max(0, vh - headerH - ttsH); - setContainerHeight(`${h}px`); + if (h > 0) { + setContainerHeight(`${h}px`); + } // compute max horizontal padding while preserving a minimum readable width, // but still allow some padding on small screens @@ -122,9 +127,15 @@ export default function EPUBPage() { setMaxPadPx(maxPad); }; compute(); + const settleT1 = window.setTimeout(compute, 0); + const settleT2 = window.setTimeout(compute, 120); window.addEventListener('resize', compute); - return () => window.removeEventListener('resize', compute); - }, []); + return () => { + window.removeEventListener('resize', compute); + window.clearTimeout(settleT1); + window.clearTimeout(settleT2); + }; + }, [isLoading, activeSidebar]); // Nudge EPUB renderer to reflow on horizontal padding changes useEffect(() => { @@ -162,7 +173,6 @@ export default function EPUBPage() {

{error}

clearCurrDoc()} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -180,7 +190,6 @@ export default function EPUBPage() { left={ clearCurrDoc()} className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" aria-label="Back to documents" > diff --git a/src/app/(app)/epub/[id]/useEpubDocument.ts b/src/app/(app)/epub/[id]/useEpubDocument.ts index d487ce2..8c5a78a 100644 --- a/src/app/(app)/epub/[id]/useEpubDocument.ts +++ b/src/app/(app)/epub/[id]/useEpubDocument.ts @@ -101,7 +101,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { baseUrl, providerRef, ttsSegmentMaxBlockLength, - smartSentenceSplitting, epubTheme, epubHighlightEnabled, } = useConfig(); @@ -239,22 +238,13 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { const leadingPreview = collectLeadingContextFromRange(range); const continuationPreview = collectContinuationFromRange(range); - if (smartSentenceSplitting) { - setTTSText(textContent, { - shouldPause, - location: start.cfi, - previousText: leadingPreview, - nextLocation: end.cfi, - nextText: continuationPreview - }); - } else { - // When smart splitting is disabled, behave like the original implementation: - // send only the current page/location text without any continuation preview. - setTTSText(textContent, { - shouldPause, - location: start.cfi, - }); - } + setTTSText(textContent, { + shouldPause, + location: start.cfi, + previousText: leadingPreview, + nextLocation: end.cfi, + nextText: continuationPreview + }); setCurrDocText(textContent); return textContent; @@ -262,7 +252,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { console.error('Error extracting EPUB text:', error); return ''; } - }, [setRenderedTextMaps, setTTSText, smartSentenceSplitting]); + }, [setRenderedTextMaps, setTTSText]); /** * Resolves a draft EPUB locator (typically `{ readerType: 'epub', location: diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index d3f6e55..21ad5c6 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -17,6 +17,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; +import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import { useHtmlDocument } from './useHtmlDocument'; @@ -102,6 +103,8 @@ export default function HTMLPage() { loadDocument(); }, [loadDocument, isLoading]); + useUnmountCleanupRef(clearCurrDoc); + // Compute available height = viewport - (header height + tts bar height) useEffect(() => { const compute = () => { @@ -111,7 +114,9 @@ export default function HTMLPage() { const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0; const vh = window.innerHeight; const h = Math.max(0, vh - headerH - ttsH); - setContainerHeight(`${h}px`); + if (h > 0) { + setContainerHeight(`${h}px`); + } // Adaptive minimum content width: allow some padding on narrow screens const vw = window.innerWidth; @@ -121,9 +126,15 @@ export default function HTMLPage() { setMaxPadPx(maxPad); }; compute(); + const settleT1 = window.setTimeout(compute, 0); + const settleT2 = window.setTimeout(compute, 120); window.addEventListener('resize', compute); - return () => window.removeEventListener('resize', compute); - }, []); + return () => { + window.removeEventListener('resize', compute); + window.clearTimeout(settleT1); + window.clearTimeout(settleT2); + }; + }, [isLoading, activeSidebar]); const handleGenerateAudiobook = useCallback(async ( onProgress: (progress: number) => void, @@ -149,7 +160,6 @@ export default function HTMLPage() {

{error}

{ clearCurrDoc(); }} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -167,7 +177,6 @@ export default function HTMLPage() { left={ clearCurrDoc()} className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" aria-label="Back to documents" > diff --git a/src/app/(app)/html/[id]/useHtmlDocument.ts b/src/app/(app)/html/[id]/useHtmlDocument.ts index e8e854d..fe23c0f 100644 --- a/src/app/(app)/html/[id]/useHtmlDocument.ts +++ b/src/app/(app)/html/[id]/useHtmlDocument.ts @@ -68,7 +68,6 @@ export function useHtmlDocument(): HtmlDocumentState { apiKey, baseUrl, providerRef, - smartSentenceSplitting, ttsSegmentMaxBlockLength, } = useConfig(); @@ -133,10 +132,9 @@ export function useHtmlDocument(): HtmlDocumentState { createHtmlAudiobookSourceAdapter({ blocks, isTxt, - smartSentenceSplitting, maxBlockLength: ttsSegmentMaxBlockLength, }), - [blocks, isTxt, smartSentenceSplitting, ttsSegmentMaxBlockLength], + [blocks, isTxt, ttsSegmentMaxBlockLength], ); const createFullAudioBook = useCallback( diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index fb14c2c..7cb8cb8 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -3,7 +3,6 @@ import type { ReactNode } from 'react'; import { Toaster } from 'react-hot-toast'; import { Providers } from '@/app/providers'; -import ClaimDataPopup from '@/components/auth/ClaimDataModal'; import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; export const dynamic = 'force-dynamic'; @@ -36,7 +35,6 @@ export default function AppLayout({ children }: { children: ReactNode }) { githubAuthEnabled={githubAuthEnabled} >
- {authEnabled && }
{children}
import('@/components/views/PDFViewer').then((module) => module.PDFViewer), { ssr: false, - loading: () => + loading: () => null } ); +const PARSE_LOADER_EXPAND_DELAY_MS = 320; + export default function PDFViewerPage() { const canExportAudiobook = useFeatureFlag('enableAudiobookExport'); const { id } = useParams(); @@ -41,6 +51,13 @@ export default function PDFViewerPage() { clearCurrDoc, currDocPage, currDocPages, + parseStatus, + parseProgress, + documentSettings, + updateDocumentSettings, + parsedOverlayEnabled, + setParsedOverlayEnabled, + forceReparseParsedPdf, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter, } = pdfState; @@ -48,14 +65,29 @@ export default function PDFViewerPage() { const { isAtLimit } = useAuthRateLimit(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [isPdfViewerReady, setIsPdfViewerReady] = useState(false); const [zoomLevel, setZoomLevel] = useState(100); const [activeSidebar, setActiveSidebar] = useState(null); + const [showForceReparseConfirm, setShowForceReparseConfirm] = useState(false); + const [showDetailedParseLoader, setShowDetailedParseLoader] = useState(false); const [containerHeight, setContainerHeight] = useState('auto'); const inFlightDocIdRef = useRef(null); const loadedDocIdRef = useRef(null); + const [isNavigatingBack, setIsNavigatingBack] = useState(false); + const parseUiState: NonNullable = parseStatus ?? 'pending'; + const hasResolvedParseStatus = parseStatus !== null; + const isParseReady = parseUiState === 'ready'; + const forceReparseDisabled = isForceReparseDisabled(parseStatus); + const hasRealParseProgress = !!parseProgress + && parseProgress.totalPages > 0 + && parseProgress.pagesParsed >= 0; + const shouldShowExpandedParseLoader = !isLoading + && hasResolvedParseStatus + && (parseUiState === 'pending' || parseUiState === 'running' || parseUiState === 'failed' || hasRealParseProgress); useEffect(() => { setIsLoading(true); + setIsPdfViewerReady(false); setError(null); setActiveSidebar(null); inFlightDocIdRef.current = null; @@ -67,6 +99,7 @@ export default function PDFViewerPage() { console.log('Loading new document (from page.tsx)'); let didRedirect = false; let startedLoad = false; + let loadSucceeded = false; try { if (!id) { setError('Document not found'); @@ -89,8 +122,20 @@ export default function PDFViewerPage() { startedLoad = true; inFlightDocIdRef.current = resolved; stop(); // Reset TTS when loading new document - await setCurrentDocument(resolved); - loadedDocIdRef.current = resolved; + for (let attempt = 0; attempt < 2; attempt += 1) { + const loaded = await setCurrentDocument(resolved); + if (loaded) { + loadSucceeded = true; + loadedDocIdRef.current = resolved; + break; + } + if (attempt === 0) { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + if (!loadSucceeded) { + throw new Error(`Failed to load PDF document ${resolved}`); + } } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); @@ -98,7 +143,7 @@ export default function PDFViewerPage() { if (startedLoad) { inFlightDocIdRef.current = null; } - if (!didRedirect && startedLoad) { + if (!didRedirect && startedLoad && loadSucceeded) { setIsLoading(false); } } @@ -108,6 +153,30 @@ export default function PDFViewerPage() { loadDocument(); }, [loadDocument]); + useUnmountCleanupRef(clearCurrDoc); + + useEffect(() => { + if (isLoading) return; + if (isParseReady) return; + stop(); + }, [isLoading, isParseReady, stop]); + + useEffect(() => { + if (!shouldShowExpandedParseLoader) { + // Keep the current loader variant stable during the final + // parse-ready -> first-frame handoff to avoid a visual flash. + if (!isLoading && isParseReady && !isPdfViewerReady) { + return; + } + setShowDetailedParseLoader(false); + return; + } + const timeout = window.setTimeout(() => { + setShowDetailedParseLoader(true); + }, PARSE_LOADER_EXPAND_DELAY_MS); + return () => window.clearTimeout(timeout); + }, [shouldShowExpandedParseLoader, id, isLoading, isParseReady, isPdfViewerReady]); + // Compute available height = viewport - (header height + tts bar height) useEffect(() => { const compute = () => { @@ -117,24 +186,55 @@ export default function PDFViewerPage() { const ttsH = ttsbar ? ttsbar.getBoundingClientRect().height : 0; const vh = window.innerHeight; const h = Math.max(0, vh - headerH - ttsH); - setContainerHeight(`${h}px`); + // Avoid locking the reader at 0px during transient startup layout states. + if (h > 0) { + setContainerHeight(`${h}px`); + } }; compute(); + const settleT1 = window.setTimeout(compute, 0); + const settleT2 = window.setTimeout(compute, 120); window.addEventListener('resize', compute); - return () => window.removeEventListener('resize', compute); - }, []); + return () => { + window.removeEventListener('resize', compute); + window.clearTimeout(settleT1); + window.clearTimeout(settleT2); + }; + }, [isLoading, isParseReady, isAtLimit, activeSidebar]); const handleZoomIn = () => setZoomLevel(prev => Math.min(prev + 10, 300)); const handleZoomOut = () => setZoomLevel(prev => Math.max(prev - 10, 50)); + const handleBackToDocuments = useCallback((event?: MouseEvent) => { + event?.preventDefault(); + if (isNavigatingBack) return; + setIsNavigatingBack(true); + stop(); + setActiveSidebar(null); + router.push('/app'); + }, [isNavigatingBack, stop, router]); + + const requestForceReparse = useCallback(() => { + if (forceReparseDisabled) return; + setShowForceReparseConfirm(true); + }, [forceReparseDisabled]); + + const confirmForceReparse = useCallback(() => { + setShowForceReparseConfirm(false); + void forceReparseParsedPdf(); + }, [forceReparseParsedPdf]); + const handleGenerateAudiobook = useCallback(async ( onProgress: (progress: number) => void, signal: AbortSignal, onChapterComplete: (chapter: TTSAudiobookChapter) => void, settings: AudiobookGenerationSettings ) => { + if (!isParseReady) { + throw new Error('PDF parsing is not ready yet.'); + } return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, settings.format, settings); - }, [createPDFAudioBook, id]); + }, [createPDFAudioBook, id, isParseReady]); const handleRegenerateChapter = useCallback(async ( chapterIndex: number, @@ -142,8 +242,11 @@ export default function PDFViewerPage() { settings: AudiobookGenerationSettings, signal: AbortSignal ) => { + if (!isParseReady) { + throw new Error('PDF parsing is not ready yet.'); + } return regeneratePDFChapter(chapterIndex, bookId, settings.format, signal, settings); - }, [regeneratePDFChapter]); + }, [regeneratePDFChapter, isParseReady]); if (error) { return ( @@ -151,7 +254,7 @@ export default function PDFViewerPage() {

{error}

{ clearCurrDoc(); }} + onClick={handleBackToDocuments} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -163,13 +266,124 @@ export default function PDFViewerPage() { ); } + const renderPdfStatusLoader = () => { + const compactLabel = isLoading + ? 'Opening PDF...' + : (parseUiState === 'ready' ? 'Rendering pages...' : 'Preparing PDF layout...'); + const compactSubLabel = isLoading + ? 'Loading document data' + : (parseUiState === 'ready' ? 'Preparing first frame' : 'Queueing parser and preparing page extraction'); + + const totalPages = parseProgress?.totalPages ?? 0; + const pagesParsed = parseProgress?.pagesParsed ?? 0; + const progressPercent = totalPages > 0 + ? Math.max(0, Math.min(100, (pagesParsed / totalPages) * 100)) + : 0; + const hasMeasuredProgress = totalPages > 0; + const isMerging = parseProgress?.phase === 'merge'; + + let statusText = 'Loading PDF...'; + let statusSubText = 'Initializing document renderer'; + if (!isLoading) { + if (parseUiState === 'pending') { + statusText = 'Preparing PDF layout...'; + statusSubText = parseProgress?.phase === 'merge' + ? 'Finalizing stitched block structure' + : 'Queueing parser and preparing page extraction'; + } else if (parseUiState === 'running') { + statusText = 'Parsing PDF layout blocks...'; + statusSubText = parseProgress?.phase === 'merge' + ? 'Merging cross-page sections' + : 'Inferring reading order and text regions'; + } else if (parseUiState === 'failed') { + statusText = 'PDF parsing failed. Retry to continue.'; + statusSubText = 'The parser could not build a usable layout map'; + } + } + + const stageLabel = parseUiState === 'failed' + ? 'Stage: blocked' + : (parseUiState === 'pending' + ? 'Stage: prepare' + : (isMerging ? 'Stage: merge' : 'Stage: infer')); + + return ( +
+
+ {showDetailedParseLoader ? ( +
+
+
+
+
+ + PDF Layout Parse +
+

{statusText}

+
+ +
+
+

+ {hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'} +

+

{stageLabel}

+
+
+
+
+

+ {hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : statusSubText} +

+
+ + {!isLoading && parseUiState === 'failed' ? ( +
+ +
+ ) : null} +
+
+ ) : ( +
+
+
+
+

{compactLabel}

+

{compactSubLabel}

+
+ + + +
+
+ + + +
+
+ )} +
+
+ ); + }; + return ( <>
clearCurrDoc()} + onClick={handleBackToDocuments} className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" aria-label="Back to documents" > @@ -199,15 +413,21 @@ export default function PDFViewerPage() {
} /> -
- - {isLoading ? ( -
- +
+ {isParseReady ? ( +
+ setIsPdfViewerReady(true)} + pdfState={pdfState} + />
- ) : ( - - )} + ) : null} + {isLoading || !isParseReady || !isPdfViewerReady ? ( +
+ {renderPdfStatusLoader()} +
+ ) : null}
{canExportAudiobook && (
- ) : ( + ) : isParseReady ? ( - )} + ) : null} setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))} + pdf={{ + parseStatus, + parsedOverlayEnabled, + skipBlockKinds: documentSettings.pdf?.skipBlockKinds ?? [], + onToggleOverlay: (enabled) => setParsedOverlayEnabled(enabled), + onToggleSkipKind: (kind, enabled) => { + const current = new Set(documentSettings.pdf?.skipBlockKinds ?? []); + if (enabled) current.add(kind); + else current.delete(kind); + void updateDocumentSettings({ + ...documentSettings, + schemaVersion: 1, + pdf: { + ...(documentSettings.pdf ?? {}), + skipBlockKinds: Array.from(current), + }, + }); + }, + onForceReparse: requestForceReparse, + }} + /> + setShowForceReparseConfirm(false)} + onConfirm={confirmForceReparse} + title={FORCE_REPARSE_CONFIRM_TITLE} + message={FORCE_REPARSE_CONFIRM_MESSAGE} + confirmText={FORCE_REPARSE_CONFIRM_TEXT} + cancelText="Cancel" /> Promise; + parsedDocument: ParsedPdfDocument | null; + parseStatus: PdfParseStatus | null; + parseProgress: PdfParseProgress | null; + documentSettings: DocumentSettings; + updateDocumentSettings: (settings: DocumentSettings) => Promise; + parsedOverlayEnabled: boolean; + setParsedOverlayEnabled: (enabled: boolean) => void; + forceReparseParsedPdf: () => Promise; + setCurrentDocument: (id: string) => Promise; clearCurrDoc: () => void; // PDF functionality onDocumentLoadSuccess: (pdf: PDFDocumentProxy) => void; - highlightPattern: (text: string, pattern: string, containerRef: RefObject) => void; + highlightPattern: ( + text: string, + pattern: string, + containerRef: RefObject, + options?: { + parsedDocument?: ParsedPdfDocument | null; + locator?: TTSSegmentLocator | null; + useBlockGeometryOnly?: boolean; + }, + ) => void; clearHighlights: () => void; clearWordHighlights: () => void; highlightWordIndex: ( @@ -84,9 +116,6 @@ export interface PdfDocumentState { isAudioCombining: boolean; } -const EMPTY_TEXT_RETRY_DELAY_MS = 120; -const EMPTY_TEXT_MAX_RETRIES = 6; - /** * Main PDF route hook. */ @@ -101,14 +130,9 @@ export function usePdfDocument(): PdfDocumentState { registerVisualPageChangeHandler, } = useTTS(); const { - headerMargin, - footerMargin, - leftMargin, - rightMargin, apiKey, baseUrl, providerRef, - smartSentenceSplitting, segmentPreloadDepthPages, ttsSegmentMaxBlockLength, } = useConfig(); @@ -119,18 +143,18 @@ export function usePdfDocument(): PdfDocumentState { const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); const [pdfDocument, setPdfDocument] = useState(); + const [parsedDocument, setParsedDocument] = useState(null); + const [parseStatus, setParseStatus] = useState(null); + const [parseProgress, setParseProgress] = useState(null); + const [, setActiveParseOpId] = useState(null); + const [documentSettings, setDocumentSettings] = useState(DEFAULT_DOCUMENT_SETTINGS); + const [parsedOverlayEnabled, setParsedOverlayEnabled] = useState(false); const [isAudioCombining] = useState(false); const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({ - pdfDocument, - margins: { - header: headerMargin, - footer: footerMargin, - left: leftMargin, - right: rightMargin, - }, - smartSentenceSplitting, + parsed: parsedDocument ?? undefined, + settings: documentSettings, maxBlockLength: ttsSegmentMaxBlockLength, - }), [pdfDocument, headerMargin, footerMargin, leftMargin, rightMargin, smartSentenceSplitting, ttsSegmentMaxBlockLength]); + }), [parsedDocument, documentSettings, ttsSegmentMaxBlockLength]); const pageTextCacheRef = useRef>(new Map()); const [currDocPage, setCurrDocPage] = useState(currDocPageNumber); @@ -139,11 +163,129 @@ export function usePdfDocument(): PdfDocumentState { const pdfDocGenerationRef = useRef(0); const pdfDocumentRef = useRef(undefined); const loadSeqRef = useRef(0); - const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType | null } | null>(null); // Guards for setCurrentDocument to prevent stale loads from overwriting newer selections. const docLoadSeqRef = useRef(0); const docLoadAbortRef = useRef(null); + const parsePollAbortRef = useRef(null); + const parseSseCloseRef = useRef<(() => void) | null>(null); + + const fetchParsedDocument = useCallback(async ( + documentId: string, + initialStatus: PdfParseStatus | null, + signal: AbortSignal, + initialOpId?: string | null, + ): Promise => { + // Legacy PDFs may have null parseStatus; treat as pending so opening the + // document backfills parse output via the parsed endpoint polling path. + const effectiveInitialStatus: PdfParseStatus = initialStatus ?? 'pending'; + setParseStatus(effectiveInitialStatus); + setParseProgress(null); + const delayMs = 1200; + const retryFailed = effectiveInitialStatus === 'failed'; + let attempt = 0; + let effectiveOpId = initialOpId?.trim() || null; + while (!signal.aborted) { + if (signal.aborted) return; + const result = await getParsedPdfDocument(documentId, { + signal, + retryFailed: retryFailed && attempt === 0, + ...(effectiveOpId ? { opId: effectiveOpId } : {}), + }); + if (result.status === 'ready') { + setParsedDocument(result.parsed); + setParseStatus('ready'); + setParseProgress(null); + setActiveParseOpId(null); + return; + } + if ('opId' in result && typeof result.opId === 'string' && result.opId.trim()) { + effectiveOpId = result.opId.trim(); + setActiveParseOpId(effectiveOpId); + } + setParseStatus(result.status); + setParseProgress(result.parseProgress ?? null); + if (result.status === 'failed') { + setParsedDocument(null); + setActiveParseOpId(null); + return; + } + attempt += 1; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + }, []); + + const fetchDocumentSettings = useCallback(async (documentId: string, signal: AbortSignal): Promise => { + try { + const response = await getDocumentSettings(documentId, { signal }); + setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings)); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return; + console.warn('Failed to load document settings, using defaults:', error); + setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); + } + }, []); + + const startParsedPolling = useCallback((documentId: string, initialOpId?: string | null) => { + parsePollAbortRef.current?.abort(); + parseSseCloseRef.current?.(); + parseSseCloseRef.current = null; + setParseProgress(null); + setActiveParseOpId(initialOpId?.trim() || null); + const controller = new AbortController(); + parsePollAbortRef.current = controller; + + const closeSse = subscribeParsedPdfDocumentEvents(documentId, { + opId: initialOpId?.trim() || null, + }, { + onSnapshot: (snapshot) => { + if (controller.signal.aborted) return; + if (typeof snapshot.opId === 'string' && snapshot.opId.trim()) { + setActiveParseOpId(snapshot.opId.trim()); + } + setParseStatus(snapshot.parseStatus); + setParseProgress(snapshot.parseProgress); + if (snapshot.parseStatus === 'ready' || snapshot.parseStatus === 'failed') { + if (snapshot.parseStatus === 'failed') { + setParsedDocument(null); + setActiveParseOpId(null); + } else { + void fetchParsedDocument( + documentId, + 'ready', + controller.signal, + typeof snapshot.opId === 'string' ? snapshot.opId : (initialOpId ?? null), + ); + } + closeSse(); + parseSseCloseRef.current = null; + if (parsePollAbortRef.current === controller) { + parsePollAbortRef.current = null; + } + return; + } + }, + onError: () => { + // EventSource reconnects automatically. Keep stream open, but log so + // production debugging can correlate UI stalls with SSE churn. + if (controller.signal.aborted) return; + console.warn('[pdf] parsed/events stream error; waiting for auto-reconnect', { + documentId, + }); + }, + }); + parseSseCloseRef.current = closeSse; + + controller.signal.addEventListener('abort', () => { + closeSse(); + if (parseSseCloseRef.current === closeSse) { + parseSseCloseRef.current = null; + } + if (parsePollAbortRef.current === controller) { + parsePollAbortRef.current = null; + } + }, { once: true }); + }, [fetchParsedDocument, setActiveParseOpId]); useEffect(() => { pdfDocumentRef.current = pdfDocument; @@ -167,7 +309,7 @@ export function usePdfDocument(): PdfDocumentState { /** * Loads and processes text from the current document page - * Extracts text from the PDF and updates both document text and TTS text states + * Uses parsed PDF blocks only and updates both document text and TTS text states. * * @returns {Promise} */ @@ -179,20 +321,18 @@ export function usePdfDocument(): PdfDocumentState { const seq = ++loadSeqRef.current; const pageNumber = currDocPageNumber; - const existingRetry = emptyRetryRef.current; - if (existingRetry?.timer) { - clearTimeout(existingRetry.timer); - } - emptyRetryRef.current = - existingRetry && existingRetry.page === pageNumber - ? { ...existingRetry, timer: null } - : null; + const pageFromParsed = (pageNum: number): ParsedPdfPage | undefined => + parsedDocument?.pages.find((page) => page.pageNumber === pageNum); - const margins = { - header: headerMargin, - footer: footerMargin, - left: leftMargin, - right: rightMargin + if (parseStatus !== 'ready' || !parsedDocument) { + setCurrDocText(undefined); + setTTSText('', { location: currDocPageNumber }); + return; + } + + const sourceUnitsFromParsedPage = (pageNum: number): CanonicalTtsSourceUnit[] => { + const page = pageFromParsed(pageNum); + return buildPdfPageSourceUnits(page, pageNum, documentSettings.pdf?.skipBlockKinds ?? []); }; const getPageText = async (pageNumber: number, shouldCache = false): Promise => { @@ -209,7 +349,10 @@ export function usePdfDocument(): PdfDocumentState { return cached; } - const extracted = await extractTextFromPDF(currentPdf, pageNumber, margins); + const parsedPage = pageFromParsed(pageNumber); + const extracted = parsedPage + ? buildPageTextFromBlocks(parsedPage, documentSettings.pdf?.skipBlockKinds ?? []) + : ''; if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { throw new DOMException('Stale PDF extraction', 'AbortError'); @@ -237,14 +380,15 @@ export function usePdfDocument(): PdfDocumentState { prevPageNumber ? getPageText(prevPageNumber) : Promise.resolve(undefined), ...upcomingPageNumbers.map((pageNum) => getPageText(pageNum, true)), ]); - const nextText = upcomingTexts[0]; - const additionalUpcoming = upcomingPageNumbers - .slice(1) - .map((pageNum, idx) => ({ - location: pageNum, - text: upcomingTexts[idx + 1] || '', - })) - .filter((item) => item.text.trim().length > 0); + const { + nextText, + nextSourceUnits, + additionalUpcoming, + } = buildPdfPrefetchPayload( + upcomingPageNumbers, + upcomingTexts, + sourceUnitsFromParsedPage, + ); if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { return; @@ -253,40 +397,17 @@ export function usePdfDocument(): PdfDocumentState { return; } - const trimmed = text.trim(); - if (!trimmed) { - const prevAttempt = emptyRetryRef.current?.page === pageNumber ? emptyRetryRef.current.attempt : 0; - const attempt = prevAttempt + 1; - - // Avoid pushing empty text into TTS immediately; transient empty extractions can happen - // during page turns or react-pdf worker churn. Retry a few times before treating it as - // a truly blank page. - if (attempt <= EMPTY_TEXT_MAX_RETRIES) { - const timer = setTimeout(() => { - if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) { - return; - } - if (pageNumber !== currDocPageNumber) { - return; - } - void loadCurrDocText(); - }, EMPTY_TEXT_RETRY_DELAY_MS); - - emptyRetryRef.current = { page: pageNumber, attempt, timer }; - return; - } - } else { - emptyRetryRef.current = null; - } - if (text !== currDocText || text === '') { setCurrDocText(text); + const sourceUnits = sourceUnitsFromParsedPage(currDocPageNumber); setTTSText(text, { location: currDocPageNumber, previousText: prevText, nextLocation: nextPageNumber, nextText: nextText, + nextSourceUnits, upcomingLocations: additionalUpcoming, + ...(sourceUnits.length > 0 ? { sourceUnits } : {}), }); } } catch (error) { @@ -300,11 +421,10 @@ export function usePdfDocument(): PdfDocumentState { currDocPages, setTTSText, currDocText, - headerMargin, - footerMargin, - leftMargin, - rightMargin, segmentPreloadDepthPages, + parsedDocument, + parseStatus, + documentSettings, ]); /** @@ -324,7 +444,7 @@ export function usePdfDocument(): PdfDocumentState { * @param {string} id - The unique identifier of the document to set * @returns {Promise} */ - const setCurrentDocument = useCallback(async (id: string): Promise => { + const setCurrentDocument = useCallback(async (id: string): Promise => { // --- race-condition guard --- const seq = ++docLoadSeqRef.current; docLoadAbortRef.current?.abort(); @@ -337,10 +457,10 @@ export function usePdfDocument(): PdfDocumentState { // or fast refresh. pdfDocGenerationRef.current += 1; loadSeqRef.current += 1; - if (emptyRetryRef.current?.timer) { - clearTimeout(emptyRetryRef.current.timer); - } - emptyRetryRef.current = null; + parsePollAbortRef.current?.abort(); + parsePollAbortRef.current = null; + parseSseCloseRef.current?.(); + parseSseCloseRef.current = null; pageTextCacheRef.current.clear(); setPdfDocument(undefined); setCurrDocPages(undefined); @@ -348,35 +468,85 @@ export function usePdfDocument(): PdfDocumentState { setCurrDocId(id); setCurrDocName(undefined); setCurrDocData(undefined); + setParsedDocument(null); + setParseStatus(null); + setParseProgress(null); + setActiveParseOpId(null); + setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); const meta = await getDocumentMetadata(id, { signal: controller.signal }); - if (seq !== docLoadSeqRef.current) return; // stale + if (seq !== docLoadSeqRef.current) return false; // stale if (!meta) { console.error('Document not found on server'); - return; + return false; + } + if (meta.type === 'pdf') { + const initialParseStatus = (meta.parseStatus ?? null) as PdfParseStatus | null; + setParseStatus(initialParseStatus); + setParseProgress(null); + setActiveParseOpId(null); + startParsedPolling(id, null); + void fetchDocumentSettings(id, controller.signal); } const doc = await ensureCachedDocument(meta, { signal: controller.signal }); - if (seq !== docLoadSeqRef.current) return; // stale + if (seq !== docLoadSeqRef.current) return false; // stale if (doc.type !== 'pdf') { console.error('Document is not a PDF'); - return; + return false; } setCurrDocName(doc.name); // IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the // buffer passed into the worker; we always pass clones to react-pdf. setCurrDocData(doc.data.slice(0)); + return true; } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') return; + if (error instanceof DOMException && error.name === 'AbortError') return false; console.error('Failed to get document:', error); + return false; } finally { // Clean up the controller only if it's still ours (a newer call hasn't replaced it). if (docLoadAbortRef.current === controller) { docLoadAbortRef.current = null; } } - }, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument]); + return false; + }, [ + setCurrDocId, + setCurrDocName, + setCurrDocData, + setCurrDocPages, + setCurrDocText, + setPdfDocument, + fetchDocumentSettings, + startParsedPolling, + ]); + + const updateDocumentSettings = useCallback(async (settings: DocumentSettings): Promise => { + if (!currDocId) return; + setDocumentSettings(settings); + try { + const updated = await putDocumentSettings(currDocId, settings); + setDocumentSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, updated.settings)); + } catch (error) { + console.warn('Failed to persist document settings:', error); + } + }, [currDocId]); + + const forceReparseParsedPdf = useCallback(async (): Promise => { + if (!currDocId) return; + try { + const forced = await forceReparsePdfDocument(currDocId); + setParsedDocument(null); + setParseStatus(forced.status); + setParseProgress(null); + setActiveParseOpId(forced.opId ?? null); + startParsedPolling(currDocId, forced.opId ?? null); + } catch (error) { + console.error('Failed to force PDF reparse:', error); + } + }, [currDocId, startParsedPolling]); /** * Clears the current document state @@ -390,16 +560,21 @@ export function usePdfDocument(): PdfDocumentState { docLoadSeqRef.current += 1; docLoadAbortRef.current?.abort(); docLoadAbortRef.current = null; - if (emptyRetryRef.current?.timer) { - clearTimeout(emptyRetryRef.current.timer); - } - emptyRetryRef.current = null; + parsePollAbortRef.current?.abort(); + parsePollAbortRef.current = null; + parseSseCloseRef.current?.(); + parseSseCloseRef.current = null; setCurrDocId(undefined); setCurrDocName(undefined); setCurrDocData(undefined); setCurrDocText(undefined); setCurrDocPages(undefined); setPdfDocument(undefined); + setParsedDocument(null); + setParseStatus(null); + setParseProgress(null); + setActiveParseOpId(null); + setDocumentSettings(DEFAULT_DOCUMENT_SETTINGS); pageTextCacheRef.current.clear(); stop(); }, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument, stop]); @@ -499,6 +674,14 @@ export function usePdfDocument(): PdfDocumentState { currDocPages, currDocPage, currDocText, + parsedDocument, + parseStatus, + parseProgress, + documentSettings, + updateDocumentSettings, + parsedOverlayEnabled, + setParsedOverlayEnabled, + forceReparseParsedPdf, clearCurrDoc, highlightPattern, clearHighlights, @@ -518,6 +701,14 @@ export function usePdfDocument(): PdfDocumentState { currDocPages, currDocPage, currDocText, + parsedDocument, + parseStatus, + parseProgress, + documentSettings, + updateDocumentSettings, + parsedOverlayEnabled, + setParsedOverlayEnabled, + forceReparseParsedPdf, clearCurrDoc, pdfDocument, createFullAudioBook, diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts index 5e59e83..740aae5 100644 --- a/src/app/api/account/delete/route.ts +++ b/src/app/api/account/delete/route.ts @@ -4,6 +4,8 @@ import { auth } from '@/lib/server/auth/auth'; import { isAuthEnabled } from '@/lib/server/auth/config'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { deleteUserStorageData } from '@/lib/server/user/data-cleanup'; +import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export async function DELETE() { if (!isAuthEnabled() || !auth) { @@ -28,7 +30,13 @@ export async function DELETE() { try { await deleteUserStorageData(session.user.id, testNamespace); } catch (error) { - console.error('[account-delete] Failed to clean up namespaced user storage before deletion:', error); + serverLogger.warn({ + event: 'account.delete.storage_cleanup_failed', + degraded: true, + step: 'namespaced_storage_cleanup', + userIdHash: hashForLog(session.user.id), + error: errorToLog(error), + }, 'Failed to clean up namespaced user storage before deletion'); } } @@ -40,10 +48,13 @@ export async function DELETE() { return NextResponse.json({ success: true }); } catch (error) { - console.error('Failed to delete account:', error); - return NextResponse.json( - { error: 'Failed to delete account' }, - { status: 500 } - ); + serverLogger.error({ + event: 'account.delete.failed', + error: errorToLog(error), + }, 'Failed to delete account'); + return errorResponse(error, { + apiErrorMessage: 'Failed to delete account', + normalize: { code: 'ACCOUNT_DELETE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/admin/providers/[id]/route.ts b/src/app/api/admin/providers/[id]/route.ts index 2fac788..e5fc4a3 100644 --- a/src/app/api/admin/providers/[id]/route.ts +++ b/src/app/api/admin/providers/[id]/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAdminContext } from '@/lib/server/auth/admin'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { AdminProviderError, deleteAdminProvider, @@ -39,10 +41,25 @@ export async function PUT( return NextResponse.json({ provider: toMasked(updated) }); } catch (error) { if (error instanceof AdminProviderError) { - return NextResponse.json({ error: error.message }, { status: error.status }); + return errorResponse(error, { + apiErrorMessage: error.message, + normalize: { + code: 'ADMIN_PROVIDERS_UPDATE_REQUEST_FAILED', + errorClass: error.status >= 500 ? 'db' : 'validation', + httpStatus: error.status, + retryable: error.status >= 500, + }, + }); } - console.error('[admin/providers/:id] update failed:', error); - return NextResponse.json({ error: 'Internal error' }, { status: 500 }); + serverLogger.error({ + event: 'admin.providers.update.failed', + providerId: id, + error: errorToLog(error), + }, 'Admin provider update failed'); + return errorResponse(error, { + apiErrorMessage: 'Internal error', + normalize: { code: 'ADMIN_PROVIDERS_UPDATE_FAILED', errorClass: 'db' }, + }); } } @@ -59,9 +76,24 @@ export async function DELETE( return NextResponse.json({ ok: true }); } catch (error) { if (error instanceof AdminProviderError) { - return NextResponse.json({ error: error.message }, { status: error.status }); + return errorResponse(error, { + apiErrorMessage: error.message, + normalize: { + code: 'ADMIN_PROVIDERS_DELETE_REQUEST_FAILED', + errorClass: error.status >= 500 ? 'db' : 'validation', + httpStatus: error.status, + retryable: error.status >= 500, + }, + }); } - console.error('[admin/providers/:id] delete failed:', error); - return NextResponse.json({ error: 'Internal error' }, { status: 500 }); + serverLogger.error({ + event: 'admin.providers.delete.failed', + providerId: id, + error: errorToLog(error), + }, 'Admin provider delete failed'); + return errorResponse(error, { + apiErrorMessage: 'Internal error', + normalize: { code: 'ADMIN_PROVIDERS_DELETE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/admin/providers/route.ts b/src/app/api/admin/providers/route.ts index a2c7d8b..1600036 100644 --- a/src/app/api/admin/providers/route.ts +++ b/src/app/api/admin/providers/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAdminContext } from '@/lib/server/auth/admin'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { AdminProviderError, createAdminProvider, @@ -44,9 +46,23 @@ export async function POST(req: NextRequest) { return NextResponse.json({ provider: toMasked(record) }, { status: 201 }); } catch (error) { if (error instanceof AdminProviderError) { - return NextResponse.json({ error: error.message }, { status: error.status }); + return errorResponse(error, { + apiErrorMessage: error.message, + normalize: { + code: 'ADMIN_PROVIDERS_CREATE_REQUEST_FAILED', + errorClass: error.status >= 500 ? 'db' : 'validation', + httpStatus: error.status, + retryable: error.status >= 500, + }, + }); } - console.error('[admin/providers] create failed:', error); - return NextResponse.json({ error: 'Internal error' }, { status: 500 }); + serverLogger.error({ + event: 'admin.providers.create.failed', + error: errorToLog(error), + }, 'Admin provider create failed'); + return errorResponse(error, { + apiErrorMessage: 'Internal error', + normalize: { code: 'ADMIN_PROVIDERS_CREATE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 83c89a5..c2a2f27 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -11,6 +11,7 @@ import { requireAuthContext } from '@/lib/server/auth/auth'; import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter'; import { getClientIp } from '@/lib/server/rate-limit/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; import { deleteAudiobookObject, getAudiobookObjectBuffer, @@ -43,6 +44,7 @@ import { coerceAudiobookGenerationSettings, type SharedProviderPolicyEntry, } from '@/lib/server/audiobooks/settings'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -185,7 +187,12 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise { } ffmpeg.stderr.on('data', (data) => { - console.error(`ffmpeg stderr: ${data}`); + serverLogger.warn({ + event: 'audiobook.chapter.ffmpeg.stderr', + degraded: true, + step: 'ffmpeg', + stderr: String(data), + }, 'ffmpeg stderr'); }); ffmpeg.on('close', (code) => { @@ -325,8 +332,19 @@ export async function POST(request: NextRequest) { fallbackProviderRef: runtimeConfig.defaultTtsProvider, }); if (!existingResult.settings) { - console.error('Invalid audiobook.meta.json settings payload', { bookId, storageUserId }); - return NextResponse.json({ error: 'Invalid audiobook metadata settings' }, { status: 500 }); + serverLogger.error({ + event: 'audiobook.chapter.meta_settings.invalid', + bookId, + storageUserId, + error: { + name: 'AudiobookMetaSettingsInvalid', + message: 'Invalid audiobook.meta.json settings payload', + }, + }, 'Invalid audiobook.meta.json settings payload'); + return errorResponse(new Error('Invalid audiobook metadata settings payload'), { + apiErrorMessage: 'Invalid audiobook metadata settings', + normalize: { code: 'AUDIOBOOK_CHAPTER_META_SETTINGS_INVALID', errorClass: 'validation', httpStatus: 500 }, + }); } normalizedExistingSettings = normalizeNativeSpeedForSettings(existingResult.settings); existingSettingsNeedsMigration = existingResult.migrated; @@ -399,11 +417,14 @@ export async function POST(request: NextRequest) { testNamespace, ); } catch (error) { - console.warn('Failed to persist migrated audiobook metadata settings', { + serverLogger.warn({ + event: 'audiobook.chapter.meta_settings.persist_migration_failed', + degraded: true, + step: 'persist_migrated_settings', bookId, storageUserId, - error: error instanceof Error ? error.message : String(error), - }); + error: errorToLog(error), + }, 'Failed to persist migrated audiobook metadata settings'); } } @@ -486,7 +507,10 @@ export async function POST(request: NextRequest) { } const provider = credResolved.provider; if (!isBuiltInTtsProviderId(provider)) { - return NextResponse.json({ error: `Unsupported TTS provider type: ${provider}` }, { status: 500 }); + return errorResponse(new Error(`Unsupported TTS provider type: ${provider}`), { + apiErrorMessage: `Unsupported TTS provider type: ${provider}`, + normalize: { code: 'AUDIOBOOK_CHAPTER_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 }, + }); } const openApiKey = credResolved.apiKey || 'none'; const openApiBaseUrl = credResolved.baseUrl; @@ -608,7 +632,12 @@ export async function POST(request: NextRequest) { request.signal, ); } catch (copyError) { - console.warn('Chapter remux failed; falling back to mp3 re-encode:', copyError); + serverLogger.warn({ + event: 'audiobook.chapter.remux.failed', + degraded: true, + fallbackPath: 'mp3_reencode', + error: errorToLog(copyError), + }, 'Chapter remux failed; falling back to mp3 re-encode'); await runFFmpeg( chapterEncodeArgs(inputPath, chapterOutputTempPath, format, postSpeed, titleTag), request.signal, @@ -728,8 +757,14 @@ export async function POST(request: NextRequest) { return response; } - console.error('Error processing audio chapter:', error); - const response = NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 }); + serverLogger.error({ + event: 'audiobook.chapter.process.failed', + error: errorToLog(error), + }, 'Failed to process audio chapter'); + const response = errorResponse(error, { + apiErrorMessage: 'Failed to process audio chapter', + normalize: { code: 'AUDIOBOOK_CHAPTER_PROCESS_FAILED', errorClass: 'upstream' }, + }); attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); return response; } finally { @@ -823,8 +858,14 @@ export async function GET(request: NextRequest) { }, }); } catch (error) { - console.error('Error downloading chapter:', error); - return NextResponse.json({ error: 'Failed to download chapter' }, { status: 500 }); + serverLogger.error({ + event: 'audiobook.chapter.download.failed', + error: errorToLog(error), + }, 'Failed to download chapter'); + return errorResponse(error, { + apiErrorMessage: 'Failed to download chapter', + normalize: { code: 'AUDIOBOOK_CHAPTER_DOWNLOAD_FAILED', errorClass: 'storage' }, + }); } } @@ -891,7 +932,13 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ success: true }); } catch (error) { - console.error('Error deleting chapter:', error); - return NextResponse.json({ error: 'Failed to delete chapter' }, { status: 500 }); + serverLogger.error({ + event: 'audiobook.chapter.delete.failed', + error: errorToLog(error), + }, 'Failed to delete chapter'); + return errorResponse(error, { + apiErrorMessage: 'Failed to delete chapter', + normalize: { code: 'AUDIOBOOK_CHAPTER_DELETE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index afeae62..0b2a5da 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -7,6 +7,8 @@ import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { audiobookPrefix, deleteAudiobookObject, @@ -115,7 +117,12 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise { } ffmpeg.stderr.on('data', (data) => { - console.error(`ffmpeg stderr: ${data}`); + serverLogger.warn({ + event: 'audiobook.ffmpeg.stderr', + degraded: true, + step: 'ffmpeg', + stderr: String(data), + }, 'ffmpeg stderr'); }); ffmpeg.on('close', (code) => { @@ -282,7 +289,12 @@ export async function GET(request: NextRequest) { request.signal, ); } catch (copyError) { - console.warn('MP3 concat copy failed; falling back to re-encode:', copyError); + serverLogger.warn({ + event: 'audiobook.concat_copy.mp3.failed', + degraded: true, + fallbackPath: 'reencode', + error: errorToLog(copyError), + }, 'MP3 concat copy failed; falling back to re-encode'); await runFFmpeg( ['-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath], request.signal, @@ -311,7 +323,12 @@ export async function GET(request: NextRequest) { request.signal, ); } catch (copyError) { - console.warn('M4B concat copy failed; falling back to re-encode:', copyError); + serverLogger.warn({ + event: 'audiobook.concat_copy.m4b.failed', + degraded: true, + fallbackPath: 'reencode', + error: errorToLog(copyError), + }, 'M4B concat copy failed; falling back to re-encode'); await runFFmpeg( [ '-f', @@ -360,8 +377,14 @@ export async function GET(request: NextRequest) { if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) { return NextResponse.json({ error: 'cancelled' }, { status: 499 }); } - console.error('Error creating full audiobook:', error); - return NextResponse.json({ error: 'Failed to create full audiobook file' }, { status: 500 }); + serverLogger.error({ + event: 'audiobook.create.failed', + error: errorToLog(error), + }, 'Failed to create full audiobook'); + return errorResponse(error, { + apiErrorMessage: 'Failed to create full audiobook file', + normalize: { code: 'AUDIOBOOK_CREATE_FAILED', errorClass: 'upstream' }, + }); } finally { if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {}); } @@ -408,7 +431,13 @@ export async function DELETE(request: NextRequest) { const deleted = await deleteAudiobookPrefix(audiobookPrefix(bookId, storageUserId, testNamespace)).catch(() => 0); return NextResponse.json({ success: true, existed: deleted > 0 }); } catch (error) { - console.error('Error resetting audiobook:', error); - return NextResponse.json({ error: 'Failed to reset audiobook' }, { status: 500 }); + serverLogger.error({ + event: 'audiobook.reset.failed', + error: errorToLog(error), + }, 'Failed to reset audiobook'); + return errorResponse(error, { + apiErrorMessage: 'Failed to reset audiobook', + normalize: { code: 'AUDIOBOOK_RESET_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index 78d0f53..29bda02 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -11,6 +11,8 @@ import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/li import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope'; import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -155,7 +157,13 @@ export async function GET(request: NextRequest) { settings, }); } catch (error) { - console.error('Error fetching chapters:', error); - return NextResponse.json({ error: 'Failed to fetch chapters' }, { status: 500 }); + serverLogger.error({ + event: 'audiobook.status.fetch.failed', + error: errorToLog(error), + }, 'Failed to fetch audiobook chapters'); + return errorResponse(error, { + apiErrorMessage: 'Failed to fetch chapters', + normalize: { code: 'AUDIOBOOK_STATUS_FETCH_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts new file mode 100644 index 0000000..eb7e550 --- /dev/null +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -0,0 +1,519 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker'; +import { snapshotFromWorkerState } from '@/lib/server/compute/worker-parse-state'; +import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state'; +import { isValidDocumentId } from '@/lib/server/documents/blobstore'; +import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state'; +import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import { createRequestLogger, hashForLog } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; +import { logDegraded, logServerError } from '@/lib/server/errors/logging'; +import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; +import { parseSseEventId, parseSsePayload } from '@openreader/compute-core'; +import type { PdfLayoutJobResult, WorkerOperationEvent, WorkerOperationState } from '@openreader/compute-core/api-contracts'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; +export const maxDuration = 300; + +const SSE_KEEPALIVE_MS = 15_000; +const SSE_RESYNC_INTERVAL_MS = 30_000; + +type ParseRow = { + id: string; + userId: string; + parseState: string | null; +}; + +type ParsedSnapshot = { + parseStatus: PdfParseStatus; + parseProgress: PdfParseProgress | null; + opId?: string | null; +}; + +type SnapshotState = { + snapshot: ParsedSnapshot; + opId: string | null; + fromWorker: boolean; +}; + +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function toSnapshotState(row: ParseRow, preferredOpId?: string | null): Promise { + const state = await healStaleDocumentParseState({ + documentId: row.id, + userId: row.userId, + state: parseDocumentParseState(row.parseState), + }); + const parseStatus = normalizeParseStatus(state.status); + const dbOpId = typeof state.opId === 'string' && state.opId.trim() ? state.opId.trim() : null; + const requestedOpId = preferredOpId?.trim() || null; + const opId = requestedOpId ?? dbOpId; + // When a caller pins an opId, that op is the live source of truth even if + // the per-user document row currently says "ready" or has a different opId. + if (opId && (requestedOpId !== null || parseStatus !== 'ready')) { + const workerState = await fetchWorkerOperationState(opId); + if (workerState && workerState.opId === opId) { + return { + snapshot: { + ...snapshotFromWorkerState(workerState), + opId: workerState.opId, + }, + opId: workerState.opId, + fromWorker: true, + }; + } + } + return { + snapshot: { + parseStatus, + parseProgress: state.progress ?? null, + opId, + }, + opId, + fromWorker: false, + }; +} + +async function loadPreferredRow(input: { + documentId: string; + storageUserId: string; + allowedUserIds: string[]; +}): Promise { + const rows = (await db + .select({ + id: documents.id, + userId: documents.userId, + parseState: documents.parseState, + }) + .from(documents) + .where(and(eq(documents.id, input.documentId), inArray(documents.userId, input.allowedUserIds)))) as ParseRow[]; + + return rows.find((candidate) => candidate.userId === input.storageUserId) ?? rows[0] ?? null; +} + +async function syncFromDb(input: { + documentId: string; + storageUserId: string; + allowedUserIds: string[]; + signature: string; + preferredOpId?: string | null; + writeSnapshot: (snapshot: ParsedSnapshot) => void; +}): Promise<{ snapshot: ParsedSnapshot; opId: string | null; fromWorker: boolean; signature: string } | null> { + const nextRow = await loadPreferredRow({ + documentId: input.documentId, + storageUserId: input.storageUserId, + allowedUserIds: input.allowedUserIds, + }); + if (!nextRow) return null; + + const nextState = await toSnapshotState(nextRow, input.preferredOpId); + const nextSignature = JSON.stringify(nextState.snapshot); + if (nextSignature !== input.signature) { + input.writeSnapshot(nextState.snapshot); + } + + return { + snapshot: nextState.snapshot, + opId: nextState.opId, + fromWorker: nextState.fromWorker, + signature: nextSignature, + }; +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const { logger, requestId } = createRequestLogger({ + route: '/api/documents/[id]/parsed/events', + request: req, + }); + try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + + const authCtxOrRes = await requireAuthContext(req); + if (authCtxOrRes instanceof Response) return authCtxOrRes; + + const params = await ctx.params; + const id = (params.id || '').trim().toLowerCase(); + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + const requestedOpIdRaw = req.nextUrl.searchParams.get('opId'); + const requestedOpId = typeof requestedOpIdRaw === 'string' && requestedOpIdRaw.trim() + ? requestedOpIdRaw.trim() + : null; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; + const storageUserIdHash = hashForLog(storageUserId); + const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const row = await loadPreferredRow({ + documentId: id, + storageUserId, + allowedUserIds, + }); + + if (!row) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const initialState = await toSnapshotState(row, requestedOpId); + const workerCfg = getWorkerClientConfigFromEnv(); + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + let closed = false; + let keepaliveTimer: ReturnType | null = null; + let resyncTimer: ReturnType | null = null; + let workerAbort: AbortController | null = null; + + const writeSnapshot = (snapshot: ParsedSnapshot): void => { + controller.enqueue(encoder.encode(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`)); + }; + + const closeStream = (): void => { + if (closed) return; + closed = true; + if (keepaliveTimer) { + clearInterval(keepaliveTimer); + keepaliveTimer = null; + } + if (resyncTimer) { + clearInterval(resyncTimer); + resyncTimer = null; + } + if (workerAbort) { + workerAbort.abort(); + workerAbort = null; + } + try { + controller.close(); + } catch { + // no-op + } + }; + + const runWorkerProxy = async () => { + let current = initialState.snapshot; + let signature = JSON.stringify(current); + let currentOpId = requestedOpId ?? initialState.opId; + let currentFromWorker = initialState.fromWorker; + let lastEventId: number | null = null; + let loggedMissingOpId = false; + const pinnedRequestedOp = requestedOpId ?? null; + const shouldCloseForTerminalSnapshot = (snapshot: ParsedSnapshot, fromWorker: boolean): boolean => { + const isTerminal = snapshot.parseStatus === 'ready' || snapshot.parseStatus === 'failed'; + if (!isTerminal) return false; + // If caller pinned an opId, keep streaming until worker confirms that + // op state. DB fallback can report stale terminal status for other rows. + if (pinnedRequestedOp && snapshot.opId === pinnedRequestedOp && !fromWorker) return false; + return true; + }; + + writeSnapshot(current); + + if (shouldCloseForTerminalSnapshot(current, currentFromWorker)) { + closeStream(); + return; + } + + keepaliveTimer = setInterval(() => { + if (closed) return; + controller.enqueue(encoder.encode(': keepalive\n\n')); + }, SSE_KEEPALIVE_MS); + + resyncTimer = setInterval(() => { + if (closed) return; + void syncFromDb({ + documentId: id, + storageUserId, + allowedUserIds, + signature, + preferredOpId: requestedOpId ?? currentOpId, + writeSnapshot, + }).then((next) => { + if (closed) return; + if (!next) { + closeStream(); + return; + } + current = next.snapshot; + signature = next.signature; + currentFromWorker = next.fromWorker; + if (!requestedOpId && next.opId !== currentOpId) { + currentOpId = next.opId; + lastEventId = null; + if (workerAbort) { + workerAbort.abort(); + workerAbort = null; + } + } + if (shouldCloseForTerminalSnapshot(current, currentFromWorker)) { + closeStream(); + } + }).catch((error) => { + if (closed) return; + logDegraded(logger, { + event: 'documents.parsed.events.db_resync_failed', + msg: 'SSE DB resync failed', + step: 'db_resync', + context: { + documentId: id, + storageUserIdHash, + requestId, + }, + error, + }); + controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`)); + }); + }, SSE_RESYNC_INTERVAL_MS); + + while (!closed) { + if (!currentOpId) { + await sleep(SSE_RESYNC_INTERVAL_MS); + const next = await syncFromDb({ + documentId: id, + storageUserId, + allowedUserIds, + signature, + preferredOpId: requestedOpId ?? currentOpId, + writeSnapshot, + }); + if (!next) { + closeStream(); + return; + } + current = next.snapshot; + signature = next.signature; + currentFromWorker = next.fromWorker; + currentOpId = requestedOpId ?? next.opId; + if (!currentOpId) { + if (!loggedMissingOpId) { + loggedMissingOpId = true; + logger.warn({ + event: 'documents.parsed.events.missing_opid_non_terminal', + degraded: true, + step: 'poll_without_worker_op', + documentId: id, + storageUserIdHash, + parseStatus: current.parseStatus, + requestedOpId, + }, 'Parse stream running without opId and non-terminal status'); + } + } else if (loggedMissingOpId) { + loggedMissingOpId = false; + } + if (shouldCloseForTerminalSnapshot(current, currentFromWorker)) { + closeStream(); + return; + } + continue; + } + + workerAbort = new AbortController(); + const query = lastEventId && lastEventId > 0 + ? `?sinceEventId=${encodeURIComponent(String(lastEventId))}` + : ''; + const response = await fetch( + `${workerCfg.baseUrl}/ops/${encodeURIComponent(currentOpId)}/events${query}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${workerCfg.token}`, + Accept: 'text/event-stream', + ...(lastEventId && lastEventId > 0 ? { 'Last-Event-ID': String(lastEventId) } : {}), + }, + cache: 'no-store', + signal: workerAbort.signal, + }, + ); + + if (closed) return; + + if (!response.ok) { + const upstreamResponseBody = await response.text().catch(() => ''); + logger.warn({ + event: 'documents.parsed.events.worker_stream_request_failed', + degraded: true, + step: 'worker_stream_request', + documentId: id, + opId: currentOpId, + status: response.status, + upstreamResponseBody, + error: { + name: 'WorkerStreamRequestFailed', + message: `Worker stream request failed with status ${response.status}`, + }, + }, 'Worker stream request failed'); + await sleep(500); + continue; + } + if (!response.body) { + logger.warn({ + event: 'documents.parsed.events.worker_stream_missing_body', + degraded: true, + step: 'worker_stream_body', + documentId: id, + opId: currentOpId, + error: { + name: 'WorkerStreamMissingBody', + message: 'Worker stream response missing body', + }, + }, 'Worker stream response missing body'); + await sleep(500); + continue; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let streamEnded = false; + + while (!closed && !streamEnded) { + const read = await reader.read(); + if (read.done) { + streamEnded = true; + break; + } + + buffer += decoder.decode(read.value, { stream: true }); + + while (true) { + const frameEnd = buffer.indexOf('\n\n'); + if (frameEnd < 0) break; + const frame = buffer.slice(0, frameEnd); + buffer = buffer.slice(frameEnd + 2); + + const eventId = parseSseEventId(frame); + if (eventId && eventId > 0) { + lastEventId = eventId; + } + + const payload = parseSsePayload(frame); + if (!payload) continue; + + let parsed: WorkerOperationEvent | WorkerOperationState; + try { + parsed = JSON.parse(payload) as WorkerOperationEvent | WorkerOperationState; + } catch { + continue; + } + + const workerSnapshot: WorkerOperationState = ( + parsed && typeof parsed === 'object' && 'snapshot' in parsed + ? parsed.snapshot + : parsed as WorkerOperationState + ); + if (!workerSnapshot || workerSnapshot.opId !== currentOpId) continue; + + const nextSnapshot: ParsedSnapshot = { + ...snapshotFromWorkerState(workerSnapshot), + opId: workerSnapshot.opId, + }; + const nextSignature = JSON.stringify(nextSnapshot); + if (nextSignature !== signature) { + current = nextSnapshot; + signature = nextSignature; + currentFromWorker = true; + writeSnapshot(current); + } + + if (shouldCloseForTerminalSnapshot(current, currentFromWorker)) { + closeStream(); + return; + } + } + } + + if (closed) return; + + const next = await syncFromDb({ + documentId: id, + storageUserId, + allowedUserIds, + signature, + preferredOpId: requestedOpId ?? currentOpId, + writeSnapshot, + }); + if (!next) { + closeStream(); + return; + } + current = next.snapshot; + signature = next.signature; + currentFromWorker = next.fromWorker; + if (!requestedOpId && next.opId !== currentOpId) { + currentOpId = next.opId; + lastEventId = null; + } + if (shouldCloseForTerminalSnapshot(current, currentFromWorker)) { + closeStream(); + return; + } + await sleep(250); + } + }; + + void runWorkerProxy() + .catch((error) => { + logServerError(logger, { + event: 'documents.parsed.events.worker_proxy_crashed', + error, + msg: 'Worker proxy crashed while streaming parse events', + context: { documentId: id }, + normalize: { + code: 'DOCUMENTS_PARSED_EVENTS_WORKER_PROXY_CRASHED', + errorClass: 'upstream', + }, + }); + if (!closed) { + controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ error: String(error) })}\n\n`)); + } + }) + .finally(() => { + closeStream(); + }); + + req.signal.addEventListener('abort', () => { + closeStream(); + }, { once: true }); + }, + cancel() { + return; + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); + } catch (error) { + return errorResponse(error, { + logger, + event: 'documents.parsed.events.route_failed', + msg: 'Parsed events route failed', + apiErrorMessage: 'Failed to stream parsed PDF progress', + normalize: { code: 'DOCUMENTS_PARSED_EVENTS_ROUTE_FAILED', errorClass: 'upstream' }, + }); + } +} diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts new file mode 100644 index 0000000..51ef630 --- /dev/null +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -0,0 +1,411 @@ +import { randomUUID } from 'node:crypto'; +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { createOrReusePdfWorkerOperation } from '@/lib/server/compute/worker-op-create'; +import { snapshotFromWorkerState } from '@/lib/server/compute/worker-parse-state'; +import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state'; +import { + documentKey, + getParsedDocumentBlob, + getParsedDocumentBlobByKey, + isMissingBlobError, + isValidDocumentId, + putParsedDocumentBlob, +} from '@/lib/server/documents/blobstore'; +import { + normalizeParseStatus, + parseDocumentParseState, + stringifyDocumentParseState, +} from '@/lib/server/documents/parse-state'; +import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import { createRequestLogger, hashForLog, type ServerLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; +import { logDegraded } from '@/lib/server/errors/logging'; +import type { ParsedPdfDocument } from '@/types/parsed-pdf'; +import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; + +export const dynamic = 'force-dynamic'; + +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +type ParseRow = { + id: string; + userId: string; + parseState: string | null; + parsedJsonKey: string | null; +}; + +function hasAnyParsedBlocks(doc: ParsedPdfDocument | null): boolean { + if (!doc || !Array.isArray(doc.pages)) return false; + return doc.pages.some((page) => Array.isArray(page.blocks) && page.blocks.length > 0); +} + +function normalizeOpId(value: string | null | undefined): string | null { + const normalized = typeof value === 'string' ? value.trim() : ''; + return normalized || null; +} + +async function loadRows(input: { + documentId: string; + allowedUserIds: string[]; +}): Promise { + return (await db + .select({ + id: documents.id, + userId: documents.userId, + parseState: documents.parseState, + parsedJsonKey: documents.parsedJsonKey, + }) + .from(documents) + .where(and(eq(documents.id, input.documentId), inArray(documents.userId, input.allowedUserIds)))) as ParseRow[]; +} + +function pickPreferredRow(rows: ParseRow[], storageUserId: string): ParseRow | null { + return rows.find((candidate) => candidate.userId === storageUserId) ?? rows[0] ?? null; +} + +async function writeParseRowState(input: { + documentId: string; + userId: string; + parseState: string; + parsedJsonKey?: string | null; +}): Promise { + await db + .update(documents) + .set({ + parseState: input.parseState, + ...(typeof input.parsedJsonKey === 'string' || input.parsedJsonKey === null + ? { parsedJsonKey: input.parsedJsonKey } + : {}), + }) + .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); +} + +async function finalizeFromWorkerState(input: { + workerState: WorkerOperationState; + row: ParseRow; + namespace: string | null; + logger: ServerLogger; +}): Promise { + const snapshot = snapshotFromWorkerState(input.workerState); + + if (snapshot.parseStatus === 'pending' || snapshot.parseStatus === 'running') { + return NextResponse.json({ + parseStatus: snapshot.parseStatus, + parseProgress: snapshot.parseProgress, + opId: input.workerState.opId, + }, { status: 202 }); + } + + if (snapshot.parseStatus === 'failed') { + await writeParseRowState({ + documentId: input.row.id, + userId: input.row.userId, + parseState: stringifyDocumentParseState({ + status: 'failed', + progress: null, + updatedAt: Date.now(), + error: input.workerState.error?.message ?? 'Worker parse failed', + }), + }); + + return NextResponse.json({ + parseStatus: 'failed', + parseProgress: null, + opId: input.workerState.opId, + error: input.workerState.error?.message ?? 'Worker parse failed', + }, { status: 202 }); + } + + let parsedJsonKey: string | null = null; + if (input.workerState.result && 'parsedObjectKey' in input.workerState.result) { + parsedJsonKey = typeof input.workerState.result.parsedObjectKey === 'string' + ? input.workerState.result.parsedObjectKey + : null; + } + + if (!parsedJsonKey && input.workerState.result && 'parsed' in input.workerState.result && input.workerState.result.parsed) { + const parsedJson = Buffer.from(JSON.stringify(input.workerState.result.parsed)); + parsedJsonKey = await putParsedDocumentBlob(input.row.id, parsedJson, input.namespace); + } + + if (!parsedJsonKey) { + return errorResponse(new Error('Worker completed without parsed output'), { + apiErrorMessage: 'Worker completed without parsed output', + normalize: { code: 'DOCUMENTS_PARSED_WORKER_OUTPUT_MISSING', errorClass: 'upstream' }, + }); + } + + await writeParseRowState({ + documentId: input.row.id, + userId: input.row.userId, + parseState: stringifyDocumentParseState({ + status: 'ready', + progress: null, + updatedAt: Date.now(), + }), + parsedJsonKey, + }); + + const json = await getParsedDocumentBlobByKey(parsedJsonKey); + let parsedDoc: ParsedPdfDocument | null = null; + try { + parsedDoc = JSON.parse(Buffer.from(json).toString('utf8')) as ParsedPdfDocument; + } catch { + parsedDoc = null; + } + + if (!hasAnyParsedBlocks(parsedDoc)) { + input.logger.warn({ + event: 'documents.parsed.no_blocks_in_output', + documentId: input.row.id, + userIdHash: hashForLog(input.row.userId), + parsedJsonKey, + }, 'Worker output parsed successfully but contained no blocks'); + } + + return new NextResponse(new Uint8Array(json), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }); +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const { logger } = createRequestLogger({ + route: '/api/documents/[id]/parsed', + request: req, + }); + try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + + const authCtxOrRes = await requireAuthContext(req); + if (authCtxOrRes instanceof Response) return authCtxOrRes; + + const params = await ctx.params; + const id = (params.id || '').trim().toLowerCase(); + const retryFailed = req.nextUrl.searchParams.get('retry') === '1'; + const requestedOpId = normalizeOpId(req.nextUrl.searchParams.get('opId')); + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const rows = await loadRows({ documentId: id, allowedUserIds }); + const row = pickPreferredRow(rows, storageUserId); + if (!row) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (requestedOpId) { + const workerState = await fetchWorkerOperationState(requestedOpId); + if (workerState && workerState.opId === requestedOpId) { + return finalizeFromWorkerState({ + workerState, + row, + namespace: testNamespace, + logger, + }); + } + logDegraded(logger, { + event: 'documents.parsed.requested_op_unavailable', + msg: 'Requested worker operation id was unavailable', + step: 'requested_op_lookup', + context: { + documentId: id, + userIdHash: hashForLog(row.userId), + opId: requestedOpId, + error: { + name: 'RequestedOperationUnavailable', + message: `Requested worker operation ${requestedOpId} was unavailable`, + }, + }, + }); + } + + let state = parseDocumentParseState(row.parseState); + state = await healStaleDocumentParseState({ + documentId: id, + userId: row.userId, + state, + }); + + const effectiveStatus = normalizeParseStatus(state.status); + const effectiveProgress = state.progress ?? null; + const effectiveOpId = normalizeOpId(state.opId); + + if (effectiveOpId && effectiveStatus !== 'ready') { + const workerState = await fetchWorkerOperationState(effectiveOpId); + if (workerState && workerState.opId === effectiveOpId) { + return finalizeFromWorkerState({ + workerState, + row, + namespace: testNamespace, + logger, + }); + } + } + + if (effectiveStatus === 'failed' && retryFailed) { + const created = await createOrReusePdfWorkerOperation({ + documentId: id, + namespace: testNamespace, + documentObjectKey: documentKey(id, testNamespace), + }); + const snapshot = snapshotFromWorkerState(created); + return NextResponse.json({ + parseStatus: snapshot.parseStatus, + parseProgress: snapshot.parseProgress, + opId: created.opId, + }, { status: 202 }); + } + + if (effectiveStatus !== 'ready') { + return NextResponse.json({ + parseStatus: effectiveStatus, + parseProgress: effectiveProgress, + opId: effectiveOpId, + }, { status: 202 }); + } + + try { + const json = row.parsedJsonKey?.trim() + ? await getParsedDocumentBlobByKey(row.parsedJsonKey) + : await getParsedDocumentBlob(id, testNamespace); + let parsedDoc: ParsedPdfDocument | null = null; + try { + parsedDoc = JSON.parse(Buffer.from(json).toString('utf8')) as ParsedPdfDocument; + } catch { + parsedDoc = null; + } + + if (!hasAnyParsedBlocks(parsedDoc)) { + logger.warn({ + event: 'documents.parsed.no_blocks_from_blob', + documentId: id, + userIdHash: hashForLog(row.userId), + parsedJsonKey: row.parsedJsonKey, + }, 'Parsed document blob contained no blocks'); + } + + return new NextResponse(new Uint8Array(json), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }); + } catch (error) { + if (isMissingBlobError(error)) { + return NextResponse.json({ parseStatus: 'failed', error: 'Parsed document not found' }, { status: 404 }); + } + throw error; + } + } catch (error) { + return errorResponse(error, { + logger, + event: 'documents.parsed.get_failed', + msg: 'Failed to read parsed PDF', + apiErrorMessage: 'Failed to read parsed PDF', + normalize: { code: 'DOCUMENTS_PARSED_GET_FAILED', errorClass: 'storage' }, + }); + } +} + +export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const { logger } = createRequestLogger({ + route: '/api/documents/[id]/parsed', + request: req, + }); + try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + + const authCtxOrRes = await requireAuthContext(req); + if (authCtxOrRes instanceof Response) return authCtxOrRes; + + const params = await ctx.params; + const id = (params.id || '').trim().toLowerCase(); + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + let replace = false; + try { + const body = (await req.json()) as { replace?: unknown }; + replace = body?.replace === true; + } catch { + replace = false; + } + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const rows = await loadRows({ documentId: id, allowedUserIds }); + const row = pickPreferredRow(rows, storageUserId); + if (!row) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + let state = parseDocumentParseState(row.parseState); + state = await healStaleDocumentParseState({ + documentId: id, + userId: row.userId, + state, + }); + + const existingOpId = normalizeOpId(state.opId); + if (existingOpId) { + const existing = await fetchWorkerOperationState(existingOpId); + if (existing && (existing.status === 'queued' || existing.status === 'running') && !replace) { + const snapshot = snapshotFromWorkerState(existing); + return NextResponse.json({ + error: 'Parse operation already in progress', + parseStatus: snapshot.parseStatus, + parseProgress: snapshot.parseProgress, + opId: existing.opId, + }, { status: 409 }); + } + } + + const created = await createOrReusePdfWorkerOperation({ + documentId: id, + namespace: testNamespace, + documentObjectKey: documentKey(id, testNamespace), + forceToken: randomUUID(), + }); + + const snapshot = snapshotFromWorkerState(created); + + return NextResponse.json({ + parseStatus: snapshot.parseStatus, + parseProgress: snapshot.parseProgress, + opId: created.opId, + }, { status: 202 }); + } catch (error) { + return errorResponse(error, { + logger, + event: 'documents.parsed.force_refresh_failed', + msg: 'Failed to force PDF refresh', + apiErrorMessage: 'Failed to force PDF refresh', + normalize: { code: 'DOCUMENTS_PARSED_FORCE_REFRESH_FAILED', errorClass: 'upstream' }, + }); + } +} diff --git a/src/app/api/documents/[id]/settings/route.ts b/src/app/api/documents/[id]/settings/route.ts new file mode 100644 index 0000000..5b94e23 --- /dev/null +++ b/src/app/api/documents/[id]/settings/route.ts @@ -0,0 +1,181 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documentSettings, documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth/auth'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { mergeDocumentSettings } from '@/lib/shared/document-settings'; +import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings'; +import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; + +export const dynamic = 'force-dynamic'; + +function serializeForDb(payload: Record): Record | string { + if (process.env.POSTGRES_URL) return payload; + return JSON.stringify(payload); +} + +function normalizeClientUpdatedAtMs(value: unknown): number { + const normalized = coerceTimestampMs(value, nowTimestampMs()); + if (normalized <= 0) return nowTimestampMs(); + return normalized; +} + +function parseStored(value: unknown): DocumentSettings { + if (typeof value === 'string') { + try { + return mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, JSON.parse(value)); + } catch { + return mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, null); + } + } + return mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, value); +} + +async function resolveDocumentAccess(req: NextRequest, documentId: string): Promise< + | { ownerUserId: string; allowedUserIds: string[] } + | Response +> { + const authCtxOrRes = await requireAuthContext(req); + if (authCtxOrRes instanceof Response) return authCtxOrRes; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const rows = await db + .select({ userId: documents.userId }) + .from(documents) + .where(and(eq(documents.id, documentId), inArray(documents.userId, allowedUserIds))) + .limit(1); + + if (!rows[0]) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + return { + ownerUserId: rows[0].userId, + allowedUserIds, + }; +} + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + const { id } = await ctx.params; + const documentId = (id || '').trim().toLowerCase(); + if (!documentId) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + const scope = await resolveDocumentAccess(req, documentId); + if (scope instanceof Response) return scope; + + const rows = await db + .select({ + dataJson: documentSettings.dataJson, + clientUpdatedAtMs: documentSettings.clientUpdatedAtMs, + }) + .from(documentSettings) + .where(and( + eq(documentSettings.documentId, documentId), + eq(documentSettings.userId, scope.ownerUserId), + )) + .limit(1); + + const row = rows[0]; + const settings = parseStored(row?.dataJson); + + return NextResponse.json({ + settings, + clientUpdatedAtMs: Number(row?.clientUpdatedAtMs ?? 0), + hasStoredSettings: Boolean(row), + }); + } catch (error) { + serverLogger.error({ + event: 'documents.settings.load.failed', + error: errorToLog(error), + }, 'Failed to load document settings'); + return errorResponse(error, { + apiErrorMessage: 'Failed to load document settings', + normalize: { code: 'DOCUMENTS_SETTINGS_LOAD_FAILED', errorClass: 'db' }, + }); + } +} + +export async function PUT(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + const { id } = await ctx.params; + const documentId = (id || '').trim().toLowerCase(); + if (!documentId) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + const scope = await resolveDocumentAccess(req, documentId); + if (scope instanceof Response) return scope; + + const body = (await req.json().catch(() => null)) as { settings?: unknown; clientUpdatedAtMs?: unknown } | null; + const incoming = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, body?.settings ?? null); + const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs); + + const existingRows = await db + .select({ + dataJson: documentSettings.dataJson, + clientUpdatedAtMs: documentSettings.clientUpdatedAtMs, + }) + .from(documentSettings) + .where(and( + eq(documentSettings.documentId, documentId), + eq(documentSettings.userId, scope.ownerUserId), + )) + .limit(1); + + const existing = existingRows[0]; + const existingUpdatedAt = Number(existing?.clientUpdatedAtMs ?? 0); + if (existing && clientUpdatedAtMs < existingUpdatedAt) { + return NextResponse.json({ + settings: parseStored(existing.dataJson), + clientUpdatedAtMs: existingUpdatedAt, + applied: false, + }); + } + + const updatedAt = nowTimestampMs(); + const payload = serializeForDb(incoming as unknown as Record); + + await db + .insert(documentSettings) + .values({ + documentId, + userId: scope.ownerUserId, + dataJson: payload as never, + clientUpdatedAtMs, + updatedAt, + }) + .onConflictDoUpdate({ + target: [documentSettings.documentId, documentSettings.userId], + set: { + dataJson: payload as never, + clientUpdatedAtMs, + updatedAt, + }, + }); + + return NextResponse.json({ + settings: incoming, + clientUpdatedAtMs, + applied: true, + }); + } catch (error) { + serverLogger.error({ + event: 'documents.settings.update.failed', + error: errorToLog(error), + }, 'Failed to update document settings'); + return errorResponse(error, { + apiErrorMessage: 'Failed to update document settings', + normalize: { code: 'DOCUMENTS_SETTINGS_UPDATE_FAILED', errorClass: 'db' }, + }); + } +} diff --git a/src/app/api/documents/blob/get/fallback/route.ts b/src/app/api/documents/blob/get/fallback/route.ts index 3049db4..5584cfc 100644 --- a/src/app/api/documents/blob/get/fallback/route.ts +++ b/src/app/api/documents/blob/get/fallback/route.ts @@ -7,6 +7,8 @@ import { contentTypeForName } from '@/lib/server/storage/library-mount'; import { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -44,7 +46,12 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); } - console.info('[blob-fallback] download proxy used', { id }); + serverLogger.info({ + event: 'documents.blob.get.fallback.proxy_used', + degraded: true, + fallbackPath: 'download_proxy', + documentId: id, + }, 'Document download fallback proxy used'); const rows = (await db .select({ id: documents.id, userId: documents.userId, name: documents.name }) @@ -81,7 +88,13 @@ export async function GET(req: NextRequest) { throw error; } } catch (error) { - console.error('Error loading document content fallback:', error); - return NextResponse.json({ error: 'Failed to load document content' }, { status: 500 }); + serverLogger.error({ + event: 'documents.blob.get.fallback.failed', + error: errorToLog(error), + }, 'Failed to load document content fallback'); + return errorResponse(error, { + apiErrorMessage: 'Failed to load document content', + normalize: { code: 'DOCUMENTS_BLOB_GET_FALLBACK_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/documents/blob/get/presign/route.ts b/src/app/api/documents/blob/get/presign/route.ts index a63753e..8346c3c 100644 --- a/src/app/api/documents/blob/get/presign/route.ts +++ b/src/app/api/documents/blob/get/presign/route.ts @@ -6,6 +6,8 @@ import { requireAuthContext } from '@/lib/server/auth/auth'; import { isValidDocumentId, presignGet } from '@/lib/server/documents/blobstore'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -50,7 +52,12 @@ export async function GET(req: NextRequest) { const fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(doc.id)}`; const directUrl = await presignGet(doc.id, testNamespace).catch(() => null); if (!directUrl) { - console.warn('[blob-fallback] presign download unavailable, redirecting to proxy fallback', { id: doc.id }); + serverLogger.warn({ + event: 'documents.blob.get.presign.unavailable', + degraded: true, + fallbackPath: 'download_proxy', + documentId: doc.id, + }, 'Presigned document download unavailable, redirecting to proxy fallback'); return NextResponse.redirect(fallbackUrl, { status: 307, headers: { 'Cache-Control': 'no-store' }, @@ -62,7 +69,13 @@ export async function GET(req: NextRequest) { headers: { 'Cache-Control': 'no-store' }, }); } catch (error) { - console.error('Error creating document download signature:', error); - return NextResponse.json({ error: 'Failed to prepare document download' }, { status: 500 }); + serverLogger.error({ + event: 'documents.blob.get.presign.failed', + error: errorToLog(error), + }, 'Failed to create document download signature'); + return errorResponse(error, { + apiErrorMessage: 'Failed to prepare document download', + normalize: { code: 'DOCUMENTS_BLOB_GET_PRESIGN_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/documents/blob/preview/ensure/route.ts b/src/app/api/documents/blob/preview/ensure/route.ts index 186ee98..1fa3450 100644 --- a/src/app/api/documents/blob/preview/ensure/route.ts +++ b/src/app/api/documents/blob/preview/ensure/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobstore'; import { ensureDocumentPreview } from '@/lib/server/documents/previews'; import { validatePreviewRequest } from '../utils'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -50,7 +52,13 @@ export async function GET(req: NextRequest) { }, ); } catch (error) { - console.error('Error ensuring document preview:', error); - return NextResponse.json({ error: 'Failed to ensure document preview' }, { status: 500 }); + serverLogger.error({ + event: 'documents.preview.ensure.failed', + error: errorToLog(error), + }, 'Failed to ensure document preview'); + return errorResponse(error, { + apiErrorMessage: 'Failed to ensure document preview', + normalize: { code: 'DOCUMENTS_PREVIEW_ENSURE_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/documents/blob/preview/fallback/route.ts b/src/app/api/documents/blob/preview/fallback/route.ts index 44fc4dd..a83955e 100644 --- a/src/app/api/documents/blob/preview/fallback/route.ts +++ b/src/app/api/documents/blob/preview/fallback/route.ts @@ -3,6 +3,8 @@ import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { getDocumentRange, isMissingBlobError as isMissingDocumentBlobError, @@ -65,10 +67,13 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); } - console.info('[blob-fallback] preview proxy used', { - id, + serverLogger.info({ + event: 'documents.preview.fallback.proxy_used', + degraded: true, + fallbackPath: 'preview_proxy', + documentId: id, snippetRequested, - }); + }, 'Document preview fallback proxy used'); const rows = (await db .select({ @@ -175,7 +180,13 @@ export async function GET(req: NextRequest) { throw error; } } catch (error) { - console.error('Error loading document preview fallback:', error); - return NextResponse.json({ error: 'Failed to load document preview' }, { status: 500 }); + serverLogger.error({ + event: 'documents.preview.fallback.failed', + error: errorToLog(error), + }, 'Failed to load document preview fallback'); + return errorResponse(error, { + apiErrorMessage: 'Failed to load document preview', + normalize: { code: 'DOCUMENTS_PREVIEW_FALLBACK_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/documents/blob/preview/presign/route.ts b/src/app/api/documents/blob/preview/presign/route.ts index 26ed7e8..717902e 100644 --- a/src/app/api/documents/blob/preview/presign/route.ts +++ b/src/app/api/documents/blob/preview/presign/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { presignDocumentPreviewGet } from '@/lib/server/documents/previews-blobstore'; import { ensureDocumentPreview } from '@/lib/server/documents/previews'; import { validatePreviewRequest } from '../utils'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -37,7 +39,12 @@ export async function GET(req: NextRequest) { const directUrl = await presignDocumentPreviewGet(doc.id, testNamespace).catch(() => null); if (!directUrl) { - console.warn('[blob-fallback] presign preview unavailable, redirecting to proxy fallback', { id: doc.id }); + serverLogger.warn({ + event: 'documents.preview.presign.unavailable', + degraded: true, + fallbackPath: 'preview_proxy', + documentId: doc.id, + }, 'Presigned document preview unavailable, redirecting to proxy fallback'); return NextResponse.redirect(fallbackUrl, { status: 307, headers: { 'Cache-Control': 'no-store' }, @@ -49,7 +56,13 @@ export async function GET(req: NextRequest) { headers: { 'Cache-Control': 'no-store' }, }); } catch (error) { - console.error('Error creating document preview signature:', error); - return NextResponse.json({ error: 'Failed to prepare document preview' }, { status: 500 }); + serverLogger.error({ + event: 'documents.preview.presign.failed', + error: errorToLog(error), + }, 'Failed to create document preview signature'); + return errorResponse(error, { + apiErrorMessage: 'Failed to prepare document preview', + normalize: { code: 'DOCUMENTS_PREVIEW_PRESIGN_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/documents/blob/upload/fallback/route.ts b/src/app/api/documents/blob/upload/fallback/route.ts index 04a3105..bc188df 100644 --- a/src/app/api/documents/blob/upload/fallback/route.ts +++ b/src/app/api/documents/blob/upload/fallback/route.ts @@ -3,6 +3,8 @@ import { requireAuthContext } from '@/lib/server/auth/auth'; import { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents/blobstore'; import { isS3Configured } from '@/lib/server/storage/s3'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -42,15 +44,24 @@ export async function PUT(req: NextRequest) { } } - console.info('[blob-fallback] upload proxy used', { - id, + serverLogger.info({ + event: 'documents.blob.upload.fallback.proxy_used', + degraded: true, + fallbackPath: 'upload_proxy', + documentId: id, contentType, bytes: body.byteLength, - }); + }, 'Document upload fallback proxy used'); return NextResponse.json({ success: true, id }); } catch (error) { - console.error('Error proxy-uploading document blob:', error); - return NextResponse.json({ error: 'Failed to upload document blob' }, { status: 500 }); + serverLogger.error({ + event: 'documents.blob.upload.fallback.failed', + error: errorToLog(error), + }, 'Failed to proxy-upload document blob'); + return errorResponse(error, { + apiErrorMessage: 'Failed to upload document blob', + normalize: { code: 'DOCUMENTS_BLOB_UPLOAD_FALLBACK_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/documents/blob/upload/presign/route.ts b/src/app/api/documents/blob/upload/presign/route.ts index 9f66b7e..07756aa 100644 --- a/src/app/api/documents/blob/upload/presign/route.ts +++ b/src/app/api/documents/blob/upload/presign/route.ts @@ -3,6 +3,8 @@ import { requireAuthContext } from '@/lib/server/auth/auth'; import { isValidDocumentId, presignPut } from '@/lib/server/documents/blobstore'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -65,8 +67,13 @@ export async function POST(req: NextRequest) { return NextResponse.json({ uploads: signed }); } catch (error) { - console.error('Error creating document upload signatures:', error); - return NextResponse.json({ error: 'Failed to presign uploads' }, { status: 500 }); + serverLogger.error({ + event: 'documents.blob.upload.presign.failed', + error: errorToLog(error), + }, 'Failed to create document upload signatures'); + return errorResponse(error, { + apiErrorMessage: 'Failed to presign uploads', + normalize: { code: 'DOCUMENTS_BLOB_UPLOAD_PRESIGN_FAILED', errorClass: 'storage' }, + }); } } - diff --git a/src/app/api/documents/docx-to-pdf/upload/route.ts b/src/app/api/documents/docx-to-pdf/upload/route.ts index 4a65228..91866af 100644 --- a/src/app/api/documents/docx-to-pdf/upload/route.ts +++ b/src/app/api/documents/docx-to-pdf/upload/route.ts @@ -10,9 +10,13 @@ import { db } from '@/db'; import { documents } from '@/db/schema'; import { safeDocumentName } from '@/lib/server/documents/utils'; import { enqueueDocumentPreview } from '@/lib/server/documents/previews'; +import { enqueueParsePdfJob } from '@/lib/server/jobs/user-pdf-layout-job'; +import { stringifyDocumentParseState } from '@/lib/server/documents/parse-state'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import { putDocumentBlob } from '@/lib/server/documents/blobstore'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp'); @@ -136,6 +140,8 @@ export async function POST(req: NextRequest) { size: pdfContent.length, lastModified, filePath: id, + parseState: stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }), + parsedJsonKey: null, }) .onConflictDoUpdate({ target: [documents.id, documents.userId], @@ -145,6 +151,8 @@ export async function POST(req: NextRequest) { size: pdfContent.length, lastModified, filePath: id, + parseState: stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }), + parsedJsonKey: null, }, }); @@ -156,7 +164,19 @@ export async function POST(req: NextRequest) { }, testNamespace, ).catch((error) => { - console.error(`Failed to enqueue preview for converted DOCX ${id}:`, error); + serverLogger.warn({ + event: 'documents.docx.preview_enqueue.failed', + degraded: true, + fallbackPath: 'skip_preview_enqueue', + documentId: id, + error: errorToLog(error), + }, 'Failed to enqueue preview for converted DOCX'); + }); + + enqueueParsePdfJob({ + documentId: id, + userId: storageUserId, + namespace: testNamespace, }); return NextResponse.json({ @@ -172,7 +192,13 @@ export async function POST(req: NextRequest) { await rm(jobDir, { recursive: true, force: true }).catch(() => {}); } } catch (error) { - console.error('Error converting/uploading DOCX:', error); - return NextResponse.json({ error: 'Failed to convert document' }, { status: 500 }); + serverLogger.error({ + event: 'documents.docx.convert_upload.failed', + error: errorToLog(error), + }, 'Failed converting/uploading DOCX'); + return errorResponse(error, { + apiErrorMessage: 'Failed to convert document', + normalize: { code: 'DOCUMENTS_DOCX_CONVERT_UPLOAD_FAILED', errorClass: 'upstream' }, + }); } } diff --git a/src/app/api/documents/library/route.ts b/src/app/api/documents/library/route.ts index 72741f0..44c8e32 100644 --- a/src/app/api/documents/library/route.ts +++ b/src/app/api/documents/library/route.ts @@ -5,6 +5,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { parseLibraryRoots } from '@/lib/server/storage/library-mount'; import type { DocumentType } from '@/types/documents'; import { auth } from '@/lib/server/auth/auth'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -111,8 +113,14 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } } catch (error) { - console.error('Error checking auth:', error); - return NextResponse.json({ error: 'Error checking auth' }, { status: 500 }); + serverLogger.error({ + event: 'documents.library.auth_check.failed', + error: errorToLog(error), + }, 'Failed to check auth for library route'); + return errorResponse(error, { + apiErrorMessage: 'Error checking auth', + normalize: { code: 'DOCUMENTS_LIBRARY_AUTH_CHECK_FAILED', errorClass: 'auth' }, + }); } const url = new URL(req.url); diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 38eb3b9..c56891c 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -4,12 +4,20 @@ import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents/utils'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { cleanupDocumentPreviewArtifacts, deleteDocumentPreviewRows, enqueueDocumentPreview, } from '@/lib/server/documents/previews'; +import { enqueueParsePdfJob } from '@/lib/server/jobs/user-pdf-layout-job'; import { deleteDocumentBlob, headDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; +import { + normalizeParseStatus, + parseDocumentParseState, + stringifyDocumentParseState, +} from '@/lib/server/documents/parse-state'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import type { BaseDocument, DocumentType } from '@/types/documents'; @@ -129,6 +137,10 @@ export async function POST(req: NextRequest) { size: headSize, lastModified: doc.lastModified, filePath: doc.id, + parseState: doc.type === 'pdf' + ? stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }) + : null, + parsedJsonKey: null, }) .onConflictDoUpdate({ target: [documents.id, documents.userId], @@ -138,6 +150,10 @@ export async function POST(req: NextRequest) { size: headSize, lastModified: doc.lastModified, filePath: doc.id, + parseState: doc.type === 'pdf' + ? stringifyDocumentParseState({ status: 'pending', progress: null, updatedAt: Date.now() }) + : null, + parsedJsonKey: null, }, }); @@ -158,14 +174,34 @@ export async function POST(req: NextRequest) { }, testNamespace, ).catch((error) => { - console.error(`Failed to enqueue preview for document ${doc.id}:`, error); + serverLogger.warn({ + event: 'documents.preview.enqueue.failed', + degraded: true, + fallbackPath: 'skip_preview_enqueue', + documentId: doc.id, + error: errorToLog(error), + }, 'Failed to enqueue document preview'); }); + + if (doc.type === 'pdf') { + enqueueParsePdfJob({ + documentId: doc.id, + userId: storageUserId, + namespace: testNamespace, + }); + } } return NextResponse.json({ success: true, stored }); } catch (error) { - console.error('Error registering documents:', error); - return NextResponse.json({ error: 'Failed to register documents' }, { status: 500 }); + serverLogger.error({ + event: 'documents.register.failed', + error: errorToLog(error), + }, 'Failed to register documents'); + return errorResponse(error, { + apiErrorMessage: 'Failed to register documents', + normalize: { code: 'DOCUMENTS_REGISTER_FAILED', errorClass: 'db' }, + }); } } @@ -206,9 +242,25 @@ export async function GET(req: NextRequest) { size: number; lastModified: number; filePath: string; + parseState: string | null; + parsedJsonKey: string | null; }>; - const results: BaseDocument[] = rows.map((doc) => { + const preferredById = new Map(); + for (const row of rows) { + const existing = preferredById.get(row.id); + if (!existing) { + preferredById.set(row.id, row); + continue; + } + const isRowPrimary = row.userId === storageUserId; + const isExistingPrimary = existing.userId === storageUserId; + if (isRowPrimary && !isExistingPrimary) { + preferredById.set(row.id, row); + } + } + + const results: BaseDocument[] = Array.from(preferredById.values()).map((doc) => { const type = normalizeDocumentType(doc.type, doc.name); return { id: doc.id, @@ -216,14 +268,22 @@ export async function GET(req: NextRequest) { size: Number(doc.size), lastModified: Number(doc.lastModified), type, + parseStatus: type === 'pdf' ? normalizeParseStatus(parseDocumentParseState(doc.parseState).status) : null, + parsedJsonKey: doc.parsedJsonKey, scope: doc.userId === unclaimedUserId ? 'unclaimed' : 'user', }; }); return NextResponse.json({ documents: results }); } catch (error) { - console.error('Error loading document metadata:', error); - return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 }); + serverLogger.error({ + event: 'documents.list.failed', + error: errorToLog(error), + }, 'Failed to load document metadata'); + return errorResponse(error, { + apiErrorMessage: 'Failed to load documents', + normalize: { code: 'DOCUMENTS_LIST_FAILED', errorClass: 'db' }, + }); } } @@ -302,21 +362,45 @@ export async function DELETE(req: NextRequest) { await deleteDocumentBlob(id, testNamespace); } catch (error) { if (!isMissingBlobError(error)) { - console.error(`[best-effort] Failed to delete blob for document ${id}, orphaned blob may need manual cleanup:`, error); + serverLogger.warn({ + event: 'documents.delete.blob_cleanup_failed', + degraded: true, + step: 'delete_document_blob', + documentId: id, + error: errorToLog(error), + }, 'Failed to delete document blob during cleanup'); } } await cleanupDocumentPreviewArtifacts(id, testNamespace).catch((error) => { - console.error(`Failed to cleanup preview artifacts for document ${id}:`, error); + serverLogger.warn({ + event: 'documents.delete.preview_artifacts_cleanup_failed', + degraded: true, + step: 'delete_preview_artifacts', + documentId: id, + error: errorToLog(error), + }, 'Failed to cleanup preview artifacts'); }); await deleteDocumentPreviewRows(id, testNamespace).catch((error) => { - console.error(`Failed to cleanup preview rows for document ${id}:`, error); + serverLogger.warn({ + event: 'documents.delete.preview_rows_cleanup_failed', + degraded: true, + step: 'delete_preview_rows', + documentId: id, + error: errorToLog(error), + }, 'Failed to cleanup preview rows'); }); } return NextResponse.json({ success: true, deleted: deletedRows.length }); } catch (error) { - console.error('Error deleting documents:', error); - return NextResponse.json({ error: 'Failed to delete documents' }, { status: 500 }); + serverLogger.error({ + event: 'documents.delete.failed', + error: errorToLog(error), + }, 'Failed to delete documents'); + return errorResponse(error, { + apiErrorMessage: 'Failed to delete documents', + normalize: { code: 'DOCUMENTS_DELETE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/rate-limit/status/route.ts b/src/app/api/rate-limit/status/route.ts index 27015a1..e541f9f 100644 --- a/src/app/api/rate-limit/status/route.ts +++ b/src/app/api/rate-limit/status/route.ts @@ -6,6 +6,8 @@ import { isAuthEnabled } from '@/lib/server/auth/config'; import { getClientIp } from '@/lib/server/rate-limit/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -84,10 +86,13 @@ export async function GET(req: NextRequest) { return response; } catch (error) { - console.error('Error getting rate limit status:', error); - return NextResponse.json( - { error: 'Failed to get rate limit status' }, - { status: 500 } - ); + serverLogger.error({ + event: 'rate_limit.status.get.failed', + error: errorToLog(error), + }, 'Failed to get rate limit status'); + return errorResponse(error, { + apiErrorMessage: 'Failed to get rate limit status', + normalize: { code: 'RATE_LIMIT_STATUS_GET_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/tts/segments/audio/fallback/route.ts b/src/app/api/tts/segments/audio/fallback/route.ts index d658546..22a3120 100644 --- a/src/app/api/tts/segments/audio/fallback/route.ts +++ b/src/app/api/tts/segments/audio/fallback/route.ts @@ -7,10 +7,16 @@ import { ttsSegmentsS3NotConfiguredResponse, } from '@/lib/server/tts/segments-audio'; import { getTtsSegmentAudioObjectStream } from '@/lib/server/tts/segments-blobstore'; +import { createRequestLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; export async function GET(request: NextRequest) { + const { logger } = createRequestLogger({ + route: '/api/tts/segments/audio/fallback', + request, + }); try { if (!isS3Configured()) return ttsSegmentsS3NotConfiguredResponse(); @@ -45,7 +51,12 @@ export async function GET(request: NextRequest) { headers, }); } catch (error) { - console.error('Error serving TTS segment audio fallback:', error); - return NextResponse.json({ error: 'Failed to load segment audio' }, { status: 500 }); + return errorResponse(error, { + logger, + event: 'tts.segments.audio_fallback_failed', + msg: 'Failed to load segment audio from fallback route', + apiErrorMessage: 'Failed to load segment audio', + normalize: { code: 'TTS_SEGMENT_AUDIO_FALLBACK_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/tts/segments/clear/route.ts b/src/app/api/tts/segments/clear/route.ts index 89a07ec..bcbca11 100644 --- a/src/app/api/tts/segments/clear/route.ts +++ b/src/app/api/tts/segments/clear/route.ts @@ -1,9 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { and, eq } from 'drizzle-orm'; -import { db } from '@/db'; -import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; -import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; +import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; +import { createRequestLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -16,6 +15,10 @@ function parseBody(value: unknown): { documentId: string } | null { } export async function POST(request: NextRequest) { + const { logger } = createRequestLogger({ + route: '/api/tts/segments/clear', + request, + }); try { const parsed = parseBody(await request.json().catch(() => null)); if (!parsed) { @@ -25,62 +28,24 @@ export async function POST(request: NextRequest) { const scope = await resolveSegmentDocumentScope(request, parsed.documentId); if (scope instanceof Response) return scope; - const rows = (await db - .select({ - segmentId: ttsSegmentVariants.segmentId, - audioKey: ttsSegmentVariants.audioKey, - }) - .from(ttsSegmentVariants) - .innerJoin(ttsSegmentEntries, and( - eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId), - eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId), - )) - .where(and( - eq(ttsSegmentEntries.userId, scope.storageUserId), - eq(ttsSegmentEntries.documentId, parsed.documentId), - eq(ttsSegmentEntries.documentVersion, scope.documentVersion), - ))) as Array<{ segmentId: string; audioKey: string | null }>; - - await db - .delete(ttsSegmentEntries) - .where(and( - eq(ttsSegmentEntries.userId, scope.storageUserId), - eq(ttsSegmentEntries.documentId, parsed.documentId), - eq(ttsSegmentEntries.documentVersion, scope.documentVersion), - )); - - const audioKeys = rows - .map((row) => row.audioKey) - .filter((key): key is string => Boolean(key)); - const uniqueAudioKeys = Array.from(new Set(audioKeys)); - - let deletedAudioObjects = 0; - let warning: string | undefined; - if (uniqueAudioKeys.length > 0) { - try { - deletedAudioObjects = await deleteTtsSegmentAudioObjects(uniqueAudioKeys); - if (deletedAudioObjects < uniqueAudioKeys.length) { - warning = `Deleted ${deletedAudioObjects} of ${uniqueAudioKeys.length} audio objects.`; - } - } catch (error) { - warning = error instanceof Error ? error.message : 'Failed deleting some audio objects'; - console.warn('Failed clearing some TTS segment audio objects:', { - documentId: parsed.documentId, - userId: scope.storageUserId, - error: warning, - }); - } - } + const cleared = await clearTtsSegmentCache({ + userId: scope.storageUserId, + documentId: parsed.documentId, + documentVersion: scope.documentVersion, + readerType: scope.readerType, + }); return NextResponse.json({ documentId: parsed.documentId, - deletedSegments: rows.length, - requestedAudioObjects: uniqueAudioKeys.length, - deletedAudioObjects, - ...(warning ? { warning } : {}), + ...cleared, }); } catch (error) { - console.error('Error clearing TTS segment cache:', error); - return NextResponse.json({ error: 'Failed to clear TTS segment cache' }, { status: 500 }); + return errorResponse(error, { + logger, + event: 'tts.segments.clear_failed', + msg: 'Failed to clear TTS segment cache', + apiErrorMessage: 'Failed to clear TTS segment cache', + normalize: { code: 'TTS_SEGMENTS_CLEAR_FAILED', errorClass: 'storage' }, + }); } } diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index e6e4789..a3dc204 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -30,10 +30,12 @@ import { getClientIp } from '@/lib/server/rate-limit/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; import { buildDailyQuotaExceededResponse } from '@/lib/server/rate-limit/problem-response'; import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; -import { alignAudioWithText } from '@/lib/server/whisper/alignment'; +import { userWhisperAlignJob } from '@/lib/server/jobs/user-whisper-align-job'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy'; import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls'; +import { createRequestLogger, errorToLog } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import type { TTSSegmentInput, TTSSegmentManifestItem, @@ -43,6 +45,7 @@ import type { export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +const GENERATING_STALE_MS = 360_000; function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, didCreate: boolean) { if (didCreate && deviceId) { @@ -150,6 +153,11 @@ async function deleteEntryIfUnused(userId: string, segmentEntryId: string): Prom export async function POST(request: NextRequest) { let didCreateDeviceIdCookie = false; let deviceIdToSet: string | null = null; + const { logger, requestId } = createRequestLogger({ + route: '/api/tts/segments/ensure', + request, + }); + const requestStartedAt = Date.now(); try { if (!isS3Configured()) return s3NotConfiguredResponse(); @@ -212,6 +220,7 @@ export async function POST(request: NextRequest) { const nowMs = Date.now(); const storagePrefix = getS3Config().prefix; const secret = textHmacSecret(); + const shouldRunWhisperAlignment = !scope.testNamespace; let invalidLocatorIndex = -1; const normalized = parsed.segments @@ -320,6 +329,20 @@ export async function POST(request: NextRequest) { }; for (const segment of normalized) { + if (request.signal.aborted) { + logger.info({ + event: 'tts.segments.ensure.request_aborted', + requestId, + documentId: parsed.documentId, + completedSoFar: manifest.length, + totalRequested: normalized.length, + }, 'TTS segment ensure request aborted'); + break; + } + + const segmentStartedAt = Date.now(); + const stageTimings: Record = {}; + let failedStage = 'unknown'; const locatorProjection = projectSegmentLocator(segment.locator); const segmentKeyForRow = typeof segment.original.segmentKey === 'string' && segment.original.segmentKey.trim() ? segment.original.segmentKey.trim() @@ -370,17 +393,15 @@ export async function POST(request: NextRequest) { // Self-heal transient Whisper failures: if audio exists but alignment was // previously unavailable, retry alignment using the current segment text. - if (!alignment) { + if (shouldRunWhisperAlignment && !alignment && !request.signal.aborted) { try { - const audioBuffer = await getTtsSegmentAudioObject(existing.audioKey); - const whisperBytes = Uint8Array.from(audioBuffer); - const aligned = await alignAudioWithText( - whisperBytes.buffer, - segment.text, - undefined, - { engine: 'whisper.cpp' }, - ); - alignment = aligned[0] ? { ...aligned[0], sentenceIndex: segment.original.segmentIndex } : null; + const alignStartedAt = Date.now(); + alignment = await userWhisperAlignJob({ + audioObjectKey: existing.audioKey, + text: segment.text, + sentenceIndex: segment.original.segmentIndex, + }); + stageTimings.selfHealAlignMs = Date.now() - alignStartedAt; if (alignment) { await db @@ -395,10 +416,18 @@ export async function POST(request: NextRequest) { )); } } catch (alignError) { - console.warn('Whisper alignment still unavailable for completed segment; continuing without word highlights.', { + const aborted = isAbortLikeError(alignError) || request.signal.aborted; + const level = aborted ? 'info' : 'warn'; + logger[level]({ + event: 'tts.segments.ensure.self_heal_alignment_unavailable', + requestId, + documentId: parsed.documentId, segmentId: segment.segmentId, - error: alignError instanceof Error ? alignError.message : String(alignError), - }); + aborted, + ...(aborted ? {} : { degraded: true }), + step: 'whisper_align', + error: errorToLog(alignError), + }, 'Self-heal alignment unavailable'); alignment = null; } } @@ -502,7 +531,92 @@ export async function POST(request: NextRequest) { await deleteEntryIfUnused(scope.storageUserId, movedFromEntryId); } + const [currentVariant] = await db + .select({ + status: ttsSegmentVariants.status, + updatedAt: ttsSegmentVariants.updatedAt, + error: ttsSegmentVariants.error, + audioKey: ttsSegmentVariants.audioKey, + }) + .from(ttsSegmentVariants) + .where(and( + eq(ttsSegmentVariants.segmentId, segment.segmentId), + eq(ttsSegmentVariants.userId, scope.storageUserId), + )) + .limit(1); + + if (!currentVariant) { + manifest.push({ + segmentId: segment.segmentId, + segmentIndex: segment.original.segmentIndex, + segmentKey: segmentKeyForRow, + audioPresignUrl: null, + audioFallbackUrl: null, + durationMs: 0, + alignment: null, + locator: segment.locator, + status: 'pending', + }); + continue; + } + + if (currentVariant.status === 'generating') { + const lastUpdatedAt = Number(currentVariant.updatedAt ?? 0); + const isFresh = lastUpdatedAt > 0 && (Date.now() - lastUpdatedAt) < GENERATING_STALE_MS; + if (isFresh) { + manifest.push({ + segmentId: segment.segmentId, + segmentIndex: segment.original.segmentIndex, + segmentKey: segmentKeyForRow, + audioPresignUrl: null, + audioFallbackUrl: null, + durationMs: 0, + alignment: null, + locator: segment.locator, + status: 'pending', + }); + continue; + } + } + + if (currentVariant.status === 'pending' || currentVariant.status === 'error' || currentVariant.status === 'generating') { + const expectedUpdatedAt = Number(currentVariant.updatedAt ?? 0); + const [claim] = await db + .update(ttsSegmentVariants) + .set({ + status: 'generating', + error: null, + updatedAt: Date.now(), + }) + .where(and( + eq(ttsSegmentVariants.segmentId, segment.segmentId), + eq(ttsSegmentVariants.userId, scope.storageUserId), + eq(ttsSegmentVariants.status, currentVariant.status), + eq(ttsSegmentVariants.updatedAt, expectedUpdatedAt), + )) + .returning({ + status: ttsSegmentVariants.status, + }); + + if (!claim) { + manifest.push({ + segmentId: segment.segmentId, + segmentIndex: segment.original.segmentIndex, + segmentKey: segmentKeyForRow, + audioPresignUrl: null, + audioFallbackUrl: null, + durationMs: 0, + alignment: null, + locator: segment.locator, + status: 'pending', + }); + continue; + } + } + try { + failedStage = 'tts.generate'; + const ttsStartedAt = Date.now(); const ttsBuffer = await generateTTSBuffer({ text: segment.text, voice: effectiveSettings.voice, @@ -515,33 +629,55 @@ export async function POST(request: NextRequest) { baseUrl: requestCreds.baseUrl, testNamespace: scope.testNamespace, }, request.signal); + stageTimings.generateTtsMs = Date.now() - ttsStartedAt; + failedStage = 's3.put_audio'; + const putStartedAt = Date.now(); await putTtsSegmentAudioObject(audioKey, ttsBuffer); + stageTimings.putAudioMs = Date.now() - putStartedAt; let persistedBuffer = ttsBuffer; if (persistedBuffer.byteLength === 0) { + failedStage = 's3.get_audio_after_empty_put'; + const getStartedAt = Date.now(); persistedBuffer = await getTtsSegmentAudioObject(audioKey); + stageTimings.getAudioAfterEmptyPutMs = Date.now() - getStartedAt; } + failedStage = 'audio.probe_duration'; + const probeStartedAt = Date.now(); const durationMs = await probeAudioDurationMsFromBuffer(persistedBuffer, request.signal); + stageTimings.probeDurationMs = Date.now() - probeStartedAt; let alignment: TTSSegmentManifestItem['alignment'] = null; - try { - const whisperBytes = Uint8Array.from(persistedBuffer); - const aligned = await alignAudioWithText( - whisperBytes.buffer, - segment.text, - undefined, - { engine: 'whisper.cpp' }, - ); - alignment = aligned[0] ? { ...aligned[0], sentenceIndex: segment.original.segmentIndex } : null; - } catch (alignError) { - console.warn('Whisper alignment unavailable for segment; continuing without word highlights.', { - segmentId: segment.segmentId, - error: alignError instanceof Error ? alignError.message : String(alignError), - }); - alignment = null; + if (shouldRunWhisperAlignment && !request.signal.aborted) { + try { + failedStage = 'whisper.align'; + const alignStartedAt = Date.now(); + alignment = await userWhisperAlignJob({ + audioObjectKey: audioKey, + text: segment.text, + sentenceIndex: segment.original.segmentIndex, + }); + stageTimings.whisperAlignMs = Date.now() - alignStartedAt; + } catch (alignError) { + const aborted = isAbortLikeError(alignError) || request.signal.aborted; + const level = aborted ? 'info' : 'warn'; + logger[level]({ + event: 'tts.segments.ensure.alignment_unavailable', + requestId, + documentId: parsed.documentId, + segmentId: segment.segmentId, + aborted, + ...(aborted ? {} : { degraded: true }), + step: 'whisper_align', + error: errorToLog(alignError), + }, 'Alignment unavailable'); + alignment = null; + } } + failedStage = 'db.mark_completed'; + const markCompletedStartedAt = Date.now(); await db .update(ttsSegmentVariants) .set({ @@ -555,12 +691,16 @@ export async function POST(request: NextRequest) { eq(ttsSegmentVariants.segmentId, segment.segmentId), eq(ttsSegmentVariants.userId, scope.storageUserId), )); + stageTimings.markCompletedMs = Date.now() - markCompletedStartedAt; + failedStage = 'resolve.audio_urls'; + const resolveUrlsStartedAt = Date.now(); const audioUrls = await resolveSegmentAudioUrls({ documentId: parsed.documentId, segmentId: segment.segmentId, audioKey, }); + stageTimings.resolveAudioUrlsMs = Date.now() - resolveUrlsStartedAt; manifest.push({ segmentId: segment.segmentId, @@ -573,25 +713,54 @@ export async function POST(request: NextRequest) { status: 'completed', }); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to generate segment'; + const errorMessage = error instanceof Error ? error.message : 'Failed to generate segment'; const aborted = isAbortLikeError(error); const upstreamStatus = getUpstreamStatus(error); const retryAfterSeconds = upstreamStatus === 429 ? getUpstreamRetryAfterSeconds(error) : undefined; - const errorCode = upstreamStatus === 429 + const failureCode = upstreamStatus === 429 ? 'UPSTREAM_RATE_LIMIT' : upstreamStatus && upstreamStatus >= 500 ? 'UPSTREAM_TTS_ERROR' : 'TTS_SEGMENT_GENERATION_FAILED'; + if (aborted) { + logger.info({ + event: 'tts.segments.ensure.segment_aborted', + requestId, + documentId: parsed.documentId, + segmentId: segment.segmentId, + failedStage, + elapsedMs: Date.now() - segmentStartedAt, + stageTimings, + aborted: true, + error: errorToLog(error), + completedSoFar: manifest.length, + totalRequested: normalized.length, + }, 'Stopping segment ensure after abort'); + } else { + logger.error({ + event: 'tts.segments.ensure.segment_failed', + requestId, + documentId: parsed.documentId, + segmentId: segment.segmentId, + failedStage, + elapsedMs: Date.now() - segmentStartedAt, + stageTimings, + aborted: false, + upstreamStatus, + retryAfterSeconds, + error: errorToLog(error), + }, 'TTS segment generation failed'); + } await db .update(ttsSegmentVariants) .set({ status: aborted ? 'pending' : 'error', error: aborted ? null : ( upstreamStatus - ? `${errorCode}${retryAfterSeconds ? ` (retry after ${retryAfterSeconds}s)` : ''}: ${message}` - : message + ? `${failureCode}${retryAfterSeconds ? ` (retry after ${retryAfterSeconds}s)` : ''}: ${errorMessage}` + : errorMessage ), updatedAt: Date.now(), }) @@ -613,15 +782,46 @@ export async function POST(request: NextRequest) { error: aborted ? null : { - code: errorCode, - detail: message, + code: failureCode, + detail: errorMessage, ...(typeof upstreamStatus === 'number' ? { upstreamStatus } : {}), ...(typeof retryAfterSeconds === 'number' ? { retryAfterSeconds } : {}), - }, + }, }); + + if (aborted || request.signal.aborted) { + break; + } } } + const completedCount = manifest.filter((s) => s.status === 'completed').length; + const pendingCount = manifest.filter((s) => s.status === 'pending').length; + const errorItems = manifest.filter((s) => s.status === 'error'); + if (errorItems.length > 0) { + logger.error({ + event: 'tts.segments.ensure.partial_result', + requestId, + documentId: parsed.documentId, + total: manifest.length, + completedCount, + pendingCount, + errorCount: errorItems.length, + elapsedMs: Date.now() - requestStartedAt, + error: { + name: 'PartialSegmentFailure', + message: `TTS segment ensure completed with ${errorItems.length} segment errors`, + }, + errors: errorItems.slice(0, 5).map((item) => ({ + segmentId: item.segmentId, + code: item.error?.code ?? null, + detail: item.error?.detail ?? null, + upstreamStatus: item.error?.upstreamStatus ?? null, + retryAfterSeconds: item.error?.retryAfterSeconds ?? null, + })), + }, 'TTS segment ensure completed with partial errors'); + } + const response = NextResponse.json({ documentId: parsed.documentId, segments: manifest, @@ -629,8 +829,13 @@ export async function POST(request: NextRequest) { attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); return response; } catch (error) { - console.error('Error ensuring TTS segments:', error); - const response = NextResponse.json({ error: 'Failed to ensure TTS segments' }, { status: 500 }); + const response = errorResponse(error, { + logger, + event: 'tts.segments.ensure.route_failed', + msg: 'TTS segments ensure route failed', + apiErrorMessage: 'Failed to ensure TTS segments', + normalize: { code: 'TTS_SEGMENTS_ENSURE_ROUTE_FAILED', errorClass: 'upstream' }, + }); attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); return response; } diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts index 313a8ec..c78e602 100644 --- a/src/app/api/tts/segments/manifest/route.ts +++ b/src/app/api/tts/segments/manifest/route.ts @@ -20,6 +20,8 @@ import type { import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog'; import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy'; import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls'; +import { createRequestLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -178,6 +180,10 @@ function cursorFromGroupRow(row: ManifestGroupRow): TTSSegmentManifestCursor { } export async function GET(request: NextRequest) { + const { logger } = createRequestLogger({ + route: '/api/tts/segments/manifest', + request, + }); try { const documentIdRaw = request.nextUrl.searchParams.get('documentId'); const documentId = documentIdRaw?.trim().toLowerCase(); @@ -381,7 +387,12 @@ export async function GET(request: NextRequest) { }; return NextResponse.json(response); } catch (error) { - console.error('Error listing TTS segments manifest:', error); - return NextResponse.json({ error: 'Failed to list TTS segments' }, { status: 500 }); + return errorResponse(error, { + logger, + event: 'tts.segments.manifest.list_failed', + msg: 'Failed to list TTS segments', + apiErrorMessage: 'Failed to list TTS segments', + normalize: { code: 'TTS_SEGMENTS_MANIFEST_LIST_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/tts/shared-providers/route.ts b/src/app/api/tts/shared-providers/route.ts index 1b8bb28..e778cff 100644 --- a/src/app/api/tts/shared-providers/route.ts +++ b/src/app/api/tts/shared-providers/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/lib/server/auth/auth'; import { isAuthEnabled } from '@/lib/server/auth/config'; import { listAdminProviders, toPublic } from '@/lib/server/admin/providers'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; export const dynamic = 'force-dynamic'; @@ -23,7 +24,12 @@ export async function GET(req: NextRequest) { const visible = all.filter((p) => p.enabled).map(toPublic); return NextResponse.json({ providers: visible }); } catch (error) { - console.warn('[tts/shared-providers] list failed:', error); + serverLogger.warn({ + event: 'tts.shared_providers.list.failed', + degraded: true, + fallbackPath: 'empty_provider_list', + error: errorToLog(error), + }, 'Failed to list shared providers'); return NextResponse.json({ providers: [] }); } } diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index e0cee34..0e1b5f5 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -5,6 +5,9 @@ import { defaultModelForProviderType, resolveTtsModelForProvider, resolveTtsProv import { resolveVoices } from '@/lib/server/tts/voice-resolution'; import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; +import { normalizeServerError, toApiErrorBody, toHttpStatus } from '@/lib/server/errors/contract'; export async function GET(req: NextRequest) { try { @@ -40,7 +43,10 @@ export async function GET(req: NextRequest) { } if (!isBuiltInTtsProviderId(resolved.provider)) { - return NextResponse.json({ error: `Unsupported provider type: ${resolved.provider}` }, { status: 500 }); + return errorResponse(new Error(`Unsupported provider type: ${resolved.provider}`), { + apiErrorMessage: `Unsupported provider type: ${resolved.provider}`, + normalize: { code: 'TTS_VOICES_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 }, + }); } const effectiveProviderRef = resolved.adminRecord?.slug ?? req.headers.get('x-tts-provider') @@ -61,20 +67,31 @@ export async function GET(req: NextRequest) { }); return NextResponse.json({ voices }); } catch (error) { - console.error('Error in voices endpoint:', error); + serverLogger.error({ + event: 'tts.voices.resolve.failed', + error: errorToLog(error), + }, 'Failed to resolve voices'); const providerRef = req.headers.get('x-tts-provider') || 'openai'; const model = req.headers.get('x-tts-model') || 'tts-1'; const provider = isBuiltInTtsProviderId(providerRef) ? providerRef : 'openai'; + const normalized = normalizeServerError(error, { + code: 'TTS_VOICES_RESOLVE_FAILED', + errorClass: 'upstream', + }); + const fallbackVoices = resolveTtsProviderModelPolicy({ + providerRef, + providerType: provider, + model, + }).defaultVoices; return NextResponse.json( { - error: 'Failed to resolve voices', - fallbackVoices: resolveTtsProviderModelPolicy({ - providerRef, - providerType: provider, - model, - }).defaultVoices, + ...toApiErrorBody( + { ...normalized, message: 'Failed to resolve voices' }, + { includeDetails: false, includeRetryable: false }, + ), + fallbackVoices, }, - { status: 500 }, + { status: toHttpStatus(normalized) }, ); } } diff --git a/src/app/api/user/claim/route.ts b/src/app/api/user/claim/route.ts index c958c90..14cd313 100644 --- a/src/app/api/user/claim/route.ts +++ b/src/app/api/user/claim/route.ts @@ -5,6 +5,8 @@ import { db } from '@/db'; import { audiobooks, documents, userDocumentProgress, userPreferences } from '@/db/schema'; import { count, eq, ne } from 'drizzle-orm'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; async function checkClaimMigrationReadiness(): Promise { const [legacyRows] = await db @@ -56,8 +58,14 @@ export async function GET(req: NextRequest) { const counts = await getClaimableCounts(unclaimedUserId); return NextResponse.json({ success: true, ...counts }); } catch (error) { - console.error('Error checking claimable data:', error); - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + serverLogger.error({ + event: 'user.claim.status.failed', + error: errorToLog(error), + }, 'Failed checking claimable data'); + return errorResponse(error, { + apiErrorMessage: 'Internal Server Error', + normalize: { code: 'USER_CLAIM_STATUS_FAILED', errorClass: 'db' }, + }); } } @@ -83,7 +91,13 @@ export async function POST(req: NextRequest) { }); } catch (error) { - console.error('Error claiming data:', error); - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + serverLogger.error({ + event: 'user.claim.execute.failed', + error: errorToLog(error), + }, 'Failed claiming anonymous data'); + return errorResponse(error, { + apiErrorMessage: 'Internal Server Error', + normalize: { code: 'USER_CLAIM_EXECUTE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/user/export/route.ts b/src/app/api/user/export/route.ts index d68dd50..66920bf 100644 --- a/src/app/api/user/export/route.ts +++ b/src/app/api/user/export/route.ts @@ -11,6 +11,8 @@ import { getAudiobookObjectStream, listAudiobookObjects } from '@/lib/server/aud import { isS3Configured } from '@/lib/server/storage/s3'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { nowTimestampMs } from '@/lib/shared/timestamps'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -23,7 +25,10 @@ export async function GET(req: NextRequest) { } if (!auth) { - return NextResponse.json({ error: 'Auth not initialized' }, { status: 500 }); + return errorResponse(new Error('Auth not initialized'), { + apiErrorMessage: 'Auth not initialized', + normalize: { code: 'USER_EXPORT_AUTH_NOT_INITIALIZED', errorClass: 'auth', httpStatus: 500 }, + }); } const session = await auth.api.getSession({ @@ -77,7 +82,12 @@ export async function GET(req: NextRequest) { archive.on('warning', (warning) => { if ((warning as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('User export warning:', warning); + serverLogger.warn({ + event: 'user.export.archive.warning', + degraded: true, + step: 'archive_warning', + error: errorToLog(warning), + }, 'User export warning'); } }); @@ -129,7 +139,10 @@ export async function GET(req: NextRequest) { await archive.finalize(); } } catch (error) { - console.error('Export generation failed:', error); + serverLogger.error({ + event: 'user.export.generate.failed', + error: errorToLog(error), + }, 'Export generation failed'); archive.abort(); output.destroy(error instanceof Error ? error : new Error('Failed to generate export archive')); } finally { diff --git a/src/app/api/user/state/changelog/version-check/route.ts b/src/app/api/user/state/changelog/version-check/route.ts index e4b4e0c..b36d74f 100644 --- a/src/app/api/user/state/changelog/version-check/route.ts +++ b/src/app/api/user/state/changelog/version-check/route.ts @@ -5,6 +5,8 @@ import { userPreferences } from '@/db/schema'; import { normalizeVersion, shouldOpenChangelogForVersionChange } from '@/lib/shared/changelog'; import { nowTimestampMs } from '@/lib/shared/timestamps'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { deserializeUserPreferencesPayload, extractUserPreferencesMeta, @@ -80,7 +82,13 @@ export async function POST(req: NextRequest) { lastSeenVersion, }); } catch (error) { - console.error('Error checking changelog version:', error); - return NextResponse.json({ error: 'Failed to check changelog version' }, { status: 500 }); + serverLogger.error({ + event: 'user.changelog.version_check.failed', + error: errorToLog(error), + }, 'Failed to check changelog version'); + return errorResponse(error, { + apiErrorMessage: 'Failed to check changelog version', + normalize: { code: 'USER_CHANGELOG_VERSION_CHECK_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index 96191fe..78f38ee 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -9,6 +9,8 @@ import { isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider import { listAdminProviders } from '@/lib/server/admin/providers'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; import { deserializeUserPreferencesPayload, extractUserPreferencesMeta, @@ -137,7 +139,6 @@ function sanitizePreferencesPatch( break; case 'skipBlank': case 'epubTheme': - case 'smartSentenceSplitting': case 'pdfHighlightEnabled': case 'pdfWordHighlightEnabled': case 'epubHighlightEnabled': @@ -242,8 +243,14 @@ export async function GET(req: NextRequest) { hasStoredPreferences: Boolean(row), }); } catch (error) { - console.error('Error loading user preferences:', error); - return NextResponse.json({ error: 'Failed to load user preferences' }, { status: 500 }); + serverLogger.error({ + event: 'user.preferences.load.failed', + error: errorToLog(error), + }, 'Failed to load user preferences'); + return errorResponse(error, { + apiErrorMessage: 'Failed to load user preferences', + normalize: { code: 'USER_PREFERENCES_LOAD_FAILED', errorClass: 'db' }, + }); } } @@ -316,7 +323,13 @@ export async function PUT(req: NextRequest) { applied: true, }); } catch (error) { - console.error('Error updating user preferences:', error); - return NextResponse.json({ error: 'Failed to update user preferences' }, { status: 500 }); + serverLogger.error({ + event: 'user.preferences.update.failed', + error: errorToLog(error), + }, 'Failed to update user preferences'); + return errorResponse(error, { + apiErrorMessage: 'Failed to update user preferences', + normalize: { code: 'USER_PREFERENCES_UPDATE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/user/state/progress/route.ts b/src/app/api/user/state/progress/route.ts index f34953e..1fef9f5 100644 --- a/src/app/api/user/state/progress/route.ts +++ b/src/app/api/user/state/progress/route.ts @@ -6,6 +6,8 @@ import type { ReaderType } from '@/types/user-state'; import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { errorResponse } from '@/lib/server/errors/next-response'; export const dynamic = 'force-dynamic'; @@ -62,8 +64,14 @@ export async function GET(req: NextRequest) { }, }); } catch (error) { - console.error('Error loading user progress:', error); - return NextResponse.json({ error: 'Failed to load user progress' }, { status: 500 }); + serverLogger.error({ + event: 'user.progress.load.failed', + error: errorToLog(error), + }, 'Failed to load user progress'); + return errorResponse(error, { + apiErrorMessage: 'Failed to load user progress', + normalize: { code: 'USER_PROGRESS_LOAD_FAILED', errorClass: 'db' }, + }); } } @@ -172,7 +180,13 @@ export async function PUT(req: NextRequest) { applied: true, }); } catch (error) { - console.error('Error updating user progress:', error); - return NextResponse.json({ error: 'Failed to update user progress' }, { status: 500 }); + serverLogger.error({ + event: 'user.progress.update.failed', + error: errorToLog(error), + }, 'Failed to update user progress'); + return errorResponse(error, { + apiErrorMessage: 'Failed to update user progress', + normalize: { code: 'USER_PROGRESS_UPDATE_FAILED', errorClass: 'db' }, + }); } } diff --git a/src/app/api/whisper/route.ts b/src/app/api/whisper/route.ts deleted file mode 100644 index 4c73ff3..0000000 --- a/src/app/api/whisper/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import type { TTSSentenceAlignment } from '@/types/tts'; -import { auth } from '@/lib/server/auth/auth'; -import { - alignAudioWithText, - makeWhisperCacheKey, - type WhisperRequestBody, -} from '@/lib/server/whisper/alignment'; - -export const runtime = 'nodejs'; - -export async function POST(req: NextRequest) { - try { - const session = await auth?.api.getSession({ headers: req.headers }); - if (auth && !session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const body = (await req.json()) as WhisperRequestBody; - const { text, audio, lang } = body; - - if (!text || !audio || !Array.isArray(audio)) { - return NextResponse.json( - { error: 'Missing text or audio in request body' }, - { status: 400 } - ); - } - - const cacheKey = makeWhisperCacheKey(body); - const audioBuffer = new Uint8Array(audio).buffer; - - const alignments: TTSSentenceAlignment[] = await alignAudioWithText( - audioBuffer, - text, - cacheKey, - { engine: 'whisper.cpp', lang } - ); - - return NextResponse.json({ alignments }, { status: 200 }); - } catch (error) { - console.error('Error in whisper route:', error); - return NextResponse.json( - { - error: 'WHISPER_ALIGNMENT_FAILED', - message: 'Failed to compute word-level alignment', - }, - { status: 500 } - ); - } -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1ebd8c7..1315dbe 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -56,7 +56,7 @@ export default async function RootLayout({ children }: { children: ReactNode }) ...runtimeConfig, appVersion: pkg.version, }; - const runtimeConfigInit = `window.__OPENREADER_RUNTIME_CONFIG__=${jsonEmbedSafe(runtimeConfigWithAppVersion)};`; + const runtimeConfigInit = `window.__RUNTIME_CONFIG__=${jsonEmbedSafe(runtimeConfigWithAppVersion)};`; return ( diff --git a/src/app/providers.tsx b/src/app/providers.tsx index c777ca0..6ec5de4 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -6,7 +6,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from '@/contexts/ThemeContext'; import { AuthRateLimitProvider } from '@/contexts/AuthRateLimitContext'; import { RuntimeConfigProvider } from '@/contexts/RuntimeConfigContext'; -import { PrivacyModal } from '@/components/PrivacyModal'; import { AuthLoader } from '@/components/auth/AuthLoader'; interface ProvidersProps { @@ -38,10 +37,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu > - <> - {children} - {authEnabled && } - + {children} diff --git a/src/components/PrivacyModal.tsx b/src/components/PrivacyModal.tsx index 5909cbc..30bb6f5 100644 --- a/src/components/PrivacyModal.tsx +++ b/src/components/PrivacyModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Fragment, useState, useEffect, useCallback } from 'react'; +import { Fragment, useState, useEffect } from 'react'; import { Dialog, DialogPanel, @@ -9,10 +9,12 @@ import { TransitionChild, Button, } from '@headlessui/react'; -import { updateAppConfig, getAppConfig } from '@/lib/client/dexie'; +import { updateAppConfig } from '@/lib/client/dexie'; interface PrivacyModalProps { + isOpen: boolean; onAccept?: () => void; + onDismiss?: () => void; } function PrivacyModalBody({ origin }: { origin: string }) { @@ -38,32 +40,23 @@ function PrivacyModalBody({ origin }: { origin: string }) { ); } -export function PrivacyModal({ onAccept }: PrivacyModalProps) { - const [isOpen, setIsOpen] = useState(false); +export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps) { const [origin, setOrigin] = useState(''); const [agreed, setAgreed] = useState(false); - const checkPrivacyAccepted = useCallback(async () => { - const config = await getAppConfig(); - if (!config?.privacyAccepted) { - setIsOpen(true); - } - }, []); - - useEffect(() => { - checkPrivacyAccepted().catch((err) => { - console.error('Privacy acceptance check failed:', err); - }); - }, [checkPrivacyAccepted]); - useEffect(() => { if (typeof window === 'undefined') return; setOrigin(window.location.origin); }, []); + useEffect(() => { + if (isOpen) { + setAgreed(false); + } + }, [isOpen]); + const handleAccept = async () => { await updateAppConfig({ privacyAccepted: true }); - setIsOpen(false); if (typeof window !== 'undefined') { window.dispatchEvent(new Event('openreader:privacyAccepted')); } @@ -72,7 +65,7 @@ export function PrivacyModal({ onAccept }: PrivacyModalProps) { return ( - { }}> + { })}> (null); - const changelogVersionCheckInFlightRef = useRef(null); + const { data: session } = useAuthSession(); + const { requestOpenSettings, registerSettingsController } = useOnboardingFlow(); const router = useRouter(); const isBusy = isImportingLibrary; const { @@ -208,23 +205,25 @@ export function SettingsModal({ className = '' }: { className?: string }) { const isSharedSelected = Boolean(selectedSharedProvider); const selectedProviderOption = ttsProviders.find((p) => p.id === localProviderRef) ?? ttsProviders[0]; - const checkFirstVist = useCallback(async () => { - const appConfig = await getAppConfig(); - if (authEnabled && !appConfig?.privacyAccepted) { - return; - } - const firstVisit = await getFirstVisit(); - if (!firstVisit) { - await setFirstVisit(true); - setIsOpen(true); - } - }, [authEnabled, setIsOpen]); + const closeSettings = useCallback(() => { + setIsOpen(false); + setIsChangelogOpen(false); + }, []); + + const openSettings = useCallback((options?: { changelog?: boolean }) => { + setIsOpen(true); + setIsChangelogOpen(Boolean(options?.changelog)); + }, []); useEffect(() => { - checkFirstVist().catch((err) => { - console.error('First visit check failed:', err); + registerSettingsController({ + open: openSettings, + close: closeSettings, }); - }, [checkFirstVist]); + return () => { + registerSettingsController(null); + }; + }, [closeSettings, openSettings, registerSettingsController]); useEffect(() => { setLocalApiKey(apiKey); @@ -235,39 +234,6 @@ export function SettingsModal({ className = '' }: { className?: string }) { setLocalTTSInstructions(ttsInstructions); }, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]); - useEffect(() => { - if (!authEnabled) { - return; - } - const onPrivacyAccepted = () => { - checkFirstVist().catch((err) => { - console.error('First visit check after privacy acceptance failed:', err); - }); - }; - window.addEventListener('openreader:privacyAccepted', onPrivacyAccepted); - return () => { - window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted); - }; - }, [authEnabled, checkFirstVist]); - - useEffect(() => { - return scheduleChangelogCheck({ - authEnabled, - isSessionPending, - sessionUserId: session?.user?.id, - appVersion: runtimeConfig.appVersion, - completedRef: changelogVersionCheckKeyRef, - inFlightRef: changelogVersionCheckInFlightRef, - postCheck: async (currentVersion) => postChangelogVersionCheck(currentVersion), - onShouldOpen: () => { - setIsOpen(true); - setIsChangelogOpen(true); - }, - delayMs: 120, - retryDelayMs: 400, - }); - }, [authEnabled, isSessionPending, session?.user?.id, runtimeConfig.appVersion]); - useEffect(() => { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { setCustomModelInput(modelValue); @@ -532,7 +498,9 @@ export function SettingsModal({ className = '' }: { className?: string }) { return ( <> - + + ); + } + return ( -
-
+
+
); } \ No newline at end of file diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx index 8986daa..41aca85 100644 --- a/src/components/admin/AdminFeaturesPanel.tsx +++ b/src/components/admin/AdminFeaturesPanel.tsx @@ -303,14 +303,6 @@ export function AdminFeaturesPanel() { right={renderSource('enableUserSignups')} variant="flat" /> - updateDraft('enableWordHighlight', checked)} - right={renderSource('enableWordHighlight')} - variant="flat" - /> void; + onClaimed: () => void; +}; + +export default function ClaimDataModal({ + isOpen, + claimableCounts, + onDismiss, + onClaimed, +}: ClaimDataModalProps) { const router = useRouter(); - const [isOpen, setIsOpen] = useState(false); - const [hasChecked, setHasChecked] = useState(false); const [isClaiming, setIsClaiming] = useState(false); - const [claimableCounts, setClaimableCounts] = useState({ - documents: 0, - audiobooks: 0, - preferences: 0, - progress: 0, - }); - const user = sessionData?.user; - const userId = user?.id; - - const checkClaimableData = useCallback(async () => { - setHasChecked(true); - - try { - const res = await fetch('/api/user/claim', { - method: 'GET', - }); - if (res.ok) { - const data = await res.json(); - const counts = toClaimableCounts(data); - setClaimableCounts(counts); - - if (counts.documents + counts.audiobooks + counts.preferences + counts.progress > 0) { - setIsOpen(true); - } - } - } catch (e) { - console.error(e); - } - }, []); - - useEffect(() => { - // Reset per-user guard so account switches trigger a fresh check. - setHasChecked(false); - }, [userId]); - - useEffect(() => { - // Only check once per authenticated user - if (userId && !user?.isAnonymous && !hasChecked) { - checkClaimableData(); - } - }, [userId, user?.isAnonymous, hasChecked, checkClaimableData]); const handleClaim = async () => { setIsClaiming(true); @@ -93,8 +60,7 @@ export default function ClaimDataModal() { + `${claimed.preferences} preference set(s), and ` + `${claimed.progress} reading progress record(s)!`, ); - - setIsOpen(false); + onClaimed(); router.refresh(); return; } @@ -107,15 +73,9 @@ export default function ClaimDataModal() { } }; - const handleDismiss = () => { - // Close the modal for this session - will reappear on next page load/refresh - setIsOpen(false); - // Keep hasChecked = true so useEffect doesn't re-trigger in this session - }; - return ( - + + ); + })} +
+ {selectedView.id === 'scroll' ? ( +

Scroll mode may be slower on large PDFs.

+ ) : null} +
+ updateConfigKey('pdfHighlightEnabled', checked)} + variant="flat" + /> + updateConfigKey('pdfWordHighlightEnabled', checked)} + variant="flat" + /> + + )} + + {isPdfMode && pdf && ( +
+ + + PP-DocLayout-V3 + + + {pdf.parseStatus ?? 'pending'} + + +
+ } + > + +
+ + Skip Block Kinds While Reading Aloud + +
+ {PDF_SKIP_KIND_OPTIONS.map((option) => ( + pdf.onToggleSkipKind(option.kind, enabled)} + /> + ))} +
+
+ + )} +
)} - updateConfigKey('smartSentenceSplitting', checked)} - variant="flat" - />
- {!epub && !html && ( -
-
- -
- {viewTypeTextMapping.map((view) => { - const active = selectedView.id === view.id; - return ( - - ); - })} -
- {selectedView.id === 'scroll' ? ( -

Scroll mode may be slower on large PDFs.

- ) : null} -
- -
-

Text extraction margins

-

- Ignore edge content before extraction. -

-
- {(['header', 'footer', 'left', 'right'] as MarginKey[]).map((margin) => ( -
-
- {margin} - {Math.round(localMargins[margin] * 100)}% -
- -
- ))} -
-
-
- )} - - {!epub && !html && ( -
- updateConfigKey('pdfHighlightEnabled', checked)} - variant="flat" - /> - updateConfigKey('pdfWordHighlightEnabled', checked)} - variant="flat" - /> -
- )} - {epub && (
updateConfigKey('epubWordHighlightEnabled', checked)} @@ -370,7 +368,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { /> updateConfigKey('htmlWordHighlightEnabled', checked)} diff --git a/src/components/formPrimitives.tsx b/src/components/formPrimitives.tsx index 73ce83d..6095591 100644 --- a/src/components/formPrimitives.tsx +++ b/src/components/formPrimitives.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { ReactNode } from 'react'; +import { useId, type ReactNode } from 'react'; /** * Shared compact form primitives used by settings-like surfaces across @@ -78,7 +78,7 @@ export function Section({ variant = 'panel', }: { title: string; - subtitle?: string; + subtitle?: ReactNode; children: ReactNode; action?: ReactNode; variant?: 'panel' | 'flat'; @@ -130,6 +130,65 @@ export function Card({ ); } +export type SwitchSize = 'sm' | 'md'; + +const SWITCH_SIZE: Record = { + sm: { + track: 'h-4 w-7', + thumb: 'h-3 w-3', + on: 'translate-x-3', + off: 'translate-x-0.5', + }, + md: { + track: 'h-5 w-9', + thumb: 'h-4 w-4', + on: 'translate-x-4', + off: 'translate-x-0.5', + }, +}; + +export function Switch({ + checked, + onChange, + disabled = false, + size = 'md', + ariaLabel, + ariaLabelledBy, + ariaDescribedBy, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + size?: SwitchSize; + ariaLabel?: string; + ariaLabelledBy?: string; + ariaDescribedBy?: string; +}) { + const s = SWITCH_SIZE[size]; + return ( + + ); +} + export function ToggleRow({ label, description, @@ -147,32 +206,76 @@ export function ToggleRow({ right?: ReactNode; variant?: 'card' | 'flat'; }) { + const labelId = useId(); + const descId = useId(); const rowClass = variant === 'flat' ? 'px-0.5 pt-1 pb-2 border-b border-offbase last:border-b-0 transition-transform duration-200 ease-out hover:scale-[1.003]' : 'rounded-md border border-offbase bg-background px-2.5 py-1.5 transition-transform duration-200 ease-out hover:scale-[1.005]'; + const handleTextToggle = () => { + if (!disabled) onChange(!checked); + }; return (
- +
+ {label} + {description} +
{right ?
{right}
: null} +
); } +export function CheckItem({ + label, + checked, + onChange, + disabled = false, +}: { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +}) { + const labelId = useId(); + const handleTextToggle = () => { + if (!disabled) onChange(!checked); + }; + return ( +
+ + {label} + + +
+ ); +} + export function Field({ label, hint, diff --git a/src/components/icons/Icons.tsx b/src/components/icons/Icons.tsx index b23217d..9e0d10b 100644 --- a/src/components/icons/Icons.tsx +++ b/src/components/icons/Icons.tsx @@ -620,3 +620,19 @@ export function FileSettingsIcon(props: React.SVGProps) { ); } + +export function SparkleIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 1e79ccc..8752f4a 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -185,6 +185,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: isPlaying, playFromSegment, activeReaderType, + clearSegmentCaches, } = useTTS(); const { providerRef, @@ -241,6 +242,9 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: const listRef = useRef(null); const didAutoScrollOnOpenRef = useRef(false); + const userScrollUntilMsRef = useRef(0); + const programmaticScrollUntilMsRef = useRef(0); + const lastSegmentRefreshKeyRef = useRef(''); const segmentsQueryKey = useMemo( () => [SEGMENTS_MANIFEST_QUERY_KEY, documentId] as const, [documentId], @@ -319,6 +323,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: return payload; }, onSuccess: async (payload) => { + clearSegmentCaches(); if (payload?.warning) { toast.error(`Segments cleared, but audio cleanup was partial: ${payload.warning}`); } else if (payload) { @@ -352,20 +357,55 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: await clearSegments(); }, [documentId, isClearingSegments, clearSegments]); + useEffect(() => { + if (!isOpen || !isPlaying) return; + const locationKey = activeReaderType === 'epub' + ? String(currDocPage) + : String(currDocPageNumber); + const refreshKey = `${activeReaderType}|${locationKey}|${currentSentenceIndex}`; + if (lastSegmentRefreshKeyRef.current === refreshKey) return; + lastSegmentRefreshKeyRef.current = refreshKey; + void refetchManifest(); + }, [ + isOpen, + isPlaying, + activeReaderType, + currDocPage, + currDocPageNumber, + currentSentenceIndex, + refetchManifest, + ]); + useEffect(() => { if (!isOpen) return; const node = listRef.current; if (!node) return; + const markUserScrollActive = () => { + userScrollUntilMsRef.current = Date.now() + 1200; + }; + const onScroll = () => { + if (Date.now() > programmaticScrollUntilMsRef.current) { + markUserScrollActive(); + } if (!hasMoreManifestPages || isLoadingMoreManifest) return; const distance = node.scrollHeight - node.scrollTop - node.clientHeight; if (distance > 280) return; void fetchNextPage(); }; + const onWheel = () => markUserScrollActive(); + const onTouchMove = () => markUserScrollActive(); + node.addEventListener('scroll', onScroll); - return () => node.removeEventListener('scroll', onScroll); + node.addEventListener('wheel', onWheel, { passive: true }); + node.addEventListener('touchmove', onTouchMove, { passive: true }); + return () => { + node.removeEventListener('scroll', onScroll); + node.removeEventListener('wheel', onWheel); + node.removeEventListener('touchmove', onTouchMove); + }; }, [isOpen, hasMoreManifestPages, isLoadingMoreManifest, fetchNextPage]); const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => { @@ -551,10 +591,12 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: const container = listRef.current; if (!container) return; + if (Date.now() < userScrollUntilMsRef.current) return; const activeRow = container.querySelector('[data-active-segment="true"]'); if (!activeRow) return; requestAnimationFrame(() => { + programmaticScrollUntilMsRef.current = Date.now() + 300; activeRow.scrollIntoView({ block: 'center', behavior: 'auto' }); didAutoScrollOnOpenRef.current = true; }); @@ -566,6 +608,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: const root = listRef.current; if (!root) return; + if (Date.now() < userScrollUntilMsRef.current) return; const activeRow = root.querySelector('[data-active-segment="true"]'); if (!activeRow) return; @@ -574,6 +617,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: if (isElementFullyVisibleWithinContainer(activeRow, scrollContainer)) return; requestAnimationFrame(() => { + programmaticScrollUntilMsRef.current = Date.now() + 300; activeRow.scrollIntoView({ block: 'center', behavior: 'auto' }); }); }, [ diff --git a/src/components/views/PDFViewer.tsx b/src/components/views/PDFViewer.tsx index 01c980c..6ac12de 100644 --- a/src/components/views/PDFViewer.tsx +++ b/src/components/views/PDFViewer.tsx @@ -5,14 +5,15 @@ import { Document, Page } from 'react-pdf'; import type { Dest } from 'react-pdf/src/shared/types.js'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; -import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { usePDFResize } from '@/hooks/pdf/usePDFResize'; import type { PdfDocumentState } from '@/app/(app)/pdf/[id]/usePdfDocument'; +import type { ParsedPdfBlock, ParsedPdfPage } from '@/types/parsed-pdf'; interface PDFViewerProps { zoomLevel: number; + onDocumentReady?: () => void; pdfState: Pick< PdfDocumentState, | 'highlightPattern' @@ -25,6 +26,8 @@ interface PDFViewerProps { | 'currDocPages' | 'currDocText' | 'currDocPage' + | 'parsedDocument' + | 'parsedOverlayEnabled' >; } @@ -33,9 +36,10 @@ interface PDFOnLinkClickArgs { dest?: Dest; } -export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { +export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerProps) { const containerRef = useRef(null); const [isPageRendering, setIsPageRendering] = useState(false); + const hasSignaledReadyRef = useRef(false); const scaleRef = useRef(1); const { containerWidth, containerHeight } = usePDFResize(containerRef); const sentenceHighlightSeqRef = useRef(0); @@ -53,6 +57,7 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { currentSentence, currentWordIndex, currentSentenceAlignment, + currentSegment, skipToLocation, } = useTTS(); @@ -67,6 +72,8 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { currDocPages, currDocText, currDocPage, + parsedDocument, + parsedOverlayEnabled, } = pdfState; // IMPORTANT: @@ -93,6 +100,16 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { } }, [layoutKey]); + const markViewerReady = useCallback(() => { + if (hasSignaledReadyRef.current) return; + hasSignaledReadyRef.current = true; + onDocumentReady?.(); + }, [onDocumentReady]); + + useEffect(() => { + hasSignaledReadyRef.current = false; + }, [currDocId, currDocData]); + const clearSentenceHighlightTimeouts = useCallback(() => { for (const t of sentenceHighlightTimeoutsRef.current) clearTimeout(t); sentenceHighlightTimeoutsRef.current = []; @@ -113,6 +130,13 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { wordHighlightTimeoutsRef.current.push(t); }, []); + useEffect(() => { + return () => { + clearHighlights(); + clearWordHighlights(); + }; + }, [clearHighlights, clearWordHighlights]); + useEffect(() => { /* * Handles highlighting the current sentence being read by TTS. @@ -140,41 +164,68 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { return; } + // Root-cause guard: do not repaint highlights while react-pdf is still + // replacing page/text layers for a new page or viewport layout. + if (isPageRendering) { + return; + } + const seq = ++sentenceHighlightSeqRef.current; const isLayoutChange = layoutKey !== lastSentenceLayoutKeyRef.current; lastSentenceLayoutKeyRef.current = layoutKey; + const activeLocator = currentSegment?.ownerLocator ?? null; + const hasParsedBlockLocator = + !!parsedDocument + && activeLocator?.readerType === 'pdf' + && typeof activeLocator.blockId === 'string' + && activeLocator.blockId.length > 0; - if (isLayoutChange) { + if (isLayoutChange || !hasParsedBlockLocator) { clearHighlights(); } + if (!hasParsedBlockLocator) { + return; + } + + const useBlockGeometryOnly = !pdfWordHighlightEnabled; + const tryApply = (attempt: number) => { if (seq !== sentenceHighlightSeqRef.current) return; const container = containerRef.current; if (!container) return; - const spans = container.querySelectorAll('.react-pdf__Page__textContent span'); - if (!spans.length) { - if (attempt < 10) scheduleSentenceTimeout(() => tryApply(attempt + 1), 75); - return; + if (!useBlockGeometryOnly) { + const spans = container.querySelectorAll('.react-pdf__Page__textContent span'); + if (!spans.length) { + if (attempt < 1) scheduleSentenceTimeout(() => tryApply(attempt + 1), 90); + return; + } } - highlightPattern(currDocText, currentSentence, containerRef as RefObject); + highlightPattern(currDocText, currentSentence, containerRef as RefObject, { + parsedDocument, + locator: activeLocator, + useBlockGeometryOnly, + }); }; - scheduleSentenceTimeout(() => tryApply(0), 200); + scheduleSentenceTimeout(() => tryApply(0), useBlockGeometryOnly ? 80 : 120); return () => { clearSentenceHighlightTimeouts(); - clearHighlights(); }; }, [ currDocText, currentSentence, + currentSegment, highlightPattern, clearHighlights, pdfHighlightEnabled, + pdfWordHighlightEnabled, + parsedDocument, layoutKey, + isPageRendering, clearSentenceHighlightTimeouts, scheduleSentenceTimeout ]); @@ -204,6 +255,10 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { return; } + if (isPageRendering) { + return; + } + const seq = ++wordHighlightSeqRef.current; const isLayoutChange = layoutKey !== lastWordLayoutKeyRef.current; lastWordLayoutKeyRef.current = layoutKey; @@ -252,7 +307,8 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { highlightWordIndex, layoutKey, clearWordHighlightTimeouts, - scheduleWordTimeout + scheduleWordTimeout, + isPageRendering ]); // Add page dimensions state @@ -299,6 +355,150 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { return scaleRef.current; }, [calculateScale]); + const parsedPageByNumber = useMemo(() => { + const map = new Map(); + for (const page of parsedDocument?.pages ?? []) { + map.set(page.pageNumber, page); + } + return map; + }, [parsedDocument]); + + const parsedOverlayByPage = useMemo(() => { + const map = new Map>(); + + const seen = new Set(); + for (const page of parsedDocument?.pages ?? []) { + for (const block of page.blocks) { + for (let fragmentIndex = 0; fragmentIndex < block.fragments.length; fragmentIndex += 1) { + const fragment = block.fragments[fragmentIndex]; + if (!fragment) continue; + const key = `${block.id}:${fragment.page}:${fragment.readingOrder}`; + if (seen.has(key)) continue; + seen.add(key); + + const list = map.get(fragment.page) ?? []; + list.push({ + block, + fragment, + isContinuation: fragmentIndex > 0, + }); + map.set(fragment.page, list); + } + } + } + + for (const list of map.values()) { + const geoKey = (entry: { + block: ParsedPdfBlock; + fragment: ParsedPdfBlock['fragments'][number]; + isContinuation: boolean; + }): string => { + const [x0, y0, x1, y1] = entry.fragment.bbox; + const round = (value: number) => Math.round(value * 10) / 10; + return [ + entry.block.kind, + round(x0), + round(y0), + round(x1), + round(y1), + ].join(':'); + }; + + const continuationGeometry = new Set(); + for (const entry of list) { + if (entry.isContinuation) { + continuationGeometry.add(geoKey(entry)); + } + } + + const filtered = list.filter((entry) => { + if (entry.isContinuation) return true; + return !continuationGeometry.has(geoKey(entry)); + }); + + list.length = 0; + list.push(...filtered); + + list.sort((a, b) => { + if (a.fragment.readingOrder !== b.fragment.readingOrder) { + return a.fragment.readingOrder - b.fragment.readingOrder; + } + return a.block.id.localeCompare(b.block.id); + }); + } + + return map; + }, [parsedDocument]); + + const colorForKind = (kind: ParsedPdfBlock['kind']): string => { + switch (kind) { + case 'paragraph_title': return 'rgba(34,197,94,0.20)'; + case 'doc_title': return 'rgba(16,185,129,0.20)'; + case 'figure_title': return 'rgba(245,158,11,0.20)'; + case 'table': return 'rgba(59,130,246,0.20)'; + case 'chart': + case 'image': return 'rgba(139,92,246,0.20)'; + case 'header': + case 'footer': + case 'footnote': + case 'vision_footnote': return 'rgba(239,68,68,0.20)'; + case 'formula': + case 'formula_number': return 'rgba(251,146,60,0.22)'; + case 'abstract': + case 'algorithm': + case 'aside_text': + case 'content': + case 'reference': + case 'reference_content': + case 'text': + case 'number': return 'rgba(14,165,233,0.18)'; + default: return 'rgba(14,165,233,0.18)'; + } + }; + + const renderParsedOverlay = (pageNumber: number) => { + if (!parsedOverlayEnabled) return null; + const parsedPage = parsedPageByNumber.get(pageNumber); + if (!parsedPage) return null; + const overlayEntries = parsedOverlayByPage.get(pageNumber) ?? []; + return ( +
+ {overlayEntries.map(({ block, fragment, isContinuation }) => { + const [x0, y0, x1, y1] = fragment.bbox; + const width = parsedPage.width || 1; + const height = parsedPage.height || 1; + const leftPct = (x0 / width) * 100; + const boxWidthPct = Math.max(0, ((x1 - x0) / width) * 100); + // Parsed model bboxes are top-left based; use y0 directly. + const topPct = (y0 / height) * 100; + const boxHeightPct = Math.max(0, ((y1 - y0) / height) * 100); + + return ( +
+ + {isContinuation ? `${block.kind} (cont)` : block.kind} + +
+ ); + })} +
+ ); + }; + return (
} - noData={} + loading={null} + noData={null} file={documentFile} onLoadSuccess={(pdf) => { onDocumentLoadSuccess(pdf); @@ -330,62 +530,74 @@ export function PDFViewer({ zoomLevel, pdfState }: PDFViewerProps) { // Scroll mode: render all pages
{currDocPages && [...Array(currDocPages)].map((_, i) => ( - { - lastRenderedLayoutKeyRef.current = layoutKey; - setIsPageRendering(false); - }} - onLoadSuccess={(page) => { - setPageWidth(page.originalWidth); - setPageHeight(page.originalHeight); - }} - /> +
+ { + lastRenderedLayoutKeyRef.current = layoutKey; + setIsPageRendering(false); + markViewerReady(); + }} + onLoadSuccess={(page) => { + setPageWidth(page.originalWidth); + setPageHeight(page.originalHeight); + }} + /> + {renderParsedOverlay(i + 1)} +
))}
) : ( // Single/Dual page mode
{currDocPages && leftPage > 0 && ( - { - lastRenderedLayoutKeyRef.current = layoutKey; - setIsPageRendering(false); - }} - onLoadSuccess={(page) => { - setPageWidth(page.originalWidth); - setPageHeight(page.originalHeight); - }} - /> +
+ { + lastRenderedLayoutKeyRef.current = layoutKey; + setIsPageRendering(false); + markViewerReady(); + }} + onLoadSuccess={(page) => { + setPageWidth(page.originalWidth); + setPageHeight(page.originalHeight); + }} + /> + {renderParsedOverlay(leftPage)} +
)} {currDocPages && rightPage && rightPage <= currDocPages && viewType === 'dual' && ( - { - lastRenderedLayoutKeyRef.current = layoutKey; - setIsPageRendering(false); - }} - onLoadSuccess={(page) => { - setPageWidth(page.originalWidth); - setPageHeight(page.originalHeight); - }} - /> +
+ { + lastRenderedLayoutKeyRef.current = layoutKey; + setIsPageRendering(false); + markViewerReady(); + }} + onLoadSuccess={(page) => { + setPageWidth(page.originalWidth); + setPageHeight(page.originalHeight); + }} + /> + {renderParsedOverlay(rightPage)} +
)}
)} diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 2fb4154..f119ddb 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -29,7 +29,6 @@ interface ConfigContextType { voice: string; skipBlank: boolean; epubTheme: boolean; - smartSentenceSplitting: boolean; segmentPreloadDepthPages: number; segmentPreloadSentenceLookahead: number; ttsSegmentMaxBlockLength: number; @@ -352,7 +351,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) { ttsModel, ttsInstructions, savedVoices, - smartSentenceSplitting, segmentPreloadDepthPages, segmentPreloadSentenceLookahead, ttsSegmentMaxBlockLength, @@ -475,7 +473,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) { voice, skipBlank, epubTheme, - smartSentenceSplitting, segmentPreloadDepthPages, segmentPreloadSentenceLookahead, ttsSegmentMaxBlockLength, diff --git a/src/contexts/OnboardingFlowContext.tsx b/src/contexts/OnboardingFlowContext.tsx new file mode 100644 index 0000000..9fcacc9 --- /dev/null +++ b/src/contexts/OnboardingFlowContext.tsx @@ -0,0 +1,322 @@ +'use client'; + +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal'; +import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal'; +import { PrivacyModal } from '@/components/PrivacyModal'; +import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; +import { useAuthSession } from '@/hooks/useAuthSession'; +import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; +import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppConfig, setFirstVisit } from '@/lib/client/dexie'; +import { listDocuments } from '@/lib/client/api/documents'; +import { postChangelogVersionCheck } from '@/lib/client/api/user-state'; +import { scheduleChangelogCheck } from '@/lib/client/changelog-check'; +import { ONBOARDING_STATE_REGISTRY } from '@/lib/shared/onboarding-state'; + +type SettingsOpenOptions = { + changelog?: boolean; +}; + +type SettingsController = { + open: (options?: SettingsOpenOptions) => void; + close: () => void; +}; + +type OnboardingFlowContextValue = { + requestOpenSettings: (options?: SettingsOpenOptions) => Promise; + registerSettingsController: (controller: SettingsController | null) => void; +}; + +const OnboardingFlowContext = createContext(null); + +type MigrationPromptState = { + shouldPrompt: boolean; + localCount: number; + missingCount: number; +}; + +const EMPTY_CLAIM_COUNTS: ClaimableCounts = { + documents: 0, + audiobooks: 0, + preferences: 0, + progress: 0, +}; + +function toClaimableCounts(value: unknown): ClaimableCounts { + const rec = (value && typeof value === 'object') ? (value as Record) : {}; + return { + documents: Number(rec.documents ?? 0), + audiobooks: Number(rec.audiobooks ?? 0), + preferences: Number(rec.preferences ?? 0), + progress: Number(rec.progress ?? 0), + }; +} + +async function fetchClaimableCounts(): Promise { + const res = await fetch('/api/user/claim', { method: 'GET' }); + if (!res.ok) { + return EMPTY_CLAIM_COUNTS; + } + const data = await res.json(); + return toClaimableCounts(data); +} + +async function getMigrationPromptState(): Promise { + const cfg = await getAppConfig(); + if (!cfg?.privacyAccepted || cfg.documentsMigrationPrompted) { + return { shouldPrompt: false, localCount: 0, missingCount: 0 }; + } + + const [pdfs, epubs, htmls] = await Promise.all([ + getAllPdfDocuments(), + getAllEpubDocuments(), + getAllHtmlDocuments(), + ]); + const localDocs = [ + ...pdfs.map((d) => d.id), + ...epubs.map((d) => d.id), + ...htmls.map((d) => d.id), + ]; + const localCount = localDocs.length; + if (localCount === 0) { + return { shouldPrompt: false, localCount: 0, missingCount: 0 }; + } + + const serverDocs = await listDocuments().catch(() => null); + if (!serverDocs) { + return { shouldPrompt: true, localCount, missingCount: localCount }; + } + + const serverIds = new Set(serverDocs.map((d) => d.id)); + const missingCount = localDocs.filter((id) => !serverIds.has(id)).length; + return { + shouldPrompt: missingCount > 0, + localCount, + missingCount, + }; +} + +type LocalOnboardingSnapshot = { + privacyAccepted: boolean; + firstVisitSettingsOpened: boolean; +}; + +async function readLocalOnboardingSnapshot(): Promise { + const appConfig = await getAppConfig(); + const row = appConfig as Record | null; + const privacyKey = ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey; + const firstVisitKey = ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey; + + return { + privacyAccepted: privacyKey ? Boolean(row?.[privacyKey]) : false, + firstVisitSettingsOpened: firstVisitKey ? Boolean(row?.[firstVisitKey]) : false, + }; +} + +export function OnboardingFlowProvider({ children }: { children: ReactNode }) { + const { authEnabled } = useAuthConfig(); + const { data: session, isPending: isSessionPending } = useAuthSession(); + const runtimeConfig = useRuntimeConfig(); + const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined; + const userId = user?.id ?? null; + const isAnonymous = Boolean(user?.isAnonymous); + + const [activeBlockingModal, setActiveBlockingModal] = useState<'privacy' | 'claim' | 'migration' | null>(null); + const [claimableCounts, setClaimableCounts] = useState(EMPTY_CLAIM_COUNTS); + const [migrationCounts, setMigrationCounts] = useState<{ localCount: number; missingCount: number }>({ + localCount: 0, + missingCount: 0, + }); + + const settingsControllerRef = useRef(null); + const pendingChangelogOpenRef = useRef(false); + const runningAdvanceRef = useRef(false); + const claimDismissedUsersRef = useRef>(new Set()); + const changelogVersionCheckKeyRef = useRef(null); + const changelogVersionCheckInFlightRef = useRef(null); + + const openSettingsNow = useCallback((options?: SettingsOpenOptions) => { + settingsControllerRef.current?.open(options); + }, []); + + const advanceFlow = useCallback(async () => { + if (runningAdvanceRef.current) { + return; + } + runningAdvanceRef.current = true; + try { + const local = await readLocalOnboardingSnapshot(); + if (authEnabled && !local.privacyAccepted) { + setActiveBlockingModal('privacy'); + return; + } + if (activeBlockingModal === 'privacy') { + setActiveBlockingModal(null); + } else if (activeBlockingModal) { + return; + } + + if (authEnabled && userId && !isAnonymous && !claimDismissedUsersRef.current.has(userId)) { + const counts = await fetchClaimableCounts(); + const total = counts.documents + counts.audiobooks + counts.preferences + counts.progress; + if (total > 0) { + setClaimableCounts(counts); + setActiveBlockingModal('claim'); + return; + } + claimDismissedUsersRef.current.add(userId); + } + + const migrationState = await getMigrationPromptState(); + if (migrationState.shouldPrompt) { + setMigrationCounts({ + localCount: migrationState.localCount, + missingCount: migrationState.missingCount, + }); + setActiveBlockingModal('migration'); + return; + } + + if (!local.firstVisitSettingsOpened) { + await setFirstVisit(true); + openSettingsNow(); + return; + } + + if (pendingChangelogOpenRef.current) { + pendingChangelogOpenRef.current = false; + openSettingsNow({ changelog: true }); + } + } finally { + runningAdvanceRef.current = false; + } + }, [activeBlockingModal, authEnabled, isAnonymous, openSettingsNow, userId]); + + const requestOpenSettings = useCallback(async (options?: SettingsOpenOptions): Promise => { + const local = await readLocalOnboardingSnapshot(); + if (authEnabled && !local.privacyAccepted) { + if (options?.changelog) { + pendingChangelogOpenRef.current = true; + } + settingsControllerRef.current?.close(); + return false; + } + + if (activeBlockingModal) { + if (options?.changelog) { + pendingChangelogOpenRef.current = true; + } + return false; + } + + if (!settingsControllerRef.current) { + if (options?.changelog) { + pendingChangelogOpenRef.current = true; + } + return false; + } + + settingsControllerRef.current.open(options); + return true; + }, [activeBlockingModal, authEnabled]); + + const registerSettingsController = useCallback((controller: SettingsController | null) => { + settingsControllerRef.current = controller; + if (controller) { + void advanceFlow(); + } + }, [advanceFlow]); + + const handleClaimComplete = useCallback(() => { + if (userId) { + claimDismissedUsersRef.current.add(userId); + } + setActiveBlockingModal(null); + void advanceFlow(); + }, [advanceFlow, userId]); + + const handleMigrationComplete = useCallback(() => { + setActiveBlockingModal(null); + void advanceFlow(); + }, [advanceFlow]); + + const handlePrivacyAccepted = useCallback(() => { + setActiveBlockingModal(null); + void advanceFlow(); + }, [advanceFlow]); + + useEffect(() => { + void advanceFlow(); + }, [advanceFlow, authEnabled, isAnonymous, userId]); + + useEffect(() => { + if (!authEnabled) { + return; + } + const onPrivacyAccepted = () => { + void advanceFlow(); + }; + window.addEventListener('openreader:privacyAccepted', onPrivacyAccepted); + return () => { + window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted); + }; + }, [advanceFlow, authEnabled]); + + useEffect(() => { + return scheduleChangelogCheck({ + authEnabled, + isSessionPending, + sessionUserId: userId, + appVersion: runtimeConfig.appVersion, + completedRef: changelogVersionCheckKeyRef, + inFlightRef: changelogVersionCheckInFlightRef, + postCheck: async (currentVersion) => postChangelogVersionCheck(currentVersion), + onShouldOpen: () => { + pendingChangelogOpenRef.current = true; + void advanceFlow(); + }, + delayMs: 120, + retryDelayMs: 400, + }); + }, [advanceFlow, authEnabled, isSessionPending, runtimeConfig.appVersion, userId]); + + const contextValue = useMemo(() => ({ + requestOpenSettings, + registerSettingsController, + }), [registerSettingsController, requestOpenSettings]); + + return ( + + {children} + {authEnabled && ( + { }} + /> + )} + {authEnabled && ( + + )} + + + ); +} + +export function useOnboardingFlow() { + const context = useContext(OnboardingFlowContext); + if (!context) { + throw new Error('useOnboardingFlow must be used inside OnboardingFlowProvider'); + } + return context; +} diff --git a/src/contexts/RuntimeConfigContext.tsx b/src/contexts/RuntimeConfigContext.tsx index 16a665c..1870b8e 100644 --- a/src/contexts/RuntimeConfigContext.tsx +++ b/src/contexts/RuntimeConfigContext.tsx @@ -4,8 +4,8 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react'; /** * Site-wide runtime config resolved at SSR time and injected via - * `window.__OPENREADER_RUNTIME_CONFIG__`. Replaces module-scope reads of - * `process.env.NEXT_PUBLIC_*` so admin edits take effect on the next page + * `window.__RUNTIME_CONFIG__`. Replaces module-scope reads of + * build-time public env flags so admin edits take effect on the next page * load without a redeploy. Read-only from the client; admin writes go * through `/api/admin/settings` and trigger a reload. * @@ -18,11 +18,11 @@ export interface RuntimeConfig { enableUserSignups: boolean; restrictUserApiKeys: boolean; enableTtsProvidersTab: boolean; - enableWordHighlight: boolean; enableAudiobookExport: boolean; enableDocxConversion: boolean; enableDestructiveDeleteActions: boolean; showAllProviderModels: boolean; + computeAvailable: boolean; } const RUNTIME_DEFAULTS: RuntimeConfig = { @@ -32,23 +32,23 @@ const RUNTIME_DEFAULTS: RuntimeConfig = { enableUserSignups: true, restrictUserApiKeys: true, enableTtsProvidersTab: true, - enableWordHighlight: true, enableAudiobookExport: true, enableDocxConversion: true, enableDestructiveDeleteActions: true, showAllProviderModels: true, + computeAvailable: true, }; declare global { // Injected via SSR in `src/app/layout.tsx`. Always defined in the browser. interface Window { - __OPENREADER_RUNTIME_CONFIG__?: Partial; + __RUNTIME_CONFIG__?: Partial; } } function readInjectedConfig(): RuntimeConfig { if (typeof window === 'undefined') return { ...RUNTIME_DEFAULTS }; - const injected = window.__OPENREADER_RUNTIME_CONFIG__; + const injected = window.__RUNTIME_CONFIG__; if (!injected || typeof injected !== 'object') return { ...RUNTIME_DEFAULTS }; return { ...RUNTIME_DEFAULTS, ...injected }; } diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index fa4b428..404a014 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -146,6 +146,7 @@ interface TTSContextType extends TTSPlaybackState { setSpeedAndRestart: (speed: number) => void; setAudioPlayerSpeedAndRestart: (speed: number) => void; setVoiceAndRestart: (voice: string) => void; + clearSegmentCaches: () => void; skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void; registerLocationChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void; // EPUB-only: Handles chapter navigation registerEpubLocationWalker: (walker: EpubRenderedLocationWalker | null) => void; @@ -159,11 +160,13 @@ interface TTSContextType extends TTSPlaybackState { interface SetTextOptions { shouldPause?: boolean; location?: TTSLocation; + sourceUnits?: CanonicalTtsSourceUnit[]; previousLocation?: TTSLocation; nextLocation?: TTSLocation; nextText?: string; + nextSourceUnits?: CanonicalTtsSourceUnit[]; previousText?: string; - upcomingLocations?: Array<{ location: TTSLocation; text: string }>; + upcomingLocations?: Array<{ location: TTSLocation; text: string; sourceUnits?: CanonicalTtsSourceUnit[] }>; } type TTSSegmentPlaybackSource = { @@ -216,10 +219,10 @@ const WARM_AUDIO_CACHE_MAX_ITEMS = 6; const wordHighlightFeatureEnabled = (() => { if (typeof window === 'undefined') return true; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__; + const injected = (window as any).__RUNTIME_CONFIG__; if (!injected || typeof injected !== 'object') return true; - return typeof injected.enableWordHighlight === 'boolean' - ? injected.enableWordHighlight + return typeof injected.computeAvailable === 'boolean' + ? injected.computeAvailable : true; })(); @@ -244,12 +247,16 @@ const buildCacheKey = ( speed: number, provider: string, model: string, + providerType: string, + instructions: string, ) => { return [ `provider=${provider || ''}`, + `providerType=${providerType || ''}`, `model=${model || ''}`, `voice=${voice || ''}`, `speed=${Number.isFinite(speed) ? speed : ''}`, + `instructions=${instructions || ''}`, `text=${sentence}`, ].join('|'); }; @@ -262,10 +269,12 @@ const buildScopedSegmentCacheKey = ( speed: number, provider: string, model: string, + providerType: string, + instructions: string, segmentKey?: string | null, ) => { return [ - buildCacheKey(sentence, voice, speed, provider, model), + buildCacheKey(sentence, voice, speed, provider, model, providerType, instructions), `segmentKey=${segmentKey || ''}`, `locator=${segmentKey ? '' : buildLocatorRequestKey(locator)}`, `segmentIndex=${segmentKey ? '' : segmentIndex}`, @@ -292,6 +301,11 @@ const buildLocatorRequestKey = (locator: TTSSegmentLocator): string => { if (typeof locator.location === 'string' && locator.location) { return normalizeLocationKey(locator.location); } + if (locator.readerType === 'pdf') { + const page = Number(locator.page || 1); + const block = typeof locator.blockId === 'string' && locator.blockId ? locator.blockId : ''; + return normalizeLocationKey(`pdf:${page}:${block}`); + } return normalizeLocationKey(Number(locator.page || 1)); }; @@ -300,9 +314,11 @@ const buildSegmentRequestKey = ( segmentIndex: number, sentence: string, segmentKey?: string | null, -): string => segmentKey - ? `${segmentKey}::${sentence}` - : `${buildLocatorRequestKey(locator)}::${segmentIndex}::${sentence}`; +): string => { + return segmentKey + ? `${segmentKey}::${sentence}` + : `${buildLocatorRequestKey(locator)}::${segmentIndex}::${sentence}`; +}; const resolveJumpIndex = (input: JumpResolutionInput): JumpResolution => { if (input.newSentenceCount <= 0) { @@ -371,7 +387,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ttsInstructions: configTTSInstructions, updateConfigKey, skipBlank, - smartSentenceSplitting, segmentPreloadDepthPages, segmentPreloadSentenceLookahead, ttsSegmentMaxBlockLength, @@ -1124,6 +1139,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ? normalizedOptions.location : currDocPage; const resolvedLocationKey = normalizeLocationKey(resolvedLocation); + const currentUnits = normalizedOptions.sourceUnits && normalizedOptions.sourceUnits.length > 0 + ? normalizedOptions.sourceUnits + : null; // Keep currDocPage aligned with whatever the caller declared as the viewport's // location. This is the canonical entry point for "the rendered page now shows @@ -1142,9 +1160,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement text, locator: locatorForLocation(resolvedLocation, activeReaderType), }; + const effectiveCurrentUnits = currentUnits && currentUnits.length > 0 ? currentUnits : [currentSource]; + const currentSourceKeySet = new Set(effectiveCurrentUnits.map((unit) => unit.sourceKey)); const contextSourceUnits: CanonicalTtsSourceUnit[] = []; - if (smartSentenceSplitting && normalizedOptions.previousText?.trim()) { + if (normalizedOptions.previousText?.trim()) { const previousLocation = normalizedOptions.previousLocation; contextSourceUnits.push({ sourceKey: previousLocation !== undefined @@ -1156,65 +1176,72 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement : null, }); } - contextSourceUnits.push(currentSource); + contextSourceUnits.push(...effectiveCurrentUnits); const sourceUnits: CanonicalTtsSourceUnit[] = [...contextSourceUnits]; plannedSegmentsByLocationRef.current.clear(); pendingNextLocationRef.current = normalizedOptions.nextLocation; - const pendingPrefetches: Array = []; + const pendingPrefetches: Array<{ + location: TTSLocation; + sourceUnits: CanonicalTtsSourceUnit[]; + }> = []; if (normalizedOptions.nextLocation !== undefined && normalizedOptions.nextText?.trim()) { - pendingPrefetches.push({ - sourceKey: sourceKeyForLocation(normalizedOptions.nextLocation, currDocPage), - location: normalizedOptions.nextLocation, - text: normalizedOptions.nextText, - locator: locatorForLocation(normalizedOptions.nextLocation, activeReaderType), - }); + const provided = normalizedOptions.nextSourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? []; + pendingPrefetches.push(provided.length > 0 + ? { + location: normalizedOptions.nextLocation, + sourceUnits: provided, + } + : { + location: normalizedOptions.nextLocation, + sourceUnits: [{ + sourceKey: sourceKeyForLocation(normalizedOptions.nextLocation, currDocPage), + text: normalizedOptions.nextText, + locator: locatorForLocation(normalizedOptions.nextLocation, activeReaderType), + }], + }); } if (Array.isArray(normalizedOptions.upcomingLocations)) { for (const item of normalizedOptions.upcomingLocations) { if (item.location === undefined || !item.text?.trim()) continue; - pendingPrefetches.push({ - sourceKey: sourceKeyForLocation(item.location, currDocPage), - location: item.location, - text: item.text, - locator: locatorForLocation(item.location, activeReaderType), - }); + const provided = item.sourceUnits?.filter((unit) => unit.text.trim().length > 0) ?? []; + pendingPrefetches.push(provided.length > 0 + ? { + location: item.location, + sourceUnits: provided, + } + : { + location: item.location, + sourceUnits: [{ + sourceKey: sourceKeyForLocation(item.location, currDocPage), + text: item.text, + locator: locatorForLocation(item.location, activeReaderType), + }], + }); } } for (const item of pendingPrefetches) { - if (smartSentenceSplitting) { - sourceUnits.push(item); - } + sourceUnits.push(...item.sourceUnits); } const plan = planCanonicalTtsSegments(sourceUnits, { readerType: activeReaderType, maxBlockLength: ttsSegmentMaxBlockLength, keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), + enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0, }); - const currentSegments = smartSentenceSplitting - ? plan.segments.filter((segment) => segment.ownerSourceKey === currentSourceKey) - : planCanonicalTtsSegments([currentSource], { - readerType: activeReaderType, - maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), - }).segments; + const currentSegments = plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey)); const newSentences = currentSegments.map((segment) => segment.text); for (const item of pendingPrefetches) { - const planned = smartSentenceSplitting - ? plan.segments.filter((segment) => segment.ownerSourceKey === item.sourceKey) - : planCanonicalTtsSegments([item], { - readerType: activeReaderType, - maxBlockLength: ttsSegmentMaxBlockLength, - keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType), - }).segments; + const sourceKeys = new Set(item.sourceUnits.map((unit) => unit.sourceKey)); + const planned = plan.segments.filter((segment) => sourceKeys.has(segment.ownerSourceKey)); if (planned.length > 0) { plannedSegmentsByLocationRef.current.set(normalizeLocationKey(item.location), planned); } } - currentSourceUnitRef.current = currentSource; + currentSourceUnitRef.current = effectiveCurrentUnits[0] ?? null; currentSourceContextUnitsRef.current = contextSourceUnits; if (handleBlankSection(newSentences.join(' '))) return; @@ -1288,7 +1315,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setCurrentSentenceAlignment(undefined); setCurrentWordIndex(null); - if (smartSentenceSplitting && !isEPUB && normalizedOptions.nextLocation !== undefined) { + if ( + !isEPUB + && normalizedOptions.nextLocation !== undefined + && effectiveCurrentUnits.length === 1 + ) { const spanningIndex = currentSegments.findIndex((segment) => segment.spansSourceBoundary && segment.startAnchor.sourceKey === currentSourceKey @@ -1335,7 +1366,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement abortAudio, isEPUB, activeReaderType, - smartSentenceSplitting, invalidatePlaybackRun, currDocPage, documentId, @@ -1534,6 +1564,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', segmentKey, ); @@ -2196,6 +2228,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', playbackSegment?.key, ); const cachedAlignment = sentenceAlignmentCacheRef.current.get(alignmentKey); @@ -2230,6 +2264,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions, + ttsInstructions, isEPUB, currDocPage, currDocPageNumber, @@ -2326,6 +2363,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', nextSegment?.key, ); if (segmentManifestCacheRef.current.has(cacheKey)) { @@ -2428,7 +2467,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ? currentSourceContextUnitsRef.current : (currentSourceUnitRef.current ? [currentSourceUnitRef.current] : []); const sourceUnits: CanonicalTtsSourceUnit[] = buildWalkerPlanningSourceUnits( - smartSentenceSplitting, liveContextUnits, upcomingUnits, ); @@ -2457,6 +2495,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', segment.key, ); if (seenCandidates.has(requestKey)) continue; @@ -2595,6 +2635,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', plannedSegment?.key, ); const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, plannedSegment?.key); @@ -2624,6 +2666,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, configProviderRef, ttsModel, + configProviderType, + providerModelPolicy.supportsInstructions ? ttsInstructions : '', segment.key, ); const requestKey = buildSegmentRequestKey(segmentLocator, index, sentence, segment.key); @@ -2774,7 +2818,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement openApiBaseUrl, providerModelPolicy.supportsInstructions, ttsInstructions, - smartSentenceSplitting, onTTSStart, onTTSComplete, processSentence, @@ -2857,6 +2900,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setCurrentWordIndex(null); }, [abortAudio, clearWarmAudioCache, invalidatePlaybackRun, clearPendingEpubJump, bumpEpubPreloadGeneration]); + const clearSegmentCaches = useCallback(() => { + // Keep the current viewport/sentence list intact, but force all audio/manifest + // state to be re-resolved after a server-side clear. + abortAudio(true); + segmentManifestCacheRef.current.clear(); + sentenceAlignmentCacheRef.current.clear(); + setCurrentSentenceAlignment(undefined); + setCurrentWordIndex(null); + }, [abortAudio]); + /** * Stops the current audio playback and starts playing from a specified index * @@ -3068,6 +3121,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setSpeedAndRestart, setAudioPlayerSpeedAndRestart, setVoiceAndRestart, + clearSegmentCaches, skipToLocation, registerLocationChangeHandler, registerEpubLocationWalker, @@ -3098,6 +3152,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setSpeedAndRestart, setAudioPlayerSpeedAndRestart, setVoiceAndRestart, + clearSegmentCaches, skipToLocation, registerLocationChangeHandler, registerEpubLocationWalker, diff --git a/src/db/schema.ts b/src/db/schema.ts index 6b3bab9..b295a06 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -11,6 +11,7 @@ export const audiobooks = usePostgres ? postgresSchema.audiobooks : sqliteSchema export const audiobookChapters = usePostgres ? postgresSchema.audiobookChapters : sqliteSchema.audiobookChapters; export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars; export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences; +export const documentSettings = usePostgres ? postgresSchema.documentSettings : sqliteSchema.documentSettings; export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress; export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews; export const ttsSegmentEntries = usePostgres ? postgresSchema.ttsSegmentEntries : sqliteSchema.ttsSegmentEntries; diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index 9778aae..13f3061 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -12,6 +12,8 @@ export const documents = pgTable('documents', { size: bigint('size', { mode: 'number' }).notNull(), lastModified: bigint('last_modified', { mode: 'number' }).notNull(), filePath: text('file_path').notNull(), + parseState: text('parse_state'), + parsedJsonKey: text('parsed_json_key'), createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), }, (table) => [ primaryKey({ columns: [table.id, table.userId] }), @@ -72,6 +74,18 @@ export const userPreferences = pgTable('user_preferences', { updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), }); +export const documentSettings = pgTable('document_settings', { + documentId: text('document_id').notNull(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + dataJson: jsonb('data_json').notNull().default({}), + clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0), + createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), + updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), +}, (table) => [ + primaryKey({ columns: [table.documentId, table.userId] }), + index('idx_document_settings_user_id').on(table.userId), +]); + export const userDocumentProgress = pgTable('user_document_progress', { userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), documentId: text('document_id').notNull(), @@ -89,12 +103,12 @@ export const userDocumentProgress = pgTable('user_document_progress', { export const documentPreviews = pgTable('document_previews', { documentId: text('document_id').notNull(), namespace: text('namespace').notNull().default(''), - variant: text('variant').notNull().default('card-240-jpeg'), + variant: text('variant').notNull(), status: text('status').notNull().default('queued'), sourceLastModifiedMs: bigint('source_last_modified_ms', { mode: 'number' }).notNull(), objectKey: text('object_key').notNull(), contentType: text('content_type').notNull().default('image/jpeg'), - width: integer('width').notNull().default(240), + width: integer('width').notNull(), height: integer('height'), byteSize: bigint('byte_size', { mode: 'number' }), eTag: text('etag'), diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 920a42d..2781e7d 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -12,6 +12,8 @@ export const documents = sqliteTable('documents', { size: integer('size').notNull(), lastModified: integer('last_modified').notNull(), filePath: text('file_path').notNull(), + parseState: text('parse_state'), + parsedJsonKey: text('parsed_json_key'), createdAt: integer('created_at').default(SQLITE_NOW_MS), }, (table) => [ primaryKey({ columns: [table.id, table.userId] }), @@ -72,6 +74,18 @@ export const userPreferences = sqliteTable('user_preferences', { updatedAt: integer('updated_at').default(SQLITE_NOW_MS), }); +export const documentSettings = sqliteTable('document_settings', { + documentId: text('document_id').notNull(), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + dataJson: text('data_json').notNull().default('{}'), + clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0), + createdAt: integer('created_at').default(SQLITE_NOW_MS), + updatedAt: integer('updated_at').default(SQLITE_NOW_MS), +}, (table) => [ + primaryKey({ columns: [table.documentId, table.userId] }), + index('idx_document_settings_user_id').on(table.userId), +]); + export const userDocumentProgress = sqliteTable('user_document_progress', { userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), documentId: text('document_id').notNull(), @@ -89,12 +103,12 @@ export const userDocumentProgress = sqliteTable('user_document_progress', { export const documentPreviews = sqliteTable('document_previews', { documentId: text('document_id').notNull(), namespace: text('namespace').notNull().default(''), - variant: text('variant').notNull().default('card-240-jpeg'), + variant: text('variant').notNull(), status: text('status').notNull().default('queued'), sourceLastModifiedMs: integer('source_last_modified_ms').notNull(), objectKey: text('object_key').notNull(), contentType: text('content_type').notNull().default('image/jpeg'), - width: integer('width').notNull().default(240), + width: integer('width').notNull(), height: integer('height'), byteSize: integer('byte_size'), eTag: text('etag'), diff --git a/src/hooks/useUnmountCleanupRef.ts b/src/hooks/useUnmountCleanupRef.ts new file mode 100644 index 0000000..ed1a9cb --- /dev/null +++ b/src/hooks/useUnmountCleanupRef.ts @@ -0,0 +1,19 @@ +import { useEffect, useRef } from 'react'; + +/** + * Runs cleanup only on unmount, while always invoking the latest cleanup function. + */ +export function useUnmountCleanupRef(cleanup: () => void) { + const cleanupRef = useRef(cleanup); + + useEffect(() => { + cleanupRef.current = cleanup; + }, [cleanup]); + + useEffect(() => { + return () => { + cleanupRef.current(); + }; + }, []); +} + diff --git a/src/lib/client/api/audiobooks.ts b/src/lib/client/api/audiobooks.ts index d737815..eb32a86 100644 --- a/src/lib/client/api/audiobooks.ts +++ b/src/lib/client/api/audiobooks.ts @@ -5,8 +5,6 @@ import type { AudiobookStatusResponse, CreateChapterPayload, VoicesResponse, - AlignmentPayload, - AlignmentResponse, TTSSegmentsEnsureRequest, TTSSegmentsEnsureResponse, } from '@/types/client'; @@ -208,23 +206,6 @@ export const getVoices = async (headers: HeadersInit): Promise = return await response.json(); }; -// --- Whisper API --- - - - -export const alignAudio = async (payload: AlignmentPayload): Promise => { - const response = await fetch('/api/whisper', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) return null; - return await response.json(); -}; - export const ensureTtsSegments = async ( payload: TTSSegmentsEnsureRequest, headers: TTSRequestHeaders, diff --git a/src/lib/client/api/documents.ts b/src/lib/client/api/documents.ts index 5cd5697..7eb4570 100644 --- a/src/lib/client/api/documents.ts +++ b/src/lib/client/api/documents.ts @@ -1,5 +1,7 @@ import { sha256HexFromArrayBuffer } from '@/lib/client/sha256'; import type { BaseDocument, DocumentType } from '@/types/documents'; +import type { ParsedPdfDocument, PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; +import type { DocumentSettings } from '@/types/document-settings'; export type UploadSource = { id: string; @@ -76,6 +78,161 @@ export async function getDocumentMetadata(id: string, options?: { signal?: Abort return docs[0] ?? null; } +export async function getParsedPdfDocument( + id: string, + options?: { signal?: AbortSignal; retryFailed?: boolean; opId?: string }, +): Promise< + | { status: 'ready'; parsed: ParsedPdfDocument } + | { status: 'pending' | 'running' | 'failed'; parseProgress?: PdfParseProgress | null; opId?: string | null } +> { + const params = new URLSearchParams(); + if (options?.retryFailed) params.set('retry', '1'); + if (options?.opId) params.set('opId', options.opId); + const query = params.size > 0 ? `?${params.toString()}` : ''; + const res = await fetch(`/api/documents/${encodeURIComponent(id)}/parsed${query}`, { + signal: options?.signal, + cache: 'no-store', + }); + + if (res.status === 202) { + const data = (await res.json().catch(() => null)) as { + parseStatus?: string; + parseProgress?: PdfParseProgress | null; + opId?: string | null; + } | null; + const parseStatus = data?.parseStatus; + if (parseStatus === 'pending' || parseStatus === 'running' || parseStatus === 'failed') { + return { status: parseStatus, parseProgress: data?.parseProgress ?? null, opId: data?.opId ?? null }; + } + return { status: 'pending', parseProgress: data?.parseProgress ?? null, opId: data?.opId ?? null }; + } + + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to load parsed PDF'); + } + + const parsed = (await res.json()) as ParsedPdfDocument; + return { status: 'ready', parsed }; +} + +export function subscribeParsedPdfDocumentEvents( + id: string, + options: { + opId?: string | null; + }, + handlers: { + onSnapshot: (snapshot: { + parseStatus: PdfParseStatus; + parseProgress: PdfParseProgress | null; + opId?: string | null; + }) => void; + onError?: (error: Event) => void; + }, +): () => void { + const params = new URLSearchParams(); + if (options.opId) params.set('opId', options.opId); + const query = params.size > 0 ? `?${params.toString()}` : ''; + const source = new EventSource(`/api/documents/${encodeURIComponent(id)}/parsed/events${query}`); + source.addEventListener('snapshot', (event) => { + if (!(event instanceof MessageEvent)) return; + try { + const payload = JSON.parse(event.data) as { + parseStatus: PdfParseStatus; + parseProgress: PdfParseProgress | null; + opId?: string | null; + }; + handlers.onSnapshot(payload); + } catch { + // Ignore malformed payloads to avoid breaking active streams. + } + }); + source.addEventListener('error', (event) => { + handlers.onError?.(event); + }); + return () => { + source.close(); + }; +} + +export async function forceReparsePdfDocument( + id: string, + options?: { signal?: AbortSignal }, +): Promise<{ status: 'pending' | 'running'; opId?: string | null }> { + const res = await fetch(`/api/documents/${encodeURIComponent(id)}/parsed`, { + method: 'POST', + signal: options?.signal, + cache: 'no-store', + }); + + if (res.status === 409) { + const data = (await res.json().catch(() => null)) as { + parseStatus?: string; + opId?: string | null; + error?: string; + } | null; + if (typeof data?.opId === 'string' && data.opId.trim()) { + return { + status: data?.parseStatus === 'running' ? 'running' : 'pending', + opId: data.opId, + }; + } + } + + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to force PDF reparse'); + } + + const data = (await res.json().catch(() => null)) as { parseStatus?: string; opId?: string | null } | null; + return { + status: data?.parseStatus === 'running' ? 'running' : 'pending', + opId: data?.opId ?? null, + }; +} + +type DocumentSettingsResponse = { + settings: DocumentSettings; + clientUpdatedAtMs: number; + hasStoredSettings?: boolean; +}; + +export async function getDocumentSettings( + id: string, + options?: { signal?: AbortSignal }, +): Promise { + const res = await fetch(`/api/documents/${encodeURIComponent(id)}/settings`, { + signal: options?.signal, + cache: 'no-store', + }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to load document settings'); + } + return (await res.json()) as DocumentSettingsResponse; +} + +export async function putDocumentSettings( + id: string, + settings: DocumentSettings, + options?: { signal?: AbortSignal; clientUpdatedAtMs?: number }, +): Promise { + const res = await fetch(`/api/documents/${encodeURIComponent(id)}/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings, + clientUpdatedAtMs: options?.clientUpdatedAtMs ?? Date.now(), + }), + signal: options?.signal, + }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to update document settings'); + } + return (await res.json()) as DocumentSettingsResponse & { applied: boolean }; +} + export async function uploadDocumentSources(sources: UploadSource[], options?: UploadOptions): Promise { if (sources.length === 0) return []; diff --git a/src/lib/client/audiobooks/adapters/html.ts b/src/lib/client/audiobooks/adapters/html.ts index 9880c5a..f44e8ca 100644 --- a/src/lib/client/audiobooks/adapters/html.ts +++ b/src/lib/client/audiobooks/adapters/html.ts @@ -5,7 +5,6 @@ import type { HtmlBlock } from '@/lib/client/html/blocks'; interface HtmlAudiobookAdapterOptions { blocks: HtmlBlock[]; isTxt: boolean; - smartSentenceSplitting: boolean; maxBlockLength?: number; /** * For markdown: any heading with `headingLevel <= chapterHeadingLevel` @@ -83,7 +82,6 @@ function buildChapterDrafts({ function chapterText( draft: ChapterDraft, - smartSentenceSplitting: boolean, maxBlockLength?: number, ): string { const joined = draft.blocks @@ -91,14 +89,14 @@ function chapterText( .filter((t) => t && t.trim()) .join('\n\n'); if (!joined) return ''; - return smartSentenceSplitting ? normalizeTextForTts(joined, { maxBlockLength }) : joined; + return normalizeTextForTts(joined, { maxBlockLength }); } function preparedChapters(options: HtmlAudiobookAdapterOptions): PreparedAudiobookChapter[] { const drafts = buildChapterDrafts(options); const out: PreparedAudiobookChapter[] = []; for (const draft of drafts) { - const text = chapterText(draft, options.smartSentenceSplitting, options.maxBlockLength); + const text = chapterText(draft, options.maxBlockLength); if (!text.trim()) continue; out.push({ index: out.length, diff --git a/src/lib/client/audiobooks/adapters/pdf.ts b/src/lib/client/audiobooks/adapters/pdf.ts index 85300f5..19c86e8 100644 --- a/src/lib/client/audiobooks/adapters/pdf.ts +++ b/src/lib/client/audiobooks/adapters/pdf.ts @@ -1,49 +1,91 @@ -import type { PDFDocumentProxy } from 'pdfjs-dist'; - -import { extractTextFromPDF } from '@/lib/client/pdf'; import type { AudiobookSourceAdapter, PreparedAudiobookChapter } from '@/lib/client/audiobooks/pipeline'; import { normalizeTextForTts } from '@/lib/shared/nlp'; +import type { ParsedPdfDocument, ParsedPdfBlock } from '@/types/parsed-pdf'; +import type { DocumentSettings } from '@/types/document-settings'; +import { DEFAULT_DOCUMENT_SETTINGS } from '@/types/document-settings'; interface PdfAudiobookAdapterOptions { - pdfDocument?: PDFDocumentProxy; - margins: { - header: number; - footer: number; - left: number; - right: number; - }; - smartSentenceSplitting: boolean; + parsed?: ParsedPdfDocument; + settings?: DocumentSettings; maxBlockLength?: number; } -async function extractPreparedPdfChapters({ - pdfDocument, - margins, - smartSentenceSplitting, +function chapterTextFromBlocks( + blocks: ParsedPdfBlock[], + maxBlockLength?: number, +): string { + const text = blocks + .map((block) => block.text.trim()) + .filter(Boolean) + .join('\n\n'); + if (!text) return ''; + return normalizeTextForTts(text, { maxBlockLength }); +} + +function prepareParsedChapters({ + parsed, + settings, maxBlockLength, -}: PdfAudiobookAdapterOptions): Promise { - if (!pdfDocument) { - throw new Error('No PDF document loaded'); - } +}: { + parsed: ParsedPdfDocument; + settings: DocumentSettings; + maxBlockLength?: number; +}): PreparedAudiobookChapter[] { + const skip = new Set(settings.pdf?.skipBlockKinds ?? DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? []); + const allBlocks = parsed.pages + .flatMap((page) => page.blocks) + .filter((block) => !skip.has(block.kind)); + if (!allBlocks.length) return []; const chapters: PreparedAudiobookChapter[] = []; - for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) { - const rawText = await extractTextFromPDF(pdfDocument, pageNum, margins); - const trimmedText = rawText.trim(); - if (!trimmedText) { + let currentTitle = 'Introduction'; + let currentBlocks: ParsedPdfBlock[] = []; + + const chapterBoundaryKinds = new Set(['paragraph_title', 'doc_title']); + + const flush = () => { + if (!currentBlocks.length) return; + const text = chapterTextFromBlocks(currentBlocks, maxBlockLength); + if (text) { + chapters.push({ + index: chapters.length, + title: currentTitle, + text, + }); + } + currentBlocks = []; + }; + + for (const block of allBlocks) { + if (chapterBoundaryKinds.has(block.kind)) { + flush(); + currentTitle = block.text.trim() || `Chapter ${chapters.length + 1}`; + currentBlocks.push(block); continue; } - - chapters.push({ - index: chapters.length, - title: `Page ${chapters.length + 1}`, - text: smartSentenceSplitting ? normalizeTextForTts(trimmedText, { maxBlockLength }) : trimmedText, - }); + currentBlocks.push(block); } + flush(); return chapters; } +async function extractPreparedPdfChapters({ + parsed, + settings = DEFAULT_DOCUMENT_SETTINGS, + maxBlockLength, +}: PdfAudiobookAdapterOptions): Promise { + if (!parsed) { + throw new Error('PDF parsing is not ready yet.'); + } + + return prepareParsedChapters({ + parsed, + settings, + maxBlockLength, + }); +} + export function createPdfAudiobookSourceAdapter(options: PdfAudiobookAdapterOptions): AudiobookSourceAdapter { return { noContentMessage: 'No text content found in PDF', diff --git a/src/lib/client/cache/previews.ts b/src/lib/client/cache/previews.ts index a5f91a9..736c273 100644 --- a/src/lib/client/cache/previews.ts +++ b/src/lib/client/cache/previews.ts @@ -8,6 +8,7 @@ import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/cli const inMemoryPreviewUrlCache = new Map(); const inFlightPreviewPrime = new Map>(); +const PREVIEW_CACHE_SCHEMA_VERSION = 4; function revokeIfBlobUrl(url: string | null | undefined): void { if (!url) return; @@ -46,6 +47,11 @@ export async function getPersistedDocumentPreviewUrl( const row = await getDocumentPreviewCache(docId); if (!row) return null; + if (Number((row as { previewVersion?: number }).previewVersion ?? 1) !== PREVIEW_CACHE_SCHEMA_VERSION) { + await removeDocumentPreviewCache(docId).catch(() => {}); + return null; + } + if (Number(row.lastModified) !== Number(lastModified)) { await removeDocumentPreviewCache(docId).catch(() => {}); return null; @@ -102,6 +108,7 @@ export async function primeDocumentPreviewCache( contentType, data: bytes, cachedAt: Date.now(), + previewVersion: PREVIEW_CACHE_SCHEMA_VERSION, }); const url = URL.createObjectURL(new Blob([bytes], { type: contentType })); diff --git a/src/lib/client/dexie.ts b/src/lib/client/dexie.ts index 6f505ae..3bb4d0a 100644 --- a/src/lib/client/dexie.ts +++ b/src/lib/client/dexie.ts @@ -61,6 +61,7 @@ export interface DocumentPreviewCacheRow { data: ArrayBuffer; cachedAt: number; byteSize?: number; + previewVersion?: number; } export interface ConfigRow { @@ -193,8 +194,6 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow { voice: '', skipBlank: raw.skipBlank === 'false' ? false : APP_CONFIG_DEFAULTS.skipBlank, epubTheme: raw.epubTheme === 'true', - smartSentenceSplitting: - raw.smartSentenceSplitting === 'false' ? false : APP_CONFIG_DEFAULTS.smartSentenceSplitting, headerMargin: raw.headerMargin ? parseFloat(raw.headerMargin) : APP_CONFIG_DEFAULTS.headerMargin, footerMargin: raw.footerMargin ? parseFloat(raw.footerMargin) : APP_CONFIG_DEFAULTS.footerMargin, leftMargin: raw.leftMargin ? parseFloat(raw.leftMargin) : APP_CONFIG_DEFAULTS.leftMargin, diff --git a/src/lib/client/epub/tts-epub-preload.ts b/src/lib/client/epub/tts-epub-preload.ts index 36858ea..5f23e14 100644 --- a/src/lib/client/epub/tts-epub-preload.ts +++ b/src/lib/client/epub/tts-epub-preload.ts @@ -29,10 +29,8 @@ export function selectUpcomingWalkerItems( * (previous/current) so walker boundary behavior aligns with setText. */ export function buildWalkerPlanningSourceUnits( - smartSentenceSplitting: boolean, contextUnits: readonly CanonicalTtsSourceUnit[], upcomingUnits: readonly CanonicalTtsSourceUnit[], ): CanonicalTtsSourceUnit[] { - if (!smartSentenceSplitting) return [...upcomingUnits]; return [...contextUnits, ...upcomingUnits]; } diff --git a/src/lib/client/pdf-block-text.ts b/src/lib/client/pdf-block-text.ts new file mode 100644 index 0000000..ac793ac --- /dev/null +++ b/src/lib/client/pdf-block-text.ts @@ -0,0 +1,20 @@ +import type { ParsedPdfBlockKind, ParsedPdfPage } from '@/types/parsed-pdf'; + +export function buildPageTextFromBlocks( + page: ParsedPdfPage, + skipKinds: ParsedPdfBlockKind[] = [], +): string { + const skip = new Set(skipKinds); + return page.blocks + .filter((block) => !skip.has(block.kind)) + .sort((a, b) => { + const aOrder = a.fragments[0]?.readingOrder ?? 0; + const bOrder = b.fragments[0]?.readingOrder ?? 0; + return aOrder - bOrder; + }) + .map((block) => block.text.trim()) + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/src/lib/client/pdf-tts-planning.ts b/src/lib/client/pdf-tts-planning.ts new file mode 100644 index 0000000..23e2074 --- /dev/null +++ b/src/lib/client/pdf-tts-planning.ts @@ -0,0 +1,58 @@ +import type { CanonicalTtsSourceUnit } from '@/lib/shared/tts-segment-plan'; +import type { TTSSegmentLocator } from '@/types/client'; +import type { ParsedPdfBlockKind, ParsedPdfPage } from '@/types/parsed-pdf'; + +type PdfUpcomingLocation = { + location: number; + text: string; + sourceUnits: CanonicalTtsSourceUnit[]; +}; + +export function buildPdfPageSourceUnits( + page: ParsedPdfPage | undefined, + pageNum: number, + skipKinds: ParsedPdfBlockKind[] = [], +): CanonicalTtsSourceUnit[] { + if (!page) return []; + const skip = new Set(skipKinds); + return page.blocks + .filter((block) => !skip.has(block.kind)) + .map((block) => ({ + sourceKey: `pdf:${pageNum}:${block.id}`, + text: block.text, + locator: { + readerType: 'pdf', + page: pageNum, + blockId: block.id, + } as TTSSegmentLocator, + })) + .filter((unit) => unit.text.trim().length > 0); +} + +export function buildPdfPrefetchPayload( + upcomingPageNumbers: number[], + upcomingTexts: string[], + sourceUnitsForPage: (pageNum: number) => CanonicalTtsSourceUnit[], +): { + nextText: string | undefined; + nextSourceUnits: CanonicalTtsSourceUnit[]; + additionalUpcoming: PdfUpcomingLocation[]; +} { + const nextPageNumber = upcomingPageNumbers[0]; + const nextText = upcomingTexts[0]; + const nextSourceUnits = nextPageNumber ? sourceUnitsForPage(nextPageNumber) : []; + const additionalUpcoming = upcomingPageNumbers + .slice(1) + .map((pageNum, idx) => ({ + location: pageNum, + text: upcomingTexts[idx + 1] || '', + sourceUnits: sourceUnitsForPage(pageNum), + })) + .filter((item) => item.text.trim().length > 0); + + return { + nextText, + nextSourceUnits, + additionalUpcoming, + }; +} diff --git a/src/lib/client/pdf.ts b/src/lib/client/pdf.ts index 6c09d24..9727d4b 100644 --- a/src/lib/client/pdf.ts +++ b/src/lib/client/pdf.ts @@ -1,9 +1,10 @@ import { pdfjs } from 'react-pdf'; -import type { TextItem } from 'pdfjs-dist/types/src/display/api'; -import { type PDFDocumentProxy, TextLayer } from 'pdfjs-dist'; +import { TextLayer } from 'pdfjs-dist'; import "core-js/proposals/promise-with-resolvers"; import type { TTSSentenceAlignment } from '@/types/tts'; +import type { ParsedPdfDocument, ParsedPdfPage } from '@/types/parsed-pdf'; import { CmpStr } from 'cmpstr'; +import type { TTSSegmentLocator } from '@/types/client'; const cmp = CmpStr.create().setMetric('dice').setFlags('itw'); @@ -80,18 +81,16 @@ function runHighlightTokenMatch( function shouldUseLegacyBuild() { try { if (typeof window === 'undefined') return false; - + const ua = window.navigator.userAgent; const isSafari = /^((?!chrome|android).)*safari/i.test(ua); - - console.log(isSafari ? 'Running on Safari' : 'Not running on Safari'); + if (!isSafari) return false; - + // Extract Safari version - matches "Version/18" format const match = ua.match(/Version\/(\d+)/i); - console.log('Safari version:', match); if (!match || !match[1]) return true; // If we can't determine version, use legacy to be safe - + const version = parseInt(match[1]); return version < 18; // Use legacy build for Safari versions equal or below 18 } catch (e) { @@ -106,10 +105,9 @@ function initPDFWorker() { if (typeof window !== 'undefined') { const useLegacy = shouldUseLegacyBuild(); // Use local worker file instead of unpkg - const workerSrc = useLegacy + const workerSrc = useLegacy ? new URL('pdfjs-dist/legacy/build/pdf.worker.min.mjs', import.meta.url).href : new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href; - console.log('Setting PDF worker to:', workerSrc); pdfjs.GlobalWorkerOptions.workerSrc = workerSrc; pdfjs.GlobalWorkerOptions.workerPort = null; } @@ -188,146 +186,200 @@ const normalizeWordForMatch = (text: string): string => .replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, '') .toLowerCase(); -// Text Processing functions -export async function extractTextFromPDF( - pdf: PDFDocumentProxy, - pageNumber: number, - margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 } -): Promise { - try { - // Log pdf worker version - //console.log('PDF worker version:', pdfjs.GlobalWorkerOptions.workerSrc); - - const page = await pdf.getPage(pageNumber); - const textContent = await page.getTextContent(); - - const viewport = page.getViewport({ scale: 1.0 }); - const pageHeight = viewport.height; - const pageWidth = viewport.width; - - const textItems = textContent.items.filter((item): item is TextItem => { - if (!('str' in item && 'transform' in item)) return false; - - const [scaleX, skewX, skewY, scaleY, x, y] = item.transform; - - // Basic text filtering - if (Math.abs(scaleX) < 1 || Math.abs(scaleX) > 20) return false; - if (Math.abs(scaleY) < 1 || Math.abs(scaleY) > 20) return false; - if (Math.abs(skewX) > 0.5 || Math.abs(skewY) > 0.5) return false; - - // Calculate margins in PDF coordinate space (y=0 is at bottom) - const headerY = pageHeight * (1 - margins.header); // Convert from top margin to bottom-based Y - const footerY = pageHeight * margins.footer; // Footer Y stays as is since it's already bottom-based - const leftX = pageWidth * margins.left; - const rightX = pageWidth * (1 - margins.right); - - // Check margins - remember y=0 is at bottom of page in PDF coordinates - if (y > headerY || y < footerY) { // Y greater than headerY means it's in header area, less than footerY means footer area - return false; - } - - // Check horizontal margins - if (x < leftX || x > rightX) { - return false; - } - - // Sanity check for coordinates - if (x < 0 || x > pageWidth) return false; - - return item.str.trim().length > 0; - }); - - //console.log('Filtered text items:', textItems); - - const tolerance = 2; - const lines: TextItem[][] = []; - let currentLine: TextItem[] = []; - let currentY: number | null = null; - - textItems.forEach((item) => { - const y = item.transform[5]; - if (currentY === null) { - currentY = y; - currentLine.push(item); - } else if (Math.abs(y - currentY) < tolerance) { - currentLine.push(item); - } else { - lines.push(currentLine); - currentLine = [item]; - currentY = y; - } - }); - lines.push(currentLine); - - let pageText = ''; - for (const line of lines) { - line.sort((a, b) => a.transform[4] - b.transform[4]); - let lineText = ''; - let prevItem: TextItem | null = null; - - for (const item of line) { - if (!prevItem) { - lineText = item.str; - } else { - const prevEndX = prevItem.transform[4] + (prevItem.width ?? 0); - const currentStartX = item.transform[4]; - const space = currentStartX - prevEndX; - - // Get average character width as fallback - const avgCharWidth = (item.width ?? 0) / Math.max(1, item.str.length); - - // Multiple conditions for space detection - const needsSpace = - // Primary check: significant gap between items - space > Math.max(avgCharWidth * 0.3, 2) || - // Secondary check: natural word boundary - (!/^\W/.test(item.str) && !/\W$/.test(prevItem.str)) || - // Tertiary check: items are far enough apart relative to their size - (space > ((prevItem.width ?? 0) * 0.25)); - - if (needsSpace) { - lineText += ' ' + item.str; - } else { - lineText += item.str; - } - } - prevItem = item; - } - pageText += lineText + ' '; - } - - return pageText.replace(/\s+/g, ' ').trim(); - } catch (error) { - // During Next.js fast refresh / route transitions, react-pdf can tear down the - // underlying worker and pdf.js may throw a TypeError like: - // "null is not an object (evaluating 'this.messageHandler.sendWithPromise')". - // Treat this as a cancellation so the app can ignore it. - if ( - error instanceof TypeError && - typeof error.message === 'string' && - error.message.includes('messageHandler') && - error.message.includes('sendWithPromise') - ) { - throw new DOMException('PDF worker torn down', 'AbortError'); - } - - console.error('Error extracting text from PDF:', error); - // Preserve the original error so callers can decide whether to retry/ignore. - throw error; - } -} - // Highlighting functions let highlightPatternSeq = 0; -export function clearHighlights() { - const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span'); - textNodes.forEach((node) => { - const element = node as HTMLElement; - element.style.backgroundColor = ''; - element.style.opacity = '1'; - }); +type HighlightPatternOptions = { + parsedDocument?: ParsedPdfDocument | null; + locator?: TTSSegmentLocator | null; + useBlockGeometryOnly?: boolean; +}; +function getHighlightLayerForPage(pageElement: HTMLElement): { + layer: HTMLElement; + pageRect: DOMRect; +} { + let layer = pageElement.querySelector('.pdf-highlight-layer') as HTMLElement | null; + if (!layer) { + layer = document.createElement('div'); + layer.className = 'pdf-highlight-layer'; + pageElement.appendChild(layer); + } + + layer.style.position = 'absolute'; + layer.style.inset = '0'; + layer.style.pointerEvents = 'none'; + layer.style.zIndex = '4'; + layer.style.overflow = 'hidden'; + layer.style.transform = 'translateZ(0)'; + + return { layer, pageRect: pageElement.getBoundingClientRect() }; +} + +function findRenderedPageElement(container: HTMLElement, pageNumber: number): HTMLElement | null { + const direct = container.querySelector(`.react-pdf__Page[data-page-number="${pageNumber}"]`) as HTMLElement | null; + if (direct) return direct; + + const pageNodes = Array.from(container.querySelectorAll('.react-pdf__Page')) as HTMLElement[]; + for (const pageNode of pageNodes) { + const attr = pageNode.getAttribute('data-page-number'); + if (Number(attr) === pageNumber) return pageNode; + } + return null; +} + +function highlightParsedBlockGeometry( + containerRef: React.RefObject, + parsedDocument: ParsedPdfDocument, + locator: TTSSegmentLocator, +): boolean { + if (locator.readerType !== 'pdf') return false; + if (!locator.blockId) return false; + const container = containerRef.current; + if (!container) return false; + + let targetBlock: + | ParsedPdfPage['blocks'][number] + | null = null; + for (const page of parsedDocument.pages) { + const found = page.blocks.find((block) => block.id === locator.blockId); + if (found) { + targetBlock = found; + break; + } + } + if (!targetBlock) return false; + + let firstRect: { top: number; left: number } | null = null; + let drewAny = false; + + for (const fragment of targetBlock.fragments) { + const parsedPage = parsedDocument.pages.find((page) => page.pageNumber === fragment.page); + if (!parsedPage || parsedPage.width <= 0 || parsedPage.height <= 0) continue; + + const pageElement = findRenderedPageElement(container, fragment.page); + if (!pageElement) continue; + + const { layer, pageRect } = getHighlightLayerForPage(pageElement); + const [x0, y0, x1, y1] = fragment.bbox; + const left = (x0 / parsedPage.width) * pageRect.width; + const top = (y0 / parsedPage.height) * pageRect.height; + const width = ((x1 - x0) / parsedPage.width) * pageRect.width; + const height = ((y1 - y0) / parsedPage.height) * pageRect.height; + + if (!(width > 0 && height > 0)) continue; + + const highlight = document.createElement('div'); + highlight.className = 'pdf-text-highlight-overlay'; + highlight.style.position = 'absolute'; + highlight.style.backgroundColor = 'grey'; + highlight.style.opacity = '0.4'; + highlight.style.pointerEvents = 'none'; + highlight.style.zIndex = '1'; + highlight.style.left = `${left}px`; + highlight.style.top = `${top}px`; + highlight.style.width = `${width}px`; + highlight.style.height = `${height}px`; + layer.appendChild(highlight); + + if (!firstRect) { + firstRect = { top: pageRect.top + top, left: pageRect.left + left }; + } + drewAny = true; + } + + if (!drewAny || !firstRect) return drewAny; + + const containerRect = container.getBoundingClientRect(); + const visibleTop = container.scrollTop; + const visibleBottom = visibleTop + containerRect.height; + const elementTop = firstRect.top - containerRect.top + container.scrollTop; + + if (elementTop < visibleTop || elementTop > visibleBottom) { + container.scrollTo({ + top: elementTop - containerRect.height / 3, + behavior: 'smooth', + }); + } + return true; +} + +function resolveParsedBlock( + parsedDocument: ParsedPdfDocument | null | undefined, + locator: TTSSegmentLocator | null | undefined, +): ParsedPdfPage['blocks'][number] | null { + if (!parsedDocument || !locator || locator.readerType !== 'pdf' || !locator.blockId) { + return null; + } + + for (const page of parsedDocument.pages) { + const found = page.blocks.find((block) => block.id === locator.blockId); + if (found) return found; + } + return null; +} + +function isRectOverlap( + a: { left: number; top: number; right: number; bottom: number }, + b: { left: number; top: number; right: number; bottom: number }, +): boolean { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +} + +function collectSpanNodesForParsedBlock( + container: HTMLElement, + parsedDocument: ParsedPdfDocument, + locator: TTSSegmentLocator, +): HTMLElement[] | null { + const block = resolveParsedBlock(parsedDocument, locator); + if (!block) return null; + + const collected: HTMLElement[] = []; + const seen = new Set(); + + for (const fragment of block.fragments) { + const parsedPage = parsedDocument.pages.find((page) => page.pageNumber === fragment.page); + if (!parsedPage || parsedPage.width <= 0 || parsedPage.height <= 0) continue; + + const pageElement = findRenderedPageElement(container, fragment.page); + if (!pageElement) continue; + + const textLayer = pageElement.querySelector('.react-pdf__Page__textContent') as HTMLElement | null; + if (!textLayer) continue; + + const pageRect = pageElement.getBoundingClientRect(); + const [x0, y0, x1, y1] = fragment.bbox; + const blockRect = { + left: (x0 / parsedPage.width) * pageRect.width, + top: (y0 / parsedPage.height) * pageRect.height, + right: (x1 / parsedPage.width) * pageRect.width, + bottom: (y1 / parsedPage.height) * pageRect.height, + }; + + const spans = Array.from(textLayer.querySelectorAll('span')) as HTMLElement[]; + for (const span of spans) { + const node = span.firstChild; + if (!node || node.nodeType !== Node.TEXT_NODE) continue; + + const rect = span.getBoundingClientRect(); + const spanRect = { + left: rect.left - pageRect.left, + top: rect.top - pageRect.top, + right: rect.right - pageRect.left, + bottom: rect.bottom - pageRect.top, + }; + + if (!isRectOverlap(spanRect, blockRect)) continue; + if (seen.has(span)) continue; + seen.add(span); + collected.push(span); + } + } + + return collected.length > 0 ? collected : null; +} + +export function clearHighlights() { const overlays = document.querySelectorAll('.pdf-text-highlight-overlay'); overlays.forEach((node) => { const element = node as HTMLElement; @@ -357,7 +409,8 @@ export function clearWordHighlights() { export function highlightPattern( text: string, pattern: string, - containerRef: React.RefObject + containerRef: React.RefObject, + options?: HighlightPatternOptions, ) { const seq = ++highlightPatternSeq; clearHighlights(); @@ -371,12 +424,23 @@ export function highlightPattern( lastSentencePattern = cleanPattern; lastSentenceWordToTokenMap = null; lastSentenceTokenWindow = null; + const parsedDocument = options?.parsedDocument ?? null; + const locator = options?.locator ?? null; - const spanNodes = Array.from( - container.querySelectorAll('.react-pdf__Page__textContent span') - ) as HTMLElement[]; + // Canonical path: parsed block locator is required for PDF sentence + // highlighting. Avoid broad full-page text matching fallbacks. + if (!parsedDocument || !locator || locator.readerType !== 'pdf' || !locator.blockId) { + return; + } - if (!spanNodes.length) return; + const spanNodes = collectSpanNodesForParsedBlock(container, parsedDocument, locator) ?? []; + + if (!spanNodes.length) { + if (options?.useBlockGeometryOnly) { + highlightParsedBlockGeometry(containerRef, parsedDocument, locator); + } + return; + } lastSpanNodes = spanNodes; const tokens: PDFToken[] = []; @@ -405,6 +469,15 @@ export function highlightPattern( if (!tokens.length) return; lastTokens = tokens; + if (options?.useBlockGeometryOnly) { + lastSentenceTokenWindow = { + start: 0, + end: tokens.length - 1, + }; + highlightParsedBlockGeometry(containerRef, parsedDocument, locator); + return; + } + const patternLen = cleanPattern.length; // Core application of highlight logic once we know the best token window (if any) diff --git a/src/lib/client/pdf/force-reparse.ts b/src/lib/client/pdf/force-reparse.ts new file mode 100644 index 0000000..425976d --- /dev/null +++ b/src/lib/client/pdf/force-reparse.ts @@ -0,0 +1,9 @@ +import type { PdfParseStatus } from '@/types/parsed-pdf'; + +export const FORCE_REPARSE_CONFIRM_TITLE = 'Reparse PDF Layout?'; +export const FORCE_REPARSE_CONFIRM_MESSAGE = 'This reruns page layout parsing from scratch and can take a while on large documents.'; +export const FORCE_REPARSE_CONFIRM_TEXT = 'Reparse Now'; + +export function isForceReparseDisabled(status: PdfParseStatus | null): boolean { + return status === 'pending' || status === 'running'; +} diff --git a/src/lib/server/admin/email-sync.ts b/src/lib/server/admin/email-sync.ts index 584a015..105ec17 100644 --- a/src/lib/server/admin/email-sync.ts +++ b/src/lib/server/admin/email-sync.ts @@ -1,5 +1,7 @@ import { eq } from 'drizzle-orm'; import { db } from '@/db'; +import { hashForLog, serverLogger } from '@/lib/server/logger'; +import { logDegraded } from '@/lib/server/errors/logging'; // We only need the `user` table here. Better Auth manages its own schema files; // we import them lazily to avoid coupling this module to a single dialect. @@ -60,7 +62,13 @@ export async function syncAdminFlag( await db.update(user).set({ isAdmin: shouldBeAdmin }).where(eq(user.id, userId)); return shouldBeAdmin; } catch (error) { - console.warn('[admin] Failed to sync isAdmin flag for user', userId, error); + logDegraded(serverLogger, { + event: 'admin.email_sync.user_flag_update.failed', + msg: 'Failed to sync isAdmin flag for user', + step: 'sync_admin_flag', + context: { userIdHash: hashForLog(userId) }, + error, + }); return currentIsAdmin; } } diff --git a/src/lib/server/admin/seed.ts b/src/lib/server/admin/seed.ts index 18e3896..281f3d7 100644 --- a/src/lib/server/admin/seed.ts +++ b/src/lib/server/admin/seed.ts @@ -3,16 +3,18 @@ import { adminProviders, adminSettings } from '@/db/schema'; import { encryptSecret, apiKeyLast4 } from '@/lib/server/crypto/secrets'; import { randomUUID } from 'node:crypto'; import { and, eq } from 'drizzle-orm'; +import { serverLogger } from '@/lib/server/logger'; import { RUNTIME_CONFIG_SCHEMA, seedRuntimeConfigFromEnv, } from '@/lib/server/admin/settings'; +import { logDegraded } from '@/lib/server/errors/logging'; /** * Idempotent boot-time seeding for the admin layer. Safe to call multiple * times. Runs: * - * 1. `seedRuntimeConfigFromEnv()` — for each `NEXT_PUBLIC_*` env var that + * 1. `seedRuntimeConfigFromEnv()` — for each `RUNTIME_SEED_*` env var that * maps to a runtime config key, write the value as `source='env-seed'`. * * 2. Default admin provider seed — if `admin_providers` is empty AND @@ -30,7 +32,12 @@ let seedPromise: Promise | null = null; export async function ensureAdminSeed(): Promise { if (!seedPromise) { seedPromise = runSeed().catch((error) => { - console.warn('[admin-seed] failed:', error); + logDegraded(serverLogger, { + event: 'admin.seed.run.failed', + msg: 'Admin seed run failed', + step: 'run_admin_seed', + error, + }); // Reset so a subsequent call can retry (e.g. once migrations run). seedPromise = null; throw error; @@ -58,7 +65,12 @@ async function seedDefaultAdminProvider(): Promise { try { existing = await db.select({ id: adminProviders.id }).from(adminProviders).limit(1); } catch (error) { - console.warn('[admin-seed] could not check admin_providers (table missing?)', error); + logDegraded(serverLogger, { + event: 'admin.seed.providers.check_failed', + msg: 'Could not check admin_providers', + step: 'check_existing_admin_providers', + error, + }); return; } if (existing.length > 0) return; @@ -85,17 +97,26 @@ async function seedDefaultAdminProvider(): Promise { updatedAt: now, }) .onConflictDoNothing({ target: adminSettings.key }); - console.warn( - '[admin-seed] API_KEY present but AUTH_SECRET missing; defaulting restrictUserApiKeys=false so BYOK remains available', - ); + logDegraded(serverLogger, { + event: 'admin.seed.restrict_user_api_keys.defaulted', + msg: 'API_KEY present but AUTH_SECRET missing; defaulting restrictUserApiKeys=false', + step: 'set_restrict_user_api_keys_fallback', + }); } catch (fallbackError) { - console.warn( - '[admin-seed] failed to write restrictUserApiKeys fallback after encryption failure', - fallbackError, - ); + logDegraded(serverLogger, { + event: 'admin.seed.restrict_user_api_keys.fallback_write_failed', + msg: 'Failed to write restrictUserApiKeys fallback after encryption failure', + step: 'set_restrict_user_api_keys_fallback', + error: fallbackError, + }); } } - console.warn('[admin-seed] failed to encrypt default provider API key', error); + logDegraded(serverLogger, { + event: 'admin.seed.provider_key_encrypt.failed', + msg: 'Failed to encrypt default provider API key', + step: 'encrypt_default_provider_key', + error, + }); return; } @@ -114,9 +135,18 @@ async function seedDefaultAdminProvider(): Promise { createdAt: now, updatedAt: now, }); - console.log('[admin-seed] created default-openai admin provider from env'); + serverLogger.info({ + event: 'admin.seed.provider_insert.succeeded', + providerSlug: 'default-openai', + }, 'Created default-openai admin provider from env'); } catch (error) { - console.warn('[admin-seed] failed to insert default-openai provider', error); + logDegraded(serverLogger, { + event: 'admin.seed.provider_insert.failed', + msg: 'Failed to insert default-openai provider', + step: 'insert_default_provider', + context: { providerSlug: 'default-openai' }, + error, + }); } } @@ -140,7 +170,12 @@ async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise { ), ); } catch (error) { - console.warn('[admin-seed] failed to cleanup legacy defaultTtsProvider seed row', error); + logDegraded(serverLogger, { + event: 'admin.seed.legacy_default_provider_cleanup.failed', + msg: 'Failed to cleanup legacy defaultTtsProvider seed row', + step: 'cleanup_legacy_default_provider_seed', + error, + }); } } @@ -150,6 +185,11 @@ async function cleanupLegacyDefaultTtsModelRows(): Promise { .delete(adminSettings) .where(eq(adminSettings.key, 'defaultTtsModel')); } catch (error) { - console.warn('[admin-seed] failed to cleanup legacy defaultTtsModel rows', error); + logDegraded(serverLogger, { + event: 'admin.seed.legacy_default_model_cleanup.failed', + msg: 'Failed to cleanup legacy defaultTtsModel rows', + step: 'cleanup_legacy_default_model_rows', + error, + }); } } diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index 8e55824..f10dffe 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -2,9 +2,11 @@ import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; import { adminProviders, adminSettings } from '@/db/schema'; import { isAuthEnabled } from '@/lib/server/auth/config'; +import { serverLogger } from '@/lib/server/logger'; +import { logDegraded } from '@/lib/server/errors/logging'; /** - * Runtime config: site-wide settings that used to live in `NEXT_PUBLIC_*` + * Runtime config: site-wide settings that used to live in build-time env vars. * env vars. Each key has: * - a TypeScript value type * - an env var name used for the first-run seed @@ -75,17 +77,16 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef< } export const RUNTIME_CONFIG_SCHEMA = { - defaultTtsProvider: stringValue('custom-openai', 'NEXT_PUBLIC_DEFAULT_TTS_PROVIDER'), - changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json', 'NEXT_PUBLIC_CHANGELOG_FEED_URL'), - enableUserSignups: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_USER_SIGNUPS'), - restrictUserApiKeys: booleanFlag(true, 'NEXT_PUBLIC_RESTRICT_USER_API_KEYS'), + defaultTtsProvider: stringValue('custom-openai', 'RUNTIME_SEED_DEFAULT_TTS_PROVIDER'), + changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json', 'RUNTIME_SEED_CHANGELOG_FEED_URL'), + enableUserSignups: booleanFlag(true, 'RUNTIME_SEED_ENABLE_USER_SIGNUPS'), + restrictUserApiKeys: booleanFlag(true, 'RUNTIME_SEED_RESTRICT_USER_API_KEYS'), // Historically the env semantics were "true unless explicitly 'false'", // i.e. the feature defaults to ON. - enableTtsProvidersTab: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB'), - enableWordHighlight: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT'), - enableAudiobookExport: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT'), - enableDocxConversion: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DOCX_CONVERSION'), - enableDestructiveDeleteActions: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'), + enableTtsProvidersTab: booleanFlag(true, 'RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB'), + enableAudiobookExport: booleanFlag(true, 'RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT'), + enableDocxConversion: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DOCX_CONVERSION'), + enableDestructiveDeleteActions: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'), showAllProviderModels: runtimeBoolean(true), } as const satisfies Record>; @@ -114,7 +115,12 @@ async function resolveImplicitDefaultTtsProvider(): Promise .limit(1); return rows[0]?.slug; } catch (error) { - console.warn('[runtime-config] implicit defaultTtsProvider lookup failed:', error); + logDegraded(serverLogger, { + event: 'admin.runtime_config.default_provider_lookup.failed', + msg: 'Implicit defaultTtsProvider lookup failed', + step: 'resolve_implicit_default_provider', + error, + }); return undefined; } } @@ -142,7 +148,12 @@ async function readAllRows(): Promise betterAuth({ requireEmailVerification: false, // Set to true in production async sendResetPassword(data) { // Send an email to the user with a link to reset their password - console.log("Password reset requested for:", data.user.email); + serverLogger.info({ + event: 'auth.password_reset.requested', + userEmailHash: hashForLog(data.user.email), + }, 'Password reset requested'); }, }, user: { @@ -90,7 +95,13 @@ const createAuth = () => betterAuth({ const { deleteUserStorageData } = await import('@/lib/server/user/data-cleanup'); await deleteUserStorageData(user.id, null); } catch (error) { - console.error('[auth] Failed to clean up user storage before deletion:', error); + logDegraded(serverLogger, { + event: 'auth.user_delete.storage_cleanup_failed', + msg: 'Failed to clean up user storage before deletion', + step: 'delete_user_storage', + context: { userIdHash: hashForLog(user.id) }, + error, + }); // Don't throw – allow the user deletion to proceed even if S3 cleanup fails. // Orphaned blobs are preferable to a blocked account deletion. } @@ -139,18 +150,18 @@ const createAuth = () => betterAuth({ }, }, plugins: [ - nextCookies(), // Enable Next.js cookie handling ...(isAnonymousAuthSessionsEnabled() ? [ anonymous({ onLinkAccount: async ({ anonymousUser, newUser }) => { try { // Log when anonymous user links to a real account - console.log("Anonymous user linked to account:", { - anonymousUserId: anonymousUser.user.id, - newUserId: newUser.user.id, - newUserEmail: newUser.user.email, - }); + serverLogger.info({ + event: 'auth.link_account.started', + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + newUserEmailHash: hashForLog(newUser.user.email), + }, 'Anonymous user linked to account'); // Lazy-load heavy modules only when account linking actually happens const [{ rateLimiter }, claimData] = await Promise.all([ @@ -161,9 +172,22 @@ const createAuth = () => betterAuth({ // Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user try { await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id); - console.log(`Successfully transferred rate limit data from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + serverLogger.info({ + event: 'auth.link_account.transfer.rate_limit.succeeded', + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, 'Transferred rate limit data during account linking'); } catch (error) { - console.error("Error transferring rate limit data during account linking:", error); + logDegraded(serverLogger, { + event: 'auth.link_account.transfer.rate_limit.failed', + msg: 'Failed transferring rate limit data during account linking', + step: 'transfer_rate_limit', + context: { + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, + error, + }); // Don't throw here to prevent blocking the account linking process } @@ -171,10 +195,24 @@ const createAuth = () => betterAuth({ try { const transferred = await claimData.transferUserAudiobooks(anonymousUser.user.id, newUser.user.id); if (transferred > 0) { - console.log(`Successfully transferred ${transferred} audiobook(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + serverLogger.info({ + event: 'auth.link_account.transfer.audiobooks.succeeded', + transferred, + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, 'Transferred audiobooks during account linking'); } } catch (error) { - console.error("Error transferring audiobooks during account linking:", error); + logDegraded(serverLogger, { + event: 'auth.link_account.transfer.audiobooks.failed', + msg: 'Failed transferring audiobooks during account linking', + step: 'transfer_audiobooks', + context: { + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, + error, + }); // Don't throw here to prevent blocking the account linking process } @@ -182,10 +220,24 @@ const createAuth = () => betterAuth({ try { const transferred = await claimData.transferUserDocuments(anonymousUser.user.id, newUser.user.id); if (transferred > 0) { - console.log(`Successfully transferred ${transferred} document(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + serverLogger.info({ + event: 'auth.link_account.transfer.documents.succeeded', + transferred, + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, 'Transferred documents during account linking'); } } catch (error) { - console.error("Error transferring documents during account linking:", error); + logDegraded(serverLogger, { + event: 'auth.link_account.transfer.documents.failed', + msg: 'Failed transferring documents during account linking', + step: 'transfer_documents', + context: { + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, + error, + }); // Don't throw here to prevent blocking the account linking process } @@ -193,10 +245,24 @@ const createAuth = () => betterAuth({ try { const transferred = await claimData.transferUserPreferences(anonymousUser.user.id, newUser.user.id); if (transferred > 0) { - console.log(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + serverLogger.info({ + event: 'auth.link_account.transfer.preferences.succeeded', + transferred, + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, 'Transferred preferences during account linking'); } } catch (error) { - console.error("Error transferring preferences during account linking:", error); + logDegraded(serverLogger, { + event: 'auth.link_account.transfer.preferences.failed', + msg: 'Failed transferring preferences during account linking', + step: 'transfer_preferences', + context: { + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, + error, + }); // Don't throw here to prevent blocking the account linking process } @@ -204,14 +270,32 @@ const createAuth = () => betterAuth({ try { const transferred = await claimData.transferUserProgress(anonymousUser.user.id, newUser.user.id); if (transferred > 0) { - console.log(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + serverLogger.info({ + event: 'auth.link_account.transfer.progress.succeeded', + transferred, + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, 'Transferred reading progress during account linking'); } } catch (error) { - console.error("Error transferring reading progress during account linking:", error); + logDegraded(serverLogger, { + event: 'auth.link_account.transfer.progress.failed', + msg: 'Failed transferring reading progress during account linking', + step: 'transfer_progress', + context: { + anonymousUserIdHash: hashForLog(anonymousUser.user.id), + newUserIdHash: hashForLog(newUser.user.id), + }, + error, + }); // Don't throw here to prevent blocking the account linking process } } catch (error) { - console.error("Error in onLinkAccount callback:", error); + logServerError(serverLogger, { + event: 'auth.link_account.failed', + msg: 'onLinkAccount callback failed', + error, + }); // Don't throw here to prevent blocking the account linking process } // Note: Anonymous user will be automatically deleted after this callback completes @@ -219,6 +303,9 @@ const createAuth = () => betterAuth({ }), ] : []), + // Better Auth requires cookie integration plugins last so post-hooks can + // still append Set-Cookie headers that are forwarded to Next.js. + nextCookies(), ], }); diff --git a/src/lib/server/compute/index.ts b/src/lib/server/compute/index.ts new file mode 100644 index 0000000..ac62a56 --- /dev/null +++ b/src/lib/server/compute/index.ts @@ -0,0 +1,18 @@ +import type { ComputeBackend } from '@/lib/server/compute/types'; +import { isWorkerClientConfigAvailable, WorkerComputeBackend } from '@/lib/server/compute/worker'; + +let backendPromise: Promise | null = null; + +export async function getCompute(): Promise { + if (!backendPromise) { + backendPromise = Promise.resolve(new WorkerComputeBackend()).catch((error) => { + backendPromise = null; + throw error; + }); + } + return backendPromise; +} + +export function isComputeAvailable(): boolean { + return isWorkerClientConfigAvailable(); +} diff --git a/src/lib/server/compute/types.ts b/src/lib/server/compute/types.ts new file mode 100644 index 0000000..ffd0951 --- /dev/null +++ b/src/lib/server/compute/types.ts @@ -0,0 +1,36 @@ +import type { + TTSAudioBuffer, + TTSSentenceAlignment, + ParsedPdfDocument, +} from '@openreader/compute-core/types'; +import type { PdfLayoutProgress, WhisperAlignJobBase } from '@openreader/compute-core/api-contracts'; +import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; + +export interface WhisperAlignInput extends WhisperAlignJobBase { + audioBuffer?: TTSAudioBuffer; + audioObjectKey?: string; +} + +export interface WhisperAlignResult { + alignments: TTSSentenceAlignment[]; +} + +export interface PdfLayoutInput { + documentId: string; + namespace?: string | null; + documentObjectKey?: string; + pdfBytes?: ArrayBuffer; + forceToken?: string; + onProgress?: (progress: PdfLayoutProgress) => void | Promise; + onWorkerSnapshot?: (snapshot: WorkerOperationState) => void | Promise; +} + +export type PdfLayoutResult = + | { parsed: ParsedPdfDocument; parsedObjectKey?: never } + | { parsed?: never; parsedObjectKey: string }; + +export interface ComputeBackend { + mode: 'worker'; + alignWords(input: WhisperAlignInput): Promise; + parsePdfLayout(input: PdfLayoutInput): Promise; +} diff --git a/src/lib/server/compute/worker-op-create.ts b/src/lib/server/compute/worker-op-create.ts new file mode 100644 index 0000000..433b68e --- /dev/null +++ b/src/lib/server/compute/worker-op-create.ts @@ -0,0 +1,60 @@ +import { getWorkerClientConfigFromEnv, buildPdfOpKey } from '@/lib/server/compute/worker'; +import type { PdfLayoutInput } from '@/lib/server/compute/types'; +import type { + PdfLayoutJobResult, + WorkerOperationState, +} from '@openreader/compute-core/api-contracts'; + +type CreatePdfWorkerOpInput = + Pick + & { documentObjectKey: string }; + +const CREATE_OP_TIMEOUT_MS = 10_000; + +export async function createOrReusePdfWorkerOperation( + input: CreatePdfWorkerOpInput, +): Promise> { + const cfg = getWorkerClientConfigFromEnv(); + const opKey = buildPdfOpKey({ + documentId: input.documentId, + namespace: input.namespace, + documentObjectKey: input.documentObjectKey, + forceToken: input.forceToken, + }); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CREATE_OP_TIMEOUT_MS); + try { + const res = await fetch(`${cfg.baseUrl}/ops`, { + method: 'POST', + headers: { + Authorization: `Bearer ${cfg.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + kind: 'pdf_layout', + opKey, + payload: { + documentId: input.documentId, + namespace: input.namespace, + documentObjectKey: input.documentObjectKey, + }, + }), + cache: 'no-store', + signal: controller.signal, + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ''); + throw new Error(`Worker op create failed: ${res.status}${detail ? ` ${detail}` : ''}`); + } + + const parsed = await res.json() as WorkerOperationState; + if (!parsed || typeof parsed !== 'object' || typeof parsed.opId !== 'string' || !parsed.opId.trim()) { + throw new Error('Worker op create returned invalid response'); + } + return parsed; + } finally { + clearTimeout(timeout); + } +} diff --git a/src/lib/server/compute/worker-op-state.ts b/src/lib/server/compute/worker-op-state.ts new file mode 100644 index 0000000..dc36e1e --- /dev/null +++ b/src/lib/server/compute/worker-op-state.ts @@ -0,0 +1,83 @@ +import { getWorkerClientConfigFromEnv } from '@/lib/server/compute/worker'; +import type { WorkerOperationState } from '@openreader/compute-core/api-contracts'; +import { serverLogger } from '@/lib/server/logger'; +import { logDegraded } from '@/lib/server/errors/logging'; + +const WORKER_OP_REQUEST_TIMEOUT_MS = 2_500; + +export async function fetchWorkerOperationState( + opId: string | null | undefined, +): Promise | null> { + const normalized = opId?.trim(); + if (!normalized) return null; + + let cfg: { baseUrl: string; token: string }; + try { + cfg = getWorkerClientConfigFromEnv(); + } catch (error) { + logDegraded(serverLogger, { + event: 'compute.worker_op_state.config.invalid', + msg: 'Worker client env missing/invalid', + step: 'read_worker_config', + context: { opId: normalized }, + error, + }); + return null; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), WORKER_OP_REQUEST_TIMEOUT_MS); + + try { + const res = await fetch(`${cfg.baseUrl}/ops/${encodeURIComponent(normalized)}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${cfg.token}`, + Accept: 'application/json', + }, + cache: 'no-store', + signal: controller.signal, + }); + + if (!res.ok) { + const upstreamResponseBody = await res.text().catch(() => ''); + logDegraded(serverLogger, { + event: 'compute.worker_op_state.fetch.failed', + msg: 'Worker op request failed', + step: 'fetch_worker_op', + context: { + opId: normalized, + status: res.status, + upstreamResponseBody, + }, + error: { + name: 'WorkerOpStateFetchFailed', + message: `Worker op request failed with status ${res.status}`, + }, + }); + return null; + } + const parsed = await res.json() as WorkerOperationState; + if (!parsed || typeof parsed !== 'object' || parsed.opId !== normalized) { + logDegraded(serverLogger, { + event: 'compute.worker_op_state.response.invalid', + msg: 'Worker op response invalid', + step: 'validate_worker_op_response', + context: { opId: normalized }, + }); + return null; + } + return parsed; + } catch (error) { + logDegraded(serverLogger, { + event: 'compute.worker_op_state.fetch.error', + msg: 'Worker op request threw', + step: 'fetch_worker_op', + context: { opId: normalized }, + error, + }); + return null; + } finally { + clearTimeout(timeout); + } +} diff --git a/src/lib/server/compute/worker-parse-state.ts b/src/lib/server/compute/worker-parse-state.ts new file mode 100644 index 0000000..c0eaa3a --- /dev/null +++ b/src/lib/server/compute/worker-parse-state.ts @@ -0,0 +1,42 @@ +import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; +import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; + +export function mapWorkerStatusToParseStatus(status: WorkerOperationState['status']): PdfParseStatus { + switch (status) { + case 'queued': + return 'pending'; + case 'running': + return 'running'; + case 'succeeded': + return 'ready'; + case 'failed': + return 'failed'; + default: + return 'pending'; + } +} + +export function snapshotFromWorkerState( + state: WorkerOperationState, +): { parseStatus: PdfParseStatus; parseProgress: PdfParseProgress | null } { + const parseStatus = mapWorkerStatusToParseStatus(state.status); + return { + parseStatus, + parseProgress: parseStatus === 'running' ? (state.progress ?? null) : null, + }; +} + +export function mergeNonReadyParseSnapshot(input: { + parseStatus: PdfParseStatus; + parseProgress: PdfParseProgress | null; + workerState: WorkerOperationState; +}): { parseStatus: PdfParseStatus; parseProgress: PdfParseProgress | null } { + const workerSnapshot = snapshotFromWorkerState(input.workerState); + if (workerSnapshot.parseStatus === 'ready') { + return { + parseStatus: input.parseStatus, + parseProgress: input.parseProgress, + }; + } + return workerSnapshot; +} diff --git a/src/lib/server/compute/worker.ts b/src/lib/server/compute/worker.ts new file mode 100644 index 0000000..6f94501 --- /dev/null +++ b/src/lib/server/compute/worker.ts @@ -0,0 +1,704 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { ComputeBackend, PdfLayoutInput, WhisperAlignInput, WhisperAlignResult } from '@/lib/server/compute/types'; +import { parseSseEventId, parseSsePayload } from '@openreader/compute-core'; +import { getWorkerClientWaitTimeoutMs } from '@openreader/compute-core'; +import { errorToLog, serverLogger } from '@/lib/server/logger'; +import { logDegraded, logServerError } from '@/lib/server/errors/logging'; +import type { + PdfLayoutJobRequest, + PdfLayoutJobResult, + WorkerOperationEvent, + WhisperAlignJobRequest, + WhisperAlignJobResult, + WorkerOperationState, +} from '@openreader/compute-core/api-contracts'; + +class WorkerHttpError extends Error { + status: number; + retryAfterMs: number | null; + + constructor(message: string, status: number, retryAfterMs: number | null = null) { + super(message); + this.name = 'WorkerHttpError'; + this.status = status; + this.retryAfterMs = retryAfterMs; + } +} + +const DEFAULT_RETRIES = 2; +const MAX_LOG_DETAIL_CHARS = 600; +const LOG_EVENTS = new Set([ + 'align.request.failed', + 'align.request.attempt_error', + 'pdf_layout.request.failed', + 'pdf_layout.request.attempt_error', + 'http.request.failed', + 'sse.wait.http_failed', + 'sse.wait.ended_without_terminal', + 'sse.wait.failed', +]); + +type WorkerLogLevel = 'info' | 'warn' | 'error'; + +function truncateForLog(value: string, maxChars = MAX_LOG_DETAIL_CHARS): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}...`; +} + +function logWorker(level: WorkerLogLevel, event: string, fields: Record): void { + if (!LOG_EVENTS.has(event)) return; + const eventName = `compute.worker_client.${event}`; + const msg = `Worker client ${event}`; + if (level === 'error') { + logServerError(serverLogger, { + event: eventName, + msg, + error: fields.error ?? new Error(msg), + context: { + operation: 'compute_worker_client', + ...fields, + }, + normalize: { code: 'COMPUTE_WORKER_CLIENT_EVENT_FAILED', errorClass: 'upstream' }, + }); + return; + } + if (level === 'warn') { + logDegraded(serverLogger, { + event: eventName, + msg, + step: 'worker_client', + context: { + operation: 'compute_worker_client', + ...fields, + }, + error: fields.error, + }); + return; + } + serverLogger.info({ + event: eventName, + operation: 'compute_worker_client', + ...fields, + }, `Worker client ${event}`); +} + +function opSummary(value: unknown): Record { + if (!value || typeof value !== 'object') return {}; + const record = value as Record; + const summary: Record = {}; + if (typeof record.opId === 'string') summary.opId = record.opId; + if (typeof record.status === 'string') summary.status = record.status; + if (typeof record.jobId === 'string') summary.jobId = record.jobId; + if (typeof record.updatedAt === 'string') summary.updatedAt = record.updatedAt; + return summary; +} + +function readRequiredEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required for compute worker client`); + return value; +} + +function normalizeWorkerBaseUrl(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) throw new Error('COMPUTE_WORKER_URL is empty'); + + const withScheme = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed) + ? trimmed + : (/^(localhost|127(?:\.\d{1,3}){3})(:\d+)?(\/|$)/.test(trimmed) + ? `http://${trimmed}` + : `https://${trimmed}`); + + let parsed: URL; + try { + parsed = new URL(withScheme); + } catch { + throw new Error( + `Invalid COMPUTE_WORKER_URL="${raw}". Expected full URL like https://example.com (or http://localhost:4000).`, + ); + } + + parsed.pathname = parsed.pathname.replace(/\/+$/, ''); + return parsed.toString().replace(/\/+$/, ''); +} + +export function getWorkerClientConfigFromEnv(): { baseUrl: string; token: string } { + return { + baseUrl: normalizeWorkerBaseUrl(readRequiredEnv('COMPUTE_WORKER_URL')), + token: readRequiredEnv('COMPUTE_WORKER_TOKEN'), + }; +} + +export function isWorkerClientConfigAvailable(): boolean { + try { + getWorkerClientConfigFromEnv(); + return true; + } catch { + return false; + } +} + +function parseRetryAfterMs(value: string | null): number | null { + if (!value) return null; + const asNum = Number(value); + if (Number.isFinite(asNum)) { + return Math.max(0, Math.floor(asNum * 1000)); + } + const when = Date.parse(value); + if (Number.isNaN(when)) return null; + return Math.max(0, when - Date.now()); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function shouldRetry(error: unknown): boolean { + if (error instanceof WorkerHttpError) { + return error.status === 429 || error.status === 502 || error.status === 503 || error.status === 504; + } + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + return msg.includes('network') || msg.includes('timeout') || msg.includes('fetch failed'); + } + return false; +} + +function isTerminalStatus(status: string): boolean { + return status === 'succeeded' || status === 'failed'; +} + +function sha256Hex(input: string): string { + return createHash('sha256').update(input).digest('hex'); +} + +function buildWhisperOpKey(input: WhisperAlignInput): string { + const cacheKey = input.cacheKey?.trim(); + if (cacheKey) { + return `whisper_align|v1|cache|${cacheKey}|${input.audioObjectKey}`; + } + return [ + 'whisper_align', + 'v1', + input.audioObjectKey, + input.lang ?? '', + sha256Hex(input.text), + ].join('|'); +} + +export function buildPdfOpKey(input: PdfLayoutInput): string { + return [ + 'pdf_layout', + 'v1', + input.documentId, + input.namespace ?? '', + input.documentObjectKey ?? '', + input.forceToken?.trim() || '', + ].join('|'); +} + +type RetryMeta = { + attempt: number, + maxAttempts: number, + willRetry: boolean, + delayMs: number | null, + error: unknown, +}; + +async function withRetries( + attempts: number, + operation: (attempt: number) => Promise, + onAttemptError?: (meta: RetryMeta) => void, +): Promise { + let lastError: unknown = null; + for (let attemptIndex = 0; attemptIndex < attempts; attemptIndex += 1) { + const attempt = attemptIndex + 1; + try { + return await operation(attempt); + } catch (error) { + lastError = error; + const willRetry = attemptIndex < attempts - 1 && shouldRetry(error); + let delayMs: number | null = null; + if (willRetry) { + if (error instanceof WorkerHttpError && typeof error.retryAfterMs === 'number') { + delayMs = error.retryAfterMs; + } else { + delayMs = attempt * 250; + } + } + onAttemptError?.({ + attempt, + maxAttempts: attempts, + willRetry, + delayMs, + error, + }); + if (!willRetry) break; + await sleep(delayMs ?? 0); + } + } + throw lastError instanceof Error ? lastError : new Error('Unknown worker compute failure'); +} + +export class WorkerComputeBackend implements ComputeBackend { + readonly mode = 'worker' as const; + private readonly baseUrl: string; + private readonly token: string; + private readonly waitTimeoutMsByKind: Record<'whisper_align' | 'pdf_layout', number>; + private readonly retries: number; + + constructor() { + const cfg = getWorkerClientConfigFromEnv(); + this.baseUrl = cfg.baseUrl; + this.token = cfg.token; + this.waitTimeoutMsByKind = { + whisper_align: getWorkerClientWaitTimeoutMs('whisper_align'), + pdf_layout: getWorkerClientWaitTimeoutMs('pdf_layout'), + }; + this.retries = DEFAULT_RETRIES; + } + + async alignWords(input: WhisperAlignInput): Promise { + if (!input.audioObjectKey) { + throw new Error('Worker compute alignment requires audioObjectKey'); + } + const payload: WhisperAlignJobRequest = { + text: input.text, + lang: input.lang, + cacheKey: input.cacheKey, + audioObjectKey: input.audioObjectKey, + }; + const traceId = randomUUID(); + const opKey = buildWhisperOpKey(input); + const opKeyHash = sha256Hex(opKey).slice(0, 16); + const startedAt = Date.now(); + logWorker('info', 'align.request.start', { + traceId, + kind: 'whisper_align', + opKeyHash, + audioObjectKey: input.audioObjectKey, + cacheKey: input.cacheKey ?? null, + lang: input.lang ?? null, + textLength: input.text.length, + waitTimeoutMs: this.waitTimeoutMsByKind.whisper_align, + maxRetries: this.retries, + }); + + try { + const result = await withRetries(this.retries, async (attempt) => { + const op = await this.requestJson>('POST', '/ops', { + kind: 'whisper_align', + opKey, + payload, + }, { + traceId, + kind: 'whisper_align', + opKeyHash, + attempt, + }); + + const final = isTerminalStatus(op.status) + ? op + : await this.waitForOperation(op.opId, { + traceId, + kind: 'whisper_align', + opKeyHash, + attempt, + waitTimeoutMs: this.waitTimeoutMsByKind.whisper_align, + }); + + if (final.status !== 'succeeded' || !final.result) { + throw new Error(final.error?.message || 'Whisper worker operation did not complete'); + } + return { alignments: final.result.alignments }; + }, ({ attempt, maxAttempts, willRetry, delayMs, error }) => { + logWorker(willRetry ? 'warn' : 'error', 'align.request.attempt_error', { + traceId, + kind: 'whisper_align', + opKeyHash, + attempt, + maxAttempts, + willRetry, + delayMs, + error: errorToLog(error), + }); + }); + + logWorker('info', 'align.request.succeeded', { + traceId, + kind: 'whisper_align', + opKeyHash, + durationMs: Date.now() - startedAt, + }); + return result; + } catch (error) { + logWorker('error', 'align.request.failed', { + traceId, + kind: 'whisper_align', + opKeyHash, + durationMs: Date.now() - startedAt, + error: errorToLog(error), + }); + throw error; + } + } + + async parsePdfLayout(input: PdfLayoutInput) { + if (!input.documentObjectKey) { + throw new Error('Worker compute PDF layout requires documentObjectKey'); + } + const payload: PdfLayoutJobRequest = { + documentId: input.documentId, + namespace: input.namespace ?? null, + documentObjectKey: input.documentObjectKey, + }; + const traceId = randomUUID(); + const opKey = buildPdfOpKey(input); + const opKeyHash = sha256Hex(opKey).slice(0, 16); + const startedAt = Date.now(); + logWorker('info', 'pdf_layout.request.start', { + traceId, + kind: 'pdf_layout', + opKeyHash, + documentId: input.documentId, + namespace: input.namespace ?? null, + documentObjectKey: input.documentObjectKey, + waitTimeoutMs: this.waitTimeoutMsByKind.pdf_layout, + maxRetries: this.retries, + }); + + try { + const result = await withRetries(this.retries, async (attempt) => { + const op = await this.requestJson>('POST', '/ops', { + kind: 'pdf_layout', + opKey, + payload, + }, { + traceId, + kind: 'pdf_layout', + opKeyHash, + documentId: input.documentId, + attempt, + }); + await input.onWorkerSnapshot?.(op); + + const final = isTerminalStatus(op.status) + ? op + : await this.waitForOperation(op.opId, { + traceId, + kind: 'pdf_layout', + opKeyHash, + documentId: input.documentId, + attempt, + waitTimeoutMs: this.waitTimeoutMsByKind.pdf_layout, + onSnapshot: (snapshot) => { + void input.onWorkerSnapshot?.(snapshot); + if (snapshot.progress) { + void input.onProgress?.(snapshot.progress); + } + }, + }); + + if (final.status !== 'succeeded' || !final.result) { + throw new Error(final.error?.message || 'PDF layout worker operation did not complete'); + } + if (final.result.parsedObjectKey) { + return { parsedObjectKey: final.result.parsedObjectKey }; + } + if (final.result.parsed) { + return { parsed: final.result.parsed }; + } + throw new Error('PDF layout worker operation completed without parsed output'); + }, ({ attempt, maxAttempts, willRetry, delayMs, error }) => { + logWorker(willRetry ? 'warn' : 'error', 'pdf_layout.request.attempt_error', { + traceId, + kind: 'pdf_layout', + opKeyHash, + documentId: input.documentId, + attempt, + maxAttempts, + willRetry, + delayMs, + error: errorToLog(error), + }); + }); + + logWorker('info', 'pdf_layout.request.succeeded', { + traceId, + kind: 'pdf_layout', + opKeyHash, + documentId: input.documentId, + durationMs: Date.now() - startedAt, + }); + return result; + } catch (error) { + logWorker('error', 'pdf_layout.request.failed', { + traceId, + kind: 'pdf_layout', + opKeyHash, + documentId: input.documentId, + durationMs: Date.now() - startedAt, + error: errorToLog(error), + }); + throw error; + } + } + + private async requestJson( + method: 'GET' | 'POST', + path: string, + body?: unknown, + context: Record = {}, + ): Promise { + const startedAt = Date.now(); + const traceId = typeof context.traceId === 'string' ? context.traceId : randomUUID(); + logWorker('info', 'http.request.start', { + ...context, + traceId, + method, + path, + }); + const res = await fetch(`${this.baseUrl}${path}`, { + method, + headers: { + Authorization: `Bearer ${this.token}`, + 'x-openreader-trace-id': traceId, + ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}), + }, + ...(method === 'POST' ? { body: JSON.stringify(body ?? {}) } : {}), + }); + + if (!res.ok) { + const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after')); + const upstreamResponseBody = await res.text().catch(() => ''); + const requestError = new WorkerHttpError( + `Worker request failed (${method} ${path}): ${res.status}${upstreamResponseBody ? ` ${upstreamResponseBody}` : ''}`, + res.status, + retryAfterMs, + ); + logWorker(res.status >= 500 ? 'warn' : 'error', 'http.request.failed', { + ...context, + traceId, + method, + path, + status: res.status, + retryAfterMs, + durationMs: Date.now() - startedAt, + upstreamResponseBody: truncateForLog(upstreamResponseBody), + error: errorToLog(requestError), + }); + throw requestError; + } + + const parsed = await res.json() as T; + const operationSummary = opSummary(parsed); + logWorker('info', 'http.request.succeeded', { + ...context, + traceId, + method, + path, + httpStatus: res.status, + durationMs: Date.now() - startedAt, + ...operationSummary, + }); + return parsed; + } + + private async waitForOperation( + opId: string, + context: Record & { + waitTimeoutMs?: number; + onSnapshot?: (snapshot: WorkerOperationState) => void + } = {}, + ): Promise> { + const waitTimeoutMs = typeof context.waitTimeoutMs === 'number' + ? context.waitTimeoutMs + : this.waitTimeoutMsByKind.whisper_align; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), waitTimeoutMs); + const startedAt = Date.now(); + const traceId = typeof context.traceId === 'string' ? context.traceId : randomUUID(); + logWorker('info', 'sse.wait.start', { + ...context, + traceId, + opId, + waitTimeoutMs, + }); + + try { + let latest: WorkerOperationState | null = null; + let lastEventId: number | null = null; + let eventCount = 0; + let lastStatus: string | null = null; + + while (!controller.signal.aborted) { + const query = lastEventId && lastEventId > 0 + ? `?sinceEventId=${encodeURIComponent(String(lastEventId))}` + : ''; + const res = await fetch(`${this.baseUrl}/ops/${encodeURIComponent(opId)}/events${query}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.token}`, + Accept: 'text/event-stream', + 'x-openreader-trace-id': traceId, + ...(lastEventId && lastEventId > 0 ? { 'Last-Event-ID': String(lastEventId) } : {}), + }, + signal: controller.signal, + }); + + if (!res.ok) { + const retryAfterMs = parseRetryAfterMs(res.headers.get('retry-after')); + const upstreamResponseBody = await res.text().catch(() => ''); + const requestError = new WorkerHttpError( + `Worker request failed (GET /ops/${encodeURIComponent(opId)}/events): ${res.status}${upstreamResponseBody ? ` ${upstreamResponseBody}` : ''}`, + res.status, + retryAfterMs, + ); + logWorker(res.status >= 500 ? 'warn' : 'error', 'sse.wait.http_failed', { + ...context, + traceId, + opId, + status: res.status, + retryAfterMs, + durationMs: Date.now() - startedAt, + upstreamResponseBody: truncateForLog(upstreamResponseBody), + error: errorToLog(requestError), + }); + throw requestError; + } + + if (!res.body) { + logWorker('error', 'sse.wait.no_body', { + ...context, + traceId, + opId, + durationMs: Date.now() - startedAt, + }); + throw new Error('Worker operation stream response has no body'); + } + + logWorker('info', 'sse.wait.connected', { + ...context, + traceId, + opId, + status: res.status, + }); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + while (true) { + const frameEnd = buffer.indexOf('\n\n'); + if (frameEnd < 0) break; + const frame = buffer.slice(0, frameEnd); + buffer = buffer.slice(frameEnd + 2); + + const eventId = parseSseEventId(frame); + if (eventId && eventId > 0) { + lastEventId = eventId; + } + const payload = parseSsePayload(frame); + if (!payload) continue; + + let event: WorkerOperationEvent | WorkerOperationState; + try { + event = JSON.parse(payload) as WorkerOperationEvent | WorkerOperationState; + } catch { + logWorker('warn', 'sse.wait.json_parse_skipped', { + ...context, + traceId, + opId, + sample: truncateForLog(payload), + }); + continue; + } + + const snapshot: WorkerOperationState = ( + event && typeof event === 'object' && 'snapshot' in event + ? (event as WorkerOperationEvent).snapshot + : (event as WorkerOperationState) + ); + if (!snapshot || typeof snapshot !== 'object') continue; + if (snapshot.opId !== opId) continue; + + eventCount += 1; + latest = snapshot; + context.onSnapshot?.(snapshot); + if (snapshot.status !== lastStatus) { + lastStatus = snapshot.status; + logWorker('info', 'sse.wait.status', { + ...context, + traceId, + opId, + eventCount, + status: snapshot.status, + jobId: snapshot.jobId ?? null, + updatedAt: snapshot.updatedAt ?? null, + }); + } + if (isTerminalStatus(snapshot.status)) { + logWorker('info', 'sse.wait.terminal', { + ...context, + traceId, + opId, + eventCount, + status: snapshot.status, + durationMs: Date.now() - startedAt, + }); + return snapshot; + } + } + } + + if (latest && isTerminalStatus(latest.status)) { + logWorker('info', 'sse.wait.terminal_after_close', { + ...context, + traceId, + opId, + eventCount, + status: latest.status, + durationMs: Date.now() - startedAt, + }); + return latest; + } + + if (controller.signal.aborted) break; + await sleep(250); + } + + if (latest && isTerminalStatus(latest.status)) { + return latest; + } + + logWorker('error', 'sse.wait.ended_without_terminal', { + ...context, + traceId, + opId, + eventCount, + latestStatus: latest?.status ?? null, + durationMs: Date.now() - startedAt, + error: { + name: 'WorkerSseWaitEndedWithoutTerminal', + message: `Operation stream ended before terminal state for op ${opId}`, + }, + }); + throw new Error(`Operation stream ended before terminal state for op ${opId}`); + } catch (error) { + logWorker('error', 'sse.wait.failed', { + ...context, + traceId, + opId, + durationMs: Date.now() - startedAt, + error: errorToLog(error), + }); + throw error; + } finally { + clearTimeout(timeout); + } + } +} diff --git a/src/lib/server/documents/blobstore.ts b/src/lib/server/documents/blobstore.ts index d99f444..5ecae17 100644 --- a/src/lib/server/documents/blobstore.ts +++ b/src/lib/server/documents/blobstore.ts @@ -80,6 +80,26 @@ export function documentKey(id: string, namespace: string | null): string { return `${cfg.prefix}/documents_v1/${nsSegment}${id}`; } +export function documentParsedKey(id: string, namespace: string | null): string { + if (!isValidDocumentId(id)) { + throw new Error(`Invalid document id: ${id}`); + } + const cfg = getS3Config(); + const ns = sanitizeNamespace(namespace); + const nsSegment = ns ? `ns/${ns}/` : ''; + return `${cfg.prefix}/documents_v1/parsed_v1/${nsSegment}${id}.json`; +} + +function legacyDocumentParsedKey(id: string, namespace: string | null): string { + if (!isValidDocumentId(id)) { + throw new Error(`Invalid document id: ${id}`); + } + const cfg = getS3Config(); + const ns = sanitizeNamespace(namespace); + const nsSegment = ns ? `ns/${ns}/` : ''; + return `${cfg.prefix}/documents_v1/${nsSegment}${id}/parsed.v1.json`; +} + export async function presignPut( id: string, contentType: string, @@ -94,7 +114,6 @@ export async function presignPut( Bucket: cfg.bucket, Key: key, ContentType: normalizedType, - IfNoneMatch: '*', ServerSideEncryption: 'AES256', }); const url = await getSignedUrl(client, command, { expiresIn: 60 * 5 }); @@ -103,7 +122,6 @@ export async function presignPut( url, headers: { 'Content-Type': normalizedType, - 'If-None-Match': '*', 'x-amz-server-side-encryption': 'AES256', }, }; @@ -169,6 +187,49 @@ export async function getDocumentBlobStream(id: string, namespace: string | null return res.Body as DocumentBlobBody; } +export async function getParsedDocumentBlob(id: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3ProxyClient(); + const key = documentParsedKey(id, namespace); + const res = await client.send( + new GetObjectCommand({ + Bucket: cfg.bucket, + Key: key, + }), + ); + return bodyToBuffer(res.Body); +} + +export async function getParsedDocumentBlobByKey(key: string): Promise { + const cfg = getS3Config(); + const client = getS3ProxyClient(); + const trimmed = key.trim(); + if (!trimmed) throw new Error('Parsed document key is empty'); + const res = await client.send( + new GetObjectCommand({ + Bucket: cfg.bucket, + Key: trimmed, + }), + ); + return bodyToBuffer(res.Body); +} + +export async function putParsedDocumentBlob(id: string, body: Buffer, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3ProxyClient(); + const key = documentParsedKey(id, namespace); + await client.send( + new PutObjectCommand({ + Bucket: cfg.bucket, + Key: key, + Body: body, + ContentType: 'application/json', + ServerSideEncryption: 'AES256', + }), + ); + return key; +} + export async function presignGet( id: string, namespace: string | null, @@ -202,7 +263,6 @@ export async function putDocumentBlob( Key: key, Body: body, ContentType: contentType, - IfNoneMatch: '*', ServerSideEncryption: 'AES256', }), ); @@ -212,7 +272,13 @@ export async function deleteDocumentBlob(id: string, namespace: string | null): const cfg = getS3Config(); const client = getS3ProxyClient(); const key = documentKey(id, namespace); + const parsedKey = documentParsedKey(id, namespace); + const legacyParsedKey = legacyDocumentParsedKey(id, namespace); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key })); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: parsedKey })).catch(() => undefined); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: legacyParsedKey })).catch(() => undefined); + await deleteDocumentPrefix(`${key}/`).catch(() => undefined); } export function isMissingBlobError(error: unknown): boolean { diff --git a/src/lib/server/documents/parse-state-healing.ts b/src/lib/server/documents/parse-state-healing.ts new file mode 100644 index 0000000..5824e37 --- /dev/null +++ b/src/lib/server/documents/parse-state-healing.ts @@ -0,0 +1,33 @@ +import { and, eq } from 'drizzle-orm'; +import { getComputeOpStaleMs } from '@openreader/compute-core'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { + isDocumentParseStateStale, + parseDocumentParseState, + stringifyDocumentParseState, + type DocumentParseState, +} from '@/lib/server/documents/parse-state'; + +export async function healStaleDocumentParseState(input: { + documentId: string; + userId: string; + state: DocumentParseState; +}): Promise { + const staleMs = getComputeOpStaleMs(); + if (!isDocumentParseStateStale(input.state, staleMs)) return input.state; + + const nextState = parseDocumentParseState(stringifyDocumentParseState({ + status: 'failed', + progress: null, + updatedAt: Date.now(), + error: `Parse state stale for more than ${staleMs}ms; marked failed for retry`, + })); + + await db + .update(documents) + .set({ parseState: stringifyDocumentParseState(nextState) }) + .where(and(eq(documents.id, input.documentId), eq(documents.userId, input.userId))); + + return nextState; +} diff --git a/src/lib/server/documents/parse-state.ts b/src/lib/server/documents/parse-state.ts new file mode 100644 index 0000000..431e20c --- /dev/null +++ b/src/lib/server/documents/parse-state.ts @@ -0,0 +1,88 @@ +import type { PdfParseProgress, PdfParseStatus } from '@/types/parsed-pdf'; + +export interface DocumentParseState { + status: PdfParseStatus; + progress?: PdfParseProgress | null; + updatedAt?: number; + error?: string | null; + opId?: string; + jobId?: string; +} + +export function isInProgressParseStatus(status: PdfParseStatus): status is 'pending' | 'running' { + return status === 'pending' || status === 'running'; +} + +export function isDocumentParseStateStale( + state: DocumentParseState, + staleMs: number, + nowMs = Date.now(), +): boolean { + if (!isInProgressParseStatus(state.status)) return false; + if (!Number.isFinite(staleMs) || staleMs <= 0) return false; + const updatedAt = Number(state.updatedAt ?? 0); + if (!Number.isFinite(updatedAt) || updatedAt <= 0) return false; + return (nowMs - updatedAt) > staleMs; +} + +export function normalizeParseStatus(status: string | null | undefined): PdfParseStatus { + if (status === 'pending' || status === 'running' || status === 'ready' || status === 'failed') { + return status; + } + return 'pending'; +} + +function normalizeProgress(progress: unknown): PdfParseProgress | null { + if (!progress || typeof progress !== 'object') return null; + const rec = progress as Record; + const totalPages = Number(rec.totalPages ?? 0); + if (!Number.isFinite(totalPages) || totalPages <= 0) return null; + const pagesParsedRaw = Number(rec.pagesParsed ?? 0); + const pagesParsed = Math.max(0, Math.min(totalPages, Number.isFinite(pagesParsedRaw) ? pagesParsedRaw : 0)); + const phase = rec.phase === 'merge' ? 'merge' : 'infer'; + const currentPageRaw = Number(rec.currentPage ?? pagesParsed); + const currentPage = Number.isFinite(currentPageRaw) && currentPageRaw > 0 + ? Math.max(1, Math.min(totalPages, currentPageRaw)) + : undefined; + return { + totalPages, + pagesParsed, + currentPage, + phase, + }; +} + +export function parseDocumentParseState(value: string | null): DocumentParseState { + if (!value) return { status: 'pending', progress: null }; + try { + const parsed = JSON.parse(value) as Record; + const status = normalizeParseStatus(typeof parsed.status === 'string' ? parsed.status : null); + const progress = normalizeProgress(parsed.progress); + const updatedAtRaw = Number(parsed.updatedAt ?? 0); + const updatedAt = Number.isFinite(updatedAtRaw) && updatedAtRaw > 0 ? updatedAtRaw : undefined; + const error = typeof parsed.error === 'string' ? parsed.error : null; + const opId = typeof parsed.opId === 'string' ? parsed.opId : undefined; + const jobId = typeof parsed.jobId === 'string' ? parsed.jobId : undefined; + return { + status, + progress, + ...(typeof updatedAt === 'number' ? { updatedAt } : {}), + ...(error ? { error } : {}), + ...(opId ? { opId } : {}), + ...(jobId ? { jobId } : {}), + }; + } catch { + return { status: 'pending', progress: null }; + } +} + +export function stringifyDocumentParseState(state: DocumentParseState): string { + return JSON.stringify({ + status: normalizeParseStatus(state.status), + progress: state.progress ?? null, + updatedAt: typeof state.updatedAt === 'number' ? state.updatedAt : Date.now(), + ...(state.error ? { error: state.error } : {}), + ...(state.opId ? { opId: state.opId } : {}), + ...(state.jobId ? { jobId: state.jobId } : {}), + }); +} diff --git a/src/lib/server/documents/previews-blobstore.ts b/src/lib/server/documents/previews-blobstore.ts index c69f86b..aa2d973 100644 --- a/src/lib/server/documents/previews-blobstore.ts +++ b/src/lib/server/documents/previews-blobstore.ts @@ -10,10 +10,10 @@ import { getS3Client, getS3Config, getS3ProxyClient } from '@/lib/server/storage const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; const DEFAULT_NAMESPACE_SEGMENT = '_default'; -export const DOCUMENT_PREVIEW_VARIANT = 'card-240-jpeg'; -export const DOCUMENT_PREVIEW_FILE_NAME = 'card-240.jpg'; +export const DOCUMENT_PREVIEW_VARIANT = 'card-400-jpeg'; +export const DOCUMENT_PREVIEW_FILE_NAME = 'card-400.jpg'; export const DOCUMENT_PREVIEW_CONTENT_TYPE = 'image/jpeg'; -export const DOCUMENT_PREVIEW_WIDTH = 240; +export const DOCUMENT_PREVIEW_WIDTH = 400; function sanitizeNamespace(namespace: string | null): string | null { if (!namespace) return null; diff --git a/src/lib/server/documents/previews-render.ts b/src/lib/server/documents/previews-render.ts index 87415c5..c32bbce 100644 --- a/src/lib/server/documents/previews-render.ts +++ b/src/lib/server/documents/previews-render.ts @@ -1,7 +1,8 @@ import path from 'path'; -import { DOMMatrix, Path2D, createCanvas, loadImage } from '@napi-rs/canvas'; +import { createCanvas, loadImage } from '@napi-rs/canvas'; import JSZip from 'jszip'; import { XMLParser } from 'fast-xml-parser'; +import { renderPage } from '@openreader/compute-core'; export type RenderedDocumentPreview = { bytes: Buffer; @@ -9,26 +10,7 @@ export type RenderedDocumentPreview = { height: number; }; -type CanvasAndContext = { - canvas: unknown; - context: unknown; -}; - -type NodeCanvasFactory = { - create: (width: number, height: number) => CanvasAndContext; - reset: (target: CanvasAndContext, width: number, height: number) => void; - destroy: (target: CanvasAndContext) => void; -}; - -function ensureNodeCanvasGlobals(): void { - const g = globalThis as Record; - if (typeof g.DOMMatrix === 'undefined') { - g.DOMMatrix = DOMMatrix as unknown; - } - if (typeof g.Path2D === 'undefined') { - g.Path2D = Path2D as unknown; - } -} +const PREVIEW_JPEG_QUALITY = 82; function normalizeTargetWidth(targetWidth: number): number { if (!Number.isFinite(targetWidth) || targetWidth <= 0) return 240; @@ -99,9 +81,11 @@ async function renderImageBytesToJpeg(imageBytes: Buffer, targetWidth: number): const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, outWidth, outHeight); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; ctx.drawImage(bitmap, 0, 0, outWidth, outHeight); return { - bytes: canvas.toBuffer('image/jpeg', 82), + bytes: canvas.toBuffer('image/jpeg', PREVIEW_JPEG_QUALITY), width: outWidth, height: outHeight, }; @@ -150,109 +134,18 @@ export async function renderEpubCoverToJpeg(sourceBytes: Buffer, targetWidth: nu } export async function renderPdfFirstPageToJpeg(sourceBytes: Buffer, targetWidth: number): Promise { - ensureNodeCanvasGlobals(); - - type PdfViewport = { - width: number; - height: number; - }; - type PdfPage = { - getViewport: (params: { scale: number }) => PdfViewport; - render: (params: { - canvasContext: unknown; - viewport: PdfViewport; - intent: 'display'; - canvasFactory?: NodeCanvasFactory; - }) => { promise: Promise }; - }; - - // pdfjs-dist legacy build works in Node and avoids relying on DOM workers. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - pdfjs-dist legacy build path has no dedicated TypeScript declaration. - const pdfjs = (await import('pdfjs-dist/legacy/build/pdf.mjs')) as { - getDocument: (options: Record) => { - promise: Promise<{ getPage: (n: number) => Promise; destroy: () => Promise }>; - destroy: () => Promise; - }; - GlobalWorkerOptions?: { workerSrc?: string; workerPort?: unknown }; - }; - - if (pdfjs.GlobalWorkerOptions) { - pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs'; - pdfjs.GlobalWorkerOptions.workerPort = null; - } - - const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts'); - const standardFontDataUrl = `${standardFontDir.replace(/\/?$/, '/')}`; - - const nodeCanvasFactory: NodeCanvasFactory = { - create: (width, height) => { - const canvas = createCanvas(width, height); - const context = canvas.getContext('2d'); - return { canvas, context }; - }, - reset: (target, width, height) => { - const canvas = target.canvas as { width: number; height: number }; - canvas.width = width; - canvas.height = height; - }, - destroy: (target) => { - const canvas = target.canvas as { width: number; height: number }; - canvas.width = 0; - canvas.height = 0; - }, - }; - - class PdfNodeCanvasFactory { - create(width: number, height: number): CanvasAndContext { - return nodeCanvasFactory.create(width, height); - } - reset(target: CanvasAndContext, width: number, height: number): void { - nodeCanvasFactory.reset(target, width, height); - } - destroy(target: CanvasAndContext): void { - nodeCanvasFactory.destroy(target); - } - } - - const loadingTask = pdfjs.getDocument({ - data: new Uint8Array(sourceBytes), - useWorkerFetch: false, - standardFontDataUrl, - CanvasFactory: PdfNodeCanvasFactory, - isEvalSupported: false, + const width = normalizeTargetWidth(targetWidth); + const isolatedBytes = Uint8Array.from(sourceBytes); + const rendered = await renderPage({ + pdfBytes: isolatedBytes.buffer as ArrayBuffer, + pageNumber: 1, + targetWidth: width, + format: 'jpeg', + jpegQuality: PREVIEW_JPEG_QUALITY, }); - const pdf = await loadingTask.promise; - - try { - const page = await pdf.getPage(1); - const viewport = page.getViewport({ scale: 1 }); - const target = normalizeTargetWidth(targetWidth); - const scale = target / viewport.width; - const scaledViewport = page.getViewport({ scale }); - - const outWidth = Math.max(1, Math.floor(scaledViewport.width)); - const outHeight = Math.max(1, Math.floor(scaledViewport.height)); - const canvas = createCanvas(outWidth, outHeight); - const ctx = canvas.getContext('2d'); - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, outWidth, outHeight); - - const renderTask = page.render({ - canvasContext: ctx, - viewport: scaledViewport, - intent: 'display', - canvasFactory: nodeCanvasFactory, - }); - await renderTask.promise; - - return { - bytes: canvas.toBuffer('image/jpeg', 82), - width: outWidth, - height: outHeight, - }; - } finally { - await pdf.destroy().catch(() => undefined); - await loadingTask.destroy().catch(() => undefined); - } + return { + bytes: rendered.image, + width: rendered.width, + height: rendered.height, + }; } diff --git a/src/lib/server/documents/previews.ts b/src/lib/server/documents/previews.ts index e73e382..44699c6 100644 --- a/src/lib/server/documents/previews.ts +++ b/src/lib/server/documents/previews.ts @@ -1,10 +1,9 @@ import { randomUUID } from 'crypto'; -import { mkdtemp, rm, writeFile } from 'fs/promises'; -import { tmpdir } from 'os'; -import { join } from 'path'; import { and, eq, inArray, lt, or, sql } from 'drizzle-orm'; import { db } from '@/db'; import { documentPreviews } from '@/db/schema'; +import { serverLogger } from '@/lib/server/logger'; +import { logServerError } from '@/lib/server/errors/logging'; import { DOCUMENT_PREVIEW_CONTENT_TYPE, DOCUMENT_PREVIEW_VARIANT, @@ -290,28 +289,17 @@ async function markPreviewFailed(docId: string, namespaceKey: string, error: unk } async function generateAndStorePreview(doc: PreviewSourceDocument, namespace: string | null): Promise { - let workDir: string | null = null; - try { - const sourceBytes = await getDocumentBlob(doc.id, namespace); - workDir = await mkdtemp(join(tmpdir(), 'openreader-preview-')); - const sourcePath = join(workDir, 'source'); - await writeFile(sourcePath, sourceBytes); - - let rendered; - if (doc.type === 'pdf') { - rendered = await renderPdfFirstPageToJpeg(sourceBytes, DOCUMENT_PREVIEW_WIDTH); - } else if (doc.type === 'epub') { - rendered = await renderEpubCoverToJpeg(sourceBytes, DOCUMENT_PREVIEW_WIDTH); - } else { - throw new Error(`Unsupported preview type: ${doc.type}`); - } - - await putDocumentPreviewBuffer(doc.id, rendered.bytes, namespace); - } finally { - if (workDir) { - await rm(workDir, { recursive: true, force: true }).catch(() => {}); - } + const sourceBytes = await getDocumentBlob(doc.id, namespace); + let rendered; + if (doc.type === 'pdf') { + rendered = await renderPdfFirstPageToJpeg(sourceBytes, DOCUMENT_PREVIEW_WIDTH); + } else if (doc.type === 'epub') { + rendered = await renderEpubCoverToJpeg(sourceBytes, DOCUMENT_PREVIEW_WIDTH); + } else { + throw new Error(`Unsupported preview type: ${doc.type}`); } + // Hot path: overwrite current variant key directly to avoid prefix list/delete latency. + await putDocumentPreviewBuffer(doc.id, rendered.bytes, namespace); } function pendingResult(status: PreviewStatus, lastError: string | null): EnsureDocumentPreviewResult { @@ -388,7 +376,16 @@ export async function ensureDocumentPreview(doc: PreviewSourceDocument, namespac eTag: head.eTag, }); } catch (error) { - console.error(`[document-previews] Preview generation failed for ${doc.id} (type=${doc.type}):`, error); + logServerError(serverLogger, { + event: 'documents.preview.generate.failed', + msg: 'Preview generation failed', + error, + context: { + documentId: doc.id, + documentType: doc.type, + }, + normalize: { code: 'DOCUMENT_PREVIEW_GENERATE_FAILED', errorClass: 'storage' }, + }); await markPreviewFailed(doc.id, namespaceKey, error); } } diff --git a/src/lib/server/errors/README.md b/src/lib/server/errors/README.md new file mode 100644 index 0000000..7227445 --- /dev/null +++ b/src/lib/server/errors/README.md @@ -0,0 +1,60 @@ +# Server Error Contract + +This module is the centralized server error contract for API routes, background jobs, and server libraries. + +## Canonical Shapes + +- App error contract: `ServerAppError` in `contract.ts` +- Normalization entrypoint: `normalizeServerError(error, ctx?)` +- API response mapping: `toApiErrorBody(...)` + `toHttpStatus(...)` +- Log helpers: `logServerError(...)` and `logDegraded(...)` + +## Classification Policy + +`ServerErrorClass` defaults: + +- `validation` -> `400`, `retryable=false` +- `auth` -> `401`, `retryable=false` +- `permission` -> `403`, `retryable=false` +- `upstream` -> `502`, `retryable=true` +- `storage` -> `503`, `retryable=true` +- `db` -> `500`, `retryable=true` +- `timeout` -> `504`, `retryable=true` +- `unknown` -> `500`, `retryable=false` + +## Code Naming + +App error codes use low-cardinality uppercase snake case: `DOMAIN_ACTION_REASON`. + +Examples: + +- `AUDIOBOOK_CHAPTER_PROCESS_FAILED` +- `DOCUMENT_PREVIEW_GENERATE_FAILED` +- `USER_EXPORT_AUTH_NOT_INITIALIZED` +- `UNKNOWN_SERVER_ERROR` + +## Route Contract + +Terminal route failures should use `errorResponse(...)` from `next-response.ts`. + +Response body shape: + +- `error` +- optional `errorCode` +- optional `retryable` +- optional safe `details` + +## Logging Contract + +Failure logs: + +- `event` +- `msg` +- nested `error` (from `errorToLog(...)`) +- optional `error.code` for app classification + +Degraded warnings: + +- `degraded: true` +- `step` or `fallbackPath` +- `event` + `msg` diff --git a/src/lib/server/errors/catalog.ts b/src/lib/server/errors/catalog.ts new file mode 100644 index 0000000..c7febb0 --- /dev/null +++ b/src/lib/server/errors/catalog.ts @@ -0,0 +1,65 @@ +import type { ServerErrorClass } from '@/lib/server/errors/contract'; + +export type ServerErrorCatalogEntry = { + code: string; + errorClass: ServerErrorClass; + httpStatus: number; + retryable: boolean; + ownerDomain: string; + description: string; +}; + +export const SERVER_ERROR_CATALOG: ServerErrorCatalogEntry[] = [ + { + code: 'UPSTREAM_RATE_LIMIT', + errorClass: 'upstream', + httpStatus: 429, + retryable: true, + ownerDomain: 'tts', + description: 'Upstream provider rate limited the request.', + }, + { + code: 'UPSTREAM_TTS_ERROR', + errorClass: 'upstream', + httpStatus: 502, + retryable: true, + ownerDomain: 'tts', + description: 'Upstream TTS provider returned a 5xx/transport failure.', + }, + { + code: 'DOCUMENTS_BLOB_MISSING', + errorClass: 'storage', + httpStatus: 404, + retryable: false, + ownerDomain: 'documents', + description: 'Document blob was not found in backing storage.', + }, + { + code: 'COMPUTE_WORKER_UNAVAILABLE', + errorClass: 'upstream', + httpStatus: 503, + retryable: true, + ownerDomain: 'compute', + description: 'Compute worker endpoint unavailable or misconfigured.', + }, + { + code: 'AUTH_UNAUTHORIZED', + errorClass: 'auth', + httpStatus: 401, + retryable: false, + ownerDomain: 'auth', + description: 'Request requires authentication and no valid session was present.', + }, + { + code: 'UNKNOWN_SERVER_ERROR', + errorClass: 'unknown', + httpStatus: 500, + retryable: false, + ownerDomain: 'server', + description: 'Fallback classification for uncategorized server exceptions.', + }, +]; + +export const SERVER_ERROR_CATALOG_BY_CODE = new Map( + SERVER_ERROR_CATALOG.map((entry) => [entry.code, entry] as const), +); diff --git a/src/lib/server/errors/contract.ts b/src/lib/server/errors/contract.ts new file mode 100644 index 0000000..6917bd1 --- /dev/null +++ b/src/lib/server/errors/contract.ts @@ -0,0 +1,203 @@ +export type ServerErrorClass = + | 'validation' + | 'auth' + | 'permission' + | 'upstream' + | 'storage' + | 'db' + | 'timeout' + | 'unknown'; + +type ServerErrorDefaults = { + httpStatus: number; + retryable: boolean; +}; + +const CLASS_DEFAULTS: Record = { + validation: { httpStatus: 400, retryable: false }, + auth: { httpStatus: 401, retryable: false }, + permission: { httpStatus: 403, retryable: false }, + upstream: { httpStatus: 502, retryable: true }, + storage: { httpStatus: 503, retryable: true }, + db: { httpStatus: 500, retryable: true }, + timeout: { httpStatus: 504, retryable: true }, + unknown: { httpStatus: 500, retryable: false }, +}; + +const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{2,}$/; + +export type ServerErrorContext = { + code?: string; + message?: string; + errorClass?: ServerErrorClass; + httpStatus?: number; + retryable?: boolean; + details?: Record; +}; + +export class ServerAppError extends Error { + readonly code: string; + readonly errorClass: ServerErrorClass; + readonly httpStatus: number; + readonly retryable: boolean; + readonly details?: Record; + + constructor(input: { + code: string; + message: string; + errorClass: ServerErrorClass; + httpStatus?: number; + retryable?: boolean; + details?: Record; + cause?: unknown; + }) { + super(input.message, input.cause !== undefined ? { cause: input.cause } : undefined); + this.name = 'ServerAppError'; + this.code = input.code; + this.errorClass = input.errorClass; + const defaults = CLASS_DEFAULTS[input.errorClass]; + this.httpStatus = input.httpStatus ?? defaults.httpStatus; + this.retryable = input.retryable ?? defaults.retryable; + if (input.details) this.details = input.details; + } +} + +export function isServerAppError(value: unknown): value is ServerAppError { + return value instanceof ServerAppError; +} + +function normalizeFromErrorInstance(error: Error): { + message: string; + code?: string; + errorClass?: ServerErrorClass; + httpStatus?: number; + retryable?: boolean; + details?: Record; +} { + const rec = error as Error & { + code?: unknown; + errorClass?: unknown; + httpStatus?: unknown; + retryable?: unknown; + details?: unknown; + }; + const code = typeof rec.code === 'string' && rec.code.trim() ? rec.code.trim() : undefined; + const errorClass = typeof rec.errorClass === 'string' && rec.errorClass in CLASS_DEFAULTS + ? rec.errorClass as ServerErrorClass + : undefined; + const httpStatus = typeof rec.httpStatus === 'number' && Number.isFinite(rec.httpStatus) + ? Math.max(400, Math.min(599, Math.floor(rec.httpStatus))) + : undefined; + const retryable = typeof rec.retryable === 'boolean' ? rec.retryable : undefined; + const details = rec.details && typeof rec.details === 'object' + ? rec.details as Record + : undefined; + return { + message: error.message || 'Internal server error', + ...(code ? { code } : {}), + ...(errorClass ? { errorClass } : {}), + ...(httpStatus ? { httpStatus } : {}), + ...(typeof retryable === 'boolean' ? { retryable } : {}), + ...(details ? { details } : {}), + }; +} + +export type NormalizedServerError = { + code: string; + message: string; + errorClass: ServerErrorClass; + httpStatus: number; + retryable: boolean; + details?: Record; + cause?: unknown; +}; + +export function normalizeServerError( + error: unknown, + ctx: ServerErrorContext = {}, +): NormalizedServerError { + if (isServerAppError(error)) { + return { + code: error.code, + message: error.message, + errorClass: error.errorClass, + httpStatus: error.httpStatus, + retryable: error.retryable, + ...(error.details ? { details: error.details } : {}), + ...(error.cause !== undefined ? { cause: error.cause } : {}), + }; + } + + const normalizedFromThrowable = error instanceof Error ? normalizeFromErrorInstance(error) : { + message: String(error), + }; + + const errorClass = ctx.errorClass + ?? normalizedFromThrowable.errorClass + ?? 'unknown'; + const defaults = CLASS_DEFAULTS[errorClass]; + + const rawCode = ctx.code + ?? normalizedFromThrowable.code + ?? 'UNKNOWN_SERVER_ERROR'; + const code = ERROR_CODE_PATTERN.test(rawCode) ? rawCode : 'UNKNOWN_SERVER_ERROR'; + + return { + code, + message: ctx.message + ?? normalizedFromThrowable.message + ?? 'Internal server error', + errorClass, + httpStatus: ctx.httpStatus + ?? normalizedFromThrowable.httpStatus + ?? defaults.httpStatus, + retryable: ctx.retryable + ?? normalizedFromThrowable.retryable + ?? defaults.retryable, + ...(ctx.details ? { details: ctx.details } : normalizedFromThrowable.details ? { details: normalizedFromThrowable.details } : {}), + ...(error instanceof Error && error.cause !== undefined ? { cause: error.cause } : {}), + }; +} + +export function toApiErrorBody( + input: NormalizedServerError, + options: { + includeErrorCode?: boolean; + includeRetryable?: boolean; + includeDetails?: boolean; + } = {}, +): { + error: string; + errorCode?: string; + retryable?: boolean; + details?: Record; +} { + const includeErrorCode = options.includeErrorCode ?? true; + const includeRetryable = options.includeRetryable ?? true; + const includeDetails = options.includeDetails ?? true; + return { + error: input.message, + ...(includeErrorCode ? { errorCode: input.code } : {}), + ...(includeRetryable ? { retryable: input.retryable } : {}), + ...(includeDetails && input.details ? { details: input.details } : {}), + }; +} + +export function toHttpStatus(input: NormalizedServerError): number { + return input.httpStatus; +} + +export function createServerAppError(input: { + code: string; + message: string; + errorClass: ServerErrorClass; + httpStatus?: number; + retryable?: boolean; + details?: Record; + cause?: unknown; +}): ServerAppError { + if (!ERROR_CODE_PATTERN.test(input.code)) { + throw new Error(`Invalid ServerAppError code "${input.code}" (expected ${ERROR_CODE_PATTERN.toString()})`); + } + return new ServerAppError(input); +} diff --git a/src/lib/server/errors/logging.ts b/src/lib/server/errors/logging.ts new file mode 100644 index 0000000..0785134 --- /dev/null +++ b/src/lib/server/errors/logging.ts @@ -0,0 +1,48 @@ +import type { ServerLogger } from '@/lib/server/logger'; +import { errorToLog } from '@/lib/server/logger'; +import { normalizeServerError, type NormalizedServerError, type ServerErrorContext } from '@/lib/server/errors/contract'; + +type LogContext = Record; + +export function logServerError( + logger: ServerLogger, + input: { + event: string; + error: unknown; + msg: string; + context?: LogContext; + normalize?: ServerErrorContext; + }, +): NormalizedServerError { + const normalized = normalizeServerError(input.error, input.normalize); + logger.error({ + event: input.event, + ...(input.context || {}), + error: { + ...errorToLog(input.error), + code: normalized.code, + }, + }, `${input.msg}`); + return normalized; +} + +export function logDegraded( + logger: ServerLogger, + input: { + event: string; + msg: string; + step?: string; + fallbackPath?: string; + context?: LogContext; + error?: unknown; + }, +): void { + logger.warn({ + event: input.event, + degraded: true, + ...(input.step ? { step: input.step } : {}), + ...(input.fallbackPath ? { fallbackPath: input.fallbackPath } : {}), + ...(input.context || {}), + ...(input.error !== undefined ? { error: errorToLog(input.error) } : {}), + }, `${input.msg}`); +} diff --git a/src/lib/server/errors/next-response.ts b/src/lib/server/errors/next-response.ts new file mode 100644 index 0000000..599d8c0 --- /dev/null +++ b/src/lib/server/errors/next-response.ts @@ -0,0 +1,90 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import type { ServerLogger } from '@/lib/server/logger'; +import { normalizeServerError, toApiErrorBody, toHttpStatus, type NormalizedServerError, type ServerErrorContext } from '@/lib/server/errors/contract'; +import { logServerError } from '@/lib/server/errors/logging'; + +export type ErrorResponseOptions = { + logger?: ServerLogger; + event?: string; + msg?: string; + normalize?: ServerErrorContext; + apiErrorMessage?: string; + includeErrorCode?: boolean; + includeRetryable?: boolean; + includeDetails?: boolean; +}; + +export function errorResponse( + error: unknown, + options: ErrorResponseOptions = {}, +): NextResponse { + const normalized = normalizeServerError(error, options.normalize); + if (options.logger && options.event && options.msg) { + logServerError(options.logger, { + event: options.event, + error, + msg: options.msg, + context: {}, + normalize: options.normalize, + }); + } + const apiError = options.apiErrorMessage ?? normalized.message; + const body = toApiErrorBody( + { ...normalized, message: apiError }, + { + includeErrorCode: options.includeErrorCode ?? true, + includeRetryable: options.includeRetryable ?? true, + includeDetails: options.includeDetails ?? false, + }, + ); + return NextResponse.json(body, { status: toHttpStatus(normalized) }); +} + +export type ErrorBoundaryContext = { + route: string; + logger: ServerLogger; + event: string; + msg: string; + normalize?: ServerErrorContext; + apiErrorMessage?: string; +}; + +export function withErrorBoundary( + handler: (...args: Args) => Promise, + ctx: ErrorBoundaryContext, +): (...args: Args) => Promise { + return async (...args: Args): Promise => { + try { + return await handler(...args); + } catch (error) { + return errorResponse(error, { + logger: ctx.logger, + event: ctx.event, + msg: ctx.msg, + normalize: ctx.normalize, + apiErrorMessage: ctx.apiErrorMessage, + }); + } + }; +} + +export function asRouteErrorContext(input: { + request: NextRequest; + route: string; + method?: string; + requestId?: string; +}): Record { + return { + route: input.route, + method: input.method ?? input.request.method, + ...(input.requestId ? { requestId: input.requestId } : {}), + }; +} + +export function normalizeForResponseOnly( + error: unknown, + normalize?: ServerErrorContext, +): NormalizedServerError { + return normalizeServerError(error, normalize); +} diff --git a/src/lib/server/jobs/user-pdf-layout-job.ts b/src/lib/server/jobs/user-pdf-layout-job.ts new file mode 100644 index 0000000..341fd55 --- /dev/null +++ b/src/lib/server/jobs/user-pdf-layout-job.ts @@ -0,0 +1,434 @@ +import { and, eq, inArray, isNull } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { documentKey, putParsedDocumentBlob } from '@/lib/server/documents/blobstore'; +import { serverLogger } from '@/lib/server/logger'; +import { logDegraded, logServerError } from '@/lib/server/errors/logging'; +import { + parseDocumentParseState, + stringifyDocumentParseState, + type DocumentParseState, +} from '@/lib/server/documents/parse-state'; +import { getCompute } from '@/lib/server/compute'; +import { clearTtsSegmentCache } from '@/lib/server/tts/segments-cache'; +import { UNCLAIMED_USER_ID } from '@/lib/server/storage/docstore-legacy'; +import type { + PdfLayoutJobBase, + PdfLayoutJobResult, + PdfLayoutProgress, + WorkerOperationState, +} from '@openreader/compute-core/api-contracts'; + +type UserPdfLayoutJobRequest = PdfLayoutJobBase & { + userId: string; + forceToken?: string; +}; + +const running = new Set(); + +const FOLLOWER_WAIT_TIMEOUT_MS = 180_000; +const FOLLOWER_POLL_MS = 1_200; +const PROGRESS_DB_THROTTLE_MS = 10_000; + +type ParseRow = { + userId: string; + parseState: string | null; + parsedJsonKey: string | null; +}; + +function keyFor(input: UserPdfLayoutJobRequest): string { + const forceToken = input.forceToken?.trim(); + if (forceToken) { + return `force:${input.documentId}:${input.namespace || ''}:${forceToken}`; + } + return `shared:${input.documentId}:${input.namespace || ''}`; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function inferNamespaceFromUnclaimedUserId(userId: string): string | null { + const prefix = `${UNCLAIMED_USER_ID}::`; + if (!userId.startsWith(prefix)) return null; + const ns = userId.slice(prefix.length).trim(); + return ns || null; +} + +function rowMatchesScope(row: ParseRow, input: UserPdfLayoutJobRequest): boolean { + const inferredNamespace = inferNamespaceFromUnclaimedUserId(row.userId); + if (inferredNamespace !== null) { + return inferredNamespace === (input.namespace ?? null); + } + + // Regular (non-unclaimed) users are only shared in the non-namespaced path. + if (!input.namespace) return true; + + // In namespaced contexts with regular users, avoid touching other users' + // rows since namespace is not persisted on document rows. + return row.userId === input.userId; +} + +async function loadScopedRows(input: UserPdfLayoutJobRequest): Promise { + const rows = (await db + .select({ + userId: documents.userId, + parseState: documents.parseState, + parsedJsonKey: documents.parsedJsonKey, + }) + .from(documents) + .where(eq(documents.id, input.documentId))) as ParseRow[]; + + return rows.filter((row) => rowMatchesScope(row, input)); +} + +function isReadyRow(row: ParseRow): row is ParseRow & { parsedJsonKey: string } { + if (!row.parsedJsonKey) return false; + return parseDocumentParseState(row.parseState).status === 'ready'; +} + +function userIdsFromRows(rows: ParseRow[]): string[] { + return Array.from(new Set(rows.map((row) => row.userId).filter(Boolean))); +} + +function parseStateMatchCondition(expected: string | null) { + return expected === null ? isNull(documents.parseState) : eq(documents.parseState, expected); +} + +async function updateParseStateForUsers(input: { + documentId: string; + userIds: string[]; + parseState: string; + parsedJsonKey?: string | null; +}): Promise { + if (input.userIds.length === 0) return; + + await db + .update(documents) + .set({ + parseState: input.parseState, + ...(typeof input.parsedJsonKey === 'string' || input.parsedJsonKey === null + ? { parsedJsonKey: input.parsedJsonKey } + : {}), + }) + .where( + and( + eq(documents.id, input.documentId), + input.userIds.length === 1 + ? eq(documents.userId, input.userIds[0]) + : inArray(documents.userId, input.userIds), + ), + ); +} + +async function syncCallerToSharedResult(input: UserPdfLayoutJobRequest): Promise { + const deadline = Date.now() + FOLLOWER_WAIT_TIMEOUT_MS; + + while (Date.now() < deadline) { + const rows = await loadScopedRows(input); + const ready = rows.find(isReadyRow); + if (ready) { + const readyState: DocumentParseState = { + status: 'ready', + progress: null, + updatedAt: Date.now(), + }; + await updateParseStateForUsers({ + documentId: input.documentId, + userIds: [input.userId], + parseState: stringifyDocumentParseState(readyState), + parsedJsonKey: ready.parsedJsonKey, + }); + return; + } + + const statuses = rows.map((row) => parseDocumentParseState(row.parseState)); + const hasInFlight = statuses.some((state) => state.status === 'pending' || state.status === 'running'); + const failedState = statuses.find((state) => state.status === 'failed'); + if (failedState && !hasInFlight) { + const nextFailedState: DocumentParseState = { + status: 'failed', + progress: null, + updatedAt: Date.now(), + ...(failedState.error ? { error: failedState.error } : {}), + }; + await updateParseStateForUsers({ + documentId: input.documentId, + userIds: [input.userId], + parseState: stringifyDocumentParseState(nextFailedState), + }); + return; + } + + await sleep(FOLLOWER_POLL_MS); + } +} + +export async function parsePdfJob(input: UserPdfLayoutJobRequest): Promise { + const key = keyFor(input); + if (running.has(key)) { + if (!input.forceToken?.trim()) { + await syncCallerToSharedResult(input); + } + return; + } + running.add(key); + + try { + const scopedRows = await loadScopedRows(input); + const scopedUserIds = userIdsFromRows(scopedRows); + + // Non-force jobs can short-circuit by reusing existing ready output from + // any row in the same document scope. + if (!input.forceToken?.trim()) { + const ready = scopedRows.find(isReadyRow); + if (ready) { + const readyState: DocumentParseState = { + status: 'ready', + progress: null, + updatedAt: Date.now(), + }; + await updateParseStateForUsers({ + documentId: input.documentId, + userIds: [input.userId], + parseState: stringifyDocumentParseState(readyState), + parsedJsonKey: ready.parsedJsonKey, + }); + return; + } + } + + const coordinator = [...scopedRows].sort((a, b) => a.userId.localeCompare(b.userId))[0]; + if (!coordinator) return; + + const runningStateData: DocumentParseState = { + status: 'running', + progress: null, + updatedAt: Date.now(), + }; + const runningState = stringifyDocumentParseState(runningStateData); + + const claimRows = (await db + .update(documents) + .set({ + parseState: runningState, + }) + .where( + and( + eq(documents.id, input.documentId), + eq(documents.userId, coordinator.userId), + parseStateMatchCondition(coordinator.parseState), + ), + ) + .returning({ userId: documents.userId })) as Array<{ userId: string }>; + + const claimed = claimRows.some((row) => row.userId === coordinator.userId); + if (!claimed) { + if (!input.forceToken?.trim()) { + await syncCallerToSharedResult(input); + } + return; + } + + const cohortUserIds = Array.from(new Set([...scopedUserIds, input.userId, coordinator.userId])); + await updateParseStateForUsers({ + documentId: input.documentId, + userIds: cohortUserIds, + parseState: runningState, + }); + + const compute = await getCompute(); + let activeOpId: string | undefined; + let activeJobId: string | undefined; + let lastProgressWriteAt = 0; + let lastSnapshotWriteAt = 0; + let lastSnapshotStatus: 'pending' | 'running' | null = null; + let lastSnapshotOpId: string | undefined; + let lastSnapshotJobId: string | undefined; + + const persistRunningState = async (state: DocumentParseState): Promise => { + await updateParseStateForUsers({ + documentId: input.documentId, + userIds: cohortUserIds, + parseState: stringifyDocumentParseState(state), + }); + }; + + const onWorkerSnapshot = async (snapshot: WorkerOperationState): Promise => { + if (snapshot.opId) activeOpId = snapshot.opId; + if (snapshot.jobId) activeJobId = snapshot.jobId; + + const mappedStatus = snapshot.status === 'queued' + ? 'pending' + : snapshot.status === 'running' + ? 'running' + : null; + if (!mappedStatus) return; + + const now = Date.now(); + const forceWrite = ( + mappedStatus !== lastSnapshotStatus + || activeOpId !== lastSnapshotOpId + || activeJobId !== lastSnapshotJobId + ); + if (!forceWrite && (now - lastSnapshotWriteAt) < PROGRESS_DB_THROTTLE_MS) { + return; + } + + const nextState: DocumentParseState = { + status: mappedStatus, + progress: snapshot.progress ?? null, + updatedAt: now, + ...(activeOpId ? { opId: activeOpId } : {}), + ...(activeJobId ? { jobId: activeJobId } : {}), + }; + await persistRunningState(nextState); + lastSnapshotWriteAt = now; + lastSnapshotStatus = mappedStatus; + lastSnapshotOpId = activeOpId; + lastSnapshotJobId = activeJobId; + }; + + const writeProgress = async (progress: PdfLayoutProgress): Promise => { + const now = Date.now(); + if ((now - lastProgressWriteAt) < PROGRESS_DB_THROTTLE_MS && progress.pagesParsed < progress.totalPages) { + return; + } + lastProgressWriteAt = now; + + const runningProgressState: DocumentParseState = { + status: 'running', + progress: { + totalPages: progress.totalPages, + pagesParsed: progress.pagesParsed, + currentPage: progress.currentPage, + phase: progress.phase, + }, + updatedAt: Date.now(), + ...(activeOpId ? { opId: activeOpId } : {}), + ...(activeJobId ? { jobId: activeJobId } : {}), + }; + await persistRunningState(runningProgressState); + }; + + const layout = await compute.parsePdfLayout({ + documentId: input.documentId, + namespace: input.namespace, + documentObjectKey: documentKey(input.documentId, input.namespace), + forceToken: input.forceToken, + onProgress: writeProgress, + onWorkerSnapshot, + }); + + let parsedJsonKey = layout.parsedObjectKey ?? null; + if (!parsedJsonKey) { + if (!layout.parsed) throw new Error('Compute backend did not return parsed result'); + const parsedJson = Buffer.from(JSON.stringify(layout.parsed)); + parsedJsonKey = await putParsedDocumentBlob(input.documentId, parsedJson, input.namespace); + } + + const finalScopedRows = await loadScopedRows(input); + const finalUserIds = userIdsFromRows(finalScopedRows); + const readyState: DocumentParseState = { + status: 'ready', + progress: null, + updatedAt: Date.now(), + }; + const readyUserIds = finalUserIds.length > 0 ? finalUserIds : cohortUserIds; + await updateParseStateForUsers({ + documentId: input.documentId, + userIds: readyUserIds, + parseState: stringifyDocumentParseState(readyState), + parsedJsonKey, + }); + + // Best-effort cache invalidation should not block parse readiness. + for (const userId of readyUserIds) { + void clearTtsSegmentCache({ + userId, + documentId: input.documentId, + readerType: 'pdf', + }).then((cleared) => { + if (cleared.warning) { + logDegraded(serverLogger, { + event: 'documents.parse.cache_invalidation.warning', + msg: 'Parse cache invalidation warning', + step: 'clear_tts_segment_cache', + context: { + documentId: input.documentId, + userId, + warning: cleared.warning, + }, + }); + } + }).catch((cacheError) => { + logDegraded(serverLogger, { + event: 'documents.parse.cache_invalidation.failed', + msg: 'Parse cache invalidation failed', + step: 'clear_tts_segment_cache', + context: { + documentId: input.documentId, + userId, + }, + error: cacheError, + }); + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const parseStatus = 'failed'; + try { + const scopedRows = await loadScopedRows(input); + const scopedUserIds = userIdsFromRows(scopedRows); + const failedState: DocumentParseState = { + status: 'failed', + progress: null, + updatedAt: Date.now(), + error: message, + }; + const failedUserIds = scopedUserIds.length > 0 ? scopedUserIds : [input.userId]; + await updateParseStateForUsers({ + documentId: input.documentId, + userIds: failedUserIds, + parseState: stringifyDocumentParseState(failedState), + }); + } catch (statusError) { + logServerError(serverLogger, { + event: 'documents.parse.status_write.failed', + msg: 'Failed to write parse status', + error: statusError, + context: { + documentId: input.documentId, + parseStatus, + }, + normalize: { code: 'DOCUMENT_PARSE_STATUS_WRITE_FAILED', errorClass: 'db' }, + }); + } + logServerError(serverLogger, { + event: 'documents.parse.job.failed', + msg: 'Parse job failed', + error, + context: { + documentId: input.documentId, + parseStatus, + }, + normalize: { code: 'DOCUMENT_PARSE_JOB_FAILED', errorClass: 'upstream' }, + }); + } finally { + running.delete(key); + } +} + +export function enqueueParsePdfJob(input: UserPdfLayoutJobRequest): void { + Promise.resolve() + .then(() => parsePdfJob(input)) + .catch((error) => { + logServerError(serverLogger, { + event: 'documents.parse.job.uncaught_error', + msg: 'Parse job uncaught error', + error, + context: { documentId: input.documentId }, + normalize: { code: 'DOCUMENT_PARSE_JOB_UNCAUGHT_ERROR', errorClass: 'unknown' }, + }); + }); +} diff --git a/src/lib/server/jobs/user-whisper-align-job.ts b/src/lib/server/jobs/user-whisper-align-job.ts new file mode 100644 index 0000000..afe0127 --- /dev/null +++ b/src/lib/server/jobs/user-whisper-align-job.ts @@ -0,0 +1,25 @@ +import { getCompute } from '@/lib/server/compute'; +import type { WhisperAlignJobRequest } from '@openreader/compute-core/api-contracts'; +import type { TTSSentenceAlignment } from '@/types/tts'; + +export type UserWhisperAlignJobRequest = WhisperAlignJobRequest & { + sentenceIndex?: number; +}; + +export async function userWhisperAlignJob(input: UserWhisperAlignJobRequest): Promise { + const compute = await getCompute(); + const { alignments } = await compute.alignWords({ + audioObjectKey: input.audioObjectKey, + text: input.text, + cacheKey: input.cacheKey, + lang: input.lang, + }); + + const first = alignments[0]; + if (!first) return null; + + if (typeof input.sentenceIndex === 'number' && Number.isFinite(input.sentenceIndex)) { + return { ...first, sentenceIndex: input.sentenceIndex }; + } + return first; +} diff --git a/src/lib/server/logger.ts b/src/lib/server/logger.ts new file mode 100644 index 0000000..cacb6dc --- /dev/null +++ b/src/lib/server/logger.ts @@ -0,0 +1,146 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { NextRequest } from 'next/server'; +import pino, { type Logger } from 'pino'; +import pinoPretty from 'pino-pretty'; + +export type ServerLogger = Logger; +export type ServerLogLevel = 'error' | 'warn' | 'info'; + +export type LoggedError = { + name: string; + message: string; + code?: string; + stack?: string; + cause?: LoggedError | { message: string }; +}; + +const LOG_FORMAT = process.env.LOG_FORMAT?.trim().toLowerCase() || 'pretty'; +const LOG_LEVEL = process.env.LOG_LEVEL?.trim() || 'info'; + +function buildLoggerConfig(): pino.LoggerOptions { + return { + level: LOG_LEVEL, + base: undefined, + timestamp: pino.stdTimeFunctions.isoTime, + formatters: { + level(label) { + return { level: label }; + }, + }, + }; +} + +function createServerLogger(): ServerLogger { + const config = buildLoggerConfig(); + if (LOG_FORMAT === 'json') { + return pino(config); + } + + const prettyStream = pinoPretty({ + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + errorLikeObjectKeys: ['error'], + customPrettifiers: { + error(value) { + if (!value || typeof value !== 'object') return String(value); + const error = value as Record; + const lines: string[] = []; + if (typeof error.message === 'string' && error.message) lines.push(`error.message: ${error.message}`); + if (typeof error.code === 'string' && error.code) lines.push(`error.code: ${error.code}`); + if (typeof error.name === 'string' && error.name) lines.push(`error.name: ${error.name}`); + if (typeof error.stack === 'string' && error.stack) lines.push(`error.stack:\n${error.stack}`); + if (error.cause !== undefined) { + const causeText = typeof error.cause === 'object' + ? JSON.stringify(error.cause) + : String(error.cause); + lines.push(`error.cause: ${causeText}`); + } + if (lines.length > 0) return lines.join('\n'); + return JSON.stringify(error); + }, + }, + }); + return pino(config, prettyStream); +} + +export const serverLogger: ServerLogger = createServerLogger(); + +function normalizeErrorCode(code: unknown): string | undefined { + if (typeof code === 'string' && code.trim()) return code.trim(); + if (typeof code === 'number' && Number.isFinite(code)) return String(code); + return undefined; +} + +function serializeErrorCause(cause: unknown, depth: number): LoggedError | { message: string } { + if (depth >= 3) { + return { message: String(cause) }; + } + if (cause instanceof Error) { + return errorToLog(cause, depth + 1); + } + if (cause && typeof cause === 'object') { + const rec = cause as Record; + const message = typeof rec.message === 'string' ? rec.message : String(cause); + const name = typeof rec.name === 'string' && rec.name ? rec.name : 'ErrorCause'; + const code = normalizeErrorCode(rec.code); + const stack = typeof rec.stack === 'string' ? rec.stack : undefined; + const nestedCause = rec.cause !== undefined ? serializeErrorCause(rec.cause, depth + 1) : undefined; + return { + name, + message, + ...(code ? { code } : {}), + ...(stack ? { stack } : {}), + ...(nestedCause ? { cause: nestedCause } : {}), + }; + } + return { message: String(cause) }; +} + +export function errorToLog(error: unknown, depth = 0): LoggedError { + if (error instanceof Error) { + const rec = error as Error & { code?: unknown; cause?: unknown }; + const code = normalizeErrorCode(rec.code); + const cause = rec.cause !== undefined ? serializeErrorCause(rec.cause, depth) : undefined; + return { + name: error.name || 'Error', + message: error.message || 'Unknown error', + ...(code ? { code } : {}), + ...(error.stack ? { stack: error.stack } : {}), + ...(cause ? { cause } : {}), + }; + } + return { + name: 'NonErrorThrowable', + message: String(error), + }; +} + +export function hashForLog(value: string | null | undefined): string | null { + const normalized = typeof value === 'string' ? value.trim() : ''; + if (!normalized) return null; + return createHash('sha256').update(normalized).digest('hex').slice(0, 12); +} + +export function getRequestId(request: NextRequest): string { + const fromHeader = request.headers.get('x-request-id') + || request.headers.get('x-vercel-id'); + const normalized = fromHeader?.trim(); + return normalized || randomUUID(); +} + +export function createRequestLogger(input: { + route: string; + request: NextRequest; + requestId?: string; + fields?: Record; +}): { logger: ServerLogger; requestId: string } { + const requestId = input.requestId || getRequestId(input.request); + const logger = serverLogger.child({ + route: input.route, + method: input.request.method, + requestId, + ...(input.fields || {}), + }); + return { logger, requestId }; +} diff --git a/src/lib/server/rate-limit/rate-limiter.ts b/src/lib/server/rate-limit/rate-limiter.ts index cf96e47..053cb8d 100644 --- a/src/lib/server/rate-limit/rate-limiter.ts +++ b/src/lib/server/rate-limit/rate-limiter.ts @@ -3,6 +3,8 @@ import { userTtsChars } from '@/db/schema'; import { isAuthEnabled } from '@/lib/server/auth/config'; import { eq, and, lt, sql } from 'drizzle-orm'; import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; +import { serverLogger } from '@/lib/server/logger'; +import { logDegraded } from '@/lib/server/errors/logging'; function readPositiveIntEnv(name: string, fallback: number): number { const raw = process.env[name]; @@ -10,7 +12,16 @@ function readPositiveIntEnv(name: string, fallback: number): number { const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) { - console.warn(`[rate-limiter] Invalid ${name}=${raw}; using default ${fallback}`); + logDegraded(serverLogger, { + event: 'rate_limit.config.invalid_env', + msg: 'Invalid rate limiter env value; using default', + step: 'read_limit_env', + context: { + envVar: name, + envValue: raw, + fallbackValue: fallback, + }, + }); return fallback; } diff --git a/src/lib/server/runtime-config.ts b/src/lib/server/runtime-config.ts index b31eee6..87889d8 100644 --- a/src/lib/server/runtime-config.ts +++ b/src/lib/server/runtime-config.ts @@ -6,8 +6,11 @@ import { type RuntimeConfigKey, type RuntimeConfigSource, } from '@/lib/server/admin/settings'; +import { isComputeAvailable } from '@/lib/server/compute'; -export type ResolvedRuntimeConfig = RuntimeConfig; +export type ResolvedRuntimeConfig = RuntimeConfig & { + computeAvailable: boolean; +}; function assertServerRuntime(caller: string): void { if (typeof window !== 'undefined') { @@ -23,7 +26,11 @@ function assertServerRuntime(caller: string): void { export async function getResolvedRuntimeConfig(): Promise { assertServerRuntime('getResolvedRuntimeConfig'); await ensureAdminSeed(); - return getRuntimeConfig(); + const values = await getRuntimeConfig(); + return { + ...values, + computeAvailable: isComputeAvailable(), + }; } export async function getResolvedRuntimeConfigWithSources(): Promise<{ diff --git a/src/lib/server/storage/s3.ts b/src/lib/server/storage/s3.ts index df482ab..70b63d3 100644 --- a/src/lib/server/storage/s3.ts +++ b/src/lib/server/storage/s3.ts @@ -90,6 +90,8 @@ export function getS3Client(): S3Client { region: config.region, endpoint: config.endpoint, forcePathStyle: config.forcePathStyle, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, @@ -111,6 +113,8 @@ export function getS3ProxyClient(): S3Client { region: config.region, endpoint: loopbackEndpoint(config.endpoint), forcePathStyle: config.forcePathStyle, + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, diff --git a/src/lib/server/tts/segments-cache.ts b/src/lib/server/tts/segments-cache.ts new file mode 100644 index 0000000..bad4b1f --- /dev/null +++ b/src/lib/server/tts/segments-cache.ts @@ -0,0 +1,89 @@ +import { and, eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; +import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore'; +import type { ReaderType } from '@/types/user-state'; +import { serverLogger } from '@/lib/server/logger'; +import { logDegraded } from '@/lib/server/errors/logging'; + +type ClearTtsSegmentCacheInput = { + userId: string; + documentId: string; + documentVersion?: number; + readerType?: ReaderType; +}; + +export type ClearTtsSegmentCacheResult = { + deletedSegments: number; + requestedAudioObjects: number; + deletedAudioObjects: number; + warning?: string; +}; + +export async function clearTtsSegmentCache( + input: ClearTtsSegmentCacheInput, +): Promise { + const conditions = [ + eq(ttsSegmentEntries.userId, input.userId), + eq(ttsSegmentEntries.documentId, input.documentId), + ]; + + if (typeof input.documentVersion === 'number' && Number.isFinite(input.documentVersion)) { + conditions.push(eq(ttsSegmentEntries.documentVersion, Math.floor(input.documentVersion))); + } + if (input.readerType) { + conditions.push(eq(ttsSegmentEntries.readerType, input.readerType)); + } + + const rows = (await db + .select({ + segmentId: ttsSegmentVariants.segmentId, + audioKey: ttsSegmentVariants.audioKey, + }) + .from(ttsSegmentVariants) + .innerJoin( + ttsSegmentEntries, + and( + eq(ttsSegmentEntries.segmentEntryId, ttsSegmentVariants.segmentEntryId), + eq(ttsSegmentEntries.userId, ttsSegmentVariants.userId), + ), + ) + .where(and(...conditions))) as Array<{ segmentId: string; audioKey: string | null }>; + + await db.delete(ttsSegmentEntries).where(and(...conditions)); + + const audioKeys = rows + .map((row) => row.audioKey) + .filter((key): key is string => Boolean(key)); + const uniqueAudioKeys = Array.from(new Set(audioKeys)); + + let deletedAudioObjects = 0; + let warning: string | undefined; + if (uniqueAudioKeys.length > 0) { + try { + deletedAudioObjects = await deleteTtsSegmentAudioObjects(uniqueAudioKeys); + if (deletedAudioObjects < uniqueAudioKeys.length) { + warning = `Deleted ${deletedAudioObjects} of ${uniqueAudioKeys.length} audio objects.`; + } + } catch (error) { + warning = error instanceof Error ? error.message : 'Failed deleting some audio objects'; + logDegraded(serverLogger, { + event: 'tts.segments.cache.audio_cleanup_failed', + msg: 'Failed clearing some TTS segment audio objects', + step: 'delete_tts_audio_objects', + context: { + documentId: input.documentId, + userId: input.userId, + }, + error, + }); + } + } + + return { + deletedSegments: rows.length, + requestedAudioObjects: uniqueAudioKeys.length, + deletedAudioObjects, + ...(warning ? { warning } : {}), + }; +} diff --git a/src/lib/server/tts/segments.ts b/src/lib/server/tts/segments.ts index 9da16d2..d1bb3c7 100644 --- a/src/lib/server/tts/segments.ts +++ b/src/lib/server/tts/segments.ts @@ -81,6 +81,9 @@ export function normalizeLocator(locator: TTSSegmentLocator | undefined): TTSSeg return { readerType: 'pdf', page: Math.max(1, Math.floor(locator.page)), + ...(typeof locator.blockId === 'string' && locator.blockId.trim() + ? { blockId: locator.blockId.trim() } + : {}), }; } if (locator.readerType === 'html') { diff --git a/src/lib/server/tts/voice-resolution.ts b/src/lib/server/tts/voice-resolution.ts index be936c7..d42ab3e 100644 --- a/src/lib/server/tts/voice-resolution.ts +++ b/src/lib/server/tts/voice-resolution.ts @@ -1,4 +1,6 @@ import { LRUCache } from 'lru-cache'; +import { serverLogger } from '@/lib/server/logger'; +import { logServerError } from '@/lib/server/errors/logging'; import { resolveProviderModels, type ReplicateVoiceInputKey, @@ -192,7 +194,12 @@ async function fetchReplicateOpenApiSchema(apiKey: string, model: string): Promi if (error instanceof DOMException && error.name === 'AbortError') { return null; } - console.error('Error fetching Replicate model schema:', error); + logServerError(serverLogger, { + event: 'tts.voice_resolution.replicate_schema_fetch.failed', + msg: 'Failed fetching Replicate model schema', + error, + normalize: { code: 'TTS_VOICE_RESOLUTION_REPLICATE_SCHEMA_FETCH_FAILED', errorClass: 'upstream' }, + }); return null; } finally { clearTimeout(timeoutId); @@ -283,7 +290,12 @@ async function fetchDeepinfraVoices(apiKey: string): Promise { if (error instanceof DOMException && error.name === 'AbortError') { return []; } - console.error('Error fetching Deepinfra voices:', error); + logServerError(serverLogger, { + event: 'tts.voice_resolution.deepinfra_voices_fetch.failed', + msg: 'Failed fetching Deepinfra voices', + error, + normalize: { code: 'TTS_VOICE_RESOLUTION_DEEPINFRA_FETCH_FAILED', errorClass: 'upstream' }, + }); return []; } finally { clearTimeout(timeoutId); @@ -315,7 +327,11 @@ async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise ? data.voices : null; } catch { - console.log('Custom endpoint does not support voices, using defaults'); + serverLogger.info({ + event: 'tts.voice_resolution.custom_endpoint.voices_unsupported', + degraded: true, + fallbackPath: 'provider_default_voices', + }, 'Custom endpoint does not support voices, using defaults'); return null; } finally { clearTimeout(timeoutId); diff --git a/src/lib/server/user/data-cleanup.ts b/src/lib/server/user/data-cleanup.ts index 29e39e1..43f18b2 100644 --- a/src/lib/server/user/data-cleanup.ts +++ b/src/lib/server/user/data-cleanup.ts @@ -13,6 +13,8 @@ import { deleteDocumentPreviewArtifacts } from '@/lib/server/documents/previews- import { deleteDocumentPreviewRows } from '@/lib/server/documents/previews'; import { audiobookPrefix, deleteAudiobookPrefix } from '@/lib/server/audiobooks/blobstore'; import { deleteTtsSegmentPrefix } from '@/lib/server/tts/segments-blobstore'; +import { hashForLog, serverLogger } from '@/lib/server/logger'; +import { logDegraded } from '@/lib/server/errors/logging'; type DocumentRow = { id: string }; type AudiobookRow = { id: string }; @@ -49,13 +51,31 @@ export async function deleteUserStorageData( await deleteDocumentBlob(doc.id, namespace); docsDeleted++; } catch (error) { - console.error(`[user-data-cleanup] Failed to delete document blob ${doc.id}:`, error); + logDegraded(serverLogger, { + event: 'user.data_cleanup.document_blob_delete.failed', + msg: 'Failed to delete document blob', + step: 'delete_document_blob', + context: { + documentId: doc.id, + userIdHash: hashForLog(userId), + }, + error, + }); } try { await deleteDocumentPreviewArtifacts(doc.id, namespace); } catch (error) { - console.error(`[user-data-cleanup] Failed to delete preview for ${doc.id}:`, error); + logDegraded(serverLogger, { + event: 'user.data_cleanup.document_preview_delete.failed', + msg: 'Failed to delete preview artifacts', + step: 'delete_document_preview_artifacts', + context: { + documentId: doc.id, + userIdHash: hashForLog(userId), + }, + error, + }); } } @@ -63,7 +83,16 @@ export async function deleteUserStorageData( try { await deleteDocumentPreviewRows(doc.id, namespace); } catch (error) { - console.error(`[user-data-cleanup] Failed to delete preview rows for ${doc.id}:`, error); + logDegraded(serverLogger, { + event: 'user.data_cleanup.document_preview_rows_delete.failed', + msg: 'Failed to delete preview rows', + step: 'delete_document_preview_rows', + context: { + documentId: doc.id, + userIdHash: hashForLog(userId), + }, + error, + }); } } @@ -82,7 +111,16 @@ export async function deleteUserStorageData( await deleteAudiobookPrefix(prefix); booksDeleted++; } catch (error) { - console.error(`[user-data-cleanup] Failed to delete audiobook blobs ${book.id}:`, error); + logDegraded(serverLogger, { + event: 'user.data_cleanup.audiobook_blobs_delete.failed', + msg: 'Failed to delete audiobook blobs', + step: 'delete_audiobook_prefix', + context: { + bookId: book.id, + userIdHash: hashForLog(userId), + }, + error, + }); } } @@ -97,16 +135,25 @@ export async function deleteUserStorageData( segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV1); segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV2); } catch (error) { - console.error(`[user-data-cleanup] Failed to delete TTS segment blobs for user ${userId}:`, error); + logDegraded(serverLogger, { + event: 'user.data_cleanup.tts_segments_delete.failed', + msg: 'Failed to delete TTS segment blobs', + step: 'delete_tts_segment_prefixes', + context: { userIdHash: hashForLog(userId) }, + error, + }); } } if (docsDeleted > 0 || booksDeleted > 0 || segmentsDeleted > 0) { - console.log( - `[user-data-cleanup] Cleaned up S3 data for user ${userId}: ` + - `${docsDeleted}/${userDocs.length} document(s), ` + - `${booksDeleted}/${userBooks.length} audiobook(s), ` + - `${segmentsDeleted} tts segment object(s)`, - ); + serverLogger.info({ + event: 'user.data_cleanup.completed', + userIdHash: hashForLog(userId), + docsDeleted, + totalDocs: userDocs.length, + booksDeleted, + totalBooks: userBooks.length, + segmentsDeleted, + }, 'Completed user storage cleanup'); } } diff --git a/src/lib/server/whisper/alignment.ts b/src/lib/server/whisper/alignment.ts deleted file mode 100644 index 488b3c5..0000000 --- a/src/lib/server/whisper/alignment.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { createHash, randomUUID } from 'crypto'; -import { mkdtemp, writeFile, rm, access, mkdir, readFile } from 'fs/promises'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { spawn } from 'child_process'; -import type { TTSSentenceAlignment, TTSAudioBytes, TTSAudioBuffer } from '@/types/tts'; -import { preprocessSentenceForAudio } from '@/lib/shared/nlp'; -import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin'; - -interface WhisperAlignmentOptions { - engine?: 'whisper.cpp'; - lang?: string; -} - -interface WhisperWord { - start: number; - end: number; - word: string; -} - -export interface WhisperRequestBody { - text: string; - audio: TTSAudioBytes; - lang?: string; -} - -const alignmentCache = new Map(); - -const MODEL_NAME = 'ggml-tiny.en.bin'; -const MODEL_URL = - 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin'; -const DOCSTORE_DIR = join(process.cwd(), 'docstore'); -const MODEL_DIR = join(DOCSTORE_DIR, 'model'); -const MODEL_PATH = join(MODEL_DIR, MODEL_NAME); -const modelReadyPromises = new Map>(); - -async function ensureModelAvailable(): Promise { - try { - await access(MODEL_PATH); - return; - } catch { - // continue - } - - const existing = modelReadyPromises.get(MODEL_PATH); - if (existing) return existing; - - const promise = (async () => { - try { - await access(MODEL_PATH); - return; - } catch { - // still missing - } - - await mkdir(MODEL_DIR, { recursive: true }); - - const res = await fetch(MODEL_URL); - if (!res.ok) { - throw new Error( - `Failed to download Whisper model from ${MODEL_URL}: ${res.status} ${res.statusText}` - ); - } - - const arrayBuffer = await res.arrayBuffer(); - await writeFile(MODEL_PATH, Buffer.from(arrayBuffer)); - })(); - - modelReadyPromises.set(MODEL_PATH, promise); - return promise; -} - -async function runWhisperCpp( - wavPath: string, - opts: WhisperAlignmentOptions -): Promise { - const binary = process.env.WHISPER_CPP_BIN; - if (!binary) { - throw new Error( - 'Whisper.cpp binary path not configured. Set WHISPER_CPP_BIN to the compiled binary.' - ); - } - - await ensureModelAvailable(); - - return new Promise((resolve, reject) => { - const jsonBase = `${wavPath}.json_out`; - const jsonPath = `${jsonBase}.json`; - const args = [ - '-m', - MODEL_PATH, - '-f', - wavPath, - '-of', - jsonBase, - '-ojf', - '-np', - ]; - - if (opts.lang) { - args.push('-l', opts.lang); - } - - const child = spawn(binary, args); - - let stdout = ''; - let stderr = ''; - - child.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - child.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - child.on('error', (err) => { - reject(err); - }); - - child.on('close', (code) => { - if (code !== 0) { - return reject( - new Error( - `whisper.cpp exited with code ${code}: ${stderr || stdout}` - ) - ); - } - - readFile(jsonPath, 'utf-8') - .then((content: string) => { - const words: WhisperWord[] = []; - const parsed = JSON.parse(content) as { - transcription?: Array<{ - text?: string; - timestamps?: { from?: string; to?: string }; - offsets?: { from?: number; to?: number }; - tokens?: Array<{ - text?: string; - timestamps?: { from?: string; to?: string }; - offsets?: { from?: number; to?: number }; - }>; - }>; - }; - - const transcription = parsed.transcription; - - const parseTimecode = (value?: string): number | null => { - if (!value) return null; - const m = value.match(/(\d+):(\d+):(\d+),(\d+)/); - if (!m) return null; - const h = Number(m[1]); - const min = Number(m[2]); - const s = Number(m[3]); - const ms = Number(m[4]); - if ( - Number.isNaN(h) - || Number.isNaN(min) - || Number.isNaN(s) - || Number.isNaN(ms) - ) { - return null; - } - return h * 3600 + min * 60 + s + ms / 1000; - }; - - if (Array.isArray(transcription)) { - for (const seg of transcription) { - const segText = (seg.text || '').trim(); - const segStartSecFromTs = parseTimecode( - seg.timestamps?.from - ); - const segEndSecFromTs = parseTimecode(seg.timestamps?.to); - const segStartSecFromMs = - typeof seg.offsets?.from === 'number' - ? seg.offsets.from / 1000 - : null; - const segEndSecFromMs = - typeof seg.offsets?.to === 'number' - ? seg.offsets.to / 1000 - : null; - - const segStartSec = - segStartSecFromTs - ?? segStartSecFromMs - ?? 0; - const segEndSec = - segEndSecFromTs - ?? segEndSecFromMs - ?? segStartSec; - - const tokens = Array.isArray(seg.tokens) - ? seg.tokens - : []; - - if (tokens.length > 0) { - for (const token of tokens) { - const rawText = token.text || ''; - const tokenText = rawText.trim(); - if (!tokenText || /^\[.*\]$/.test(tokenText)) continue; - - const tokStartSecFromTs = parseTimecode( - token.timestamps?.from - ); - const tokEndSecFromTs = parseTimecode( - token.timestamps?.to - ); - const tokStartSecFromMs = - typeof token.offsets?.from === 'number' - ? token.offsets.from / 1000 - : null; - const tokEndSecFromMs = - typeof token.offsets?.to === 'number' - ? token.offsets.to / 1000 - : null; - - const startSec = - tokStartSecFromTs - ?? tokStartSecFromMs - ?? segStartSec; - const endSec = - tokEndSecFromTs - ?? tokEndSecFromMs - ?? segEndSec; - - words.push({ - word: tokenText, - start: startSec, - end: endSec, - }); - } - } else if (segText) { - const segTokens = segText.split(/\s+/).filter(Boolean); - if (segTokens.length) { - const totalDur = Math.max(segEndSec - segStartSec, 0); - const step = - segTokens.length > 0 - ? totalDur / segTokens.length - : 0; - segTokens.forEach((token, index) => { - const wStart = - step > 0 - ? segStartSec + step * index - : segStartSec; - const wEnd = - step > 0 - ? index === segTokens.length - 1 - ? segEndSec - : segStartSec + step * (index + 1) - : segEndSec; - words.push({ - word: token, - start: wStart, - end: wEnd, - }); - }); - } - } - } - } - - resolve(words); - }) - .catch((err: unknown) => { - reject(err); - }); - }); - }); -} - -function mapWordsToSentenceOffsets( - sentence: string, - words: WhisperWord[] -): TTSSentenceAlignment { - const normalizedSentence = preprocessSentenceForAudio(sentence); - let cursor = 0; - - const alignedWords = words.map((w) => { - const token = w.word.trim(); - if (!token) { - return { - text: '', - startSec: w.start, - endSec: w.end, - charStart: cursor, - charEnd: cursor, - }; - } - - const idx = normalizedSentence - .toLowerCase() - .indexOf(token.toLowerCase(), cursor); - - const start = - idx !== -1 - ? idx - : cursor; - const end = start + token.length; - - cursor = end; - - return { - text: token, - startSec: w.start, - endSec: w.end, - charStart: start, - charEnd: end, - }; - }); - - return { - sentence, - sentenceIndex: 0, - words: alignedWords.filter((w) => w.text.length > 0), - }; -} - -export async function alignAudioWithText( - audioBuffer: TTSAudioBuffer, - text: string, - cacheKey?: string, - opts: WhisperAlignmentOptions = {} -): Promise { - if (!text.trim()) { - return []; - } - - if (cacheKey && alignmentCache.has(cacheKey)) { - return alignmentCache.get(cacheKey)!; - } - - const tmpBase = await mkdtemp(join(tmpdir(), 'openreader-whisper-')); - const inputPath = join(tmpBase, `${randomUUID()}-input.bin`); - const wavPath = join(tmpBase, `${randomUUID()}-input.wav`); - - try { - await writeFile(inputPath, Buffer.from(new Uint8Array(audioBuffer))); - - await new Promise((resolve, reject) => { - const ffmpeg = spawn(getFFmpegPath(), [ - '-y', - '-i', - inputPath, - '-ar', - '16000', - '-ac', - '1', - wavPath, - ]); - - let stderr = ''; - ffmpeg.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - ffmpeg.on('error', (err) => { - reject(err); - }); - - ffmpeg.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject( - new Error(`ffmpeg failed with code ${code}: ${stderr}`) - ); - } - }); - }); - - const words = await runWhisperCpp(wavPath, opts); - const alignment = mapWordsToSentenceOffsets(text, words); - const result: TTSSentenceAlignment[] = [alignment]; - - if (cacheKey) { - alignmentCache.set(cacheKey, result); - } - - return result; - } finally { - await rm(tmpBase, { recursive: true, force: true }).catch(() => {}); - } -} - -export function makeWhisperCacheKey(input: WhisperRequestBody): string { - return createHash('sha256') - .update( - JSON.stringify({ - text: input.text, - lang: input.lang || '', - audioLen: input.audio?.length || 0, - }) - ) - .digest('hex'); -} diff --git a/src/lib/shared/document-settings.ts b/src/lib/shared/document-settings.ts new file mode 100644 index 0000000..26b45e0 --- /dev/null +++ b/src/lib/shared/document-settings.ts @@ -0,0 +1,42 @@ +import { + DEFAULT_DOCUMENT_SETTINGS, + type DocumentSettings, +} from '@/types/document-settings'; +import { PARSED_PDF_BLOCK_KINDS, type ParsedPdfBlockKind } from '@/types/parsed-pdf'; + +function normalizeSkipKinds(value: unknown): ParsedPdfBlockKind[] { + if (!Array.isArray(value)) return [...(DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? [])]; + const allow = new Set(PARSED_PDF_BLOCK_KINDS); + const out: ParsedPdfBlockKind[] = []; + for (const entry of value) { + if (typeof entry !== 'string') continue; + if (!allow.has(entry as ParsedPdfBlockKind)) continue; + out.push(entry as ParsedPdfBlockKind); + } + return Array.from(new Set(out)); +} + +export function mergeDocumentSettings( + defaults: DocumentSettings = DEFAULT_DOCUMENT_SETTINGS, + stored: unknown, +): DocumentSettings { + const base: DocumentSettings = { + schemaVersion: 1, + pdf: { + skipBlockKinds: [...(defaults.pdf?.skipBlockKinds ?? [])], + }, + }; + + if (!stored || typeof stored !== 'object') return base; + const rec = stored as Record; + const pdf = rec.pdf; + if (!pdf || typeof pdf !== 'object') return base; + const pdfRec = pdf as Record; + + return { + schemaVersion: 1, + pdf: { + skipBlockKinds: normalizeSkipKinds(pdfRec.skipBlockKinds), + }, + }; +} diff --git a/src/lib/shared/onboarding-state.ts b/src/lib/shared/onboarding-state.ts new file mode 100644 index 0000000..8b8723d --- /dev/null +++ b/src/lib/shared/onboarding-state.ts @@ -0,0 +1,45 @@ +export type OnboardingStorageScope = 'local-dexie' | 'server-user-preferences' | 'hybrid'; + +export type OnboardingStateDescriptor = { + /** + * Where this state is persisted today. + * - local-dexie: browser/device-local only + * - server-user-preferences: per-user on server + * - hybrid: intentionally persisted in both places + */ + scope: OnboardingStorageScope; + /** + * Optional local key name when state is stored in Dexie app-config. + */ + localKey?: string; + /** + * Optional server-side key when state is stored in user preferences/meta. + */ + serverKey?: string; +}; + +/** + * Central registry for onboarding-related state and where each item lives. + * Keep this map up to date whenever onboarding persistence changes. + */ +export const ONBOARDING_STATE_REGISTRY = { + privacyAccepted: { + scope: 'local-dexie', + localKey: 'privacyAccepted', + }, + firstVisitSettingsOpened: { + scope: 'local-dexie', + localKey: 'firstVisit', + }, + documentsMigrationPrompted: { + scope: 'local-dexie', + localKey: 'documentsMigrationPrompted', + }, + changelogLastSeenAppVersion: { + scope: 'server-user-preferences', + serverKey: '_meta.lastSeenAppVersion', + }, +} as const satisfies Record; + +export type OnboardingStateKey = keyof typeof ONBOARDING_STATE_REGISTRY; + diff --git a/src/lib/shared/tts-locator.ts b/src/lib/shared/tts-locator.ts index 54db024..4c7eb15 100644 --- a/src/lib/shared/tts-locator.ts +++ b/src/lib/shared/tts-locator.ts @@ -70,7 +70,10 @@ export function locatorIdentityKey(locator: TTSSegmentLocator | null): string { return `epub:${locator.spineIndex}:${locator.spineHref}:${locator.charOffset}`; } if (isPdfLocator(locator)) { - return `pdf:${Math.floor(locator.page)}`; + const blockPart = typeof locator.blockId === 'string' && locator.blockId.trim() + ? `:${locator.blockId.trim()}` + : ''; + return `pdf:${Math.floor(locator.page)}${blockPart}`; } if (isHtmlLocator(locator)) { return `html:${locator.location}`; @@ -112,7 +115,11 @@ export function compareSegmentLocators( return a.spineHref.localeCompare(b.spineHref); } if (isPdfLocator(a) && isPdfLocator(b)) { - return Math.floor(a.page) - Math.floor(b.page); + const pageCmp = Math.floor(a.page) - Math.floor(b.page); + if (pageCmp !== 0) return pageCmp; + const aBlock = typeof a.blockId === 'string' ? a.blockId : ''; + const bBlock = typeof b.blockId === 'string' ? b.blockId : ''; + return aBlock.localeCompare(bBlock); } if (isHtmlLocator(a) && isHtmlLocator(b)) { // When both locations look like positive integers (HTML reader blocks), diff --git a/src/lib/shared/tts-segment-plan.ts b/src/lib/shared/tts-segment-plan.ts index e2aa1ba..b33ca15 100644 --- a/src/lib/shared/tts-segment-plan.ts +++ b/src/lib/shared/tts-segment-plan.ts @@ -37,6 +37,7 @@ export interface CanonicalTtsSegmentPlanOptions { readerType?: ReaderType; maxBlockLength?: number; keyPrefix?: string; + enforceSourceBoundaries?: boolean; } interface PreparedSourceUnit { @@ -185,7 +186,9 @@ export function planCanonicalTtsSegments( options: CanonicalTtsSegmentPlanOptions = {}, ): CanonicalTtsSegmentPlan { const readerType = options.readerType ?? 'pdf'; + const enforceSourceBoundaries = Boolean(options.enforceSourceBoundaries); const keyPrefix = options.keyPrefix ?? TTS_SEGMENT_PLAN_VERSION; + const sourceSeparator = enforceSourceBoundaries ? '\n\n' : ' '; const preparedSources: PreparedSourceUnit[] = []; const textParts: string[] = []; let combinedLength = 0; @@ -195,8 +198,8 @@ export function planCanonicalTtsSegments( if (!text) continue; if (textParts.length > 0) { - textParts.push(' '); - combinedLength += 1; + textParts.push(sourceSeparator); + combinedLength += sourceSeparator.length; } const startOffset = combinedLength; @@ -213,9 +216,73 @@ export function planCanonicalTtsSegments( const canonicalText = textParts.join(''); const splitOptions = { maxBlockLength: options.maxBlockLength }; - const blocks = readerType === 'epub' - ? splitTextToTtsBlocksEPUB(canonicalText, splitOptions) - : splitTextToTtsBlocks(canonicalText, splitOptions); + const splitIntoBlocks = (text: string): string[] => + readerType === 'epub' + ? splitTextToTtsBlocksEPUB(text, splitOptions) + : splitTextToTtsBlocks(text, splitOptions); + + if (enforceSourceBoundaries) { + const segments: CanonicalTtsSegment[] = []; + + for (const source of preparedSources) { + const localBlocks = splitIntoBlocks(source.text); + let localRawCursor = 0; + let localNormalizedCursor = 0; + + for (const block of localBlocks) { + const text = block.trim(); + if (!text) continue; + + const exactStart = source.text.indexOf(text, localRawCursor); + let localStart: number; + let localEnd: number; + + if (exactStart >= 0) { + localStart = exactStart; + localEnd = exactStart + text.length; + localRawCursor = localEnd; + localNormalizedCursor = normalizeWithRawMap(source.text.slice(0, localEnd)).text.length; + } else { + const flexible = findFlexibleOffset(source.text, text, localNormalizedCursor); + if (flexible) { + localStart = flexible.start; + localEnd = flexible.end; + localRawCursor = localEnd; + localNormalizedCursor = flexible.normalizedEnd; + } else { + // Never drop blocks in enforced boundary mode. + localStart = Math.max(0, Math.min(localRawCursor, source.text.length)); + localEnd = Math.max(localStart, Math.min(source.text.length, localStart + text.length)); + localRawCursor = localEnd; + localNormalizedCursor = normalizeWithRawMap(source.text.slice(0, localEnd)).text.length; + } + } + + const absoluteStart = source.startOffset + localStart; + const absoluteEnd = source.startOffset + localEnd; + const ordinal = segments.length; + segments.push({ + key: buildSegmentKey(keyPrefix, text), + ordinal, + text, + ownerSourceKey: source.sourceKey, + ownerLocator: source.locator, + startAnchor: anchorForOffset(source, absoluteStart), + endAnchor: anchorForOffset(source, absoluteEnd), + spansSourceBoundary: false, + }); + } + } + + return { + version: TTS_SEGMENT_PLAN_VERSION, + readerType, + text: canonicalText, + segments, + }; + } + + const blocks = splitIntoBlocks(canonicalText); let rawCursor = 0; let normalizedCursor = 0; @@ -236,11 +303,33 @@ export function planCanonicalTtsSegments( normalizedCursor = normalizeWithRawMap(canonicalText.slice(0, endOffset)).text.length; } else { const flexible = findFlexibleOffset(canonicalText, text, normalizedCursor); - if (!flexible) continue; - startOffset = flexible.start; - endOffset = flexible.end; - rawCursor = endOffset; - normalizedCursor = flexible.normalizedEnd; + if (!flexible) { + if (!enforceSourceBoundaries) continue; + + // In enforced-boundary mode (PDF block source units), never drop a + // split block just because canonical rematching failed. Prefer a + // best-effort anchor inside the source that the cursor currently sits + // in, then emit the segment text as-is. + const fallbackSource = findSourceForOffset(preparedSources, rawCursor, 'start') + ?? preparedSources[preparedSources.length - 1] + ?? null; + if (!fallbackSource) continue; + + const fallbackStart = Math.max(fallbackSource.startOffset, Math.min(rawCursor, fallbackSource.endOffset)); + const fallbackEnd = Math.max( + fallbackStart, + Math.min(fallbackSource.endOffset, fallbackStart + text.length), + ); + startOffset = fallbackStart; + endOffset = fallbackEnd; + rawCursor = fallbackEnd; + normalizedCursor = normalizeWithRawMap(canonicalText.slice(0, fallbackEnd)).text.length; + } else { + startOffset = flexible.start; + endOffset = flexible.end; + rawCursor = endOffset; + normalizedCursor = flexible.normalizedEnd; + } } const ownerSource = findSourceForOffset(preparedSources, startOffset, 'start'); @@ -263,6 +352,41 @@ export function planCanonicalTtsSegments( spansSourceBoundary: false, }); } else { + const splitAcrossSourceBoundaries = () => { + const ownerIdx = preparedSources.indexOf(ownerSource); + const endIdx = preparedSources.indexOf(endSource); + if (ownerIdx < 0 || endIdx < 0) return; + + let subStart = startOffset; + for (let srcIdx = ownerIdx; srcIdx <= endIdx; srcIdx += 1) { + const source = preparedSources[srcIdx]; + const subEnd = srcIdx < endIdx ? source.endOffset : endOffset; + if (subEnd <= subStart) continue; + + const subText = canonicalText.slice(subStart, subEnd).trim(); + if (!subText) { + subStart = subEnd; + continue; + } + + const nextSource = srcIdx < endIdx ? preparedSources[srcIdx + 1] : null; + const subStartAnchor = anchorForOffset(source, subStart); + const subEndAnchor = anchorForOffset(source, subEnd); + const ordinal = segments.length; + segments.push({ + key: buildSegmentKey(keyPrefix, subText), + ordinal, + text: subText, + ownerSourceKey: source.sourceKey, + ownerLocator: source.locator, + startAnchor: subStartAnchor, + endAnchor: subEndAnchor, + spansSourceBoundary: false, + }); + subStart = nextSource ? nextSource.startOffset : subEnd; + } + }; + // Block spans one or more source boundaries. Decide whether to keep it // as a single boundary-spanning segment or to split it at each boundary. // @@ -286,6 +410,12 @@ export function planCanonicalTtsSegments( // continuation, so it should be filtered out of the current page's // segments. if (ownerSource.locator !== null) { + if (enforceSourceBoundaries) { + // PDF source units are logical layout blocks; never allow a segment + // to cross a block boundary. + splitAcrossSourceBoundaries(); + continue; + } // Both sources carry locators → original unified boundary behavior. const ordinal = segments.length; const startAnchor = anchorForOffset(ownerSource, startOffset); @@ -318,34 +448,7 @@ export function planCanonicalTtsSegments( if (isCleanBoundary) { // Clean boundary → split at each source boundary. - let subStart = startOffset; - for (let srcIdx = ownerIdx; srcIdx <= endIdx; srcIdx += 1) { - const source = preparedSources[srcIdx]; - const subEnd = srcIdx < endIdx ? source.endOffset : endOffset; - if (subEnd <= subStart) continue; - - const subText = canonicalText.slice(subStart, subEnd).trim(); - if (!subText) { - subStart = subEnd; - continue; - } - - const nextSource = srcIdx < endIdx ? preparedSources[srcIdx + 1] : null; - const subStartAnchor = anchorForOffset(source, subStart); - const subEndAnchor = anchorForOffset(source, subEnd); - const ordinal = segments.length; - segments.push({ - key: buildSegmentKey(keyPrefix, subText), - ordinal, - text: subText, - ownerSourceKey: source.sourceKey, - ownerLocator: source.locator, - startAnchor: subStartAnchor, - endAnchor: subEndAnchor, - spansSourceBoundary: false, - }); - subStart = nextSource ? nextSource.startOffset : subEnd; - } + splitAcrossSourceBoundaries(); } else { // Overlapping sentence → keep unified, owned by the context-only // source. This segment will be filtered out of the current page's diff --git a/src/types/client.ts b/src/types/client.ts index 5ce3e82..0b94363 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -1,8 +1,7 @@ import type { TTSAudiobookChapter, - TTSSentenceAlignment, - TTSAudioBytes, TTSAudiobookFormat, + TTSSentenceAlignment, } from '@/types/tts'; import type { TtsProviderType } from '@/lib/shared/tts-provider-catalog'; @@ -64,17 +63,6 @@ export interface VoicesResponse { voices: string[]; } -// --- Whisper API Types --- - -export interface AlignmentPayload { - text: string; - audio: TTSAudioBytes; // Array.from(new Uint8Array(arrayBuffer)) -} - -export interface AlignmentResponse { - alignments: TTSSentenceAlignment[]; -} - export interface TTSSegmentSettings { providerRef: string; providerType: TtsProviderType; @@ -107,6 +95,8 @@ export interface TTSSegmentLocator { readerType?: TTSReaderType; // PDF / legacy page?: number; + // PDF block-level locator (structured parser path) + blockId?: string; // HTML / legacy EPUB CFI (kept for in-flight drafts; not persisted for EPUB) location?: string; // Stable EPUB coordinates diff --git a/src/types/config.ts b/src/types/config.ts index 7601b06..53db98c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -5,22 +5,13 @@ import { defaultModelForProviderType } from '@/lib/shared/tts-provider-policy'; // Runtime config (admin-controlled) is layered on top of the static defaults // below. We resolve it lazily so this module stays importable from non-React // contexts (Dexie, server routes). The actual values come from -// `window.__OPENREADER_RUNTIME_CONFIG__` (SSR-injected) on the client, and +// `window.__RUNTIME_CONFIG__` (SSR-injected) on the client, and // from the built-in defaults during SSR. -function readRuntimeFlag(key: string, defaultValue: boolean): boolean { - if (typeof window === 'undefined') return defaultValue; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__; - if (!injected || typeof injected !== 'object') return defaultValue; - const value = injected[key]; - return typeof value === 'boolean' ? value : defaultValue; -} - function readRuntimeString(key: string, defaultValue: string): string { if (typeof window === 'undefined') return defaultValue; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__; + const injected = (window as any).__RUNTIME_CONFIG__; if (!injected || typeof injected !== 'object') return defaultValue; const value = injected[key]; return typeof value === 'string' && value ? value : defaultValue; @@ -71,7 +62,6 @@ export interface AppConfigValues { ttsModel: string; ttsInstructions: string; savedVoices: SavedVoices; - smartSentenceSplitting: boolean; segmentPreloadDepthPages: number; segmentPreloadSentenceLookahead: number; ttsSegmentMaxBlockLength: number; @@ -89,14 +79,13 @@ export interface AppConfigValues { /** * Build defaults lazily so we can read SSR-injected admin overrides - * (`window.__OPENREADER_RUNTIME_CONFIG__`). Modules that need the defaults + * (`window.__RUNTIME_CONFIG__`). Modules that need the defaults * statically should call `getAppConfigDefaults()` at use time. The exported * `APP_CONFIG_DEFAULTS` is a Proxy that re-resolves on each access so * mutations to the runtime config (admin edits) are picked up by anything * that reads through it. */ export function getAppConfigDefaults(): AppConfigValues { - const wordHighlightEnabledByDefault = readRuntimeFlag('enableWordHighlight', true); const runtimeProviderRef = readRuntimeString('defaultTtsProvider', 'custom-openai'); const defaultProviderRef = runtimeProviderRef.trim(); const defaultProviderType = isBuiltInTtsProviderId(defaultProviderRef) ? defaultProviderRef : 'unknown'; @@ -121,16 +110,15 @@ export function getAppConfigDefaults(): AppConfigValues { ttsModel: defaultModel, ttsInstructions: '', savedVoices: {}, - smartSentenceSplitting: true, segmentPreloadDepthPages: 1, segmentPreloadSentenceLookahead: 3, ttsSegmentMaxBlockLength: 450, pdfHighlightEnabled: true, - pdfWordHighlightEnabled: wordHighlightEnabledByDefault, + pdfWordHighlightEnabled: true, epubHighlightEnabled: true, - epubWordHighlightEnabled: wordHighlightEnabledByDefault, + epubWordHighlightEnabled: true, htmlHighlightEnabled: true, - htmlWordHighlightEnabled: wordHighlightEnabledByDefault, + htmlWordHighlightEnabled: true, firstVisit: false, documentListState: { sortBy: 'name', diff --git a/src/types/document-settings.ts b/src/types/document-settings.ts new file mode 100644 index 0000000..6908f33 --- /dev/null +++ b/src/types/document-settings.ts @@ -0,0 +1,15 @@ +import type { ParsedPdfBlockKind } from '@/types/parsed-pdf'; + +export interface DocumentSettings { + schemaVersion: 1; + pdf?: { + skipBlockKinds: ParsedPdfBlockKind[]; + }; +} + +export const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = { + schemaVersion: 1, + pdf: { + skipBlockKinds: ['header', 'footer', 'footnote', 'vision_footnote'], + }, +}; diff --git a/src/types/documents.ts b/src/types/documents.ts index cae8b99..3fa90c7 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -6,6 +6,8 @@ export interface BaseDocument { size: number; lastModified: number; type: DocumentType; + parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | null; + parsedJsonKey?: string | null; scope?: 'user' | 'unclaimed'; folderId?: string; isConverting?: boolean; diff --git a/src/types/parsed-pdf.ts b/src/types/parsed-pdf.ts new file mode 100644 index 0000000..2c7bdf7 --- /dev/null +++ b/src/types/parsed-pdf.ts @@ -0,0 +1,39 @@ +import type { ParsedPdfBlockKind } from '@openreader/compute-core/types'; + +export { + type ParsedPdfBlock, + type ParsedPdfBlockFragment, + type ParsedPdfBlockKind, + type ParsedPdfDocument, + type ParsedPdfPage, +} from '@openreader/compute-core/types'; + +export const PARSED_PDF_BLOCK_KINDS: ParsedPdfBlockKind[] = [ + 'abstract', + 'algorithm', + 'aside_text', + 'chart', + 'content', + 'formula', + 'doc_title', + 'figure_title', + 'footer', + 'footnote', + 'formula_number', + 'header', + 'image', + 'number', + 'paragraph_title', + 'reference', + 'reference_content', + 'seal', + 'table', + 'text', + 'vision_footnote', +]; + +export type { + PdfParsePhase, + PdfParseProgress, + PdfParseStatus, +} from '@openreader/compute-core/types'; diff --git a/src/types/tts.ts b/src/types/tts.ts index 0ed05cf..a3f76d8 100644 --- a/src/types/tts.ts +++ b/src/types/tts.ts @@ -1,11 +1,13 @@ import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan'; +export type { + TTSAudioBuffer, + TTSAudioBytes, + TTSSentenceAlignment, + TTSSentenceWord, +} from '@openreader/compute-core/types'; export type TTSLocation = string | number; -// Core audio representations used across TTS and audiobook flows -export type TTSAudioBuffer = ArrayBuffer; -export type TTSAudioBytes = number[]; // JSON-safe representation (Array.from(new Uint8Array(buffer))) - // Core playback state exposed by the TTS context export interface TTSPlaybackState { isPlaying: boolean; @@ -24,21 +26,6 @@ export interface TTSPageTurnEstimate { fraction: number; } -// Word-level alignment within a single spoken sentence/block -export interface TTSSentenceWord { - text: string; - startSec: number; - endSec: number; - charStart: number; - charEnd: number; -} - -// Alignment metadata for a single TTS sentence/block -export interface TTSSentenceAlignment { - sentence: string; - sentenceIndex: number; - words: TTSSentenceWord[]; -} interface EpubRenderedLocationWalkItem { /** Page-start CFI from the rendition — best-effort jump hint only. */ diff --git a/src/types/user-state.ts b/src/types/user-state.ts index 7a0e275..3fc796e 100644 --- a/src/types/user-state.ts +++ b/src/types/user-state.ts @@ -7,7 +7,6 @@ export const SYNCED_PREFERENCE_KEYS = [ 'voice', 'skipBlank', 'epubTheme', - 'smartSentenceSplitting', 'segmentPreloadDepthPages', 'segmentPreloadSentenceLookahead', 'ttsSegmentMaxBlockLength', diff --git a/tests/accessibility.spec.ts b/tests/accessibility.spec.ts index f3e4504..21337f2 100644 --- a/tests/accessibility.spec.ts +++ b/tests/accessibility.spec.ts @@ -53,6 +53,7 @@ test.describe('Accessibility smoke', () => { }); test('TTS controls expose aria labels and are keyboard focusable', async ({ page }) => { + test.setTimeout(120_000); await uploadAndDisplay(page, 'sample.pdf'); // TTS bar present diff --git a/tests/export.spec.ts b/tests/export.spec.ts index d8137a0..ce64437 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -1,4 +1,5 @@ import { test, expect, Page } from '@playwright/test'; +import type { APIResponse } from '@playwright/test'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -8,6 +9,35 @@ import { setupTest, uploadAndDisplay } from './helpers'; const execFileAsync = util.promisify(execFile); +function isTransientRequestError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const msg = error.message || ''; + return ( + msg.includes('ECONNRESET') || + msg.includes('socket hang up') || + msg.includes('ERR_SOCKET_CLOSED') || + msg.includes('ETIMEDOUT') || + msg.includes('fetch failed') + ); +} + +async function requestWithRetry( + fn: () => Promise, + { attempts = 4, backoffMs = 200 }: { attempts?: number; backoffMs?: number } = {}, +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await fn(); + } catch (error) { + lastError = error; + if (!isTransientRequestError(error) || attempt === attempts) throw error; + await new Promise((resolve) => setTimeout(resolve, backoffMs * attempt)); + } + } + throw lastError instanceof Error ? lastError : new Error('Request failed'); +} + async function getBookIdFromUrl(page: Page, expectedPrefix: 'pdf' | 'epub') { const url = new URL(page.url()); const segments = url.pathname.split('/').filter(Boolean); @@ -47,12 +77,14 @@ type DownloadedAudiobook = { cleanup: () => Promise; }; -async function downloadFullAudiobook(page: Page, timeoutMs = 60_000): Promise { - const fullDownloadButton = page.getByRole('button', { name: /Full Download/i }); - await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs }); +async function downloadViaTrigger( + page: Page, + trigger: () => Promise, + timeoutMs = 60_000, +): Promise { const [download] = await Promise.all([ page.waitForEvent('download', { timeout: timeoutMs }), - fullDownloadButton.click(), + trigger(), ]); const failure = await download.failure(); expect(failure).toBeNull(); @@ -89,6 +121,27 @@ async function downloadFullAudiobook(page: Page, timeoutMs = 60_000): Promise { + const fullDownloadButton = page.getByRole('button', { name: /Full Download/i }); + await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs }); + await expect(fullDownloadButton).toBeEnabled({ timeout: timeoutMs }); + + let lastError: unknown; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + return await downloadViaTrigger(page, () => fullDownloadButton.click(), timeoutMs); + } catch (error) { + lastError = error; + if (attempt === 3) throw error; + await page.waitForTimeout(250 * attempt); + await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs }); + await expect(fullDownloadButton).toBeEnabled({ timeout: timeoutMs }); + } + } + + throw lastError instanceof Error ? lastError : new Error('Full download failed'); +} + async function getAudioDurationSeconds(filePath: string) { const { stdout } = await execFileAsync('ffprobe', [ '-v', @@ -103,7 +156,7 @@ async function getAudioDurationSeconds(filePath: string) { } async function expectChaptersBackendState(page: Page, bookId: string) { - const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`); + const res = await requestWithRetry(() => page.request.get(`/api/audiobook/status?bookId=${bookId}`)); expect(res.ok()).toBeTruthy(); const json = await res.json(); return json; @@ -184,11 +237,18 @@ async function cancelGenerationIfVisible(page: Page): Promise { } async function resetAudiobookById(page: Page, bookId: string) { - const res = await page.request.delete(`/api/audiobook?bookId=${bookId}`); + const res = await requestWithRetry(() => page.request.delete(`/api/audiobook?bookId=${bookId}`)); expect(res.ok() || res.status() === 404).toBeTruthy(); } -async function resetAudiobookIfPresent(page: Page) { +async function resetAudiobookIfPresent(page: Page, bookId?: string) { + // Prefer backend reset when bookId is available: deterministic and independent of modal timing. + // Do not block on UI re-open during end-of-test cleanup, which can be flaky under load. + if (bookId) { + await resetAudiobookById(page, bookId); + return; + } + const resetButtons = page.getByRole('button', { name: 'Reset' }); const count = await resetButtons.count(); @@ -199,14 +259,20 @@ async function resetAudiobookIfPresent(page: Page) { const resetButton = resetButtons.first(); await resetButton.click(); - await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15_000 }); - const confirmReset = page.getByRole('button', { name: 'Reset' }).last(); + const resetDialog = page + .getByRole('dialog') + .filter({ has: page.getByRole('heading', { name: 'Reset Audiobook' }) }) + .first(); + await expect(resetDialog).toBeVisible({ timeout: 15_000 }); + const confirmReset = resetDialog.getByRole('button', { name: 'Reset' }); await confirmReset.click(); await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); } test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }, testInfo) => { + test.setTimeout(120_000); + // Ensure TTS is mocked and app is ready await setupTest(page, testInfo); @@ -256,7 +322,7 @@ test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ expect(ch.duration).toBeGreaterThan(0); } - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }, testInfo) => { @@ -312,10 +378,11 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async expect(durationSeconds).toBeLessThan(300); }); - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }, testInfo) => { + test.setTimeout(120_000); await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); @@ -335,18 +402,18 @@ test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page // Readiness gate: chapter row visibility can lead backend storage consistency by a small window. await waitForBackendDownloadReady(page, bookId, { minChapters: 1 }); - // Download via frontend button + // Download via full-download button once at least one chapter is ready. await withDownloadedFullAudiobook(page, async ({ filePath }) => { const durationSeconds = await getAudioDurationSeconds(filePath); - // For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter. expect(durationSeconds).toBeGreaterThan(9); expect(durationSeconds).toBeLessThan(300); }); - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); test('resets all MP3 audiobook PDF pages', async ({ page }, testInfo) => { + test.setTimeout(120_000); await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); @@ -385,7 +452,7 @@ test('resets all MP3 audiobook PDF pages', async ({ page }, testInfo) => { }); test('regenerates a single MP3 audiobook PDF page and exports full audiobook', async ({ page }, testInfo) => { - test.setTimeout(60_000); + test.setTimeout(120_000); await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); @@ -455,11 +522,11 @@ test('regenerates a single MP3 audiobook PDF page and exports full audiobook', a expect(ch.duration).toBeGreaterThan(0); } - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }, testInfo) => { - test.setTimeout(60_000); + test.setTimeout(120_000); await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); @@ -476,7 +543,9 @@ test('resumes audiobook when a chapter is missing and full download succeeds (PD // Delete the first chapter via the backend API so the audiobook has a missing index (0). // This is more reliable than clicking through the chapter actions menu in headless runs. - const deleteRes = await page.request.delete(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=0`); + const deleteRes = await requestWithRetry(() => + page.request.delete(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=0`) + ); expect(deleteRes.ok()).toBeTruthy(); // Wait for backend to reflect only one remaining chapter (index 1). @@ -534,5 +603,5 @@ test('resumes audiobook when a chapter is missing and full download succeeds (PD expect(ch.duration).toBeGreaterThan(0); } - await resetAudiobookIfPresent(page); + await resetAudiobookIfPresent(page, bookId); }); diff --git a/tests/files/sample.pdf b/tests/files/sample.pdf index 9e0e7e9..f08b7aa 100644 Binary files a/tests/files/sample.pdf and b/tests/files/sample.pdf differ diff --git a/tests/folders.spec.ts b/tests/folders.spec.ts index b1c413d..3f9e0fb 100644 --- a/tests/folders.spec.ts +++ b/tests/folders.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; import { setupTest, uploadFiles, ensureDocumentsListed, waitForDocumentListHintPersist, dispatchHtml5DragAndDrop } from './helpers'; test.describe('Document folders and hint persistence', () => { @@ -7,7 +7,7 @@ test.describe('Document folders and hint persistence', () => { }); // Utility to get the draggable row for a given filename (by link) - const rowFor = (page: any, fileName: string) => { + const rowFor = (page: Page, fileName: string) => { const link = page.getByRole('link', { name: new RegExp(fileName, 'i') }).first(); // The draggable attribute lives on the row container ancestor return link.locator('xpath=ancestor::*[@draggable="true"][1]'); diff --git a/tests/helpers.ts b/tests/helpers.ts index 5a7a0f5..500b3dc 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -22,6 +22,27 @@ function sha256HexOfFile(filePath: string) { return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); } +async function waitForPdfViewerReady(page: Page, timeout = 60000) { + await expect(page).toHaveURL(/\/pdf\/[A-Za-z0-9._%-]+$/, { timeout: Math.min(timeout, 20000) }); + const loader = page.getByTestId('pdf-status-loader').first(); + const parseFailedBanner = page.getByText('PDF parsing failed. Retry to continue.').first(); + await expect + .poll(async () => { + const parseFailed = await parseFailedBanner.isVisible().catch(() => false); + if (parseFailed) return 'failed'; + + const documentVisible = await page.locator('.react-pdf__Document').first().isVisible().catch(() => false); + const pageVisible = await page.locator('.react-pdf__Page').first().isVisible().catch(() => false); + const canvasVisible = await page.locator('.react-pdf__Page canvas').first().isVisible().catch(() => false); + + const hasRenderedPdf = documentVisible || pageVisible || canvasVisible; + const loaderVisible = await loader.isVisible().catch(() => false); + if (hasRenderedPdf && !loaderVisible) return 'ready'; + return 'loading'; + }, { timeout }) + .toBe('ready'); +} + /** * Upload a sample epub or pdf */ @@ -63,7 +84,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) { await expect(targetLink).toBeVisible({ timeout: 15000 }); await dismissOnboardingModals(page); await targetLink.click(); - await page.waitForSelector('.react-pdf__Document', { timeout: 15000 }); + await waitForPdfViewerReady(page, 60000); return; } @@ -80,7 +101,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) { } if (lower.endsWith('.pdf')) { - await page.waitForSelector('.react-pdf__Document', { timeout: 10000 }); + await waitForPdfViewerReady(page, 60000); } else if (lower.endsWith('.epub')) { await page.waitForSelector('.epub-container', { timeout: 10000 }); } else if (lower.endsWith('.txt') || lower.endsWith('.md')) { @@ -98,29 +119,6 @@ async function dismissOnboardingModals(page: Page): Promise { let settledWithoutDialog = 0; for (let step = 0; step < maxSteps; step += 1) { - if (await settingsDialog.isVisible().catch(() => false)) { - const backToSettingsBtn = settingsDialog.getByRole('button', { name: /back to settings/i }); - if (await backToSettingsBtn.isVisible().catch(() => false)) { - await expect(backToSettingsBtn).toBeEnabled({ timeout: 10000 }); - await backToSettingsBtn.click(); - await page.waitForTimeout(100); - settledWithoutDialog = 0; - continue; - } - - const saveBtn = page.getByTestId('settings-save-button'); - if (await saveBtn.isVisible().catch(() => false)) { - await expect(saveBtn).toBeEnabled({ timeout: 15000 }); - await saveBtn.click(); - } else { - await page.keyboard.press('Escape'); - } - await settingsDialog.waitFor({ state: 'hidden', timeout: 15000 }); - await page.waitForTimeout(100); - settledWithoutDialog = 0; - continue; - } - if (await privacyDialog.isVisible().catch(() => false)) { const privacyAgree = page.getByTestId('privacy-agree-checkbox'); if (await privacyAgree.isVisible().catch(() => false)) { @@ -137,6 +135,30 @@ async function dismissOnboardingModals(page: Page): Promise { continue; } + if (await settingsDialog.isVisible().catch(() => false)) { + const backToSettingsBtn = settingsDialog.getByRole('button', { name: /back to settings/i }); + if (await backToSettingsBtn.isVisible().catch(() => false)) { + await expect(backToSettingsBtn).toBeEnabled({ timeout: 10000 }); + await backToSettingsBtn.click(); + await page.waitForTimeout(100); + } + + // For test setup teardown, we only need to dismiss overlays. + // Avoid saving because Save can race with state changes and detach. + for (let attempt = 0; attempt < 3; attempt += 1) { + await page.keyboard.press('Escape'); + const hidden = await settingsDialog.isHidden().catch(() => false); + if (hidden) { + break; + } + await page.waitForTimeout(100); + } + await settingsDialog.waitFor({ state: 'hidden', timeout: 15000 }); + await page.waitForTimeout(100); + settledWithoutDialog = 0; + continue; + } + if (await migrationDialog.isVisible().catch(() => false)) { const skipBtn = page.getByTestId('migration-skip-button'); await expect(skipBtn).toBeEnabled({ timeout: 10000 }); @@ -350,10 +372,28 @@ export async function ensureDocumentsListed(page: Page, fileNames: string[]) { // Click the document link row by filename export async function clickDocumentLink(page: Page, fileName: string) { - await page + const link = page .getByRole('link', { name: new RegExp(escapeRegExp(fileName), 'i') }) - .first() - .click(); + .first(); + await expect(link).toBeVisible({ timeout: 15_000 }); + + const href = await link.getAttribute('href'); + if (!href) { + await link.click(); + return; + } + + const navigatedByClick = await Promise.all([ + page + .waitForURL((url) => url.pathname === href, { timeout: 8_000 }) + .then(() => true) + .catch(() => false), + link.click(), + ]).then(([ok]) => ok); + + if (!navigatedByClick) { + await page.goto(href); + } } // Expect correct URL and viewer to be visible for a given file by extension @@ -362,7 +402,7 @@ export async function expectViewerForFile(page: Page, fileName: string) { if (lower.endsWith('.pdf') || lower.endsWith('.docx')) { // DOCX converts to PDF, so viewer expectations are PDF await expect(page).toHaveURL(/\/pdf\/[A-Za-z0-9._%-]+$/); - await expect(page.locator('.react-pdf__Document')).toBeVisible({ timeout: 15000 }); + await waitForPdfViewerReady(page, 60000); return; } if (lower.endsWith('.epub')) { diff --git a/tests/landing-routing.spec.ts b/tests/landing-routing.spec.ts index f9b4bb4..ca9347c 100644 --- a/tests/landing-routing.spec.ts +++ b/tests/landing-routing.spec.ts @@ -37,6 +37,7 @@ test.describe('Landing and app routing', () => { }); test('documents back link returns to /app', async ({ page }, testInfo) => { + test.setTimeout(120_000); await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); diff --git a/tests/navigation.spec.ts b/tests/navigation.spec.ts index ddcf015..b642b20 100644 --- a/tests/navigation.spec.ts +++ b/tests/navigation.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; import { setupTest, uploadFiles, @@ -11,7 +11,7 @@ import { } from './helpers'; // Single-spec helpers kept local to avoid cluttering shared helpers: -async function navigateToPdfPageViaNavigator(page: any, targetPage: number) { +async function navigateToPdfPageViaNavigator(page: Page, targetPage: number) { // Navigator popover shows "X / Y" const navTrigger = page.getByRole('button', { name: /\d+\s*\/\s*\d+/ }); await expect(navTrigger).toBeVisible({ timeout: 10000 }); @@ -23,11 +23,11 @@ async function navigateToPdfPageViaNavigator(page: any, targetPage: number) { await input.press('Enter'); } -async function countRenderedPdfPages(page: any): Promise { +async function countRenderedPdfPages(page: Page): Promise { return await page.locator('.react-pdf__Page').count(); } -async function triggerViewportResize(page: any, width: number, height: number) { +async function triggerViewportResize(page: Page, width: number, height: number) { await page.setViewportSize({ width, height }); } @@ -37,6 +37,7 @@ test.describe('Document link navigation by type', () => { }); test('navigates to /pdf, /epub, /html and renders correct viewers', async ({ page }) => { + test.setTimeout(120_000); // Upload documents await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt'); @@ -65,10 +66,9 @@ test.describe('PDF view modes and Navigator', () => { }); test('switches Single/Dual/Scroll modes and uses Navigator to change page', async ({ page }) => { - test.setTimeout(60_000); + test.setTimeout(120_000); // Open PDF viewer await uploadAndDisplay(page, 'sample.pdf'); - await expect(page.locator('.react-pdf__Document')).toBeVisible({ timeout: 15000 }); // Open document settings (page-level settings) await page.getByRole('button', { name: 'Open settings' }).click(); diff --git a/tests/play.spec.ts b/tests/play.spec.ts index e9a6b3a..aeaaf68 100644 --- a/tests/play.spec.ts +++ b/tests/play.spec.ts @@ -45,6 +45,7 @@ test.describe('Play/Pause Tests', () => { test.describe.configure({ mode: 'serial', timeout: 60000 }); test('plays and pauses TTS for a PDF document', async ({ page }) => { + test.setTimeout(120_000); // Play TTS for the PDF document await playTTSAndWaitForASecond(page, 'sample.pdf'); @@ -61,6 +62,7 @@ test.describe('Play/Pause Tests', () => { }); test('plays and pauses TTS for an DOCX document', async ({ page }) => { + test.setTimeout(120_000); // Play TTS for the DOCX document await playTTSAndWaitForASecond(page, 'sample.docx'); @@ -77,6 +79,7 @@ test.describe('Play/Pause Tests', () => { }); test('switches to a single voice and resumes playing', async ({ page }) => { + test.setTimeout(120_000); // Start playback await playTTSAndWaitForASecond(page, 'sample.pdf'); @@ -96,6 +99,7 @@ test.describe('Play/Pause Tests', () => { }); test('keeps selected single voice instead of resetting to first option', async ({ page }) => { + test.setTimeout(120_000); await playTTSAndWaitForASecond(page, 'sample.pdf'); await openVoicesMenu(page); @@ -122,6 +126,7 @@ test.describe('Play/Pause Tests', () => { }); if (!process.env.CI) test('selects multiple Kokoro voices and resumes playing', async ({ page }) => { + test.setTimeout(120_000); // Start playback await playTTSAndWaitForASecond(page, 'sample.pdf'); @@ -142,6 +147,7 @@ test.describe('Play/Pause Tests', () => { }); test('changing TTS native speed toggles processing and returns to playing', async ({ page }) => { + test.setTimeout(120_000); await playTTSAndWaitForASecond(page, 'sample.pdf'); await changeNativeSpeedAndAssert(page, 1.5); await expectMediaState(page, 'playing'); diff --git a/tests/unit/compute-control-plane.spec.ts b/tests/unit/compute-control-plane.spec.ts new file mode 100644 index 0000000..7a9e338 --- /dev/null +++ b/tests/unit/compute-control-plane.spec.ts @@ -0,0 +1,201 @@ +import { expect, test } from '@playwright/test'; +import type { WorkerOperationRequest, WorkerOperationState } from '../../compute/core/src/api-contracts'; +import { + encodeSseFrame, + explainReplacementReason, + InMemoryOperationEventStream, + InMemoryOperationQueue, + InMemoryOperationStateStore, + OperationOrchestrator, + parseSseEventId, + parseSsePayload, + shouldReuseExistingOperation, +} from '../../compute/core/src/control-plane'; + +function buildPdfLayoutRequest(opKey: string): WorkerOperationRequest { + return { + kind: 'pdf_layout', + opKey, + payload: { + documentId: `doc-${opKey}`, + documentObjectKey: `s3://bucket/${opKey}.pdf`, + namespace: null, + }, + }; +} + +test.describe('compute control-plane', () => { + test('in-memory queue and state store support enqueue/claim/CAS', async () => { + const queue = new InMemoryOperationQueue(); + const store = new InMemoryOperationStateStore(); + + await queue.enqueue({ + jobId: 'job-1', + opId: 'op-1', + opKey: 'k-1', + kind: 'pdf_layout', + queuedAt: 1000, + payload: { documentId: 'd1', documentObjectKey: 'obj1', namespace: null }, + }); + await queue.enqueue({ + jobId: 'job-2', + opId: 'op-2', + opKey: 'k-2', + kind: 'whisper_align', + queuedAt: 1100, + payload: { text: 'hello', audioObjectKey: 'obj2' }, + }); + + expect(queue.size()).toBe(2); + expect(queue.size('pdf_layout')).toBe(1); + expect(queue.size('whisper_align')).toBe(1); + + const claimedLayout = await queue.claimNext('pdf_layout'); + expect(claimedLayout?.opId).toBe('op-1'); + expect(queue.size('pdf_layout')).toBe(0); + + const firstCas = await store.compareAndSetOpIndex({ + opKey: 'k-1', + newOpId: 'op-1', + expectedOpId: null, + }); + const secondCas = await store.compareAndSetOpIndex({ + opKey: 'k-1', + newOpId: 'op-2', + expectedOpId: null, + }); + + expect(firstCas).toBeTruthy(); + expect(secondCas).toBeFalsy(); + expect(await store.getOpIndex('k-1')).toEqual({ opId: 'op-1' }); + }); + + test('in-memory event stream replays from sinceEventId and streams live events', async () => { + const stream = new InMemoryOperationEventStream(); + + const queued: WorkerOperationState = { + opId: 'op-1', + opKey: 'k-1', + kind: 'pdf_layout', + jobId: 'job-1', + status: 'queued', + queuedAt: 1000, + updatedAt: 1000, + }; + const running: WorkerOperationState = { + ...queued, + status: 'running', + startedAt: 1200, + updatedAt: 1200, + }; + const succeeded: WorkerOperationState = { + ...running, + status: 'succeeded', + updatedAt: 1400, + result: { ok: true }, + }; + + await stream.append('op-1', queued); + await stream.append('op-1', running); + + const receivedEventIds: number[] = []; + const unsubscribe = await stream.subscribe({ + opId: 'op-1', + sinceEventId: 1, + onEvent: (event) => { + receivedEventIds.push(event.eventId); + }, + }); + + await stream.append('op-1', succeeded); + unsubscribe(); + + expect(receivedEventIds).toEqual([2, 3]); + }); + + test('orchestrator reuses fresh inflight operations and replaces stale ones', async () => { + let now = 1_000; + let nextId = 1; + const queue = new InMemoryOperationQueue(); + const stateStore = new InMemoryOperationStateStore(); + const eventStream = new InMemoryOperationEventStream(); + const orchestrator = new OperationOrchestrator({ + queue, + stateStore, + eventStream, + config: { opStaleMs: 2_000, maxCasRetries: 5 }, + clock: { now: () => now }, + idFactory: { + opId: () => `op-${nextId}`, + jobId: () => `job-${nextId++}`, + }, + }); + + const request = buildPdfLayoutRequest('same-op-key'); + + const first = await orchestrator.enqueueOrReuse(request); + expect(first.opId).toBe('op-1'); + expect(queue.size('pdf_layout')).toBe(1); + + now = 2_000; + const reused = await orchestrator.enqueueOrReuse(request); + expect(reused.opId).toBe('op-1'); + expect(queue.size('pdf_layout')).toBe(1); + + await orchestrator.markRunning({ opId: first.opId, updatedAt: 2_100 }); + + now = 6_000; + const replaced = await orchestrator.enqueueOrReuse(request); + expect(replaced.opId).toBe('op-2'); + expect(queue.size('pdf_layout')).toBe(2); + expect(await stateStore.getOpIndex('same-op-key')).toEqual({ opId: 'op-2' }); + + const op1Events = await eventStream.listSince('op-1', 0); + const op2Events = await eventStream.listSince('op-2', 0); + expect(op1Events.map((event) => event.eventId)).toEqual([1, 2]); + expect(op2Events.map((event) => event.eventId)).toEqual([1]); + }); + + test('state-machine helpers return consistent reuse/replacement decisions', () => { + const current: WorkerOperationState = { + opId: 'op-1', + opKey: 'same-op-key', + kind: 'pdf_layout', + jobId: 'job-1', + status: 'running', + queuedAt: 1000, + updatedAt: 2000, + }; + + expect(shouldReuseExistingOperation({ + current, + requestKind: 'pdf_layout', + now: 2500, + opStaleMs: 1_000, + })).toBeTruthy(); + + expect(shouldReuseExistingOperation({ + current, + requestKind: 'pdf_layout', + now: 4005, + opStaleMs: 1_000, + })).toBeFalsy(); + + expect(explainReplacementReason({ + current, + requestKind: 'pdf_layout', + now: 4005, + opStaleMs: 1_000, + })).toBe('stale_running'); + }); + + test('sse helpers encode and decode frame payload and id', () => { + const frame = encodeSseFrame({ + id: 42, + event: 'snapshot', + data: { ok: true }, + }); + expect(parseSseEventId(frame)).toBe(42); + expect(parseSsePayload(frame)).toBe('{"ok":true}'); + }); +}); diff --git a/tests/unit/compute-worker-control-plane-jetstream.spec.ts b/tests/unit/compute-worker-control-plane-jetstream.spec.ts new file mode 100644 index 0000000..fb3f3e3 --- /dev/null +++ b/tests/unit/compute-worker-control-plane-jetstream.spec.ts @@ -0,0 +1,216 @@ +import { expect, test } from '@playwright/test'; +import { OperationOrchestrator } from '../../compute/core/src/control-plane'; +import type { WorkerOperationRequest } from '../../compute/core/src/api-contracts'; +import { + JetStreamOperationEventStream, + JetStreamOperationQueue, + JetStreamOperationStateStore, + opEventsSubject, + opIndexKvKey, + opStateKvKey, + type KvEntryLike, + type KvStoreLike, +} from '../../compute/worker/src/control-plane/jetstream'; + +class FakeKvStore implements KvStoreLike { + private readonly data = new Map(); + private revision = 0; + + async get(key: string): Promise { + const value = this.data.get(key); + if (!value) return null; + return { + operation: value.operation, + value: value.value.slice(), + revision: value.revision, + }; + } + + async put(key: string, data: Uint8Array): Promise { + this.revision += 1; + this.data.set(key, { + operation: 'PUT', + value: data.slice(), + revision: this.revision, + }); + } + + async create(key: string, data: Uint8Array): Promise { + if (this.data.has(key)) { + throw new Error('key exists'); + } + this.revision += 1; + this.data.set(key, { + operation: 'PUT', + value: data.slice(), + revision: this.revision, + }); + } + + async update(key: string, data: Uint8Array, version: number): Promise { + const current = this.data.get(key); + if (!current || current.revision !== version) { + throw new Error('wrong last sequence'); + } + this.revision += 1; + this.data.set(key, { + operation: 'PUT', + value: data.slice(), + revision: this.revision, + }); + } +} + +class FakeJetStream { + private seq = 0; + readonly published: Array<{ subject: string; payload: unknown; seq: number }> = []; + readonly consumers = { + get: async () => { + throw new Error('not implemented in fake'); + }, + }; + + async publish(subject: string, data: Uint8Array): Promise<{ seq: number; stream: string; duplicate: boolean }> { + this.seq += 1; + const payload = JSON.parse(new TextDecoder().decode(data)) as unknown; + this.published.push({ subject, payload, seq: this.seq }); + return { seq: this.seq, stream: 'fake', duplicate: false }; + } +} + +function buildPdfRequest(opKey: string): WorkerOperationRequest { + return { + kind: 'pdf_layout', + opKey, + payload: { + documentId: 'd1', + namespace: null, + documentObjectKey: 's3://bucket/doc.pdf', + }, + }; +} + +test.describe('worker jetstream control-plane adapters', () => { + test('state store compareAndSet handles create/update semantics', async () => { + const kv = new FakeKvStore(); + const store = new JetStreamOperationStateStore({ getKv: async () => kv }); + + const created = await store.compareAndSetOpIndex({ + opKey: 'k1', + newOpId: 'op-1', + expectedOpId: null, + }); + const failedCreate = await store.compareAndSetOpIndex({ + opKey: 'k1', + newOpId: 'op-2', + expectedOpId: null, + }); + const wrongExpected = await store.compareAndSetOpIndex({ + opKey: 'k1', + newOpId: 'op-2', + expectedOpId: 'op-x', + }); + const updated = await store.compareAndSetOpIndex({ + opKey: 'k1', + newOpId: 'op-2', + expectedOpId: 'op-1', + }); + + expect(created).toBeTruthy(); + expect(failedCreate).toBeFalsy(); + expect(wrongExpected).toBeFalsy(); + expect(updated).toBeTruthy(); + expect(await store.getOpIndex('k1')).toEqual({ opId: 'op-2' }); + }); + + test('queue and event adapters publish expected JetStream subjects', async () => { + const js = new FakeJetStream(); + const queue = new JetStreamOperationQueue({ + getJs: async () => js as any, + whisperSubject: 'jobs.whisper', + layoutSubject: 'jobs.layout', + }); + const events = new JetStreamOperationEventStream({ + getJs: async () => js as any, + getJsm: async () => ({ + consumers: { + add: async () => ({ name: 'noop' }), + delete: async () => true, + }, + }) as any, + eventsStreamName: 'compute_events', + }); + + await queue.enqueue({ + jobId: 'j1', + opId: 'o1', + opKey: 'k1', + kind: 'pdf_layout', + queuedAt: 1000, + payload: { documentId: 'd1', namespace: null, documentObjectKey: 'obj' }, + }); + + const appended = await events.append('o1', { + opId: 'o1', + opKey: 'k1', + kind: 'pdf_layout', + jobId: 'j1', + status: 'queued', + queuedAt: 1000, + updatedAt: 1000, + }); + + expect(js.published.map((entry) => entry.subject)).toEqual(['jobs.layout', opEventsSubject('o1')]); + expect(appended.eventId).toBe(2); + }); + + test('orchestrator integration writes index/state and reuses active op', async () => { + const kv = new FakeKvStore(); + const js = new FakeJetStream(); + + const store = new JetStreamOperationStateStore({ getKv: async () => kv }); + const events = new JetStreamOperationEventStream({ + getJs: async () => js as any, + getJsm: async () => ({ + consumers: { + add: async () => ({ name: 'noop' }), + delete: async () => true, + }, + }) as any, + eventsStreamName: 'compute_events', + }); + const queue = new JetStreamOperationQueue({ + getJs: async () => js as any, + whisperSubject: 'jobs.whisper', + layoutSubject: 'jobs.layout', + }); + + let now = 1_000; + let nextId = 1; + const orchestrator = new OperationOrchestrator({ + queue, + stateStore: store, + eventStream: events, + config: { opStaleMs: 10_000, maxCasRetries: 3 }, + clock: { now: () => now }, + idFactory: { + opId: () => `op-${nextId}`, + jobId: () => `job-${nextId++}`, + }, + }); + + const req = buildPdfRequest('k-integration'); + const first = await orchestrator.enqueueOrReuse(req); + now = 2_000; + const reused = await orchestrator.enqueueOrReuse(req); + + expect(first.opId).toBe('op-1'); + expect(reused.opId).toBe('op-1'); + + const indexEntry = await kv.get(opIndexKvKey('k-integration')); + const stateEntry = await kv.get(opStateKvKey('op-1')); + expect(indexEntry?.operation).toBe('PUT'); + expect(stateEntry?.operation).toBe('PUT'); + expect(js.published.map((entry) => entry.subject)).toEqual(['ops.events.op-1', 'jobs.layout']); + }); +}); diff --git a/tests/unit/compute-worker-op-key.spec.ts b/tests/unit/compute-worker-op-key.spec.ts new file mode 100644 index 0000000..2101af6 --- /dev/null +++ b/tests/unit/compute-worker-op-key.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { buildPdfOpKey } from '../../src/lib/server/compute/worker'; + +test.describe('compute worker pdf opKey', () => { + test('keeps stable key when no force token is provided', () => { + const base = { + documentId: 'doc-123', + namespace: 'ns-1', + documentObjectKey: 'docs/ns-1/doc-123', + }; + expect(buildPdfOpKey(base)).toBe('pdf_layout|v1|doc-123|ns-1|docs/ns-1/doc-123|'); + expect(buildPdfOpKey(base)).toBe(buildPdfOpKey(base)); + }); + + test('cache-busts key when force token is provided', () => { + const base = { + documentId: 'doc-123', + namespace: 'ns-1', + documentObjectKey: 'docs/ns-1/doc-123', + }; + const opKeyA = buildPdfOpKey({ ...base, forceToken: 'force-a' }); + const opKeyB = buildPdfOpKey({ ...base, forceToken: 'force-b' }); + const normal = buildPdfOpKey(base); + + expect(opKeyA).not.toBe(opKeyB); + expect(opKeyA).not.toBe(normal); + expect(opKeyB).not.toBe(normal); + }); +}); diff --git a/tests/unit/html-audiobook-adapter.spec.ts b/tests/unit/html-audiobook-adapter.spec.ts index 15c1a31..9395533 100644 --- a/tests/unit/html-audiobook-adapter.spec.ts +++ b/tests/unit/html-audiobook-adapter.spec.ts @@ -30,7 +30,6 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', ( const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); expect(chapters.map((c) => c.title)).toEqual(['Alpha', 'Beta', 'Delta']); @@ -46,7 +45,6 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', ( const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); expect(chapters.map((c) => c.title)).toEqual(['Introduction', 'First Heading']); @@ -64,7 +62,6 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', ( const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, fallbackBlocksPerChapter: 3, }); const chapters = await adapter.prepareChapters(); @@ -88,7 +85,6 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', ( const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, chapterHeadingLevel: 1, }); const chapters = await adapter.prepareChapters(); @@ -109,7 +105,6 @@ test.describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () => const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: true, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); expect(chapters.map((c) => c.title)).toEqual(['Part 1', 'Part 2', 'Part 3']); @@ -128,7 +123,6 @@ test.describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () => const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: true, - smartSentenceSplitting: false, }); const chapters = await adapter.prepareChapters(); expect(chapters.length).toBe(1); @@ -142,7 +136,6 @@ test.describe('createHtmlAudiobookSourceAdapter — prepareChapter', () => { const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, }); const list = await adapter.prepareChapters(); const second = await adapter.prepareChapter(1); @@ -155,7 +148,6 @@ test.describe('createHtmlAudiobookSourceAdapter — prepareChapter', () => { const adapter = createHtmlAudiobookSourceAdapter({ blocks, isTxt: false, - smartSentenceSplitting: false, }); await expect(adapter.prepareChapter(42)).rejects.toThrow(/invalid chapter index/i); }); diff --git a/tests/unit/onboarding-state.spec.ts b/tests/unit/onboarding-state.spec.ts new file mode 100644 index 0000000..9379c2a --- /dev/null +++ b/tests/unit/onboarding-state.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test'; +import { ONBOARDING_STATE_REGISTRY } from '../../src/lib/shared/onboarding-state'; +import { SYNCED_PREFERENCE_KEYS } from '../../src/types/user-state'; + +test.describe('onboarding state storage scopes', () => { + test('keeps local onboarding flags out of synced server preferences', () => { + expect(ONBOARDING_STATE_REGISTRY.privacyAccepted.scope).toBe('local-dexie'); + expect(ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.scope).toBe('local-dexie'); + expect(ONBOARDING_STATE_REGISTRY.documentsMigrationPrompted.scope).toBe('local-dexie'); + expect(ONBOARDING_STATE_REGISTRY.changelogLastSeenAppVersion.scope).toBe('server-user-preferences'); + + const synced = new Set(SYNCED_PREFERENCE_KEYS); + + expect(synced.has(ONBOARDING_STATE_REGISTRY.privacyAccepted.localKey!)).toBe(false); + expect(synced.has(ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.localKey!)).toBe(false); + expect(synced.has(ONBOARDING_STATE_REGISTRY.documentsMigrationPrompted.localKey!)).toBe(false); + }); +}); + diff --git a/tests/unit/pdf-audiobook-adapter.spec.ts b/tests/unit/pdf-audiobook-adapter.spec.ts new file mode 100644 index 0000000..dd110b5 --- /dev/null +++ b/tests/unit/pdf-audiobook-adapter.spec.ts @@ -0,0 +1,135 @@ +import { expect, test } from '@playwright/test'; +import { createPdfAudiobookSourceAdapter } from '../../src/lib/client/audiobooks/adapters/pdf'; +import type { ParsedPdfDocument } from '../../src/types/parsed-pdf'; +import type { DocumentSettings } from '../../src/types/document-settings'; + +test.describe('pdf audiobook adapter', () => { + test('builds chapters from paragraph titles and filters skipped kinds', async () => { + const parsed: ParsedPdfDocument = { + schemaVersion: 1, + documentId: 'doc-1', + parserVersion: 'test', + parsedAt: Date.now(), + pages: [ + { + pageNumber: 1, + width: 100, + height: 100, + blocks: [ + { + id: 'b1', + kind: 'paragraph_title', + text: 'Intro', + fragments: [{ page: 1, bbox: [0, 80, 100, 90], text: 'Intro', readingOrder: 0 }], + }, + { + id: 'b2', + kind: 'text', + text: 'Welcome text.', + fragments: [{ page: 1, bbox: [0, 60, 100, 79], text: 'Welcome text.', readingOrder: 1 }], + }, + { + id: 'b3', + kind: 'header', + text: 'Header line', + fragments: [{ page: 1, bbox: [0, 95, 100, 100], text: 'Header line', readingOrder: 2 }], + }, + { + id: 'b4', + kind: 'paragraph_title', + text: 'Second', + fragments: [{ page: 1, bbox: [0, 40, 100, 50], text: 'Second', readingOrder: 3 }], + }, + { + id: 'b5', + kind: 'text', + text: 'More body.', + fragments: [{ page: 1, bbox: [0, 20, 100, 39], text: 'More body.', readingOrder: 4 }], + }, + ], + }, + ], + }; + + const settings: DocumentSettings = { + schemaVersion: 1, + pdf: { + skipBlockKinds: ['header'], + }, + }; + + const adapter = createPdfAudiobookSourceAdapter({ + parsed, + settings, + }); + + const chapters = await adapter.prepareChapters(); + expect(chapters).toHaveLength(2); + expect(chapters[0].title).toBe('Intro'); + expect(chapters[0].text).toContain('Welcome text.'); + expect(chapters[0].text).not.toContain('Header line'); + expect(chapters[1].title).toBe('Second'); + expect(chapters[1].text).toContain('More body.'); + }); + + test('keeps a single section chapter when only one heading is present', async () => { + const parsed: ParsedPdfDocument = { + schemaVersion: 1, + documentId: 'doc-2', + parserVersion: 'test', + parsedAt: Date.now(), + pages: [ + { + pageNumber: 1, + width: 100, + height: 100, + blocks: [ + { + id: 'p1-title', + kind: 'doc_title', + text: 'Sample PDF', + fragments: [{ page: 1, bbox: [0, 80, 100, 90], text: 'Sample PDF', readingOrder: 0 }], + }, + { + id: 'p1-text', + kind: 'text', + text: 'First page body.', + fragments: [{ page: 1, bbox: [0, 50, 100, 79], text: 'First page body.', readingOrder: 1 }], + }, + ], + }, + { + pageNumber: 2, + width: 100, + height: 100, + blocks: [ + { + id: 'p2-text', + kind: 'text', + text: 'Second page body.', + fragments: [{ page: 2, bbox: [0, 50, 100, 79], text: 'Second page body.', readingOrder: 0 }], + }, + ], + }, + ], + }; + + const settings: DocumentSettings = { + schemaVersion: 1, + pdf: { + skipBlockKinds: [], + }, + }; + + const adapter = createPdfAudiobookSourceAdapter({ + parsed, + settings, + }); + + const chapters = await adapter.prepareChapters(); + expect(chapters).toHaveLength(1); + expect(chapters[0].title).toBe('Sample PDF'); + expect(chapters[0].text).toContain('First page body.'); + expect(chapters[0].text).toContain('Second page body.'); + }); +}); diff --git a/tests/unit/pdf-build-page-text.spec.ts b/tests/unit/pdf-build-page-text.spec.ts new file mode 100644 index 0000000..247507c --- /dev/null +++ b/tests/unit/pdf-build-page-text.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { buildPageTextFromBlocks } from '../../src/lib/client/pdf-block-text'; +import type { ParsedPdfPage } from '../../src/types/parsed-pdf'; + +test.describe('buildPageTextFromBlocks', () => { + test('filters skipped kinds and preserves reading order', () => { + const page: ParsedPdfPage = { + pageNumber: 1, + width: 100, + height: 100, + blocks: [ + { + id: 'b2', + kind: 'header', + text: 'Copyright Header', + fragments: [{ page: 1, bbox: [0, 90, 100, 100], text: 'Copyright Header', readingOrder: 0 }], + }, + { + id: 'b1', + kind: 'text', + text: 'Body text', + fragments: [{ page: 1, bbox: [0, 20, 100, 80], text: 'Body text', readingOrder: 1 }], + }, + { + id: 'b3', + kind: 'figure_title', + text: 'Figure caption', + fragments: [{ page: 1, bbox: [0, 5, 100, 19], text: 'Figure caption', readingOrder: 2 }], + }, + ], + }; + + expect(buildPageTextFromBlocks(page, ['header'])).toBe('Body text Figure caption'); + expect(buildPageTextFromBlocks(page, ['header', 'figure_title'])).toBe('Body text'); + }); +}); diff --git a/tests/unit/pdf-force-reparse.spec.ts b/tests/unit/pdf-force-reparse.spec.ts new file mode 100644 index 0000000..95c2ce4 --- /dev/null +++ b/tests/unit/pdf-force-reparse.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { + FORCE_REPARSE_CONFIRM_MESSAGE, + FORCE_REPARSE_CONFIRM_TEXT, + FORCE_REPARSE_CONFIRM_TITLE, + isForceReparseDisabled, +} from '../../src/lib/client/pdf/force-reparse'; + +test.describe('pdf force reparse controls', () => { + test('disables action while parse is pending or running', () => { + expect(isForceReparseDisabled('pending')).toBeTruthy(); + expect(isForceReparseDisabled('running')).toBeTruthy(); + expect(isForceReparseDisabled('ready')).toBeFalsy(); + expect(isForceReparseDisabled('failed')).toBeFalsy(); + expect(isForceReparseDisabled(null)).toBeFalsy(); + }); + + test('confirmation copy warns about expensive rerun', () => { + expect(FORCE_REPARSE_CONFIRM_TITLE).toContain('Reparse'); + expect(FORCE_REPARSE_CONFIRM_TEXT).toContain('Reparse'); + expect(FORCE_REPARSE_CONFIRM_MESSAGE.toLowerCase()).toContain('from scratch'); + expect(FORCE_REPARSE_CONFIRM_MESSAGE.toLowerCase()).toContain('take a while'); + }); +}); diff --git a/tests/unit/pdf-merge-text-with-regions.spec.ts b/tests/unit/pdf-merge-text-with-regions.spec.ts new file mode 100644 index 0000000..86bd435 --- /dev/null +++ b/tests/unit/pdf-merge-text-with-regions.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; +import { mergeTextWithRegions } from '@openreader/compute-core'; + +test.describe('mergeTextWithRegions', () => { + test('assigns text items to containing regions by centroid and joins in reading order', () => { + const regions = [ + { bbox: [0, 0, 100, 50] as [number, number, number, number], label: 'text' as const }, + { bbox: [0, 50, 100, 100] as [number, number, number, number], label: 'figure_title' as const }, + ]; + + const textItems = [ + { text: 'world', x: 40, y: 20, width: 20, height: 8 }, + { text: 'hello', x: 10, y: 20, width: 20, height: 8 }, + { text: 'Figure', x: 10, y: 70, width: 24, height: 8 }, + { text: '1.2', x: 40, y: 70, width: 10, height: 8 }, + ]; + + const merged = mergeTextWithRegions(regions, textItems); + expect(merged).toHaveLength(2); + expect(merged[0].text).toBe('hello world'); + expect(merged[1].text).toBe('Figure 1.2'); + }); +}); diff --git a/tests/unit/pdf-parse-normalize-text-items.spec.ts b/tests/unit/pdf-parse-normalize-text-items.spec.ts new file mode 100644 index 0000000..aac976c --- /dev/null +++ b/tests/unit/pdf-parse-normalize-text-items.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from '@playwright/test'; + +import { normalizeTextItemsForLayout } from '@openreader/compute-core'; +import type { TextItem } from 'pdfjs-dist/types/src/display/api'; + +function makeTextItem( + str: string, + transform: [number, number, number, number, number, number], + width = 100, +): TextItem { + return { + str, + transform, + width, + height: Math.abs(transform[3]), + dir: 'ltr', + fontName: 'test', + hasEOL: false, + } as unknown as TextItem; +} + +test.describe('normalizeTextItemsForLayout', () => { + test('keeps horizontal body text and drops rotated/skewed margin text', () => { + const horizontal = makeTextItem( + 'Powered by large language models', + [10, 0, 0, 10, 100, 600], + ); + + // Typical 90deg-ish rotated/skewed run (like side metadata labels). + const rotated = makeTextItem( + 'arXiv:2407.16741v3 [cs.SE] 18 Apr 2025', + [0, 10, -10, 0, 30, 400], + ); + + const normalized = normalizeTextItemsForLayout([horizontal, rotated], 800); + expect(normalized).toHaveLength(1); + expect(normalized[0]?.text).toBe('Powered by large language models'); + }); +}); diff --git a/tests/unit/pdf-stitch-cross-page-blocks.spec.ts b/tests/unit/pdf-stitch-cross-page-blocks.spec.ts new file mode 100644 index 0000000..86e6d4a --- /dev/null +++ b/tests/unit/pdf-stitch-cross-page-blocks.spec.ts @@ -0,0 +1,111 @@ +import { expect, test } from '@playwright/test'; +import { stitchCrossPageBlocks } from '@openreader/compute-core'; +import type { ParsedPdfBlock, ParsedPdfDocument, ParsedPdfBlockKind } from '../../src/types/parsed-pdf'; + +function makeBlock( + id: string, + kind: ParsedPdfBlockKind, + text: string, + page: number, + readingOrder: number, +): ParsedPdfBlock { + return { + id, + kind, + text, + fragments: [{ + page, + bbox: [0, 0, 100, 10], + text, + readingOrder, + }], + }; +} + +function makeDoc(page1Blocks: ParsedPdfBlock[], page2Blocks: ParsedPdfBlock[]): ParsedPdfDocument { + return { + schemaVersion: 1, + documentId: 'doc', + parserVersion: 'test', + parsedAt: 0, + pages: [ + { pageNumber: 1, width: 100, height: 100, blocks: page1Blocks }, + { pageNumber: 2, width: 100, height: 100, blocks: page2Blocks }, + ], + }; +} + +test.describe('stitchCrossPageBlocks', () => { + test('stitches paragraph continuation across footer/header noise', () => { + const doc = makeDoc( + [ + makeBlock('b1', 'text', 'This sentence continues', 1, 0), + makeBlock('b2', 'footer', 'Footer text', 1, 1), + ], + [ + makeBlock('b3', 'header', 'Header text', 2, 0), + makeBlock('b4', 'text', 'into the next page.', 2, 1), + ], + ); + + const stitched = stitchCrossPageBlocks(doc); + const page1 = stitched.pages[0]; + const page2 = stitched.pages[1]; + + expect(page1?.blocks[0]?.text).toBe('This sentence continues into the next page.'); + expect(page1?.blocks[0]?.fragments).toHaveLength(2); + expect(page2?.blocks.map((b) => b.id)).toEqual(['b3']); + }); + + test('moves only the continuation sentence and keeps remaining text on next page', () => { + const doc = makeDoc( + [ + makeBlock('b1', 'text', 'This sentence continues', 1, 0), + ], + [ + makeBlock('b2', 'text', 'into the next page. This should stay on page two.', 2, 0), + ], + ); + + const stitched = stitchCrossPageBlocks(doc); + const page1 = stitched.pages[0]; + const page2 = stitched.pages[1]; + + expect(page1?.blocks[0]?.text).toBe('This sentence continues into the next page.'); + expect(page1?.blocks[0]?.fragments).toHaveLength(2); + expect(page2?.blocks).toHaveLength(1); + expect(page2?.blocks[0]?.id).toBe('b2'); + expect(page2?.blocks[0]?.text).toBe('This should stay on page two.'); + }); + + test('does not stitch across paragraph-title boundary', () => { + const doc = makeDoc( + [ + makeBlock('b1', 'text', 'This sentence continues', 1, 0), + ], + [ + makeBlock('b2', 'paragraph_title', '2 New Section', 2, 0), + makeBlock('b3', 'text', 'into the next page.', 2, 1), + ], + ); + + const stitched = stitchCrossPageBlocks(doc); + expect(stitched.pages[0]?.blocks[0]?.fragments).toHaveLength(1); + expect(stitched.pages[1]?.blocks.map((b) => b.id)).toEqual(['b2', 'b3']); + }); + + test('does not stitch when tail has sentence terminal', () => { + const doc = makeDoc( + [ + makeBlock('b1', 'text', 'This sentence is complete.', 1, 0), + ], + [ + makeBlock('b2', 'text', 'next sentence starts here', 2, 0), + ], + ); + + const stitched = stitchCrossPageBlocks(doc); + expect(stitched.pages[0]?.blocks[0]?.fragments).toHaveLength(1); + expect(stitched.pages[1]?.blocks.map((b) => b.id)).toEqual(['b2']); + }); +}); diff --git a/tests/unit/pdf-tts-planning.spec.ts b/tests/unit/pdf-tts-planning.spec.ts new file mode 100644 index 0000000..1066d7b --- /dev/null +++ b/tests/unit/pdf-tts-planning.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test'; + +import { buildPdfPageSourceUnits, buildPdfPrefetchPayload } from '../../src/lib/client/pdf-tts-planning'; +import type { ParsedPdfPage } from '../../src/types/parsed-pdf'; + +function buildPage(pageNumber: number): ParsedPdfPage { + return { + pageNumber, + width: 800, + height: 1200, + blocks: [ + { + id: `h-${pageNumber}`, + kind: 'header', + text: 'Header text', + fragments: [{ page: pageNumber, bbox: [0, 0, 1, 1], text: 'Header text', readingOrder: 0 }], + }, + { + id: `p-${pageNumber}-a`, + kind: 'text', + text: `Paragraph A on page ${pageNumber}.`, + // Regression guard: fragment page can drift; locator must stay pinned + // to the requested page number for stable planning/grouping. + fragments: [{ page: pageNumber + 100, bbox: [0, 0, 1, 1], text: 'x', readingOrder: 1 }], + }, + { + id: `p-${pageNumber}-b`, + kind: 'paragraph_title', + text: `Section title ${pageNumber}`, + fragments: [{ page: pageNumber, bbox: [0, 0, 1, 1], text: 'y', readingOrder: 2 }], + }, + { + id: `empty-${pageNumber}`, + kind: 'text', + text: ' ', + fragments: [{ page: pageNumber, bbox: [0, 0, 1, 1], text: 'z', readingOrder: 3 }], + }, + ], + }; +} + +test.describe('pdf tts planning helpers', () => { + test('buildPdfPageSourceUnits uses parsed blocks, honors skip kinds, and pins locator page', () => { + const page = buildPage(2); + const units = buildPdfPageSourceUnits(page, 2, ['header']); + + expect(units.map((u) => u.sourceKey)).toEqual([ + 'pdf:2:p-2-a', + 'pdf:2:p-2-b', + ]); + expect(units.map((u) => u.text)).toEqual([ + 'Paragraph A on page 2.', + 'Section title 2', + ]); + expect(units.map((u) => u.locator)).toEqual([ + { readerType: 'pdf', page: 2, blockId: 'p-2-a' }, + { readerType: 'pdf', page: 2, blockId: 'p-2-b' }, + ]); + }); + + test('buildPdfPrefetchPayload includes parsed sourceUnits for next and upcoming pages', () => { + const pages = new Map([ + [2, buildPage(2)], + [3, buildPage(3)], + [4, buildPage(4)], + ]); + const payload = buildPdfPrefetchPayload( + [2, 3, 4], + ['Page 2 text', 'Page 3 text', 'Page 4 text'], + (pageNum) => buildPdfPageSourceUnits(pages.get(pageNum), pageNum, ['header']), + ); + + expect(payload.nextText).toBe('Page 2 text'); + expect(payload.nextSourceUnits.map((u) => u.sourceKey)).toEqual([ + 'pdf:2:p-2-a', + 'pdf:2:p-2-b', + ]); + expect(payload.additionalUpcoming).toHaveLength(2); + expect(payload.additionalUpcoming[0]).toMatchObject({ + location: 3, + text: 'Page 3 text', + }); + expect(payload.additionalUpcoming[0].sourceUnits.map((u) => u.sourceKey)).toEqual([ + 'pdf:3:p-3-a', + 'pdf:3:p-3-b', + ]); + expect(payload.additionalUpcoming[1].sourceUnits.map((u) => u.sourceKey)).toEqual([ + 'pdf:4:p-4-a', + 'pdf:4:p-4-b', + ]); + }); + + test('buildPdfPrefetchPayload drops blank upcoming text entries', () => { + const pages = new Map([ + [2, buildPage(2)], + [3, buildPage(3)], + ]); + const payload = buildPdfPrefetchPayload( + [2, 3], + ['Page 2 text', ' '], + (pageNum) => buildPdfPageSourceUnits(pages.get(pageNum), pageNum, ['header']), + ); + + expect(payload.nextText).toBe('Page 2 text'); + expect(payload.additionalUpcoming).toHaveLength(0); + }); +}); diff --git a/tests/unit/server-error-contract.spec.ts b/tests/unit/server-error-contract.spec.ts new file mode 100644 index 0000000..84307d7 --- /dev/null +++ b/tests/unit/server-error-contract.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test'; +import { + ServerAppError, + createServerAppError, + isServerAppError, + normalizeServerError, + toApiErrorBody, + toHttpStatus, +} from '../../src/lib/server/errors/contract'; + +test.describe('server error contract', () => { + test('normalizes unknown throwable to fallback shape', () => { + const normalized = normalizeServerError('boom'); + expect(normalized.code).toBe('UNKNOWN_SERVER_ERROR'); + expect(normalized.errorClass).toBe('unknown'); + expect(normalized.httpStatus).toBe(500); + expect(normalized.retryable).toBe(false); + expect(normalized.message).toBe('boom'); + }); + + test('preserves ServerAppError metadata', () => { + const err = createServerAppError({ + code: 'USER_PROGRESS_UPDATE_FAILED', + message: 'Failed to update progress', + errorClass: 'db', + retryable: true, + httpStatus: 500, + details: { operation: 'update_progress' }, + }); + const normalized = normalizeServerError(err); + expect(isServerAppError(err)).toBe(true); + expect(normalized.code).toBe('USER_PROGRESS_UPDATE_FAILED'); + expect(normalized.errorClass).toBe('db'); + expect(normalized.httpStatus).toBe(500); + expect(normalized.retryable).toBe(true); + expect(normalized.details?.operation).toBe('update_progress'); + }); + + test('maps normalized errors to API body + status', () => { + const normalized = normalizeServerError( + new ServerAppError({ + code: 'UPSTREAM_TTS_ERROR', + message: 'Upstream failure', + errorClass: 'upstream', + }), + ); + const body = toApiErrorBody(normalized, { includeDetails: false }); + expect(body).toEqual({ + error: 'Upstream failure', + errorCode: 'UPSTREAM_TTS_ERROR', + retryable: true, + }); + expect(toHttpStatus(normalized)).toBe(502); + }); +}); diff --git a/tests/unit/server-error-response.spec.ts b/tests/unit/server-error-response.spec.ts new file mode 100644 index 0000000..a5ed779 --- /dev/null +++ b/tests/unit/server-error-response.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { errorResponse } from '../../src/lib/server/errors/next-response'; +import { createServerAppError } from '../../src/lib/server/errors/contract'; + +test.describe('server error response helper', () => { + test('returns mapped 4xx response with explicit app code', async () => { + const response = errorResponse( + createServerAppError({ + code: 'AUTH_UNAUTHORIZED', + message: 'Unauthorized', + errorClass: 'auth', + httpStatus: 401, + retryable: false, + }), + { + apiErrorMessage: 'Unauthorized', + }, + ); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ + error: 'Unauthorized', + errorCode: 'AUTH_UNAUTHORIZED', + retryable: false, + }); + }); + + test('normalizes unknown errors to safe 500 without stack leakage', async () => { + const response = errorResponse(new Error('sensitive internal details'), { + apiErrorMessage: 'Internal Server Error', + normalize: { code: 'UNKNOWN_SERVER_ERROR', errorClass: 'unknown' }, + }); + const body = await response.json(); + expect(response.status).toBe(500); + expect(body).toEqual({ + error: 'Internal Server Error', + errorCode: 'UNKNOWN_SERVER_ERROR', + retryable: false, + }); + expect(JSON.stringify(body)).not.toContain('stack'); + expect(JSON.stringify(body)).not.toContain('cause'); + }); +}); diff --git a/tests/unit/server-route-error-mappings.spec.ts b/tests/unit/server-route-error-mappings.spec.ts new file mode 100644 index 0000000..ac1a131 --- /dev/null +++ b/tests/unit/server-route-error-mappings.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test'; +import { errorResponse } from '../../src/lib/server/errors/next-response'; +import { AdminProviderError } from '../../src/lib/server/admin/providers'; + +test.describe('route error mapping contract', () => { + test('admin provider validation errors map to 4xx via route normalize policy', async () => { + const adminError = new AdminProviderError('slug is required', 400); + const response = errorResponse(adminError, { + apiErrorMessage: adminError.message, + normalize: { + code: 'ADMIN_PROVIDERS_CREATE_REQUEST_FAILED', + errorClass: 'validation', + httpStatus: adminError.status, + retryable: false, + }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'slug is required', + errorCode: 'ADMIN_PROVIDERS_CREATE_REQUEST_FAILED', + retryable: false, + }); + }); + + test('documents storage failures map to retryable 503', async () => { + const response = errorResponse(new Error('blobstore timeout'), { + apiErrorMessage: 'Failed to fetch document blob', + normalize: { + code: 'DOCUMENT_BLOB_FETCH_FAILED', + errorClass: 'storage', + }, + }); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: 'Failed to fetch document blob', + errorCode: 'DOCUMENT_BLOB_FETCH_FAILED', + retryable: true, + }); + }); + + test('audiobook upstream failures map to retryable 502', async () => { + const response = errorResponse(new Error('provider overloaded'), { + apiErrorMessage: 'Failed to process audio chapter', + normalize: { + code: 'AUDIOBOOK_CHAPTER_PROCESS_FAILED', + errorClass: 'upstream', + }, + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: 'Failed to process audio chapter', + errorCode: 'AUDIOBOOK_CHAPTER_PROCESS_FAILED', + retryable: true, + }); + }); + + test('user export auth initialization failure maps to 500 auth classification', async () => { + const response = errorResponse(new Error('Auth not initialized'), { + apiErrorMessage: 'Auth not initialized', + normalize: { + code: 'USER_EXPORT_AUTH_NOT_INITIALIZED', + errorClass: 'auth', + httpStatus: 500, + retryable: false, + }, + }); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + error: 'Auth not initialized', + errorCode: 'USER_EXPORT_AUTH_NOT_INITIALIZED', + retryable: false, + }); + }); +}); diff --git a/tests/unit/transfer-user-documents.spec.ts b/tests/unit/transfer-user-documents.spec.ts index 22681ca..ca58e28 100644 --- a/tests/unit/transfer-user-documents.spec.ts +++ b/tests/unit/transfer-user-documents.spec.ts @@ -20,6 +20,8 @@ test.describe('transferUserDocuments', () => { size INTEGER NOT NULL, last_modified INTEGER NOT NULL, file_path TEXT NOT NULL, + parse_state TEXT, + parsed_json_key TEXT, created_at INTEGER, PRIMARY KEY (id, user_id) ); diff --git a/tests/unit/tts-epub-preload.spec.ts b/tests/unit/tts-epub-preload.spec.ts index ea24f88..d780e51 100644 --- a/tests/unit/tts-epub-preload.spec.ts +++ b/tests/unit/tts-epub-preload.spec.ts @@ -30,7 +30,7 @@ test.describe('EPUB walker preload helpers', () => { expect(selectUpcomingWalkerItems(items, 'epubcfi(/6/2!/4/2)', 0)).toEqual([]); }); - test('buildWalkerPlanningSourceUnits includes live context when smart splitting is enabled', () => { + test('buildWalkerPlanningSourceUnits includes live context', () => { const contextUnits: CanonicalTtsSourceUnit[] = [ { sourceKey: 'previous:page-a', text: 'prev sentence', locator: null }, { sourceKey: 'page-a', text: 'current sentence', locator: { readerType: 'epub', location: 'page-a' } }, @@ -40,7 +40,7 @@ test.describe('EPUB walker preload helpers', () => { { sourceKey: 'page-c', text: 'upcoming two', locator: { readerType: 'epub', location: 'page-c' } }, ]; - const planned = buildWalkerPlanningSourceUnits(true, contextUnits, upcomingUnits); + const planned = buildWalkerPlanningSourceUnits(contextUnits, upcomingUnits); expect(planned.map((item) => item.sourceKey)).toEqual([ 'previous:page-a', 'page-a', @@ -48,17 +48,4 @@ test.describe('EPUB walker preload helpers', () => { 'page-c', ]); }); - - test('buildWalkerPlanningSourceUnits uses only upcoming units when smart splitting is disabled', () => { - const contextUnits: CanonicalTtsSourceUnit[] = [ - { sourceKey: 'previous:page-a', text: 'prev sentence', locator: null }, - { sourceKey: 'page-a', text: 'current sentence', locator: { readerType: 'epub', location: 'page-a' } }, - ]; - const upcomingUnits: CanonicalTtsSourceUnit[] = [ - { sourceKey: 'page-b', text: 'upcoming one', locator: { readerType: 'epub', location: 'page-b' } }, - ]; - - const planned = buildWalkerPlanningSourceUnits(false, contextUnits, upcomingUnits); - expect(planned.map((item) => item.sourceKey)).toEqual(['page-b']); - }); }); diff --git a/tests/unit/tts-segment-plan.spec.ts b/tests/unit/tts-segment-plan.spec.ts index 4efbd21..8f03797 100644 --- a/tests/unit/tts-segment-plan.spec.ts +++ b/tests/unit/tts-segment-plan.spec.ts @@ -164,6 +164,63 @@ test.describe('planCanonicalTtsSegments', () => { expect(plan.segments).toHaveLength(1); expect(plan.segments[0].ownerSourceKey).toBe('page:1'); }); + + test('keeps paragraph-title boundaries when source boundaries are enforced', () => { + const plan = planCanonicalTtsSegments([ + { + sourceKey: 'abstract', + locator: { page: 1, readerType: 'pdf', blockId: 'a1' }, + text: 'Released under the permissive MIT license, OpenHands is a community project spanning academia and industry with more than 2.1K contributions.', + }, + { + sourceKey: 'intro-title', + locator: { page: 1, readerType: 'pdf', blockId: 't1' }, + text: '1 INTRODUCTION', + }, + { + sourceKey: 'intro-body', + locator: { page: 1, readerType: 'pdf', blockId: 'p1' }, + text: 'Powered by large language models (LLMs; OpenAI 2024b; Team et al. 2023), user-facing AI systems have become increasingly capable of performing complex tasks such as accurately responding to user queries, solving math problems, and generating code.', + }, + ], { + readerType: 'pdf', + maxBlockLength: 450, + keyPrefix: 'doc:v1', + enforceSourceBoundaries: true, + }); + + expect(plan.segments.some((segment) => segment.ownerSourceKey === 'intro-title' && segment.text === '1 INTRODUCTION')).toBeTruthy(); + expect(plan.segments.some((segment) => segment.ownerSourceKey === 'intro-body' && segment.text.startsWith('Powered by large language models'))).toBeTruthy(); + expect(plan.segments.some((segment) => segment.text.startsWith('1 INTRODUCTION Powered by'))).toBeFalsy(); + }); + + test('does not drop first sentence when canonical rematch fails in enforced boundary mode', () => { + const plan = planCanonicalTtsSegments([ + { + sourceKey: 'title', + locator: { page: 1, readerType: 'pdf', blockId: 't1' }, + text: '1 INTRODUCTION', + }, + { + sourceKey: 'intro', + locator: { page: 1, readerType: 'pdf', blockId: 'p1' }, + // Missing whitespace after sentence terminal is a common PDF extraction artifact. + text: 'Powered by large language models have become increasingly capable of generating code.In particular, AI agents have recently received ever-increasing research focus.', + }, + ], { + readerType: 'pdf', + maxBlockLength: 450, + keyPrefix: 'doc:v1', + enforceSourceBoundaries: true, + }); + + const introSegments = plan.segments + .filter((segment) => segment.ownerSourceKey === 'intro') + .map((segment) => segment.text); + const combinedIntro = introSegments.join(' '); + expect(combinedIntro.includes('Powered by large language models')).toBeTruthy(); + expect(combinedIntro.includes('In particular, AI agents')).toBeTruthy(); + }); }); test.describe('buildSegmentKeyPrefix / buildSegmentKey contract', () => { diff --git a/tests/unit/whisper-alignment-mapping.spec.ts b/tests/unit/whisper-alignment-mapping.spec.ts new file mode 100644 index 0000000..5f91973 --- /dev/null +++ b/tests/unit/whisper-alignment-mapping.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '@playwright/test'; +import { + mapWordsToSentenceOffsets, +} from '@openreader/compute-core'; + +test.describe('whisper alignment mapping', () => { + test('maps words to sentence offsets with punctuation and repeated spaces', () => { + const aligned = mapWordsToSentenceOffsets('Hello, world again.', [ + { word: 'Hello', start: 0, end: 0.25 }, + { word: 'world', start: 0.25, end: 0.5 }, + { word: 'again', start: 0.5, end: 1.0 }, + ]); + + expect(aligned.words).toHaveLength(3); + expect(aligned.words[0].charStart).toBe(0); + expect(aligned.words[0].charEnd).toBe(5); + expect(aligned.words[1].charStart).toBeGreaterThan(aligned.words[0].charEnd); + expect(aligned.words[2].charStart).toBeGreaterThan(aligned.words[1].charEnd); + expect(aligned.words[2].charEnd).toBeLessThanOrEqual('Hello, world again.'.length); + }); +}); diff --git a/tests/unit/whisper-spectral.spec.ts b/tests/unit/whisper-spectral.spec.ts new file mode 100644 index 0000000..90857a2 --- /dev/null +++ b/tests/unit/whisper-spectral.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '@playwright/test'; +import { buildGoertzelCoefficients, goertzelPower } from '@openreader/compute-core'; + +function dftPower(samples: Float32Array, k: number): number { + const n = samples.length; + let re = 0; + let im = 0; + for (let i = 0; i < n; i += 1) { + const angle = (-2 * Math.PI * k * i) / n; + re += samples[i] * Math.cos(angle); + im += samples[i] * Math.sin(angle); + } + return (re * re) + (im * im); +} + +test.describe('whisper spectral helpers', () => { + test('goertzel power matches direct DFT for non-power-of-two frame size', () => { + const frameSize = 400; + const bins = 201; + const coeffs = buildGoertzelCoefficients(bins, frameSize); + const samples = new Float32Array(frameSize); + for (let i = 0; i < frameSize; i += 1) { + samples[i] = Math.sin((2 * Math.PI * 37 * i) / frameSize) + (0.2 * Math.cos((2 * Math.PI * 91 * i) / frameSize)); + } + + const testBins = [0, 7, 37, 91, 150, 200]; + for (const k of testBins) { + const expected = dftPower(samples, k); + const actual = goertzelPower(samples, coeffs[k]); + const rel = Math.abs(actual - expected) / Math.max(1, Math.abs(expected)); + expect(rel).toBeLessThan(1e-5); + } + }); +}); diff --git a/tests/unit/whisper-token-timestamps.spec.ts b/tests/unit/whisper-token-timestamps.spec.ts new file mode 100644 index 0000000..a584d6f --- /dev/null +++ b/tests/unit/whisper-token-timestamps.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from '@playwright/test'; +import * as ort from 'onnxruntime-node'; +import { + buildWordsFromTimestampedTokens, + extractTokenStartTimestamps, +} from '@openreader/compute-core'; + +test.describe('whisper token timestamp alignment', () => { + test('extracts monotonic token timestamps from cross-attention maps', () => { + const seqLen = 6; + const frames = 10; + const heads = 8; + const data = new Float32Array(1 * heads * seqLen * frames); + for (let s = 0; s < seqLen; s += 1) { + const peak = Math.min(frames - 1, s + 1); + for (let f = 0; f < frames; f += 1) { + const val = -Math.abs(f - peak); + const idx = (((0 * seqLen) + s) * frames) + f; + data[idx] = val; + } + } + + const cross = { + 'cross_attentions.0': new ort.Tensor('float32', data, [1, heads, seqLen, frames]), + }; + + const ts = extractTokenStartTimestamps({ + crossAttentions: cross, + decoderLayers: 6, + alignmentHeads: [[0, 0]], + numFrames: frames, + numInputIds: 3, + sequenceLength: seqLen, + timePrecision: 0.02, + }); + + expect(ts).toHaveLength(seqLen); + expect(ts[0]).toBe(0); + expect(ts[1]).toBe(0); + expect(ts[2]).toBe(0); + expect(ts[3]).toBeGreaterThanOrEqual(0); + expect(ts[4]).toBeGreaterThanOrEqual(ts[3]); + expect(ts[5]).toBeGreaterThanOrEqual(ts[4]); + }); + + test('builds word timings from token timestamps with punctuation merge', () => { + const tokenText: Record = { + 100: ' hello', + 101: ' world', + 102: '!', + }; + const tokenizer = { + decode(tokens: number[]) { + return tokens.map((t) => tokenText[t] ?? '').join(''); + }, + }; + + const timestampBeginTokenId = 50364; + const tokens = [ + 1, 2, 3, + timestampBeginTokenId, + 100, 101, 102, + timestampBeginTokenId + 50, + ]; + const starts = [0, 0, 0, 0, 0.1, 0.3, 0.5, 1.0]; + + const words = buildWordsFromTimestampedTokens({ + tokens, + tokenStartTimestamps: starts, + tokenizer, + eosTokenId: 50257, + promptLength: 3, + timestampBeginTokenId, + timePrecision: 0.02, + language: 'en', + }); + + expect(words.length).toBe(2); + expect(words[0].word.toLowerCase()).toContain('hello'); + expect(words[1].word.toLowerCase()).toContain('world'); + expect(words[1].word).toContain('!'); + expect(words[0].startSec).toBeGreaterThanOrEqual(0); + expect(words[1].endSec).toBeGreaterThanOrEqual(words[1].startSec); + }); +}); diff --git a/tests/upload.spec.ts b/tests/upload.spec.ts index 6a565e2..7c66b73 100644 --- a/tests/upload.spec.ts +++ b/tests/upload.spec.ts @@ -1,6 +1,15 @@ import { test, expect } from '@playwright/test'; import { uploadFile, uploadAndDisplay, setupTest, expectDocumentListed, uploadFiles, ensureDocumentsListed, clickDocumentLink, expectViewerForFile } from './helpers'; +interface HtmlDocumentRow { + id?: string; + data?: string; +} + +type HashCheckResult = + | { ok: true; storedId: string; computedId: string } + | { ok: false; reason: 'Missing stored html document' | 'Hash mismatch'; storedId?: string; computedId?: string }; + test.describe('Document Upload Tests', () => { test.beforeEach(async ({ page }, testInfo) => { await setupTest(page, testInfo); @@ -25,7 +34,7 @@ test.describe('Document Upload Tests', () => { await uploadFile(page, 'sample.txt'); await expectDocumentListed(page, 'sample.txt'); - const result = await page.evaluate(async () => { + const result = await page.evaluate(async () => { const idb = await new Promise((resolve, reject) => { const request = indexedDB.open('openreader-db'); request.onerror = () => reject(request.error); @@ -33,12 +42,12 @@ test.describe('Document Upload Tests', () => { }); try { - const docs = await new Promise((resolve, reject) => { + const docs = await new Promise((resolve, reject) => { const tx = idb.transaction('html-documents', 'readonly'); const store = tx.objectStore('html-documents'); const request = store.getAll(); request.onerror = () => reject(request.error); - request.onsuccess = () => resolve(request.result as any[]); + request.onsuccess = () => resolve(request.result as HtmlDocumentRow[]); }); if (!docs[0]?.data || !docs[0]?.id) { @@ -51,13 +60,19 @@ test.describe('Document Upload Tests', () => { .map((b) => b.toString(16).padStart(2, '0')) .join(''); - return { ok: computedId === docs[0].id, storedId: docs[0].id as string, computedId }; + if (computedId === docs[0].id) { + return { ok: true as const, storedId: docs[0].id as string, computedId }; + } + return { ok: false as const, reason: 'Hash mismatch', storedId: docs[0].id as string, computedId }; } finally { idb.close(); } }); - expect(result.ok, `Expected storedId=${(result as any).storedId} computedId=${(result as any).computedId}`).toBeTruthy(); + const detail = result.ok + ? `Expected storedId=${result.storedId} computedId=${result.computedId}` + : `Expected valid stored html document but got reason=${result.reason}`; + expect(result.ok, detail).toBeTruthy(); }); test('uploads and converts a DOCX document', async ({ page }) => { @@ -73,11 +88,12 @@ test.describe('Document Upload Tests', () => { }); test('displays a PDF document', async ({ page }) => { + test.setTimeout(120_000); await uploadAndDisplay(page, 'sample.pdf'); await expectViewerForFile(page, 'sample.pdf'); // Additional content checks specific to the sample PDF - await expect(page.locator('.react-pdf__Page')).toBeVisible(); - await expect(page.getByText('Sample PDF')).toBeVisible(); + await expect(page.getByRole('heading', { level: 1, name: 'sample.pdf' })).toBeVisible(); + await expect(page.getByRole('button', { name: /1\s*\/\s*2/ })).toBeVisible(); }); test('displays an EPUB document', async ({ page }) => { @@ -89,10 +105,10 @@ test.describe('Document Upload Tests', () => { }); test('displays a DOCX document as PDF after conversion', async ({ page }) => { + test.setTimeout(120_000); await uploadAndDisplay(page, 'sample.docx'); await expectViewerForFile(page, 'sample.docx'); // DOCX converts to PDF // Keep specific content checks - await expect(page.locator('.react-pdf__Page')).toBeVisible(); await expect(page.getByText('Demonstration of DOCX')).toBeVisible(); }); @@ -103,6 +119,7 @@ test.describe('Document Upload Tests', () => { }); test('uploads PDF/EPUB/TXT and opens correct viewer for each', async ({ page }) => { + test.setTimeout(120_000); // Upload multiple files await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt'); diff --git a/tsconfig.json b/tsconfig.json index 3dfc4fb..2c7a43b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,9 +19,20 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@openreader/compute-core": ["./compute/core/src/index.ts"], + "@openreader/compute-core/api-contracts": ["./compute/core/src/api-contracts/index.ts"], + "@openreader/compute-core/control-plane": ["./compute/core/src/control-plane/index.ts"], + "@openreader/compute-core/local-runtime": ["./compute/core/src/local-runtime.ts"], + "@openreader/compute-core/types": ["./compute/core/src/types/index.ts"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules", "docs-site", "docs-site/**"] + "exclude": [ + "node_modules", + "docs-site", + "docs-site/**", + "compute/worker", + "compute/worker/**" + ] }