Refactor user data cleanup and TTS storage flows (#105)

* refactor(user): overhaul user data cleanup and export for cascading deletes and TTS segment support

Revise user data cleanup logic to ensure proper cascading deletion of user-related
database rows and S3 objects, including shared document and preview artifacts.
Introduce explicit checks for last ownership before removing shared resources.
Add TTS segment cache and audio cleanup to document and user deletion flows.
Expand user data export to include TTS segment entries and audio, job events,
document settings, and linked auth sessions. Update schema with ON DELETE CASCADE
for userTtsChars and userJobEvents. Add new migration scripts and comprehensive
unit tests for cleanup and export scenarios.

* test(user): expand cleanup and export coverage for shared docs and TTS segment files

Add tests for user data cleanup with shared document ownership and for TTS segment
variant export scenarios, including cases with duplicate audio keys and storage
disabled. Refactor cleanup logic to use document row types and transactional
ownership removal with last-owner checks. Update TTS segment cache clearing to
report deleted segment count by unique entry. Adjust export logic to always
include all TTS segment variants and conditionally export file buckets based on
storage availability. Update migration to use NOT VALID/VALIDATE for new
cascading constraints.

* refactor(documents): centralize owned document deletion and add mutation locking

Introduce `deleteOwnedDocument` utility to encapsulate all logic for removing a user's ownership of a document, including TTS segment cache cleanup, preview artifact removal, and S3 blob deletion for last-owner cases. Add `withDocumentMutationLock` to serialize concurrent mutations on the same document, using advisory locks in Postgres and a local queue fallback. Refactor all API routes and user data flows to use these utilities, ensuring transactional safety and preventing race conditions during document deletion or transfer. Update tests for new flows and add coverage for document cleanup sequencing and locking behavior.

* feat(data): enhance anonymous claim to support document settings and TTS segment copy

Expand the anonymous data claim process to include document settings transfer and TTS segment S3 prefix copying. Remove foreign key constraints from user_tts_chars to allow non-user buckets. Update claim modal and onboarding flow to display claimed document settings. Refactor TTS char count transfer to merge all dates and fix upsert logic. Add S3 copy utility for TTS segments and corresponding tests. Update migrations and schema to reflect relaxed constraints.

* refactor(data): improve error handling and rollback for document and TTS segment operations

Enhance robustness of document deletion and TTS segment transfer by improving
error logging, partial rollback, and degraded state reporting. Add best-effort
cleanup and recovery mechanisms to prevent orphaned data and ensure diagnostic
information is captured for unexpected failures. Update user data cleanup to
log and swallow restoration errors without masking primary failures. Refine
claim logic to handle unmapped TTS audio keys safely.

* refactor(data): unify document mutation locking and transaction handling

Replace mutation-lock with document-lock to centralize document mutation
serialization and database transaction management. Introduce runInDbTransaction
utility to abstract SQLite/Postgres transaction differences. Update document
deletion, user data cleanup, and rate limiter logic to delegate transaction
handling and locking to shared helpers. Remove dialect-specific branching and
inline transaction logic for improved maintainability and testability.

BREAKING CHANGE: withDocumentMutationLock is removed in favor of withDocumentLock and runInDbTransaction

* fix(documents): improve rollback error handling in deleteOwnedDocument

Enhance error handling during document deletion by ensuring that failures
in restoring document ownership do not obscure the original error. Log
degraded events when ownership restoration fails after a deletion error,
providing additional context for debugging and monitoring. This change
improves reliability and traceability of document deletion operations.

* feat(tasks): introduce scheduled task engine and admin UI for background jobs

Add a general-purpose scheduled task system with a persistent registry and status tracking, supporting background maintenance jobs such as orphaned blob reaping, expired upload cleanup, job event pruning, and TTS usage retention. Implement a new `scheduled_tasks` table, task engine, and handlers for each maintenance operation. Integrate an admin UI panel for monitoring, manual runs, and configuration of tasks. Update document and user data cleanup flows to delegate shared blob and preview deletion to the scheduled reaper. Add Vercel cron integration for serverless environments.

BREAKING CHANGE: Document and user storage cleanup now relies on background scheduled tasks for shared blob and preview deletion; immediate inline deletion is no longer performed.

* refactor(user): streamline document and TTS segment transfer logic

Simplify user document transfer by consolidating storage and metadata handling. Remove inline deletion of shared blobs; only metadata is moved, and TTS segment transfer is controlled via options. Add `skipStorage` option for test scenarios to bypass storage operations. Update tests to reflect new transfer behavior.

* chore(instrumentation): delegate node-specific setup to separate module

Move Node.js-specific instrumentation logic to a dedicated file. Update
registration to dynamically import the node module only when running in a
Node.js environment. This separation clarifies environment-specific behavior
and improves maintainability.

* ci(config): update scheduled task cron to run daily at midnight

Change cron schedule for /api/admin/tasks/tick from hourly to once daily at
midnight to reduce task frequency and align with updated operational
requirements.

* refactor(tasks): enforce positive interval and improve scheduled task updates

Add database-level check constraints to ensure scheduled task intervals are always
positive for both Postgres and SQLite. Update admin API and UI to support sub-minute
intervals and stricter validation. Refactor scheduled task update logic to upsert
rows, ensuring tasks can be updated even if not yet present in the database.
Improve temporary upload cleanup to delete in paginated batches. Enhance tests to
cover new constraints and update behaviors.

* chore(docker): remove ffmpeg-static from runtime dependencies in image build

Eliminate ffmpeg-static from the production node_modules during Docker image
assembly to streamline the deployment artifact and avoid bundling unused binaries.

* refactor(admin): redesign task panel UI with Card layout and running indicator

Replace the bordered div layout in AdminTasksPanel with the Card component for
improved visual hierarchy. Add a dynamic running indicator using a custom
RunningDot component to clearly show active tasks. Update control alignment and
button states for better usability. Remove Badge-based status display in favor
of a more streamlined appearance.

* feat(tasks): add document blob lease for safe orphan reaping and improve scheduled task robustness

Introduce a document blob lease mechanism to prevent race conditions between document registration and orphaned blob cleanup. The new `document_blob_leases` table ensures that only one process can claim a document blob for mutation or deletion at a time. Update the orphan reaper to acquire a lease before deleting blobs and to re-check for ownership after acquiring the lease, avoiding accidental deletion of in-flight uploads.

Enhance scheduled task infrastructure with per-task fencing tokens to prevent stale runners from overwriting newer results, enforce runtime limits with abort signals, and expose scheduler mode and minimum interval to the admin panel and API. Adjust task handlers to accept a context with abort support, and update documentation and environment variable references for the new cron secret and scheduling behavior.

BREAKING CHANGE: Scheduled tasks now require a `document_blob_leases` table and updated handler signatures. Vercel deployments must set `CRON_SECRET` for scheduled maintenance.

* refactor(admin): add loading skeleton to tasks panel for improved UX

Introduce a TasksSkeleton component to display animated placeholders while scheduled tasks are loading. Replace direct rendering of empty task rows with the skeleton when data is pending, enhancing perceived responsiveness and user experience in the admin tasks panel.

* test: clean up playwright anonymous users

* test: make blob lease expiry deterministic

* fix(documents): improve blob lease release error handling and test stale lease scenario

Handle errors during blob lease release by logging warnings instead of allowing them to mask original results. Update unit tests to verify that releasing a stale lease does not affect a replacement lease.

* refactor(documents): implement exponential backoff with jitter for blob lease retries

Replace fixed retry delay with capped exponential backoff and jitter to reduce
contention and thundering herd effect when acquiring document blob leases. This
improves lease acquisition fairness and efficiency under high load.
This commit is contained in:
Richard R 2026-06-07 13:33:20 -06:00 committed by GitHub
parent 5185974eb3
commit e670c38523
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 11357 additions and 523 deletions

View file

@ -29,6 +29,10 @@ AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional tr
# (Optional) Comma-separated list of emails that are auto-promoted to admin. # (Optional) Comma-separated list of emails that are auto-promoted to admin.
ADMIN_EMAILS= ADMIN_EMAILS=
# (Required for the Vercel scheduled-task cron route; Vercel sends it as a bearer token.)
# Self-hosted deployments run scheduled tasks in-process and do not require this.
# CRON_SECRET=
# (Optional) Backend DB used for server-side metadata (documents/audiobooks) and auth tables. # (Optional) Backend DB used for server-side metadata (documents/audiobooks) and auth tables.
# Defaults to SQLite at docstore/sqlite3.db when not set. # Defaults to SQLite at docstore/sqlite3.db when not set.
# POSTGRES_URL= # POSTGRES_URL=

View file

@ -76,6 +76,7 @@ COPY --from=app-builder /opt/entrypoint-migration-tools/node_modules /tmp/runtim
RUN mkdir -p /app/node_modules && \ RUN mkdir -p /app/node_modules && \
rm -rf /tmp/runtime-tools-node_modules/@aws-sdk \ rm -rf /tmp/runtime-tools-node_modules/@aws-sdk \
/tmp/runtime-tools-node_modules/better-sqlite3 \ /tmp/runtime-tools-node_modules/better-sqlite3 \
/tmp/runtime-tools-node_modules/ffmpeg-static \
/tmp/runtime-tools-node_modules/pg && \ /tmp/runtime-tools-node_modules/pg && \
cp -an /tmp/runtime-tools-node_modules/. /app/node_modules/ && \ cp -an /tmp/runtime-tools-node_modules/. /app/node_modules/ && \
rm -rf /tmp/runtime-tools-node_modules rm -rf /tmp/runtime-tools-node_modules

View file

@ -0,0 +1,18 @@
export async function persistParsedPdfWhileSourceExists(input: {
sourceObjectKey: string;
sourceExists: (key: string) => Promise<boolean>;
putParsedObject: () => Promise<string>;
deleteParsedObject: (key: string) => Promise<void>;
}): Promise<string> {
if (!await input.sourceExists(input.sourceObjectKey)) {
throw new Error('PDF source object was deleted before parsed output could be persisted');
}
const parsedObjectKey = await input.putParsedObject();
if (!await input.sourceExists(input.sourceObjectKey)) {
await input.deleteParsedObject(parsedObjectKey);
throw new Error('PDF source object was deleted while parsed output was being persisted');
}
return parsedObjectKey;
}

View file

@ -49,7 +49,13 @@ import type {
WorkerOperationState, WorkerOperationState,
PdfLayoutProgress, PdfLayoutProgress,
} from '@openreader/compute-core/api-contracts'; } from '@openreader/compute-core/api-contracts';
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { import {
JetStreamOperationEventStream, JetStreamOperationEventStream,
OP_EVENTS_SUBJECT_WILDCARD, OP_EVENTS_SUBJECT_WILDCARD,
@ -64,6 +70,7 @@ import {
} from './orphan-recovery'; } from './orphan-recovery';
import { buildInferProgressForPageParsed, buildInferProgressForPageStart } from './pdf-progress'; import { buildInferProgressForPageParsed, buildInferProgressForPageStart } from './pdf-progress';
import { buildQueueWaitTiming, decideRetryAction } from './worker-loop-policy'; import { buildQueueWaitTiming, decideRetryAction } from './worker-loop-policy';
import { persistParsedPdfWhileSourceExists } from './pdf-artifact-persistence';
const JOBS_STREAM_NAME = 'compute_jobs'; const JOBS_STREAM_NAME = 'compute_jobs';
const WHISPER_JOBS_SUBJECT = 'jobs.whisper'; const WHISPER_JOBS_SUBJECT = 'jobs.whisper';
@ -701,6 +708,42 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
return toArrayBuffer(new Uint8Array(bytes)); return toArrayBuffer(new Uint8Array(bytes));
}; };
const objectExists = async (key: string): Promise<boolean> => {
if (!s3) {
throw new Error('S3 access is disabled for this worker app instance');
}
const safeKey = ensureSafeKey(key);
try {
await s3.send(new HeadObjectCommand({
Bucket: s3Bucket,
Key: safeKey,
}));
return true;
} catch (error) {
const maybe = error as { name?: string; Code?: string; $metadata?: { httpStatusCode?: number } };
if (
maybe.$metadata?.httpStatusCode === 404
|| maybe.name === 'NotFound'
|| maybe.name === 'NoSuchKey'
|| maybe.Code === 'NotFound'
|| maybe.Code === 'NoSuchKey'
) {
return false;
}
throw error;
}
};
const deleteObjectByKey = async (key: string): Promise<void> => {
if (!s3) {
throw new Error('S3 access is disabled for this worker app instance');
}
await s3.send(new DeleteObjectCommand({
Bucket: s3Bucket,
Key: ensureSafeKey(key),
}));
};
const putParsedObject = async (documentId: string, namespace: string | null, parsed: unknown): Promise<string> => { const putParsedObject = async (documentId: string, namespace: string | null, parsed: unknown): Promise<string> => {
if (!s3) { if (!s3) {
throw new Error('S3 access is disabled for this worker app instance'); throw new Error('S3 access is disabled for this worker app instance');
@ -1175,7 +1218,12 @@ export async function createComputeWorkerApp(options: CreateComputeWorkerAppOpti
phase: 'merge', phase: 'merge',
}); });
} }
const parsedObjectKey = await putParsedObject(parsed.documentId, parsed.namespace, result.parsed); const parsedObjectKey = await persistParsedPdfWhileSourceExists({
sourceObjectKey: parsed.documentObjectKey,
sourceExists: objectExists,
putParsedObject: () => putParsedObject(parsed.documentId, parsed.namespace, result.parsed),
deleteParsedObject: deleteObjectByKey,
});
return { return {
parsedObjectKey, parsedObjectKey,
timing: { timing: {

View file

@ -0,0 +1,33 @@
import { describe, expect, test, vi } from 'vitest';
import { persistParsedPdfWhileSourceExists } from '../../src/pdf-artifact-persistence';
describe('PDF artifact persistence', () => {
test('does not write parsed output after the source was deleted', async () => {
const putParsedObject = vi.fn(async () => 'parsed.json');
await expect(persistParsedPdfWhileSourceExists({
sourceObjectKey: 'document.pdf',
sourceExists: async () => false,
putParsedObject,
deleteParsedObject: async () => undefined,
})).rejects.toThrow('before parsed output');
expect(putParsedObject).not.toHaveBeenCalled();
});
test('removes parsed output when the source disappears during the write', async () => {
const sourceExists = vi.fn()
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false);
const deleteParsedObject = vi.fn(async () => undefined);
await expect(persistParsedPdfWhileSourceExists({
sourceObjectKey: 'document.pdf',
sourceExists,
putParsedObject: async () => 'parsed.json',
deleteParsedObject,
})).rejects.toThrow('while parsed output');
expect(deleteParsedObject).toHaveBeenCalledWith('parsed.json');
});
});

View file

@ -117,6 +117,17 @@ At the end of the **Site features** tab, a dedicated **TTS upstream** group cont
In v4 these settings are admin-only and are no longer configurable through environment variables. In v4 these settings are admin-only and are no longer configurable through environment variables.
## Scheduled tasks
The **Scheduled tasks** section controls background maintenance jobs such as expired-upload cleanup, orphaned-blob reaping, and rate-limit ledger pruning.
- Enable or disable each task, adjust its interval, or run it immediately.
- Runs use database-backed leases so multiple app instances do not normally execute the same task concurrently.
- A task that exceeds four minutes is aborted and recorded as failed. A crashed run can be reclaimed after its stale lease expires.
- Failures and the latest successful summary appear on the task card and in server logs.
Self-hosted Node.js deployments tick the scheduler in-process once per minute. Vercel uses the authenticated `/api/admin/tasks/tick` cron route; the checked-in Vercel Hobby schedule runs once daily, so intervals shorter than one day are unavailable there. See [Vercel Deployment](../deploy/vercel-deployment#5-scheduled-maintenance-tasks).
## Migrating off env vars ## Migrating off env vars
In v4, runtime site features are managed by admin settings and optional JSON seed. To minimize env surface area: In v4, runtime site features are managed by admin settings and optional JSON seed. To minimize env surface area:

View file

@ -37,6 +37,6 @@ If a request exceeds the active limit, the TTS API returns `429` with reset meta
## Related docs ## Related docs
- TTS/rate-limit environment variables: [Environment Variables](../reference/environment-variables#tts-provider-and-request-behavior) - TTS/rate-limit environment variables: [Environment Variables](../reference/environment-variables#tts-provider-and-request-behavior)
- PDF parsing rate limiting (separate, compute-side throttle): [Admin Panel → Site features](./admin-panel#site-features) and [Environment Variables](../reference/environment-variables#compute-pdf-parsing-rate-limiting-runtime-settings) - PDF parsing rate limiting (separate, compute-side throttle): [Admin Panel → Rate limiting](./admin-panel#rate-limiting)
- Auth configuration: [Auth](./auth) - Auth configuration: [Auth](./auth)
- Provider setup: [TTS Providers](./tts-providers) - Provider setup: [TTS Providers](./tts-providers)

View file

@ -325,6 +325,10 @@ Storage configuration details are in [Object / Blob Storage](../configure/object
Refer to [Database](../configure/database) for database modes. Refer to [Database](../configure/database) for database modes.
Learn about migration behavior and commands in [Migrations](../configure/migrations). Learn about migration behavior and commands in [Migrations](../configure/migrations).
:::info Scheduled maintenance tasks
Local and self-hosted Node.js deployments start the scheduled-task loop in-process and check for due work once per minute. No `CRON_SECRET` is required unless you intentionally invoke the cron HTTP route yourself. Manage task intervals and inspect failures from **Settings → Admin → Scheduled tasks**.
:::
4. Start the app. 4. Start the app.
<Tabs groupId="local-run-mode"> <Tabs groupId="local-run-mode">

View file

@ -36,6 +36,7 @@ S3_PREFIX=openreader
BASE_URL=https://your-app.vercel.app BASE_URL=https://your-app.vercel.app
AUTH_SECRET=... AUTH_SECRET=...
ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in-app ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in-app
CRON_SECRET=... # generate with: openssl rand -hex 32
# Heavy compute (required on Vercel in current releases) # Heavy compute (required on Vercel in current releases)
COMPUTE_WORKER_URL=https://<railway-worker-domain> COMPUTE_WORKER_URL=https://<railway-worker-domain>
@ -110,7 +111,15 @@ Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so automatic
- Run `pnpm migrate` in a controlled environment to apply Drizzle schema migrations to your Postgres DB. - Run `pnpm migrate` in a controlled environment to apply Drizzle schema migrations to your Postgres DB.
- Run `pnpm migrate-fs` only when migrating legacy local filesystem data (`docstore/documents_v1`, `docstore/audiobooks_v1`) into object storage + DB rows. Fresh Vercel deployments usually do not need this. - Run `pnpm migrate-fs` only when migrating legacy local filesystem data (`docstore/documents_v1`, `docstore/audiobooks_v1`) into object storage + DB rows. Fresh Vercel deployments usually do not need this.
## 5. FFmpeg packaging in Vercel functions ## 5. Scheduled maintenance tasks
The repository configures `/api/admin/tasks/tick` as a Vercel Cron route. Set `CRON_SECRET`; requests without the matching bearer token are rejected.
The checked-in Hobby-compatible schedule invokes the route once daily. The admin task panel therefore prevents selecting intervals shorter than one day on Vercel, even though self-hosted deployments can run tasks more frequently.
Each due task is claimed with a database-backed lease, due tasks start independently, and individual runs are aborted and marked failed after four minutes. Review failures and run tasks manually from **Settings → Admin → Scheduled tasks**.
## 6. FFmpeg packaging in Vercel functions
`ffmpeg-static` binaries must be included in function traces. This repo already does that in `next.config.ts` via `outputFileTracingIncludes` for: `ffmpeg-static` binaries must be included in function traces. This repo already does that in `next.config.ts` via `outputFileTracingIncludes` for:
@ -124,7 +133,7 @@ Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so automatic
If you change route paths or split handlers, update `outputFileTracingIncludes` accordingly. If you change route paths or split handlers, update `outputFileTracingIncludes` accordingly.
## 6. Function memory sizing ## 7. Function memory sizing
FFmpeg workloads benefit from more memory/CPU. This repo includes: FFmpeg workloads benefit from more memory/CPU. This repo includes:
@ -140,14 +149,15 @@ FFmpeg workloads benefit from more memory/CPU. This repo includes:
Adjust memory per route if your files are larger or your plan differs. Adjust memory per route if your files are larger or your plan differs.
## 7. Runtime expectations and caveats ## 8. Runtime expectations and caveats
- Audiobook APIs require S3 configuration; otherwise they return `503`. - Audiobook APIs require S3 configuration; otherwise they return `503`.
- For production Vercel deploys, use `POSTGRES_URL` instead of SQLite. - For production Vercel deploys, use `POSTGRES_URL` instead of SQLite.
## 8. Smoke test after deploy ## 9. Smoke test after deploy
1. Upload and read a PDF/EPUB document. 1. Upload and read a PDF/EPUB document.
2. Confirm sync/blob fetch works across refreshes/devices. 2. Confirm sync/blob fetch works across refreshes/devices.
3. Generate at least one audiobook chapter and play/download it. 3. Generate at least one audiobook chapter and play/download it.
4. Verify worker-backed word highlighting and PDF parsing. 4. Verify worker-backed word highlighting and PDF parsing.
5. Open **Settings → Admin → Scheduled tasks**, run one task manually, and confirm the next daily cron invocation succeeds.

View file

@ -122,6 +122,7 @@ What this command enables:
- `restrictUserApiKeys` controls shared-provider-only mode. For per-user BYOK, toggle it off in **Settings → Admin → Site features** or seed `runtimeConfig.restrictUserApiKeys=false` via runtime seed JSON. - `restrictUserApiKeys` controls shared-provider-only mode. For per-user BYOK, toggle it off in **Settings → Admin → Site features** or seed `runtimeConfig.restrictUserApiKeys=false` via runtime seed JSON.
- Use a `/app/docstore` mount if you want data to survive container/image replacement. - Use a `/app/docstore` mount if you want data to survive container/image replacement.
- Startup automatically runs DB/storage migrations via the shared entrypoint. - Startup automatically runs DB/storage migrations via the shared entrypoint.
- Scheduled maintenance tasks run in-process and can be managed from **Settings → Admin → Scheduled tasks**; Docker/self-hosted deployments do not need `CRON_SECRET`.
::: :::
:::warning Port `8333` Exposure :::warning Port `8333` Exposure

View file

@ -26,6 +26,7 @@ Runtime site features are seeded with `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_P
| `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in | | `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 | | `GITHUB_CLIENT_SECRET` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_ID` to enable GitHub sign-in |
| `ADMIN_EMAILS` | Admin | empty | Comma-separated emails auto-promoted to admin | | `ADMIN_EMAILS` | Admin | empty | Comma-separated emails auto-promoted to admin |
| `CRON_SECRET` | Scheduled tasks | unset | Required for Vercel cron invocations |
| `POSTGRES_URL` | Database | unset (SQLite mode) | Set to switch metadata/auth DB to Postgres | | `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 | | `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 | | `WEED_MINI_DIR` | Storage | `docstore/seaweedfs` | Override embedded SeaweedFS data directory |
@ -144,6 +145,15 @@ Comma-separated list of email addresses auto-promoted to admin.
- Requires auth to be enabled - Requires auth to be enabled
- Admins can manage shared providers and runtime site features in-app - Admins can manage shared providers and runtime site features in-app
### CRON_SECRET
Bearer-token secret for `GET /api/admin/tasks/tick`.
- Required on Vercel so scheduled maintenance tasks can run from the configured Vercel Cron.
- Vercel automatically sends `Authorization: Bearer <CRON_SECRET>` on cron invocations.
- Generate a strong random value, for example with `openssl rand -hex 32`.
- Self-hosted Node.js deployments run the scheduler in-process and do not require this variable.
## Database and Object Blob Storage ## Database and Object Blob Storage
### POSTGRES_URL ### POSTGRES_URL

View file

@ -0,0 +1,5 @@
ALTER TABLE "user_job_events" ADD CONSTRAINT "user_job_events_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action NOT VALID;--> statement-breakpoint
DELETE FROM "user_job_events" WHERE NOT EXISTS (
SELECT 1 FROM "user" WHERE "user"."id" = "user_job_events"."user_id"
);--> statement-breakpoint
ALTER TABLE "user_job_events" VALIDATE CONSTRAINT "user_job_events_user_id_user_id_fk";

View file

@ -0,0 +1,22 @@
CREATE TABLE "document_blob_leases" (
"document_id" text PRIMARY KEY NOT NULL,
"lease_owner" text NOT NULL,
"lease_until_ms" bigint NOT NULL
);
--> statement-breakpoint
CREATE TABLE "scheduled_tasks" (
"key" text PRIMARY KEY NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"interval_ms" bigint NOT NULL,
"last_status" text DEFAULT 'idle' NOT NULL,
"lease_owner" text,
"last_run_at" bigint,
"last_duration_ms" bigint,
"last_error" text,
"last_result_json" text,
"next_run_at" bigint,
"run_requested" boolean DEFAULT false NOT NULL,
"running_since" bigint,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint NOT NULL,
CONSTRAINT "scheduled_tasks_interval_ms_positive" CHECK ("scheduled_tasks"."interval_ms" > 0)
);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -71,6 +71,20 @@
"when": 1780625663880, "when": 1780625663880,
"tag": "0009_drop_pdf_parse", "tag": "0009_drop_pdf_parse",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1780794520924,
"tag": "0010_user-data-cleanup-cascades",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1780851107819,
"tag": "0011_scheduled-tasks",
"breakpoints": true
} }
] ]
} }

View file

@ -0,0 +1,18 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
DELETE FROM `user_job_events` WHERE NOT EXISTS (
SELECT 1 FROM `user` WHERE `user`.`id` = `user_job_events`.`user_id`
);--> statement-breakpoint
CREATE TABLE `__new_user_job_events` (
`user_id` text NOT NULL,
`action` text NOT NULL,
`op_id` text NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
PRIMARY KEY(`user_id`, `action`, `op_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_user_job_events`("user_id", "action", "op_id", "created_at") SELECT "user_id", "action", "op_id", "created_at" FROM `user_job_events`;--> statement-breakpoint
DROP TABLE `user_job_events`;--> statement-breakpoint
ALTER TABLE `__new_user_job_events` RENAME TO `user_job_events`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `idx_user_job_events_user_action_created` ON `user_job_events` (`user_id`,`action`,`created_at`);

View file

@ -0,0 +1,22 @@
CREATE TABLE `document_blob_leases` (
`document_id` text PRIMARY KEY NOT NULL,
`lease_owner` text NOT NULL,
`lease_until_ms` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `scheduled_tasks` (
`key` text PRIMARY KEY NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`interval_ms` integer NOT NULL,
`last_status` text DEFAULT 'idle' NOT NULL,
`lease_owner` text,
`last_run_at` integer,
`last_duration_ms` integer,
`last_error` text,
`last_result_json` text,
`next_run_at` integer,
`run_requested` integer DEFAULT false NOT NULL,
`running_since` integer,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
CONSTRAINT "scheduled_tasks_interval_ms_positive" CHECK("scheduled_tasks"."interval_ms" > 0)
);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -71,6 +71,20 @@
"when": 1780625663601, "when": 1780625663601,
"tag": "0009_drop_pdf_parse", "tag": "0009_drop_pdf_parse",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1780794515094,
"tag": "0010_user-data-cleanup-cascades",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1780851107505,
"tag": "0011_scheduled-tasks",
"breakpoints": true
} }
] ]
} }

View file

@ -45,6 +45,7 @@ export default defineConfig({
name: 'chromium', name: 'chromium',
use: { use: {
...devices['Desktop Chrome'], ...devices['Desktop Chrome'],
userAgent: `${devices['Desktop Chrome'].userAgent} OpenReader-Playwright/chromium`,
extraHTTPHeaders: { 'x-openreader-test-namespace': 'chromium' }, extraHTTPHeaders: { 'x-openreader-test-namespace': 'chromium' },
}, },
}, },
@ -53,6 +54,7 @@ export default defineConfig({
name: 'firefox', name: 'firefox',
use: { use: {
...devices['Desktop Firefox'], ...devices['Desktop Firefox'],
userAgent: `${devices['Desktop Firefox'].userAgent} OpenReader-Playwright/firefox`,
extraHTTPHeaders: { 'x-openreader-test-namespace': 'firefox' }, extraHTTPHeaders: { 'x-openreader-test-namespace': 'firefox' },
}, },
}, },
@ -61,6 +63,7 @@ export default defineConfig({
name: 'webkit', name: 'webkit',
use: { use: {
...devices['Desktop Safari'], ...devices['Desktop Safari'],
userAgent: `${devices['Desktop Safari'].userAgent} OpenReader-Playwright/webkit`,
extraHTTPHeaders: { 'x-openreader-test-namespace': 'webkit' }, extraHTTPHeaders: { 'x-openreader-test-namespace': 'webkit' },
}, },
}, },

View file

@ -3,7 +3,7 @@ import { NextResponse } from 'next/server';
import { auth } from '@/lib/server/auth/auth'; import { auth } from '@/lib/server/auth/auth';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { deleteUserStorageData } from '@/lib/server/user/data-cleanup'; import { deleteUserStorageData } from '@/lib/server/user/data-cleanup';
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response'; import { errorResponse } from '@/lib/server/errors/next-response';
export async function DELETE() { export async function DELETE() {
@ -18,21 +18,12 @@ export async function DELETE() {
} }
try { try {
// Best-effort cleanup for test namespaced storage using request context. // Clean test-namespaced storage using request context. The Better Auth
// The Better Auth beforeDelete hook still runs and handles non-namespaced data. // beforeDelete hook handles non-namespaced storage and blocks deletion if
// either cleanup cannot complete.
const testNamespace = getOpenReaderTestNamespace(reqHeaders); const testNamespace = getOpenReaderTestNamespace(reqHeaders);
if (testNamespace) { if (testNamespace) {
try { await deleteUserStorageData(session.user.id, testNamespace);
await deleteUserStorageData(session.user.id, testNamespace);
} catch (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');
}
} }
// Use Better Auth's built-in deleteUser to handle cascading cleanup // Use Better Auth's built-in deleteUser to handle cascading cleanup

View file

@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin';
import { updateTask } from '@/lib/server/tasks/engine';
import { TASK_REGISTRY } from '@/lib/server/tasks/registry';
import { getTaskSchedulerInfo } from '@/lib/server/tasks/scheduler';
export const dynamic = 'force-dynamic';
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ key: string }> },
): Promise<Response> {
const ctx = await requireAdminContext(req);
if (ctx instanceof Response) return ctx;
const { key } = await params;
if (!Object.hasOwn(TASK_REGISTRY, key)) {
return NextResponse.json({ error: 'Unknown task' }, { status: 404 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Expected JSON object' }, { status: 400 });
}
const payload = body as { enabled?: unknown; intervalMs?: unknown };
const patch: { enabled?: boolean; intervalMs?: number } = {};
if (Object.hasOwn(payload, 'enabled')) {
if (typeof payload.enabled !== 'boolean') {
return NextResponse.json({ error: 'enabled must be a boolean' }, { status: 400 });
}
patch.enabled = payload.enabled;
}
if (Object.hasOwn(payload, 'intervalMs')) {
if (
typeof payload.intervalMs !== 'number'
|| !Number.isFinite(payload.intervalMs)
|| payload.intervalMs <= 0
) {
return NextResponse.json({ error: 'intervalMs must be a finite positive number' }, { status: 400 });
}
patch.intervalMs = Math.max(1, Math.floor(payload.intervalMs));
const minimumIntervalMs = getTaskSchedulerInfo().minimumIntervalMs;
if (patch.intervalMs < minimumIntervalMs) {
return NextResponse.json(
{ error: `intervalMs must be at least ${minimumIntervalMs} for this deployment` },
{ status: 400 },
);
}
}
if (Object.keys(patch).length === 0) {
return NextResponse.json({ error: 'Expected enabled or intervalMs' }, { status: 400 });
}
await updateTask(key, patch);
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin';
import { runTaskNow } from '@/lib/server/tasks/engine';
import { TASK_REGISTRY } from '@/lib/server/tasks/registry';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ key: string }> },
): Promise<Response> {
const ctx = await requireAdminContext(req);
if (ctx instanceof Response) return ctx;
const { key } = await params;
if (!Object.hasOwn(TASK_REGISTRY, key)) {
return NextResponse.json({ error: 'Unknown task' }, { status: 404 });
}
const ran = await runTaskNow(key);
return NextResponse.json({ ran });
}

View file

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin';
import { listTasks } from '@/lib/server/tasks/engine';
import { getTaskSchedulerInfo } from '@/lib/server/tasks/scheduler';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest): Promise<Response> {
const ctx = await requireAdminContext(req);
if (ctx instanceof Response) return ctx;
const scheduler = getTaskSchedulerInfo();
const tasks = (await listTasks()).map((task) => ({
...task,
intervalMs: Math.max(task.intervalMs, scheduler.minimumIntervalMs),
}));
return NextResponse.json({ tasks, scheduler });
}

View file

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { runDueTasks } from '@/lib/server/tasks/engine';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Cron-driven tick for serverless (Vercel) deployments. Vercel automatically
* sends `Authorization: Bearer ${CRON_SECRET}` on scheduled invocations, so we
* require that secret. Self-hosted deployments drive ticks via the in-process
* scheduler and don't need this route.
*/
export async function GET(req: NextRequest): Promise<Response> {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json({ error: 'Cron not configured' }, { status: 503 });
}
if (req.headers.get('authorization') !== `Bearer ${secret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await runDueTasks();
return NextResponse.json({ ok: true });
}

View file

@ -9,6 +9,7 @@ import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'
import { isS3Configured } from '@/lib/server/storage/s3'; import { isS3Configured } from '@/lib/server/storage/s3';
import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response'; import { errorResponse } from '@/lib/server/errors/next-response';
import { deleteOwnedDocument } from '@/lib/server/documents/delete-owned';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -80,9 +81,11 @@ export async function GET(req: NextRequest) {
}); });
} catch (error) { } catch (error) {
if (isMissingBlobError(error)) { if (isMissingBlobError(error)) {
await db await deleteOwnedDocument({
.delete(documents) userId: doc.userId,
.where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); documentId: id,
namespace: testNamespace,
});
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: 'Not found' }, { status: 404 });
} }
throw error; throw error;

View file

@ -22,6 +22,7 @@ import {
import { extractRawTextSnippet } from '@/lib/server/documents/text-snippets'; import { extractRawTextSnippet } from '@/lib/server/documents/text-snippets';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3'; import { isS3Configured } from '@/lib/server/storage/s3';
import { deleteOwnedDocument } from '@/lib/server/documents/delete-owned';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -108,9 +109,11 @@ export async function GET(req: NextRequest) {
); );
} catch (error) { } catch (error) {
if (isMissingDocumentBlobError(error)) { if (isMissingDocumentBlobError(error)) {
await db await deleteOwnedDocument({
.delete(documents) userId: doc.userId,
.where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); documentId: id,
namespace: testNamespace,
});
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: 'Not found' }, { status: 404 });
} }
throw error; throw error;

View file

@ -18,6 +18,7 @@ import {
} from '@/lib/server/documents/blobstore'; } from '@/lib/server/documents/blobstore';
import { convertDocxBufferToPdfBuffer } from '@/lib/server/documents/docx-convert'; import { convertDocxBufferToPdfBuffer } from '@/lib/server/documents/docx-convert';
import { registerUploadedDocument } from '@/lib/server/documents/register-upload'; import { registerUploadedDocument } from '@/lib/server/documents/register-upload';
import { withDocumentBlobLease } from '@/lib/server/documents/blob-lease';
import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents/utils'; import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents/utils';
import { errorResponse } from '@/lib/server/errors/next-response'; import { errorResponse } from '@/lib/server/errors/next-response';
import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
@ -149,47 +150,52 @@ async function finalizeOne(input: {
: input.upload.name; : input.upload.name;
const documentId = createHash('sha256').update(finalizedBody).digest('hex'); const documentId = createHash('sha256').update(finalizedBody).digest('hex');
try { const stored = await withDocumentBlobLease(documentId, async () => {
await headDocumentBlob(documentId, input.namespace); // Keep the canonical blob and ownership-row write under the same durable
} catch (error) { // lease so the orphan reaper cannot delete between its ownership check and
if (!isMissingBlobError(error)) throw error; // this registration.
if (!isDocxUpload) { try {
try { await headDocumentBlob(documentId, input.namespace);
await copyTempDocumentBlobToDocument( } catch (error) {
input.upload.token, if (!isMissingBlobError(error)) throw error;
input.userId, if (!isDocxUpload) {
documentId, try {
input.namespace, await copyTempDocumentBlobToDocument(
finalizedContentType, input.upload.token,
{ ifNoneMatch: true }, input.userId,
); documentId,
} catch (copyError) { input.namespace,
if (!isPreconditionFailed(copyError)) throw copyError; finalizedContentType,
} { ifNoneMatch: true },
} else { );
try { } catch (copyError) {
await putDocumentBlob( if (!isPreconditionFailed(copyError)) throw copyError;
documentId, }
finalizedBody, } else {
finalizedContentType, try {
input.namespace, await putDocumentBlob(
{ ifNoneMatch: true }, documentId,
); finalizedBody,
} catch (putError) { finalizedContentType,
if (!isPreconditionFailed(putError)) throw putError; input.namespace,
{ ifNoneMatch: true },
);
} catch (putError) {
if (!isPreconditionFailed(putError)) throw putError;
}
} }
} }
}
const canonicalHead = await headDocumentBlob(documentId, input.namespace); const canonicalHead = await headDocumentBlob(documentId, input.namespace);
const stored = await registerUploadedDocument({ return registerUploadedDocument({
documentId, documentId,
userId: input.userId, userId: input.userId,
namespace: input.namespace, namespace: input.namespace,
name: finalizedName, name: finalizedName,
type: finalizedType, type: finalizedType,
size: canonicalHead.contentLength > 0 ? canonicalHead.contentLength : finalizedBody.byteLength, size: canonicalHead.contentLength > 0 ? canonicalHead.contentLength : finalizedBody.byteLength,
lastModified: input.upload.lastModified, lastModified: input.upload.lastModified,
});
}); });
await putTempDocumentFinalizeReceipt( await putTempDocumentFinalizeReceipt(

View file

@ -2,8 +2,6 @@ import { randomUUID } from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { requireAuthContext } from '@/lib/server/auth/auth'; import { requireAuthContext } from '@/lib/server/auth/auth';
import { import {
TEMP_DOCUMENT_UPLOAD_TTL_MS,
deleteExpiredTempDocumentUploads,
presignTempPut, presignTempPut,
} from '@/lib/server/documents/blobstore'; } from '@/lib/server/documents/blobstore';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
@ -72,8 +70,7 @@ export async function POST(req: NextRequest) {
} }
const namespace = getOpenReaderTestNamespace(req.headers); const namespace = getOpenReaderTestNamespace(req.headers);
await deleteExpiredTempDocumentUploads(userId, namespace, Date.now() - TEMP_DOCUMENT_UPLOAD_TTL_MS) // Expired temp uploads are swept by the cleanup-temp-uploads scheduled task.
.catch(() => undefined);
const signed = await Promise.all( const signed = await Promise.all(
uploads.map(async (upload) => { uploads.map(async (upload) => {
const token = randomUUID(); const token = randomUUID();

View file

@ -1,18 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { and, count, eq, inArray } from 'drizzle-orm'; import { and, inArray } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { documents } from '@/db/schema'; import { documents } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth'; import { requireAuthContext } from '@/lib/server/auth/auth';
import { toDocumentTypeFromName } from '@/lib/server/documents/utils'; import { toDocumentTypeFromName } from '@/lib/server/documents/utils';
import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response'; import { errorResponse } from '@/lib/server/errors/next-response';
import { import { isValidDocumentId } from '@/lib/server/documents/blobstore';
cleanupDocumentPreviewArtifacts,
deleteDocumentPreviewRows,
} from '@/lib/server/documents/previews';
import { deleteDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3'; import { isS3Configured } from '@/lib/server/storage/s3';
import { deleteOwnedDocument } from '@/lib/server/documents/delete-owned';
import type { BaseDocument, DocumentType } from '@/types/documents'; import type { BaseDocument, DocumentType } from '@/types/documents';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -114,10 +111,6 @@ export async function DELETE(req: NextRequest) {
const targetUserIds = [storageUserId]; const targetUserIds = [storageUserId];
if (targetUserIds.length === 0) {
return NextResponse.json({ success: true, deleted: 0 });
}
let targetIds: string[] = []; let targetIds: string[] = [];
if (idsParam) { if (idsParam) {
targetIds = idsParam targetIds = idsParam
@ -136,52 +129,26 @@ export async function DELETE(req: NextRequest) {
return NextResponse.json({ success: true, deleted: 0 }); return NextResponse.json({ success: true, deleted: 0 });
} }
const deletedRows = (await db const ownedRows = (await db
.delete(documents) .select({ id: documents.id, userId: documents.userId })
.where(and(inArray(documents.userId, targetUserIds), inArray(documents.id, targetIds))) .from(documents)
.returning({ id: documents.id })) as Array<{ id: string }>; .where(and(inArray(documents.userId, targetUserIds), inArray(documents.id, targetIds)))) as Array<{
id: string;
userId: string;
}>;
const uniqueIds = Array.from(new Set(deletedRows.map((row) => row.id))); let deleted = 0;
for (const id of uniqueIds) { for (const row of ownedRows) {
const [ref] = await db.select({ count: count() }).from(documents).where(eq(documents.id, id)); if (await deleteOwnedDocument({
const refCount = Number(ref?.count ?? 0); userId: row.userId,
if (refCount > 0) continue; documentId: row.id,
namespace: testNamespace,
try { })) {
await deleteDocumentBlob(id, testNamespace); deleted += 1;
} catch (error) {
if (!isMissingBlobError(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) => {
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) => {
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 }); return NextResponse.json({ success: true, deleted });
} catch (error) { } catch (error) {
serverLogger.error({ serverLogger.error({
event: 'documents.delete.failed', event: 'documents.delete.failed',

View file

@ -1,11 +1,12 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { and, eq, inArray } from 'drizzle-orm'; import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; import { documents, ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
import { isS3Configured, getS3Config } from '@/lib/server/storage/s3'; import { isS3Configured, getS3Config } from '@/lib/server/storage/s3';
import { generateTTSBuffer } from '@/lib/server/tts/generate'; import { generateTTSBuffer } from '@/lib/server/tts/generate';
import { import {
getTtsSegmentAudioObject, getTtsSegmentAudioObject,
deleteTtsSegmentAudioObjects,
putTtsSegmentAudioObject, putTtsSegmentAudioObject,
} from '@/lib/server/tts/segments-blobstore'; } from '@/lib/server/tts/segments-blobstore';
import { import {
@ -300,6 +301,28 @@ export async function POST(request: NextRequest) {
const existingById = new Map(rows.map((row) => [row.segmentId, row])); const existingById = new Map(rows.map((row) => [row.segmentId, row]));
const manifest: TTSSegmentManifestItem[] = []; const manifest: TTSSegmentManifestItem[] = [];
const documentOwnershipExists = async (): Promise<boolean> => {
const [row] = await db
.select({ id: documents.id })
.from(documents)
.where(and(
eq(documents.id, parsed.documentId),
eq(documents.userId, scope.storageUserId),
))
.limit(1);
return Boolean(row);
};
const assertDocumentOwnership = async (): Promise<void> => {
if (!await documentOwnershipExists()) {
throw new Error('Document ownership was removed during TTS generation');
}
};
const deleteAbandonedSegmentMetadata = async (): Promise<void> => {
await db.delete(ttsSegmentEntries).where(and(
eq(ttsSegmentEntries.userId, scope.storageUserId),
eq(ttsSegmentEntries.documentId, parsed.documentId),
));
};
const upsertSegmentEntry = async (input: { const upsertSegmentEntry = async (input: {
segmentEntryId: string; segmentEntryId: string;
segmentIndex: number; segmentIndex: number;
@ -308,6 +331,7 @@ export async function POST(request: NextRequest) {
textHash: string; textHash: string;
textLength: number; textLength: number;
}): Promise<void> => { }): Promise<void> => {
await assertDocumentOwnership();
await db await db
.insert(ttsSegmentEntries) .insert(ttsSegmentEntries)
.values({ .values({
@ -337,6 +361,10 @@ export async function POST(request: NextRequest) {
updatedAt: nowMs, updatedAt: nowMs,
}, },
}); });
if (!await documentOwnershipExists()) {
await deleteAbandonedSegmentMetadata();
throw new Error('Document ownership was removed during TTS metadata persistence');
}
}; };
for (const segment of normalized) { for (const segment of normalized) {
@ -657,7 +685,13 @@ export async function POST(request: NextRequest) {
failedStage = 's3.put_audio'; failedStage = 's3.put_audio';
const putStartedAt = Date.now(); const putStartedAt = Date.now();
await assertDocumentOwnership();
await putTtsSegmentAudioObject(audioKey, ttsBuffer); await putTtsSegmentAudioObject(audioKey, ttsBuffer);
if (!await documentOwnershipExists()) {
await deleteTtsSegmentAudioObjects([audioKey]);
await deleteAbandonedSegmentMetadata();
throw new Error('Document ownership was removed during TTS audio persistence');
}
stageTimings.putAudioMs = Date.now() - putStartedAt; stageTimings.putAudioMs = Date.now() - putStartedAt;
let persistedBuffer = ttsBuffer; let persistedBuffer = ttsBuffer;

View file

@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { claimAnonymousData } from '@/lib/server/user/claim-data'; import { claimAnonymousData } from '@/lib/server/user/claim-data';
import { auth } from '@/lib/server/auth/auth'; import { auth } from '@/lib/server/auth/auth';
import { db } from '@/db'; import { db } from '@/db';
import { audiobooks, documents, userDocumentProgress, userPreferences } from '@/db/schema'; import { audiobooks, documentSettings, documents, userDocumentProgress, userPreferences } from '@/db/schema';
import { count, eq, ne } from 'drizzle-orm'; import { count, eq, ne } from 'drizzle-orm';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
@ -26,13 +26,14 @@ async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
async function getClaimableCounts( async function getClaimableCounts(
unclaimedUserId: string, unclaimedUserId: string,
): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number }> { ): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number; documentSettings: number }> {
const [[docCount], [bookCount], [preferencesCount], [progressCount]] = const [[docCount], [bookCount], [preferencesCount], [progressCount], [settingsCount]] =
await Promise.all([ await Promise.all([
db.select({ count: count() }).from(documents).where(eq(documents.userId, unclaimedUserId)), db.select({ count: count() }).from(documents).where(eq(documents.userId, unclaimedUserId)),
db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, unclaimedUserId)), db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, unclaimedUserId)),
db.select({ count: count() }).from(userPreferences).where(eq(userPreferences.userId, unclaimedUserId)), db.select({ count: count() }).from(userPreferences).where(eq(userPreferences.userId, unclaimedUserId)),
db.select({ count: count() }).from(userDocumentProgress).where(eq(userDocumentProgress.userId, unclaimedUserId)), db.select({ count: count() }).from(userDocumentProgress).where(eq(userDocumentProgress.userId, unclaimedUserId)),
db.select({ count: count() }).from(documentSettings).where(eq(documentSettings.userId, unclaimedUserId)),
]); ]);
return { return {
@ -40,6 +41,7 @@ async function getClaimableCounts(
audiobooks: Number(bookCount?.count ?? 0), audiobooks: Number(bookCount?.count ?? 0),
preferences: Number(preferencesCount?.count ?? 0), preferences: Number(preferencesCount?.count ?? 0),
progress: Number(progressCount?.count ?? 0), progress: Number(progressCount?.count ?? 0),
documentSettings: Number(settingsCount?.count ?? 0),
}; };
} }

View file

@ -2,12 +2,26 @@ import { NextRequest, NextResponse } from 'next/server';
import { PassThrough, Readable } from 'stream'; import { PassThrough, Readable } from 'stream';
import { auth } from '@/lib/server/auth/auth'; import { auth } from '@/lib/server/auth/auth';
import { db } from '@/db'; import { db } from '@/db';
import { documents, audiobooks, audiobookChapters, userPreferences, userDocumentProgress, userTtsChars } from '@/db/schema'; import {
documents,
audiobooks,
audiobookChapters,
documentSettings,
ttsSegmentEntries,
ttsSegmentVariants,
userDocumentProgress,
userJobEvents,
userPreferences,
userTtsChars,
} from '@/db/schema';
import * as authSchemaSqlite from '@/db/schema_auth_sqlite';
import * as authSchemaPostgres from '@/db/schema_auth_postgres';
import { and, desc, eq, inArray } from 'drizzle-orm'; import { and, desc, eq, inArray } from 'drizzle-orm';
import archiver from 'archiver'; import archiver from 'archiver';
import { appendUserExportArchive } from '@/lib/server/user/data-export'; import { appendUserExportArchive } from '@/lib/server/user/data-export';
import { getDocumentBlobStream } from '@/lib/server/documents/blobstore'; import { getDocumentBlobStream } from '@/lib/server/documents/blobstore';
import { getAudiobookObjectStream, listAudiobookObjects } from '@/lib/server/audiobooks/blobstore'; import { getAudiobookObjectStream, listAudiobookObjects } from '@/lib/server/audiobooks/blobstore';
import { getTtsSegmentAudioObjectStream } from '@/lib/server/tts/segments-blobstore';
import { isS3Configured } from '@/lib/server/storage/s3'; import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { nowTimestampMs } from '@/lib/shared/timestamps'; import { nowTimestampMs } from '@/lib/shared/timestamps';
@ -17,13 +31,6 @@ import { errorResponse } from '@/lib/server/errors/next-response';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
if (!isS3Configured()) {
return NextResponse.json(
{ error: 'Export storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
if (!auth) { if (!auth) {
return errorResponse(new Error('Auth not initialized'), { return errorResponse(new Error('Auth not initialized'), {
apiErrorMessage: 'Auth not initialized', apiErrorMessage: 'Auth not initialized',
@ -41,11 +48,21 @@ export async function GET(req: NextRequest) {
const userId = session.user.id; const userId = session.user.id;
const testNamespace = getOpenReaderTestNamespace(req.headers); const testNamespace = getOpenReaderTestNamespace(req.headers);
const storageEnabled = isS3Configured();
const requireStorage = () => {
if (!storageEnabled) {
throw new Error('Storage is not configured; file content could not be exported');
}
};
const [ const [
prefs, prefs,
progress, progress,
ttsUsage, ttsUsage,
jobEvents,
perDocumentSettings,
segmentEntries,
segmentVariants,
userDocs, userDocs,
userAudiobooks, userAudiobooks,
] = await Promise.all([ ] = await Promise.all([
@ -60,6 +77,26 @@ export async function GET(req: NextRequest) {
.from(userTtsChars) .from(userTtsChars)
.where(eq(userTtsChars.userId, userId)) .where(eq(userTtsChars.userId, userId))
.orderBy(desc(userTtsChars.date)), .orderBy(desc(userTtsChars.date)),
db
.select()
.from(userJobEvents)
.where(eq(userJobEvents.userId, userId))
.orderBy(desc(userJobEvents.createdAt)),
db
.select()
.from(documentSettings)
.where(eq(documentSettings.userId, userId))
.orderBy(desc(documentSettings.updatedAt)),
db
.select()
.from(ttsSegmentEntries)
.where(eq(ttsSegmentEntries.userId, userId))
.orderBy(desc(ttsSegmentEntries.updatedAt)),
db
.select()
.from(ttsSegmentVariants)
.where(eq(ttsSegmentVariants.userId, userId))
.orderBy(desc(ttsSegmentVariants.updatedAt)),
db db
.select() .select()
.from(documents) .from(documents)
@ -72,6 +109,36 @@ export async function GET(req: NextRequest) {
.orderBy(desc(audiobooks.createdAt)), .orderBy(desc(audiobooks.createdAt)),
]); ]);
const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite;
// Auth exports intentionally select metadata only. Credential and session
// secrets must never be written into the archive.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const database = db as any;
const [authSessions, linkedAccounts] = await Promise.all([
database
.select({
id: authSchema.session.id,
expiresAt: authSchema.session.expiresAt,
createdAt: authSchema.session.createdAt,
updatedAt: authSchema.session.updatedAt,
ipAddress: authSchema.session.ipAddress,
userAgent: authSchema.session.userAgent,
})
.from(authSchema.session)
.where(eq(authSchema.session.userId, userId)),
database
.select({
id: authSchema.account.id,
accountId: authSchema.account.accountId,
providerId: authSchema.account.providerId,
scope: authSchema.account.scope,
createdAt: authSchema.account.createdAt,
updatedAt: authSchema.account.updatedAt,
})
.from(authSchema.account)
.where(eq(authSchema.account.userId, userId)),
]);
const archive = archiver('zip', { const archive = archiver('zip', {
zlib: { level: 0 }, zlib: { level: 0 },
forceZip64: true, forceZip64: true,
@ -114,7 +181,6 @@ export async function GET(req: NextRequest) {
const exportedAtMs = nowTimestampMs(); const exportedAtMs = nowTimestampMs();
const profileData = { const profileData = {
user: session.user, user: session.user,
session: session.session,
exportedAtMs, exportedAtMs,
}; };
@ -126,13 +192,32 @@ export async function GET(req: NextRequest) {
preferences: prefs[0] ?? null, preferences: prefs[0] ?? null,
readingHistory: progress, readingHistory: progress,
ttsUsage, ttsUsage,
jobEvents,
documentSettings: perDocumentSettings,
authSessions,
linkedAccounts,
documents: userDocs, documents: userDocs,
audiobooks: userAudiobooks, audiobooks: userAudiobooks,
audiobookChapters: allChapters, audiobookChapters: allChapters,
getDocumentBlobStream: async (documentId: string) => getDocumentBlobStream(documentId, testNamespace), ttsSegmentEntries: segmentEntries,
listAudiobookObjects: async (bookId: string, ownerId: string) => listAudiobookObjects(bookId, ownerId, testNamespace), ttsSegmentVariants: segmentVariants,
getAudiobookObjectStream: async (bookId: string, ownerId: string, fileName: string) => storageEnabled,
getAudiobookObjectStream(bookId, ownerId, fileName, testNamespace), getDocumentBlobStream: async (documentId: string) => {
requireStorage();
return getDocumentBlobStream(documentId, testNamespace);
},
listAudiobookObjects: async (bookId: string, ownerId: string) => {
requireStorage();
return listAudiobookObjects(bookId, ownerId, testNamespace);
},
getAudiobookObjectStream: async (bookId: string, ownerId: string, fileName: string) => {
requireStorage();
return getAudiobookObjectStream(bookId, ownerId, fileName, testNamespace);
},
getTtsSegmentAudioStream: async (audioKey: string) => {
requireStorage();
return (await getTtsSegmentAudioObjectStream(audioKey)).stream;
},
}); });
if (!req.signal.aborted) { if (!req.signal.aborted) {

View file

@ -36,6 +36,7 @@ import {
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel'; import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel'; import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
import { AdminTasksPanel } from '@/components/admin/AdminTasksPanel';
import { useSharedProviders } from '@/hooks/useSharedProviders'; import { useSharedProviders } from '@/hooks/useSharedProviders';
import { flushUserPreferencesSync } from '@/lib/client/api/user-state'; import { flushUserPreferencesSync } from '@/lib/client/api/user-state';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@ -169,7 +170,7 @@ const SIDEBAR_SECTIONS: SidebarSection[] = [
{ id: 'admin', label: 'Admin', icon: SettingsIcon, authOnly: true, adminOnly: true }, { id: 'admin', label: 'Admin', icon: SettingsIcon, authOnly: true, adminOnly: true },
]; ];
type AdminSubTab = 'providers' | 'features'; type AdminSubTab = 'providers' | 'features' | 'tasks';
export function SettingsTrigger({ export function SettingsTrigger({
className = '', className = '',
@ -1054,13 +1055,15 @@ export function SettingsModal({
options={[ options={[
{ value: 'providers', label: 'Shared providers' }, { value: 'providers', label: 'Shared providers' },
{ value: 'features', label: 'Site features' }, { value: 'features', label: 'Site features' },
{ value: 'tasks', label: 'Tasks' },
]} ]}
onChange={setAdminSubTab} onChange={setAdminSubTab}
ariaLabel="Admin tab" ariaLabel="Admin tab"
className="grid-cols-2" className="grid-cols-3"
/> />
{adminSubTab === 'providers' && <AdminProvidersPanel />} {adminSubTab === 'providers' && <AdminProvidersPanel />}
{adminSubTab === 'features' && <AdminFeaturesPanel />} {adminSubTab === 'features' && <AdminFeaturesPanel />}
{adminSubTab === 'tasks' && <AdminTasksPanel />}
</div> </div>
)} )}

View file

@ -0,0 +1,286 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { Button, Card, Input, Section, Switch } from '@/components/ui';
import { ClockIcon, RefreshIcon } from '@/components/icons/Icons';
type TaskRunStatus = 'idle' | 'running' | 'ok' | 'error';
interface TaskView {
key: string;
name: string;
description?: string;
enabled: boolean;
intervalMs: number;
lastStatus: TaskRunStatus;
lastRunAt: number | null;
lastDurationMs: number | null;
lastError: string | null;
lastResult: string | null;
nextRunAt: number | null;
running: boolean;
}
interface TaskSchedulerInfo {
mode: 'self-hosted' | 'vercel-cron';
tickIntervalMs: number;
minimumIntervalMs: number;
}
const TASKS_QUERY_KEY = ['admin-tasks'] as const;
async function fetchTasks(): Promise<{ tasks: TaskView[]; scheduler: TaskSchedulerInfo }> {
const res = await fetch('/api/admin/tasks');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as { tasks: TaskView[]; scheduler: TaskSchedulerInfo };
}
function RunningDot() {
return (
<span className="relative flex size-2 shrink-0 items-center justify-center" title="Running" aria-label="Running">
<span className="absolute inline-flex size-2 animate-ping rounded-full bg-accent opacity-60" />
<span className="relative inline-flex size-2 rounded-full bg-accent" />
</span>
);
}
function formatRelative(ms: number | null): string {
if (ms == null) return 'never';
const diff = ms - Date.now();
const abs = Math.abs(diff);
const units: Array<[number, string]> = [
[86_400_000, 'd'],
[3_600_000, 'h'],
[60_000, 'm'],
[1_000, 's'],
];
let label = 'now';
for (const [unitMs, suffix] of units) {
if (abs >= unitMs) {
label = `${Math.round(abs / unitMs)}${suffix}`;
break;
}
}
if (label === 'now') return 'just now';
return diff < 0 ? `${label} ago` : `in ${label}`;
}
function formatDuration(ms: number | null): string {
if (ms == null) return '—';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
export function AdminTasksPanel() {
const queryClient = useQueryClient();
const { data, error, isPending: isLoading } = useQuery({
queryKey: TASKS_QUERY_KEY,
queryFn: fetchTasks,
refetchInterval: 5000,
});
useEffect(() => {
if (!error) return;
console.error('[AdminTasksPanel] load failed:', error);
toast.error('Failed to load tasks');
}, [error]);
const invalidate = () => queryClient.invalidateQueries({ queryKey: TASKS_QUERY_KEY });
const patchTask = useMutation({
mutationFn: async ({ key, patch }: { key: string; patch: { enabled?: boolean; intervalMs?: number } }) => {
const res = await fetch(`/api/admin/tasks/${encodeURIComponent(key)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
},
onSuccess: invalidate,
onError: () => toast.error('Update failed'),
});
const runTask = useMutation({
mutationFn: async (key: string) => {
const res = await fetch(`/api/admin/tasks/${encodeURIComponent(key)}/run`, { method: 'POST' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return ((await res.json()) as { ran: boolean }).ran;
},
onSuccess: (ran) => {
toast.success(ran ? 'Task ran' : 'Task already running');
invalidate();
},
onError: () => toast.error('Run failed'),
});
return (
<Section title="Scheduled tasks" subtitle="Background maintenance jobs. Run them on demand or adjust their schedule.">
{data?.scheduler.mode === 'vercel-cron' && (
<p className="mb-2 text-xs text-soft">
Vercel Hobby invokes scheduled tasks once daily. Shorter intervals are unavailable on this deployment.
</p>
)}
{isLoading ? (
<TasksSkeleton />
) : (
<div className="space-y-2">
{(data?.tasks ?? []).map((task) => (
<TaskRow
key={task.key}
task={task}
schedulerMode={data?.scheduler.mode ?? 'self-hosted'}
minimumIntervalMs={data?.scheduler.minimumIntervalMs ?? 60_000}
busy={patchTask.isPending || runTask.isPending}
runPending={runTask.isPending && runTask.variables === task.key}
onToggle={(enabled) => patchTask.mutate({ key: task.key, patch: { enabled } })}
onSaveInterval={(intervalMs) => patchTask.mutate({ key: task.key, patch: { intervalMs } })}
onRun={() => runTask.mutate(task.key)}
/>
))}
{data && data.tasks.length === 0 && (
<p className="text-sm text-soft">No tasks registered.</p>
)}
</div>
)}
</Section>
);
}
function TasksSkeleton() {
const rows = Array.from({ length: 2 });
return (
<div className="space-y-2 animate-pulse" aria-label="Loading scheduled tasks" aria-busy="true">
{rows.map((_, index) => (
<Card key={index}>
<div className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1.5">
<div className="h-4 w-48 rounded bg-offbase" />
<div className="h-3 w-64 rounded bg-offbase" />
</div>
<div className="flex shrink-0 items-center gap-2 pt-0.5">
<div className="h-5 w-9 rounded-pill bg-offbase" />
<div className="h-8 w-20 rounded-md bg-offbase" />
</div>
</div>
<div className="flex items-center justify-between gap-3">
<div className="h-3 w-24 rounded bg-offbase" />
<div className="h-7 w-28 rounded-md bg-offbase" />
</div>
</div>
</Card>
))}
</div>
);
}
function TaskRow({
task,
schedulerMode,
minimumIntervalMs,
busy,
runPending,
onToggle,
onSaveInterval,
onRun,
}: {
task: TaskView;
schedulerMode: TaskSchedulerInfo['mode'];
minimumIntervalMs: number;
busy: boolean;
runPending: boolean;
onToggle: (enabled: boolean) => void;
onSaveInterval: (intervalMs: number) => void;
onRun: () => void;
}) {
const [minutes, setMinutes] = useState(String(task.intervalMs / 60000));
useEffect(() => {
setMinutes(String(task.intervalMs / 60000));
}, [task.intervalMs]);
const parsedMinutes = parseFloat(minutes);
const newIntervalMs = parsedMinutes * 60000;
const intervalDirty =
Number.isFinite(parsedMinutes)
&& newIntervalMs >= minimumIntervalMs
&& newIntervalMs !== task.intervalMs;
const running = task.running || runPending;
return (
<Card>
<div className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
{running && <RunningDot />}
<span className="truncate text-sm font-medium text-foreground">{task.name}</span>
</div>
{task.description && <p className="mt-0.5 text-xs text-soft">{task.description}</p>}
</div>
<div className="flex shrink-0 items-center gap-2">
<Switch checked={task.enabled} onChange={onToggle} ariaLabel={`Enable ${task.name}`} disabled={busy} />
<Button variant="outline" size="sm" onClick={onRun} disabled={busy || running}>
{running ? 'Running…' : 'Run now'}
</Button>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-soft">
<span
className="inline-flex items-center gap-1"
title={`Ran in ${formatDuration(task.lastDurationMs)}`}
>
<ClockIcon className="size-3 text-faint" />
{formatRelative(task.lastRunAt)}
</span>
{task.enabled && schedulerMode === 'vercel-cron' && (
<span className="inline-flex items-center gap-1 text-faint">
<RefreshIcon className="size-3" />
next daily cron
</span>
)}
{task.enabled && schedulerMode !== 'vercel-cron' && task.nextRunAt != null && (
<span className="inline-flex items-center gap-1 text-faint">
<RefreshIcon className="size-3" />
next {formatRelative(task.nextRunAt)}
</span>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-faint">
<span>Every</span>
<Input
type="number"
min={minimumIntervalMs / 60000}
step="any"
controlSize="sm"
className="w-14 text-center"
value={minutes}
onChange={(e) => setMinutes(e.target.value)}
aria-label={`${task.name} interval in minutes`}
/>
<span>min</span>
{intervalDirty && (
<Button variant="primary" size="xs" onClick={() => onSaveInterval(newIntervalMs)} disabled={busy}>
Save
</Button>
)}
</div>
</div>
{task.lastStatus === 'error' && task.lastError ? (
<p className="truncate text-xs text-danger" title={task.lastError}>{task.lastError}</p>
) : (
task.lastStatus === 'ok' && task.lastResult && (
<p className="truncate text-xs text-faint" title={task.lastResult}>{task.lastResult}</p>
)
)}
</div>
</Card>
);
}

View file

@ -10,6 +10,7 @@ export type ClaimableCounts = {
audiobooks: number; audiobooks: number;
preferences: number; preferences: number;
progress: number; progress: number;
documentSettings: number;
}; };
function toClaimableCounts(value: unknown): ClaimableCounts { function toClaimableCounts(value: unknown): ClaimableCounts {
@ -19,6 +20,7 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
audiobooks: Number(rec.audiobooks ?? 0), audiobooks: Number(rec.audiobooks ?? 0),
preferences: Number(rec.preferences ?? 0), preferences: Number(rec.preferences ?? 0),
progress: Number(rec.progress ?? 0), progress: Number(rec.progress ?? 0),
documentSettings: Number(rec.documentSettings ?? 0),
}; };
} }
@ -50,8 +52,9 @@ export default function ClaimDataModal({
toast.success( toast.success(
`Successfully claimed ${claimed.documents} documents, ` `Successfully claimed ${claimed.documents} documents, `
+ `${claimed.audiobooks} audiobooks, ` + `${claimed.audiobooks} audiobooks, `
+ `${claimed.preferences} preference set(s), and ` + `${claimed.preferences} preference set(s), `
+ `${claimed.progress} reading progress record(s)!`, + `${claimed.progress} reading progress record(s), and `
+ `${claimed.documentSettings} document setting(s)!`,
); );
onClaimed(); onClaimed();
router.refresh(); router.refresh();
@ -82,6 +85,7 @@ export default function ClaimDataModal({
<li>{claimableCounts.audiobooks} audiobook(s)</li> <li>{claimableCounts.audiobooks} audiobook(s)</li>
<li>{claimableCounts.preferences} preference set(s)</li> <li>{claimableCounts.preferences} preference set(s)</li>
<li>{claimableCounts.progress} reading progress record(s)</li> <li>{claimableCounts.progress} reading progress record(s)</li>
<li>{claimableCounts.documentSettings} document setting(s)</li>
</ul> </ul>
</div> </div>

View file

@ -30,6 +30,7 @@ const EMPTY_CLAIM_COUNTS: ClaimableCounts = {
audiobooks: 0, audiobooks: 0,
preferences: 0, preferences: 0,
progress: 0, progress: 0,
documentSettings: 0,
}; };
function toClaimableCounts(value: unknown): ClaimableCounts { function toClaimableCounts(value: unknown): ClaimableCounts {
@ -39,6 +40,7 @@ function toClaimableCounts(value: unknown): ClaimableCounts {
audiobooks: Number(rec.audiobooks ?? 0), audiobooks: Number(rec.audiobooks ?? 0),
preferences: Number(rec.preferences ?? 0), preferences: Number(rec.preferences ?? 0),
progress: Number(rec.progress ?? 0), progress: Number(rec.progress ?? 0),
documentSettings: Number(rec.documentSettings ?? 0),
}; };
} }
@ -148,7 +150,11 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
if (isClaimEligible) { if (isClaimEligible) {
claimCounts = await fetchClaimableCounts(); claimCounts = await fetchClaimableCounts();
const total = claimCounts.documents + claimCounts.audiobooks + claimCounts.preferences + claimCounts.progress; const total = claimCounts.documents
+ claimCounts.audiobooks
+ claimCounts.preferences
+ claimCounts.progress
+ claimCounts.documentSettings;
claimHasData = total > 0; claimHasData = total > 0;
if (!claimHasData && userId) { if (!claimHasData && userId) {
claimDismissedUsersRef.current.add(userId); claimDismissedUsersRef.current.add(userId);

View file

@ -0,0 +1,23 @@
import { db } from '@/db';
/**
* Run `fn` with a database connection, wrapped in a transaction on Postgres.
*
* This is the single definition of the SQLite/Postgres bridge, so callers never
* branch on `process.env.POSTGRES_URL` themselves:
* - Postgres: opens a real transaction so a multi-statement read-modify-write
* is atomic, and passes the transaction handle as `conn`.
* - SQLite: better-sqlite3 transactions require synchronous callbacks (they
* cannot be awaited) and the database is a single writer anyway, so `fn`
* runs directly on the shared connection.
*/
export async function runInDbTransaction<T>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fn: (conn: any) => Promise<T>,
): Promise<T> {
if (process.env.POSTGRES_URL) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (db as any).transaction(async (tx: any) => fn(tx));
}
return fn(db);
}

View file

@ -19,3 +19,5 @@ export const ttsSegmentEntries = usePostgres ? postgresSchema.ttsSegmentEntries
export const ttsSegmentVariants = usePostgres ? postgresSchema.ttsSegmentVariants : sqliteSchema.ttsSegmentVariants; export const ttsSegmentVariants = usePostgres ? postgresSchema.ttsSegmentVariants : sqliteSchema.ttsSegmentVariants;
export const adminProviders = usePostgres ? postgresSchema.adminProviders : sqliteSchema.adminProviders; export const adminProviders = usePostgres ? postgresSchema.adminProviders : sqliteSchema.adminProviders;
export const adminSettings = usePostgres ? postgresSchema.adminSettings : sqliteSchema.adminSettings; export const adminSettings = usePostgres ? postgresSchema.adminSettings : sqliteSchema.adminSettings;
export const scheduledTasks = usePostgres ? postgresSchema.scheduledTasks : sqliteSchema.scheduledTasks;
export const documentBlobLeases = usePostgres ? postgresSchema.documentBlobLeases : sqliteSchema.documentBlobLeases;

View file

@ -1,5 +1,5 @@
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { pgTable, text, integer, real, date, bigint, primaryKey, index, jsonb, foreignKey } from 'drizzle-orm/pg-core'; import { pgTable, text, integer, real, date, bigint, boolean, primaryKey, index, jsonb, foreignKey, check } from 'drizzle-orm/pg-core';
import { user } from './schema_auth_postgres'; import { user } from './schema_auth_postgres';
const PG_NOW_MS = sql`(extract(epoch from now()) * 1000)::bigint`; const PG_NOW_MS = sql`(extract(epoch from now()) * 1000)::bigint`;
@ -54,6 +54,7 @@ export const audiobookChapters = pgTable('audiobook_chapters', {
// defined here. Only application-specific tables belong in this file. // defined here. Only application-specific tables belong in this file.
export const userTtsChars = pgTable("user_tts_chars", { export const userTtsChars = pgTable("user_tts_chars", {
// Also stores device:* and ip:* backstop buckets, so this cannot reference user.id.
userId: text('user_id').notNull(), userId: text('user_id').notNull(),
date: date('date').notNull(), date: date('date').notNull(),
charCount: bigint('char_count', { mode: 'number' }).default(0), charCount: bigint('char_count', { mode: 'number' }).default(0),
@ -71,7 +72,7 @@ export const userTtsChars = pgTable("user_tts_chars", {
// worker bounds each op by a hard cap, "ops created in the last hard-cap // worker bounds each op by a hard cap, "ops created in the last hard-cap
// window" is an upper bound on in-flight ops. Old rows are pruned opportunistically. // window" is an upper bound on in-flight ops. Old rows are pruned opportunistically.
export const userJobEvents = pgTable('user_job_events', { export const userJobEvents = pgTable('user_job_events', {
userId: text('user_id').notNull(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
action: text('action').notNull(), action: text('action').notNull(),
opId: text('op_id').notNull(), opId: text('op_id').notNull(),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(PG_NOW_MS), createdAt: bigint('created_at', { mode: 'number' }).notNull().default(PG_NOW_MS),
@ -209,6 +210,30 @@ export const adminSettings = pgTable('admin_settings', {
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(PG_NOW_MS), updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(PG_NOW_MS),
}); });
export const scheduledTasks = pgTable('scheduled_tasks', {
key: text('key').primaryKey(),
enabled: boolean('enabled').notNull().default(true),
intervalMs: bigint('interval_ms', { mode: 'number' }).notNull(),
lastStatus: text('last_status').notNull().default('idle'),
leaseOwner: text('lease_owner'),
lastRunAt: bigint('last_run_at', { mode: 'number' }),
lastDurationMs: bigint('last_duration_ms', { mode: 'number' }),
lastError: text('last_error'),
lastResultJson: text('last_result_json'),
nextRunAt: bigint('next_run_at', { mode: 'number' }),
runRequested: boolean('run_requested').notNull().default(false),
runningSince: bigint('running_since', { mode: 'number' }),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(PG_NOW_MS),
}, (table) => [
check('scheduled_tasks_interval_ms_positive', sql`${table.intervalMs} > 0`),
]);
export const documentBlobLeases = pgTable('document_blob_leases', {
documentId: text('document_id').primaryKey(),
leaseOwner: text('lease_owner').notNull(),
leaseUntilMs: bigint('lease_until_ms', { mode: 'number' }).notNull(),
});
export const ttsSegmentVariants = pgTable('tts_segment_variants', { export const ttsSegmentVariants = pgTable('tts_segment_variants', {
segmentId: text('segment_id').notNull(), segmentId: text('segment_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),

View file

@ -1,4 +1,4 @@
import { sqliteTable, text, integer, real, primaryKey, index, foreignKey } from 'drizzle-orm/sqlite-core'; import { sqliteTable, text, integer, real, primaryKey, index, foreignKey, check } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { user } from './schema_auth_sqlite'; import { user } from './schema_auth_sqlite';
@ -54,6 +54,7 @@ export const audiobookChapters = sqliteTable('audiobook_chapters', {
// defined here. Only application-specific tables belong in this file. // defined here. Only application-specific tables belong in this file.
export const userTtsChars = sqliteTable("user_tts_chars", { export const userTtsChars = sqliteTable("user_tts_chars", {
// Also stores device:* and ip:* backstop buckets, so this cannot reference user.id.
userId: text('user_id').notNull(), userId: text('user_id').notNull(),
date: text('date').notNull(), // SQLite doesn't have native DATE type, text YYYY-MM-DD is standard date: text('date').notNull(), // SQLite doesn't have native DATE type, text YYYY-MM-DD is standard
charCount: integer('char_count').default(0), charCount: integer('char_count').default(0),
@ -71,7 +72,7 @@ export const userTtsChars = sqliteTable("user_tts_chars", {
// worker bounds each op by a hard cap, "ops created in the last hard-cap // worker bounds each op by a hard cap, "ops created in the last hard-cap
// window" is an upper bound on in-flight ops. Old rows are pruned opportunistically. // window" is an upper bound on in-flight ops. Old rows are pruned opportunistically.
export const userJobEvents = sqliteTable('user_job_events', { export const userJobEvents = sqliteTable('user_job_events', {
userId: text('user_id').notNull(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
action: text('action').notNull(), action: text('action').notNull(),
opId: text('op_id').notNull(), opId: text('op_id').notNull(),
createdAt: integer('created_at').notNull().default(SQLITE_NOW_MS), createdAt: integer('created_at').notNull().default(SQLITE_NOW_MS),
@ -209,6 +210,30 @@ export const adminSettings = sqliteTable('admin_settings', {
updatedAt: integer('updated_at').notNull().default(SQLITE_NOW_MS), updatedAt: integer('updated_at').notNull().default(SQLITE_NOW_MS),
}); });
export const scheduledTasks = sqliteTable('scheduled_tasks', {
key: text('key').primaryKey(),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
intervalMs: integer('interval_ms').notNull(),
lastStatus: text('last_status').notNull().default('idle'),
leaseOwner: text('lease_owner'),
lastRunAt: integer('last_run_at'),
lastDurationMs: integer('last_duration_ms'),
lastError: text('last_error'),
lastResultJson: text('last_result_json'),
nextRunAt: integer('next_run_at'),
runRequested: integer('run_requested', { mode: 'boolean' }).notNull().default(false),
runningSince: integer('running_since'),
updatedAt: integer('updated_at').notNull().default(SQLITE_NOW_MS),
}, (table) => [
check('scheduled_tasks_interval_ms_positive', sql`${table.intervalMs} > 0`),
]);
export const documentBlobLeases = sqliteTable('document_blob_leases', {
documentId: text('document_id').primaryKey(),
leaseOwner: text('lease_owner').notNull(),
leaseUntilMs: integer('lease_until_ms').notNull(),
});
export const ttsSegmentVariants = sqliteTable('tts_segment_variants', { export const ttsSegmentVariants = sqliteTable('tts_segment_variants', {
segmentId: text('segment_id').notNull(), segmentId: text('segment_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),

View file

@ -0,0 +1,5 @@
import { startTaskScheduler } from '@/lib/server/tasks/scheduler';
if (!process.env.VERCEL) {
startTaskScheduler();
}

10
src/instrumentation.ts Normal file
View file

@ -0,0 +1,10 @@
/**
* Next.js startup hook. On a long-lived self-hosted server we run the task
* scheduler in-process. On Vercel (serverless, no persistent process) there is
* nothing to keep a loop alive, so a cron route drives the ticks instead.
*/
export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./instrumentation.node');
}
}

View file

@ -264,25 +264,13 @@ export async function setRuntimeConfigKey<K extends RuntimeConfigKey>(
} }
const serialized = serializeForStorage(validated); const serialized = serializeForStorage(validated);
const now = Date.now(); const now = Date.now();
// Upsert with onConflict. await db
const isPg = !!process.env.POSTGRES_URL; .insert(adminSettings)
if (isPg) { .values({ key, valueJson: serialized as never, source: 'admin', updatedAt: now })
await db .onConflictDoUpdate({
.insert(adminSettings) target: adminSettings.key,
.values({ key, valueJson: serialized as never, source: 'admin', updatedAt: now }) set: { valueJson: serialized as never, source: 'admin', updatedAt: now },
.onConflictDoUpdate({ });
target: adminSettings.key,
set: { valueJson: serialized as never, source: 'admin', updatedAt: now },
});
} else {
await db
.insert(adminSettings)
.values({ key, valueJson: serialized as never, source: 'admin', updatedAt: now })
.onConflictDoUpdate({
target: adminSettings.key,
set: { valueJson: serialized as never, source: 'admin', updatedAt: now },
});
}
} }
/** Delete a runtime config row (resets to default/implicit behavior). */ /** Delete a runtime config row (resets to default/implicit behavior). */

View file

@ -255,7 +255,11 @@ export async function deleteAudiobookPrefix(prefix: string): Promise<number> {
}, },
}), }),
); );
deleted += deleteRes.Deleted?.length ?? 0; const errors = deleteRes.Errors ?? [];
if (errors.length > 0) {
throw new Error(`Failed deleting ${errors.length} audiobook storage objects`);
}
deleted += keys.length;
} }
continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined; continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined;

View file

@ -96,8 +96,9 @@ const createAuth = () => betterAuth({
context: { userIdHash: hashForLog(user.id) }, context: { userIdHash: hashForLog(user.id) },
error, error,
}); });
// Don't throw allow the user deletion to proceed even if S3 cleanup fails. // Without a durable cleanup queue, proceeding would permanently
// Orphaned blobs are preferable to a blocked account deletion. // orphan user-scoped storage and non-cascading database rows.
throw error;
} }
}, },
}, },
@ -163,134 +164,30 @@ const createAuth = () => betterAuth({
import('@/lib/server/user/claim-data'), import('@/lib/server/user/claim-data'),
]); ]);
// Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user const transferred = await claimData.claimAnonymousData(
try { newUser.user.id,
await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id); anonymousUser.user.id,
serverLogger.info({ null,
event: 'auth.link_account.transfer.rate_limit.succeeded', { cleanupLegacySources: false },
anonymousUserIdHash: hashForLog(anonymousUser.user.id), );
newUserIdHash: hashForLog(newUser.user.id), await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id);
}, 'Transferred rate limit data during account linking'); const { deleteUserStorageData } = await import('@/lib/server/user/data-cleanup');
} catch (error) { await deleteUserStorageData(anonymousUser.user.id, null);
logDegraded(serverLogger, { serverLogger.info({
event: 'auth.link_account.transfer.rate_limit.failed', event: 'auth.link_account.transfer.succeeded',
msg: 'Failed transferring rate limit data during account linking', transferred,
step: 'transfer_rate_limit', anonymousUserIdHash: hashForLog(anonymousUser.user.id),
context: { newUserIdHash: hashForLog(newUser.user.id),
anonymousUserIdHash: hashForLog(anonymousUser.user.id), }, 'Transferred anonymous user data during account linking');
newUserIdHash: hashForLog(newUser.user.id),
},
error,
});
// Don't throw here to prevent blocking the account linking process
}
// Transfer audiobooks from anonymous user to new authenticated user
try {
const transferred = await claimData.transferUserAudiobooks(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
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) {
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
}
// Transfer documents from anonymous user to new authenticated user
try {
const transferred = await claimData.transferUserDocuments(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
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) {
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
}
// Transfer preferences from anonymous user to new authenticated user
try {
const transferred = await claimData.transferUserPreferences(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
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) {
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
}
// Transfer reading progress from anonymous user to new authenticated user
try {
const transferred = await claimData.transferUserProgress(anonymousUser.user.id, newUser.user.id);
if (transferred > 0) {
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) {
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) { } catch (error) {
logServerError(serverLogger, { logServerError(serverLogger, {
event: 'auth.link_account.failed', event: 'auth.link_account.failed',
msg: 'onLinkAccount callback failed', msg: 'onLinkAccount callback failed',
error, error,
}); });
// Don't throw here to prevent blocking the account linking process // Better Auth deletes the anonymous user after this callback.
// Block linking when transfer is incomplete so data remains retryable.
throw error;
} }
// Note: Anonymous user will be automatically deleted after this callback completes // Note: Anonymous user will be automatically deleted after this callback completes
}, },

View file

@ -0,0 +1,95 @@
import { randomUUID } from 'node:crypto';
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';
import { documentBlobLeases } from '@/db/schema';
import { errorToLog, serverLogger } from '@/lib/server/logger';
const DEFAULT_LEASE_MS = 15 * 60 * 1000;
const RETRY_DELAY_MS = 100;
const MAX_RETRY_DELAY_MS = 2_000;
export type DocumentBlobLease = {
owner: string;
release: () => Promise<void>;
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function tryAcquireDocumentBlobLease(
documentId: string,
options?: { leaseMs?: number },
): Promise<DocumentBlobLease | null> {
const owner = randomUUID();
const now = Date.now();
const leaseUntilMs = now + (options?.leaseMs ?? DEFAULT_LEASE_MS);
const claimed = await db
.insert(documentBlobLeases)
.values({ documentId, leaseOwner: owner, leaseUntilMs })
.onConflictDoUpdate({
target: documentBlobLeases.documentId,
set: { leaseOwner: owner, leaseUntilMs },
where: lt(documentBlobLeases.leaseUntilMs, now),
})
.returning({ owner: documentBlobLeases.leaseOwner });
if (claimed[0]?.owner !== owner) return null;
return {
owner,
release: async () => {
await db
.delete(documentBlobLeases)
.where(and(
eq(documentBlobLeases.documentId, documentId),
eq(documentBlobLeases.leaseOwner, owner),
));
},
};
}
export async function withDocumentBlobLease<T>(
documentId: string,
fn: () => Promise<T>,
options?: { waitMs?: number; leaseMs?: number },
): Promise<T> {
const deadline = Date.now() + (options?.waitMs ?? 30_000);
let lease: DocumentBlobLease | null = null;
let backoffMs = RETRY_DELAY_MS;
while (!lease && Date.now() < deadline) {
lease = await tryAcquireDocumentBlobLease(documentId, options);
if (lease) break;
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) break;
// Exponential backoff with jitter to avoid thundering-herd retries under
// contention, capped and never sleeping past the deadline.
const jitterMs = Math.random() * backoffMs * 0.1;
await sleep(Math.min(backoffMs + jitterMs, remainingMs));
backoffMs = Math.min(backoffMs * 2, MAX_RETRY_DELAY_MS);
}
if (!lease) {
throw new Error(`Timed out waiting for document blob lease: ${documentId}`);
}
try {
return await fn();
} finally {
// Release in its own try/catch so a failing release never masks the
// original error (or result) produced by fn().
try {
await lease.release();
} catch (releaseError) {
serverLogger.warn({
event: 'documents.blobLease.release.failed',
degraded: true,
documentId,
error: errorToLog(releaseError),
}, 'Failed to release document blob lease');
}
}
}

View file

@ -10,6 +10,8 @@ import {
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PDF_PARSER_VERSION, encodeParserVersion } from '@openreader/compute-core/api-contracts'; import { PDF_PARSER_VERSION, encodeParserVersion } from '@openreader/compute-core/api-contracts';
import { getS3Client, getS3Config, getS3ProxyClient } from '@/lib/server/storage/s3'; import { getS3Client, getS3Config, getS3ProxyClient } from '@/lib/server/storage/s3';
import { serverLogger } from '@/lib/server/logger';
import { logDegraded } from '@/lib/server/errors/logging';
const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i; const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i;
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
@ -506,11 +508,27 @@ export async function deleteDocumentBlob(id: string, namespace: string | null):
const nsSegment = ns ? `ns/${ns}/` : ''; const nsSegment = ns ? `ns/${ns}/` : '';
const parsedPrefix = `${cfg.prefix}/documents_v1/parsed_v2/${nsSegment}${id}/`; const parsedPrefix = `${cfg.prefix}/documents_v1/parsed_v2/${nsSegment}${id}/`;
await deleteDocumentPrefix(parsedPrefix);
await deleteDocumentPrefix(`${key}/`);
await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: parsedKey }));
await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: legacyParsedKey }));
// Delete the source after the initial derived-artifact cleanup, then sweep
// parsed output once more to catch a worker that finished during deletion.
await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key })); await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key }));
await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: parsedKey })).catch(() => undefined); // The source blob is already gone at this point. Treat the final sweep as a
await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: legacyParsedKey })).catch(() => undefined); // best-effort cleanup: if it throws, rethrowing would make callers roll back
await deleteDocumentPrefix(parsedPrefix).catch(() => undefined); // the document row even though the source is deleted, so log and swallow.
await deleteDocumentPrefix(`${key}/`).catch(() => undefined); try {
await deleteDocumentPrefix(parsedPrefix);
} catch (error) {
logDegraded(serverLogger, {
event: 'documents.blob_delete.final_parsed_sweep.failed',
msg: 'Failed final parsed-output sweep after document deletion',
step: 'delete_document_parsed_prefix_final',
context: { parsedPrefix },
error,
});
}
} }
export async function deleteTempDocumentUpload(token: string, userId: string, namespace: string | null): Promise<void> { export async function deleteTempDocumentUpload(token: string, userId: string, namespace: string | null): Promise<void> {
@ -564,7 +582,16 @@ export async function deleteDocumentPrefix(prefix: string): Promise<number> {
}, },
}), }),
); );
deleted += deleteRes.Deleted?.length ?? 0; const errors = deleteRes.Errors ?? [];
if (errors.length > 0) {
const details = errors
.map((e) => `${e.Key ?? '?'} (${e.Code ?? 'Unknown'}: ${e.Message ?? 'no message'})`)
.join('; ');
throw new Error(
`Failed deleting ${errors.length} document storage object(s) under prefix "${cleanedPrefix}": ${details}`,
);
}
deleted += keys.length;
} }
continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined; continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined;
@ -573,52 +600,107 @@ export async function deleteDocumentPrefix(prefix: string): Promise<number> {
return deleted; return deleted;
} }
export async function deleteExpiredTempDocumentUploads( /**
userId: string, * List the source document blobs under a namespace (content-addressed objects
* directly beneath `documents_v1/`, excluding the `parsed_v2/` and `ns/`
* subtrees via the `/` delimiter). Used by the orphaned-blob reaper.
*/
export async function listDocumentSourceBlobs(
namespace: string | null,
options?: { signal?: AbortSignal },
): Promise<Array<{ id: string; lastModifiedMs: number }>> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const ns = sanitizeNamespace(namespace);
const nsSegment = ns ? `ns/${ns}/` : '';
const prefix = `${cfg.prefix}/documents_v1/${nsSegment}`;
const out: Array<{ id: string; lastModifiedMs: number }> = [];
let continuationToken: string | undefined;
do {
options?.signal?.throwIfAborted();
const listRes = await client.send(
new ListObjectsV2Command({
Bucket: cfg.bucket,
Prefix: prefix,
Delimiter: '/',
ContinuationToken: continuationToken,
}),
{ abortSignal: options?.signal },
);
for (const item of listRes.Contents ?? []) {
const key = item.Key;
if (!key) continue;
const id = key.slice(prefix.length);
if (!isValidDocumentId(id)) continue;
out.push({ id, lastModifiedMs: item.LastModified?.getTime() ?? 0 });
}
continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined;
} while (continuationToken);
return out;
}
/**
* Delete every temporary upload object (across all users) older than the given
* cutoff. Used by the cleanup-temp-uploads scheduled task.
*/
export async function deleteAllExpiredTempDocumentUploads(
namespace: string | null, namespace: string | null,
olderThanMs: number, olderThanMs: number,
options?: { signal?: AbortSignal },
): Promise<number> { ): Promise<number> {
const cfg = getS3Config(); const cfg = getS3Config();
const client = getS3ProxyClient(); const client = getS3ProxyClient();
const prefix = tempDocumentUploadPrefix(userId, namespace); const ns = sanitizeNamespace(namespace);
const nsSegment = ns ? `ns/${ns}/` : '';
const prefix = `${cfg.prefix}/document_uploads_temp_v1/${nsSegment}`;
let continuationToken: string | undefined; let continuationToken: string | undefined;
const keys: string[] = []; let deleted = 0;
do { do {
options?.signal?.throwIfAborted();
const listRes = await client.send( const listRes = await client.send(
new ListObjectsV2Command({ new ListObjectsV2Command({
Bucket: cfg.bucket, Bucket: cfg.bucket,
Prefix: prefix, Prefix: prefix,
ContinuationToken: continuationToken, ContinuationToken: continuationToken,
}), }),
{ abortSignal: options?.signal },
); );
const batch: string[] = [];
for (const item of listRes.Contents ?? []) { for (const item of listRes.Contents ?? []) {
const key = item.Key; const key = item.Key;
const lastModified = item.LastModified?.getTime() ?? 0; const lastModified = item.LastModified?.getTime() ?? 0;
if (!key || lastModified <= 0 || lastModified >= olderThanMs) continue; if (!key || lastModified <= 0 || lastModified >= olderThanMs) continue;
keys.push(key); batch.push(key);
}
if (batch.length > 0) {
const deleteRes = await client.send(
new DeleteObjectsCommand({
Bucket: cfg.bucket,
Delete: {
Objects: batch.map((Key) => ({ Key })),
Quiet: true,
},
}),
{ abortSignal: options?.signal },
);
if (deleteRes.Errors?.length) {
throw new Error(
`Failed to delete temporary uploads from bucket "${cfg.bucket}" `
+ `(keys: ${JSON.stringify(batch)}, errors: ${JSON.stringify(deleteRes.Errors)})`,
);
}
deleted += batch.length;
} }
continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined; continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined;
} while (continuationToken); } while (continuationToken);
if (keys.length === 0) return 0;
let deleted = 0;
for (let i = 0; i < keys.length; i += 1000) {
const batch = keys.slice(i, i + 1000);
const deleteRes = await client.send(
new DeleteObjectsCommand({
Bucket: cfg.bucket,
Delete: {
Objects: batch.map((Key) => ({ Key })),
Quiet: true,
},
}),
);
deleted += deleteRes.Deleted?.length ?? 0;
}
return deleted; return deleted;
} }

View file

@ -0,0 +1,41 @@
import { and, eq } from 'drizzle-orm';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { deleteDocumentTtsSegmentCache } from '@/lib/server/tts/segments-cache';
import { hashForLog, serverLogger } from '@/lib/server/logger';
import { logDegraded } from '@/lib/server/errors/logging';
/**
* Remove a user's ownership of a document.
*
* Only the per-user TTS segment cache is cleaned inline (it is keyed by userId
* and not reachable afterwards). The shared, content-addressed document blob and
* its previews are reclaimed by the `reap-orphaned-blobs` task once no owner
* remains, so there is no inline blob deletion, last-owner check, or lock.
*/
export async function deleteOwnedDocument(input: {
userId: string;
documentId: string;
namespace: string | null;
}): Promise<boolean> {
const [removed] = await db
.delete(documents)
.where(and(
eq(documents.id, input.documentId),
eq(documents.userId, input.userId),
))
.returning();
if (!removed) return false;
await deleteDocumentTtsSegmentCache(input).catch((error) => {
logDegraded(serverLogger, {
event: 'documents.delete_owned.tts_cache_cleanup.failed',
msg: 'Failed to clean TTS segment cache after document deletion',
step: 'delete_document_tts_segment_cache',
context: { documentId: input.documentId, userIdHash: hashForLog(input.userId) },
error,
});
});
return true;
}

View file

@ -8,7 +8,6 @@ import {
DOCUMENT_PREVIEW_CONTENT_TYPE, DOCUMENT_PREVIEW_CONTENT_TYPE,
DOCUMENT_PREVIEW_VARIANT, DOCUMENT_PREVIEW_VARIANT,
DOCUMENT_PREVIEW_WIDTH, DOCUMENT_PREVIEW_WIDTH,
deleteDocumentPreviewArtifacts,
documentPreviewKey, documentPreviewKey,
headDocumentPreview, headDocumentPreview,
isMissingBlobError, isMissingBlobError,
@ -419,7 +418,3 @@ export async function deleteDocumentPreviewRows(documentId: string, namespace: s
.delete(documentPreviews) .delete(documentPreviews)
.where(and(eq(documentPreviews.documentId, documentId), eq(documentPreviews.namespace, namespaceKey))); .where(and(eq(documentPreviews.documentId, documentId), eq(documentPreviews.namespace, namespaceKey)));
} }
export async function cleanupDocumentPreviewArtifacts(documentId: string, namespace: string | null): Promise<void> {
await deleteDocumentPreviewArtifacts(documentId, namespace);
}

View file

@ -1,9 +1,11 @@
import { db } from '@/db'; import { db } from '@/db';
import { documents } from '@/db/schema'; import { documents } from '@/db/schema';
import { and, eq } from 'drizzle-orm';
import { import {
enqueueDocumentPreview, enqueueDocumentPreview,
} from '@/lib/server/documents/previews'; } from '@/lib/server/documents/previews';
import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
import { deleteDocumentTtsSegmentCache } from '@/lib/server/tts/segments-cache';
import type { BaseDocument, DocumentType } from '@/types/documents'; import type { BaseDocument, DocumentType } from '@/types/documents';
type RegisterUploadedDocumentInput = { type RegisterUploadedDocumentInput = {
@ -17,6 +19,23 @@ type RegisterUploadedDocumentInput = {
}; };
export async function registerUploadedDocument(input: RegisterUploadedDocumentInput): Promise<BaseDocument> { export async function registerUploadedDocument(input: RegisterUploadedDocumentInput): Promise<BaseDocument> {
const [existing] = await db
.select({ lastModified: documents.lastModified })
.from(documents)
.where(and(
eq(documents.id, input.documentId),
eq(documents.userId, input.userId),
))
.limit(1);
if (existing && Number(existing.lastModified) !== input.lastModified) {
await deleteDocumentTtsSegmentCache({
userId: input.userId,
documentId: input.documentId,
namespace: input.namespace,
});
}
await db await db
.insert(documents) .insert(documents)
.values({ .values({

View file

@ -1,4 +1,4 @@
import { and, eq, gte, lt, sql } from 'drizzle-orm'; import { and, eq, gte, sql } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { userJobEvents } from '@/db/schema'; import { userJobEvents } from '@/db/schema';
import { nowTimestampMs } from '@/lib/shared/timestamps'; import { nowTimestampMs } from '@/lib/shared/timestamps';
@ -143,24 +143,5 @@ export async function recordJobEvent(
} catch { } catch {
// Recording is best-effort; never block op creation on ledger writes. // Recording is best-effort; never block op creation on ledger writes.
} }
// Old rows are removed by the prune-job-events scheduled task.
// Opportunistic prune of rows older than the largest configured window so
// the ledger stays small without a separate cron, but never deletes events
// that could still affect an in-window count.
if (Math.random() < 0.05) {
const largestWindowMs = config.windows.reduce(
(max, w) => (Number.isFinite(w.windowMs) && w.windowMs > max ? w.windowMs : max),
24 * 60 * 60 * 1000,
);
try {
await safeDb()
.delete(userJobEvents)
.where(and(
eq(userJobEvents.action, action),
lt(userJobEvents.createdAt, now - largestWindowMs),
));
} catch {
// ignore prune failures
}
}
} }

View file

@ -1,4 +1,5 @@
import { db } from '@/db'; import { db } from '@/db';
import { runInDbTransaction } from '@/db/run-in-transaction';
import { userTtsChars } from '@/db/schema'; import { userTtsChars } from '@/db/schema';
import { eq, and, lt, sql } from 'drizzle-orm'; import { eq, and, lt, sql } from 'drizzle-orm';
import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
@ -127,25 +128,10 @@ function getRowsAffected(result: unknown): number {
export class RateLimiter { export class RateLimiter {
constructor() { } constructor() { }
private isPostgres(): boolean {
return Boolean(process.env.POSTGRES_URL);
}
private getUpdatedAtValue(): number { private getUpdatedAtValue(): number {
return nowTimestampMs(); return nowTimestampMs();
} }
// Use a transaction only when running with Postgres.
// better-sqlite3 transactions require sync callbacks and cannot be awaited.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async runMutation<T>(fn: (conn: any) => Promise<T>): Promise<T> {
if (this.isPostgres()) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return safeDb().transaction(async (tx: any) => fn(tx));
}
return fn(safeDb());
}
/** /**
* Check if a user can use TTS and increment their char count if allowed * Check if a user can use TTS and increment their char count if allowed
*/ */
@ -189,7 +175,7 @@ export class RateLimiter {
try { try {
const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue; const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue;
return await this.runMutation(async (conn) => { return await runInDbTransaction(async (conn) => {
// Ensure records exist for each bucket // Ensure records exist for each bucket
for (const bucket of buckets) { for (const bucket of buckets) {
await conn.insert(userTtsChars) await conn.insert(userTtsChars)
@ -320,36 +306,34 @@ export class RateLimiter {
* Transfer char counts when anonymous user creates an account * Transfer char counts when anonymous user creates an account
*/ */
async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise<void> { async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise<void> {
const today = new Date().toISOString().split('T')[0];
const dateValue = today as unknown as UserTtsCharsDateValue;
const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue; const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue;
const anonymousResult = await safeDb().select({ charCount: userTtsChars.charCount }) await runInDbTransaction(async (conn) => {
.from(userTtsChars) const anonymousRows = await conn.select({
.where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, dateValue))); date: userTtsChars.date,
charCount: userTtsChars.charCount,
})
.from(userTtsChars)
.where(eq(userTtsChars.userId, anonymousUserId));
if (anonymousResult.length === 0) return; for (const anonymousRow of anonymousRows) {
const dateValue = anonymousRow.date as unknown as UserTtsCharsDateValue;
const anonymousCount = Number(anonymousRow.charCount ?? 0);
const anonymousCount = Number(anonymousResult[0].charCount); await conn.insert(userTtsChars)
.values({ userId: authenticatedUserId, date: dateValue, charCount: anonymousCount })
const existingAuth = await safeDb().select({ charCount: userTtsChars.charCount }) .onConflictDoUpdate({
.from(userTtsChars) target: [userTtsChars.userId, userTtsChars.date],
.where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, dateValue))); set: {
charCount: sql`${userTtsChars.charCount} + ${anonymousCount}`,
if (existingAuth.length === 0) { updatedAt,
await safeDb().insert(userTtsChars) },
.values({ userId: authenticatedUserId, date: dateValue, charCount: anonymousCount }); });
} else {
const existingCount = Number(existingAuth[0].charCount);
if (anonymousCount > existingCount) {
await safeDb().update(userTtsChars)
.set({ charCount: anonymousCount, updatedAt })
.where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, dateValue)));
} }
}
await safeDb().delete(userTtsChars) await conn.delete(userTtsChars)
.where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, dateValue))); .where(eq(userTtsChars.userId, anonymousUserId));
});
} }
/** /**

View file

@ -0,0 +1,242 @@
import { randomUUID } from 'node:crypto';
import { and, eq, lt, ne, or, sql } from 'drizzle-orm';
import { db } from '@/db';
import { scheduledTasks } from '@/db/schema';
import { serverLogger } from '@/lib/server/logger';
import { logDegraded } from '@/lib/server/errors/logging';
import { TASK_REGISTRY } from './registry';
import type { TaskContext, TaskDef, TaskRegistry, TaskRunStatus } from './types';
// A task still marked 'running' after this long is assumed abandoned (process
// crashed mid-run) and may be reclaimed by the next tick.
const STALE_RUNNING_MS = 60 * 60 * 1000;
const DEFAULT_TASK_MAX_RUN_MS = 4 * 60 * 1000;
/**
* Seed a `scheduled_tasks` row for every registered task. Idempotent: existing
* rows (including user-edited interval/enabled) are left untouched.
*/
async function ensureTaskRows(registry: TaskRegistry = TASK_REGISTRY): Promise<void> {
const now = Date.now();
for (const [key, def] of Object.entries(registry)) {
await db
.insert(scheduledTasks)
.values({
key,
enabled: true,
intervalMs: def.defaultIntervalMs,
lastStatus: 'idle',
nextRunAt: now,
})
.onConflictDoNothing();
}
}
/**
* Atomically claim a task for execution. Returns true only if this caller won
* the claim. The single UPDATE flips status to 'running' iff it is not already
* running (or its running marker is stale). A unique owner token fences final
* state updates if a stale runner later completes after being replaced.
*/
async function claimTask(key: string, now: number): Promise<string | null> {
const leaseOwner = randomUUID();
const claimed = await db
.update(scheduledTasks)
.set({
lastStatus: 'running',
leaseOwner,
runningSince: now,
runRequested: false,
updatedAt: now,
})
.where(and(
eq(scheduledTasks.key, key),
or(
ne(scheduledTasks.lastStatus, 'running'),
lt(scheduledTasks.runningSince, now - STALE_RUNNING_MS),
),
))
.returning({ leaseOwner: scheduledTasks.leaseOwner });
return claimed[0]?.leaseOwner === leaseOwner ? leaseOwner : null;
}
async function finishTask(
key: string,
leaseOwner: string,
outcome:
| { status: 'ok'; startedAt: number; summary: string | null }
| { status: 'error'; startedAt: number; error: unknown },
): Promise<void> {
const now = Date.now();
await db
.update(scheduledTasks)
.set({
lastStatus: outcome.status,
lastRunAt: now,
lastDurationMs: now - outcome.startedAt,
lastError: outcome.status === 'error' ? String(outcome.error) : null,
lastResultJson: outcome.status === 'ok' && outcome.summary ? outcome.summary : null,
// Schedule the next run off the row's (possibly user-edited) interval.
nextRunAt: sql`${now} + ${scheduledTasks.intervalMs}`,
leaseOwner: null,
runningSince: null,
updatedAt: now,
})
.where(and(
eq(scheduledTasks.key, key),
eq(scheduledTasks.leaseOwner, leaseOwner),
));
}
function taskTimeoutError(key: string, maxRunMs: number): Error {
return new Error(`Scheduled task "${key}" exceeded its ${maxRunMs}ms runtime limit`);
}
async function executeTask(key: string, def: TaskDef, leaseOwner: string): Promise<void> {
const startedAt = Date.now();
const maxRunMs = def.maxRunMs ?? DEFAULT_TASK_MAX_RUN_MS;
const controller = new AbortController();
const context: TaskContext = {
signal: controller.signal,
deadlineAt: startedAt + maxRunMs,
};
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const result = await Promise.race([
def.run(context),
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
controller.abort();
reject(taskTimeoutError(key, maxRunMs));
}, maxRunMs);
}),
]);
const summary = result && typeof result.summary === 'string' ? result.summary : null;
await finishTask(key, leaseOwner, { status: 'ok', startedAt, summary });
} catch (error) {
logDegraded(serverLogger, {
event: 'tasks.run.failed',
msg: `Scheduled task "${key}" failed`,
step: key,
error,
});
await finishTask(key, leaseOwner, { status: 'error', startedAt, error });
} finally {
if (timeout) clearTimeout(timeout);
}
}
/**
* Run every task that is due (interval elapsed or a manual run was requested),
* claiming each first so concurrent ticks don't double-run. Safe to call from
* any trigger: the self-host interval, a Vercel cron route, or a manual run.
*/
export async function runDueTasks(options?: { registry?: TaskRegistry }): Promise<void> {
const registry = options?.registry ?? TASK_REGISTRY;
await ensureTaskRows(registry);
const now = Date.now();
const rows = await db.select().from(scheduledTasks);
const executions: Promise<void>[] = [];
for (const row of rows) {
const def = registry[row.key];
if (!def) continue; // orphaned row for a task no longer in the registry
const due =
row.runRequested ||
(row.enabled && row.nextRunAt != null && now >= Number(row.nextRunAt));
if (!due) continue;
const leaseOwner = await claimTask(row.key, now);
if (!leaseOwner) continue;
executions.push(executeTask(row.key, def, leaseOwner));
}
await Promise.all(executions);
}
/**
* Run a single task immediately, regardless of schedule. Returns false if the
* key is unknown or the task is already running.
*/
export async function runTaskNow(key: string, registry: TaskRegistry = TASK_REGISTRY): Promise<boolean> {
const def = registry[key];
if (!def) return false;
await ensureTaskRows(registry);
const leaseOwner = await claimTask(key, Date.now());
if (!leaseOwner) return false;
await executeTask(key, def, leaseOwner);
return true;
}
/** Update a task's user-editable fields (enable/disable, run interval). */
export async function updateTask(
key: string,
patch: { enabled?: boolean; intervalMs?: number },
): Promise<void> {
const def = Object.hasOwn(TASK_REGISTRY, key) ? TASK_REGISTRY[key] : undefined;
if (!def) return;
const now = Date.now();
const set: Record<string, unknown> = { updatedAt: now };
if (typeof patch.enabled === 'boolean') set.enabled = patch.enabled;
if (typeof patch.intervalMs === 'number' && Number.isFinite(patch.intervalMs) && patch.intervalMs > 0) {
set.intervalMs = Math.max(1, Math.floor(patch.intervalMs));
}
await db
.insert(scheduledTasks)
.values({
key,
enabled: typeof set.enabled === 'boolean' ? set.enabled : true,
intervalMs: typeof set.intervalMs === 'number' ? set.intervalMs : def.defaultIntervalMs,
lastStatus: 'idle',
nextRunAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: scheduledTasks.key,
set,
});
}
export type TaskView = {
key: string;
name: string;
description?: string;
enabled: boolean;
intervalMs: number;
lastStatus: TaskRunStatus;
lastRunAt: number | null;
lastDurationMs: number | null;
lastError: string | null;
lastResult: string | null;
nextRunAt: number | null;
running: boolean;
};
/** Combined registry + stored-state view for the admin tasks UI. */
export async function listTasks(registry: TaskRegistry = TASK_REGISTRY): Promise<TaskView[]> {
await ensureTaskRows(registry);
const rows = await db.select().from(scheduledTasks);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const byKey = new Map<string, any>(rows.map((row: any) => [row.key, row]));
return Object.entries(registry).map(([key, def]) => {
const row = byKey.get(key);
return {
key,
name: def.name,
description: def.description,
enabled: !!row?.enabled,
intervalMs: Number(row?.intervalMs ?? def.defaultIntervalMs),
lastStatus: (row?.lastStatus ?? 'idle') as TaskRunStatus,
lastRunAt: row?.lastRunAt != null ? Number(row.lastRunAt) : null,
lastDurationMs: row?.lastDurationMs != null ? Number(row.lastDurationMs) : null,
lastError: row?.lastError ?? null,
lastResult: row?.lastResultJson ?? null,
nextRunAt: row?.nextRunAt != null ? Number(row.nextRunAt) : null,
running: row?.lastStatus === 'running',
};
});
}

View file

@ -0,0 +1,17 @@
import { isS3Configured } from '@/lib/server/storage/s3';
import {
TEMP_DOCUMENT_UPLOAD_TTL_MS,
deleteAllExpiredTempDocumentUploads,
} from '@/lib/server/documents/blobstore';
import type { TaskContext, TaskResult } from '../types';
/** Remove temporary upload objects past their TTL across all users. */
export async function cleanupTempUploads(context: TaskContext): Promise<TaskResult> {
if (!isS3Configured()) {
return { summary: 'Skipped: object storage not configured', deleted: 0 };
}
const cutoff = Date.now() - TEMP_DOCUMENT_UPLOAD_TTL_MS;
const deleted = await deleteAllExpiredTempDocumentUploads(null, cutoff, { signal: context.signal });
return { summary: `Deleted ${deleted} expired upload object(s)`, deleted };
}

View file

@ -0,0 +1,25 @@
import { lt } from 'drizzle-orm';
import { db } from '@/db';
import { userJobEvents } from '@/db/schema';
import type { TaskResult } from '../types';
// Retention far exceeds the largest rate-limit window, so pruning never removes
// an event that could still affect an in-window count.
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
function rowsAffected(result: unknown): number {
if (result && typeof result === 'object') {
const rec = result as Record<string, unknown>;
if (typeof rec.rowCount === 'number') return rec.rowCount;
if (typeof rec.changes === 'number') return rec.changes;
}
return 0;
}
/** Delete rate-limit job-event rows older than the retention window. */
export async function pruneJobEvents(): Promise<TaskResult> {
const cutoff = Date.now() - RETENTION_MS;
const result = await db.delete(userJobEvents).where(lt(userJobEvents.createdAt, cutoff));
const pruned = rowsAffected(result);
return { summary: `Pruned ${pruned} job event(s)`, pruned };
}

View file

@ -0,0 +1,10 @@
import { rateLimiter } from '@/lib/server/rate-limit/rate-limiter';
import type { TaskResult } from '../types';
const RETENTION_DAYS = 30;
/** Delete TTS usage counter rows (user_tts_chars) older than the retention window. */
export async function pruneTtsUsage(): Promise<TaskResult> {
await rateLimiter.cleanupOldRecords(RETENTION_DAYS);
return { summary: `Pruned TTS usage older than ${RETENTION_DAYS}d` };
}

View file

@ -0,0 +1,88 @@
import { inArray } from 'drizzle-orm';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { isS3Configured } from '@/lib/server/storage/s3';
import { deleteDocumentBlob, listDocumentSourceBlobs } from '@/lib/server/documents/blobstore';
import { deleteDocumentPreviewArtifacts } from '@/lib/server/documents/previews-blobstore';
import { deleteDocumentPreviewRows } from '@/lib/server/documents/previews';
import { serverLogger } from '@/lib/server/logger';
import { logDegraded } from '@/lib/server/errors/logging';
import { tryAcquireDocumentBlobLease } from '@/lib/server/documents/blob-lease';
import type { TaskContext, TaskResult } from '../types';
// Don't reap a blob younger than this — it may belong to an in-flight finalize
// that has written the blob but not yet committed its ownership row.
const GRACE_MS = 60 * 60 * 1000;
const OWNERSHIP_CHECK_BATCH = 200;
/**
* Delete content-addressed document blobs that no longer have any owner.
*
* Reference count = ownership rows in `documents`. A blob with zero rows and
* age past the grace window is an orphan (e.g. left by a failed inline delete)
* and is safe to remove. Production data is non-namespaced.
*/
export async function reapOrphanedBlobs(context: TaskContext): Promise<TaskResult> {
if (!isS3Configured()) {
return { summary: 'Skipped: object storage not configured', reaped: 0 };
}
const now = Date.now();
const blobs = await listDocumentSourceBlobs(null, { signal: context.signal });
const candidates = blobs.filter((blob) => now - blob.lastModifiedMs > GRACE_MS);
let reaped = 0;
const failures: unknown[] = [];
for (let i = 0; i < candidates.length; i += OWNERSHIP_CHECK_BATCH) {
context.signal.throwIfAborted();
const chunk = candidates.slice(i, i + OWNERSHIP_CHECK_BATCH);
const ids = chunk.map((c) => c.id);
const ownedRows = await db
.select({ id: documents.id })
.from(documents)
.where(inArray(documents.id, ids));
const owned = new Set(ownedRows.map((row: { id: string }) => row.id));
for (const candidate of chunk) {
context.signal.throwIfAborted();
if (owned.has(candidate.id)) continue;
const lease = await tryAcquireDocumentBlobLease(candidate.id);
if (!lease) continue;
try {
const [owner] = await db
.select({ id: documents.id })
.from(documents)
.where(inArray(documents.id, [candidate.id]))
.limit(1);
if (owner) continue;
await deleteDocumentBlob(candidate.id, null);
await deleteDocumentPreviewArtifacts(candidate.id, null);
await deleteDocumentPreviewRows(candidate.id, null);
reaped += 1;
} catch (error) {
failures.push(error);
logDegraded(serverLogger, {
event: 'tasks.reap_orphaned_blobs.delete_failed',
msg: 'Failed to reap orphaned document storage',
step: 'reap_orphaned_blob',
context: { documentId: candidate.id },
error,
});
} finally {
await lease.release();
}
}
}
if (failures.length > 0) {
throw new AggregateError(failures, `Failed to reap ${failures.length} orphaned blob(s)`);
}
return {
summary: `Reaped ${reaped} orphaned blob(s)`,
scanned: blobs.length,
candidates: candidates.length,
reaped,
};
}

View file

@ -0,0 +1,37 @@
import type { TaskRegistry } from './types';
import { reapOrphanedBlobs } from './handlers/reap-orphaned-blobs';
import { cleanupTempUploads } from './handlers/cleanup-temp-uploads';
import { pruneJobEvents } from './handlers/prune-job-events';
import { pruneTtsUsage } from './handlers/prune-tts-usage';
/**
* The catalog of scheduled tasks. Each key is the stable task id stored in the
* `scheduled_tasks` table; renaming a key orphans its row (the engine ignores
* rows with no matching definition).
*/
export const TASK_REGISTRY: TaskRegistry = {
'reap-orphaned-blobs': {
name: 'Reap orphaned document blobs',
description: 'Delete content-addressed document blobs that no longer have any owner.',
defaultIntervalMs: 6 * 60 * 60 * 1000,
run: reapOrphanedBlobs,
},
'cleanup-temp-uploads': {
name: 'Clean up expired uploads',
description: 'Remove temporary upload objects past their TTL.',
defaultIntervalMs: 60 * 60 * 1000,
run: cleanupTempUploads,
},
'prune-job-events': {
name: 'Prune job event ledger',
description: 'Delete rate-limit job events older than the retention window.',
defaultIntervalMs: 24 * 60 * 60 * 1000,
run: pruneJobEvents,
},
'prune-tts-usage': {
name: 'Prune TTS usage counters',
description: 'Delete TTS usage rows (user_tts_chars) older than 30 days.',
defaultIntervalMs: 24 * 60 * 60 * 1000,
run: pruneTtsUsage,
},
};

View file

@ -0,0 +1,55 @@
import { runDueTasks } from './engine';
import { serverLogger } from '@/lib/server/logger';
const TICK_INTERVAL_MS = 60_000;
const INITIAL_DELAY_MS = 10_000;
export const VERCEL_TICK_INTERVAL_MS = 24 * 60 * 60 * 1000;
export type TaskSchedulerInfo = {
mode: 'self-hosted' | 'vercel-cron';
tickIntervalMs: number;
minimumIntervalMs: number;
};
export function getTaskSchedulerInfo(): TaskSchedulerInfo {
if (process.env.VERCEL) {
return {
mode: 'vercel-cron',
tickIntervalMs: VERCEL_TICK_INTERVAL_MS,
minimumIntervalMs: VERCEL_TICK_INTERVAL_MS,
};
}
return {
mode: 'self-hosted',
tickIntervalMs: TICK_INTERVAL_MS,
minimumIntervalMs: TICK_INTERVAL_MS,
};
}
let started = false;
/**
* Start the in-process scheduler loop. Intended for the long-lived self-hosted
* server only on Vercel a cron route drives the ticks instead. Idempotent:
* repeated calls (e.g. dev HMR) start a single loop.
*/
export function startTaskScheduler(): void {
if (started) return;
started = true;
const tick = async () => {
try {
await runDueTasks();
} catch (error) {
serverLogger.warn(
{ event: 'tasks.scheduler.tick_failed', error: String(error) },
'Task scheduler tick failed',
);
}
};
setTimeout(tick, INITIAL_DELAY_MS);
const handle = setInterval(tick, TICK_INTERVAL_MS);
// Don't keep the process alive solely for the scheduler.
if (typeof handle.unref === 'function') handle.unref();
}

View file

@ -0,0 +1,32 @@
/** Outcome a task handler may return; surfaced in the admin UI as a summary. */
export type TaskResult = {
/** Short human-readable summary, e.g. "Reaped 3 orphaned blobs". */
summary?: string;
/** Arbitrary structured detail for debugging. */
[key: string]: unknown;
};
export type TaskContext = {
signal: AbortSignal;
deadlineAt: number;
};
export type TaskHandler = (context: TaskContext) => Promise<TaskResult | void>;
/** Static definition of a task, kept in code (the registry). */
export type TaskDef = {
/** Display name shown in the admin tasks list. */
name: string;
/** Optional longer description of what the task does. */
description?: string;
/** Default run interval in ms; the per-task row may override it. */
defaultIntervalMs: number;
/** Maximum wall-clock time before the run is marked failed and aborted. */
maxRunMs?: number;
/** The work to perform. Must be idempotent and safe to re-run. */
run: TaskHandler;
};
export type TaskRegistry = Record<string, TaskDef>;
export type TaskRunStatus = 'idle' | 'running' | 'ok' | 'error';

View file

@ -1,4 +1,5 @@
import { import {
CopyObjectCommand,
DeleteObjectsCommand, DeleteObjectsCommand,
GetObjectCommand, GetObjectCommand,
ListObjectsV2Command, ListObjectsV2Command,
@ -244,7 +245,12 @@ export async function deleteTtsSegmentPrefix(prefix: string): Promise<number> {
}, },
}), }),
); );
deleted += deleteRes.Deleted?.length ?? 0; const errors = deleteRes.Errors ?? [];
if (errors.length > 0) {
throw new Error(`Failed deleting ${errors.length} TTS segment audio objects`);
}
// Quiet=true commonly omits Deleted entries on successful requests.
deleted += keys.length;
} }
continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined; continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined;
@ -252,3 +258,51 @@ export async function deleteTtsSegmentPrefix(prefix: string): Promise<number> {
return deleted; return deleted;
} }
export async function copyTtsSegmentPrefix(sourcePrefix: string, destinationPrefix: string): Promise<number> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const source = sourcePrefix.replace(/^\/+/, '');
const destination = destinationPrefix.replace(/^\/+/, '');
if (source === destination) return 0;
let copied = 0;
let continuationToken: string | undefined;
// Track destination keys we have written so a mid-copy failure can be rolled
// back, leaving no orphaned objects behind at the destination prefix.
const copiedKeys: string[] = [];
try {
do {
const listRes = await client.send(new ListObjectsV2Command({
Bucket: cfg.bucket,
Prefix: source,
ContinuationToken: continuationToken,
}));
const keys = (listRes.Contents ?? [])
.map((item) => item.Key)
.filter((value): value is string => typeof value === 'string' && value.startsWith(source));
for (const key of keys) {
const destinationKey = `${destination}${key.slice(source.length)}`;
await client.send(new CopyObjectCommand({
Bucket: cfg.bucket,
Key: destinationKey,
CopySource: `${cfg.bucket}/${key}`,
ServerSideEncryption: 'AES256',
}));
copiedKeys.push(destinationKey);
copied += 1;
}
continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined;
} while (continuationToken);
} catch (error) {
// Best-effort rollback of the partial copy; surface the original error.
if (copiedKeys.length > 0) {
await deleteTtsSegmentAudioObjects(copiedKeys).catch(() => {});
}
throw error;
}
return copied;
}

View file

@ -1,7 +1,12 @@
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
import { deleteTtsSegmentAudioObjects } from '@/lib/server/tts/segments-blobstore'; import {
deleteTtsSegmentAudioObjects,
deleteTtsSegmentPrefix,
} from '@/lib/server/tts/segments-blobstore';
import { buildTtsSegmentDocumentPrefix } from '@/lib/server/tts/segments';
import { getS3Config } from '@/lib/server/storage/s3';
import type { ReaderType } from '@/types/user-state'; import type { ReaderType } from '@/types/user-state';
import { serverLogger } from '@/lib/server/logger'; import { serverLogger } from '@/lib/server/logger';
import { logDegraded } from '@/lib/server/errors/logging'; import { logDegraded } from '@/lib/server/errors/logging';
@ -35,6 +40,11 @@ export async function clearTtsSegmentCache(
conditions.push(eq(ttsSegmentEntries.readerType, input.readerType)); conditions.push(eq(ttsSegmentEntries.readerType, input.readerType));
} }
const entryRows = (await db
.select({ segmentEntryId: ttsSegmentEntries.segmentEntryId })
.from(ttsSegmentEntries)
.where(and(...conditions))) as Array<{ segmentEntryId: string }>;
const rows = (await db const rows = (await db
.select({ .select({
segmentId: ttsSegmentVariants.segmentId, segmentId: ttsSegmentVariants.segmentId,
@ -50,8 +60,6 @@ export async function clearTtsSegmentCache(
) )
.where(and(...conditions))) as Array<{ segmentId: string; audioKey: string | null }>; .where(and(...conditions))) as Array<{ segmentId: string; audioKey: string | null }>;
await db.delete(ttsSegmentEntries).where(and(...conditions));
const audioKeys = rows const audioKeys = rows
.map((row) => row.audioKey) .map((row) => row.audioKey)
.filter((key): key is string => Boolean(key)); .filter((key): key is string => Boolean(key));
@ -80,10 +88,38 @@ export async function clearTtsSegmentCache(
} }
} }
// Keep metadata when storage cleanup is incomplete so a later retry still
// knows which objects must be removed.
if (!warning) {
await db.delete(ttsSegmentEntries).where(and(...conditions));
}
return { return {
deletedSegments: rows.length, deletedSegments: warning ? 0 : new Set(entryRows.map((row) => row.segmentEntryId)).size,
requestedAudioObjects: uniqueAudioKeys.length, requestedAudioObjects: uniqueAudioKeys.length,
deletedAudioObjects, deletedAudioObjects,
...(warning ? { warning } : {}), ...(warning ? { warning } : {}),
}; };
} }
export async function deleteDocumentTtsSegmentCache(input: {
userId: string;
documentId: string;
namespace: string | null;
}): Promise<void> {
const storagePrefix = getS3Config().prefix;
for (const storageVersion of ['v1', 'v2'] as const) {
await deleteTtsSegmentPrefix(buildTtsSegmentDocumentPrefix({
storagePrefix,
namespace: input.namespace,
userId: input.userId,
documentId: input.documentId,
storageVersion,
}));
}
await db.delete(ttsSegmentEntries).where(and(
eq(ttsSegmentEntries.userId, input.userId),
eq(ttsSegmentEntries.documentId, input.documentId),
));
}

View file

@ -226,6 +226,17 @@ export function buildTtsSegmentAudioKey(input: {
return `${input.storagePrefix}/tts_segments_v2/${nsSegment}users/${encodeURIComponent(input.userId)}/docs/${input.documentId}/${input.documentVersion}/${input.settingsHash}/${input.segmentId}.mp3`; return `${input.storagePrefix}/tts_segments_v2/${nsSegment}users/${encodeURIComponent(input.userId)}/docs/${input.documentId}/${input.documentVersion}/${input.settingsHash}/${input.segmentId}.mp3`;
} }
export function buildTtsSegmentDocumentPrefix(input: {
storagePrefix: string;
namespace: string | null;
userId: string;
documentId: string;
storageVersion?: 'v1' | 'v2';
}): string {
const nsSegment = input.namespace ? `ns/${input.namespace}/` : '';
return `${input.storagePrefix}/tts_segments_${input.storageVersion ?? 'v2'}/${nsSegment}users/${encodeURIComponent(input.userId)}/docs/${input.documentId}/`;
}
export async function probeAudioDurationMsFromBuffer(buffer: Buffer, signal?: AbortSignal): Promise<number> { export async function probeAudioDurationMsFromBuffer(buffer: Buffer, signal?: AbortSignal): Promise<number> {
let workDir: string | null = null; let workDir: string | null = null;
try { try {

View file

@ -1,5 +1,14 @@
import { db } from '@/db'; import { db } from '@/db';
import { documents, audiobooks, audiobookChapters, userPreferences, userDocumentProgress } from '@/db/schema'; import {
documents,
audiobooks,
audiobookChapters,
documentSettings,
ttsSegmentEntries,
ttsSegmentVariants,
userPreferences,
userDocumentProgress,
} from '@/db/schema';
import { eq, and, inArray } from 'drizzle-orm'; import { eq, and, inArray } from 'drizzle-orm';
import { UNCLAIMED_USER_ID } from '../storage/docstore-legacy'; import { UNCLAIMED_USER_ID } from '../storage/docstore-legacy';
import { cleanupClaimedLegacyFsSources } from './legacy-fs-claim-cleanup'; import { cleanupClaimedLegacyFsSources } from './legacy-fs-claim-cleanup';
@ -12,6 +21,9 @@ import {
import { isS3Configured } from '../storage/s3'; import { isS3Configured } from '../storage/s3';
import { logDegraded } from '../errors/logging'; import { logDegraded } from '../errors/logging';
import { hashForLog, serverLogger } from '../logger'; import { hashForLog, serverLogger } from '../logger';
import { getS3Config } from '../storage/s3';
import { copyTtsSegmentPrefix, deleteTtsSegmentPrefix } from '../tts/segments-blobstore';
import { buildTtsSegmentDocumentPrefix } from '../tts/segments';
type AudiobookRow = { type AudiobookRow = {
id: string; id: string;
@ -61,7 +73,7 @@ function contentTypeForAudiobookObject(fileName: string): string {
return 'application/octet-stream'; return 'application/octet-stream';
} }
async function moveAudiobookBlobScope( async function copyAudiobookBlobScope(
bookId: string, bookId: string,
fromUserId: string, fromUserId: string,
toUserId: string, toUserId: string,
@ -83,15 +95,27 @@ async function moveAudiobookBlobScope(
namespace, namespace,
); );
} }
}
async function deleteAudiobookBlobScope(
bookId: string,
userId: string,
namespace: string | null,
): Promise<void> {
const objects = await listAudiobookObjects(bookId, userId, namespace);
for (const object of objects) { for (const object of objects) {
await deleteAudiobookObject(bookId, fromUserId, object.fileName, namespace).catch(() => {}); await deleteAudiobookObject(bookId, userId, object.fileName, namespace);
} }
} }
export async function claimAnonymousData(userId: string, unclaimedUserId: string = UNCLAIMED_USER_ID, namespace: string | null = null) { export async function claimAnonymousData(
userId: string,
unclaimedUserId: string = UNCLAIMED_USER_ID,
namespace: string | null = null,
options?: { cleanupLegacySources?: boolean },
) {
if (!userId) { if (!userId) {
return { documents: 0, audiobooks: 0, preferences: 0, progress: 0 }; return { documents: 0, audiobooks: 0, preferences: 0, progress: 0, documentSettings: 0 };
} }
const [claimableDocumentRows, claimableAudiobookRows] = await Promise.all([ const [claimableDocumentRows, claimableAudiobookRows] = await Promise.all([
@ -105,14 +129,18 @@ export async function claimAnonymousData(userId: string, unclaimedUserId: string
.where(eq(audiobooks.userId, unclaimedUserId)) as Promise<Array<{ id: string }>>, .where(eq(audiobooks.userId, unclaimedUserId)) as Promise<Array<{ id: string }>>,
]); ]);
const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed] = await Promise.all([ const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed, documentSettingsClaimed] = await Promise.all([
transferUserDocuments(unclaimedUserId, userId), transferUserDocuments(unclaimedUserId, userId, { namespace, transferTts: true }),
transferUserAudiobooks(unclaimedUserId, userId, namespace), transferUserAudiobooks(unclaimedUserId, userId, namespace),
transferUserPreferences(unclaimedUserId, userId), transferUserPreferences(unclaimedUserId, userId),
transferUserProgress(unclaimedUserId, userId), transferUserProgress(unclaimedUserId, userId),
transferUserDocumentSettings(unclaimedUserId, userId),
]); ]);
if (claimableDocumentRows.length > 0 || claimableAudiobookRows.length > 0) { if (
options?.cleanupLegacySources !== false
&& (claimableDocumentRows.length > 0 || claimableAudiobookRows.length > 0)
) {
await cleanupClaimedLegacyFsSources({ await cleanupClaimedLegacyFsSources({
documentIds: claimableDocumentRows.map((row) => row.id), documentIds: claimableDocumentRows.map((row) => row.id),
audiobookIds: claimableAudiobookRows.map((row) => row.id), audiobookIds: claimableAudiobookRows.map((row) => row.id),
@ -139,6 +167,7 @@ export async function claimAnonymousData(userId: string, unclaimedUserId: string
audiobooks: audiobooksClaimed, audiobooks: audiobooksClaimed,
preferences: preferencesClaimed, preferences: preferencesClaimed,
progress: progressClaimed, progress: progressClaimed,
documentSettings: documentSettingsClaimed,
}; };
} }
@ -146,7 +175,8 @@ export async function claimAnonymousData(userId: string, unclaimedUserId: string
* Transfer documents from one userId to another. * Transfer documents from one userId to another.
* *
* This is used when an anonymous user upgrades to an authenticated account. * This is used when an anonymous user upgrades to an authenticated account.
* The underlying blob storage is shared (by sha), so this only moves metadata rows. * The source document blob is shared, while user-scoped TTS metadata and audio
* are copied before the old ownership is removed.
* *
* @returns number of document rows transferred * @returns number of document rows transferred
*/ */
@ -154,26 +184,166 @@ export async function transferUserDocuments(
fromUserId: string, fromUserId: string,
toUserId: string, toUserId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
options?: { db?: any }, options?: { db?: any; namespace?: string | null; transferTts?: boolean; skipStorage?: boolean },
): Promise<number> { ): Promise<number> {
if (!fromUserId || !toUserId) return 0; if (!fromUserId || !toUserId) return 0;
if (fromUserId === toUserId) return 0; if (fromUserId === toUserId) return 0;
const database = options?.db ?? db; const database = options?.db ?? db;
// Object storage is always present in a real deployment; `skipStorage` lets
// tests transfer metadata only. The shared, content-addressed document blob is
// never touched here — it stays as long as any owner (the new user) remains.
const copyStorage = !options?.skipStorage && isS3Configured();
const rows = await database.select().from(documents).where(eq(documents.userId, fromUserId)); const rows = await database.select().from(documents).where(eq(documents.userId, fromUserId));
if (rows.length === 0) return 0; if (rows.length === 0) return 0;
await database for (const row of rows) {
.insert(documents) await database
// eslint-disable-next-line @typescript-eslint/no-explicit-any .insert(documents)
.values(rows.map((row: any) => ({ ...row, userId: toUserId }))) .values({ ...row, userId: toUserId })
.onConflictDoNothing(); .onConflictDoNothing();
if (options?.transferTts) {
await database.delete(documents).where(eq(documents.userId, fromUserId)); await transferDocumentTtsSegments({
documentId: row.id,
fromUserId,
toUserId,
namespace: options.namespace ?? null,
database,
copyStorage,
});
}
await database.delete(documents).where(and(
eq(documents.userId, fromUserId),
eq(documents.id, row.id),
));
}
return rows.length; return rows.length;
} }
async function transferDocumentTtsSegments(input: {
documentId: string;
fromUserId: string;
toUserId: string;
namespace: string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
database: any;
copyStorage: boolean;
}): Promise<void> {
// Built only when storage is in play — getS3Config() throws if storage is
// unconfigured, which is exactly the metadata-only path tests exercise.
const sourceTtsPrefixes = () => (['v1', 'v2'] as const).map((storageVersion) => ({
from: buildTtsSegmentDocumentPrefix({
storagePrefix: getS3Config().prefix,
namespace: input.namespace,
userId: input.fromUserId,
documentId: input.documentId,
storageVersion,
}),
to: buildTtsSegmentDocumentPrefix({
storagePrefix: getS3Config().prefix,
namespace: input.namespace,
userId: input.toUserId,
documentId: input.documentId,
storageVersion,
}),
}));
if (input.copyStorage) {
for (const { from, to } of sourceTtsPrefixes()) {
await copyTtsSegmentPrefix(from, to);
}
}
const entries = await input.database
.select()
.from(ttsSegmentEntries)
.where(and(
eq(ttsSegmentEntries.userId, input.fromUserId),
eq(ttsSegmentEntries.documentId, input.documentId),
));
const variants = entries.length > 0
? await input.database
.select()
.from(ttsSegmentVariants)
.where(and(
eq(ttsSegmentVariants.userId, input.fromUserId),
inArray(ttsSegmentVariants.segmentEntryId, entries.map(
(entry: typeof ttsSegmentEntries.$inferSelect) => entry.segmentEntryId,
)),
))
: [];
if (entries.length > 0) {
await input.database.insert(ttsSegmentEntries)
.values(entries.map((entry: typeof ttsSegmentEntries.$inferSelect) => ({
...entry,
userId: input.toUserId,
})))
.onConflictDoNothing();
}
const encodedFrom = encodeURIComponent(input.fromUserId);
const encodedTo = encodeURIComponent(input.toUserId);
const sourceAudioKeyPrefix = `/users/${encodedFrom}/docs/${input.documentId}/`;
const destAudioKeyPrefix = `/users/${encodedTo}/docs/${input.documentId}/`;
if (variants.length > 0) {
await input.database.insert(ttsSegmentVariants)
.values(variants.map((variant: typeof ttsSegmentVariants.$inferSelect) => {
const audioKey = variant.audioKey ?? null;
if (!audioKey || audioKey.includes(sourceAudioKeyPrefix)) {
return {
...variant,
userId: input.toUserId,
audioKey: audioKey?.replace(sourceAudioKeyPrefix, destAudioKeyPrefix) ?? null,
};
}
// The key did not contain the expected source path, so it cannot be
// safely remapped. Leaving it would point the new owner at the source
// user's (soon-deleted) audio, so null it out and log for investigation.
logDegraded(serverLogger, {
event: 'user.claim.tts_variant_audio_key.unmapped',
msg: 'TTS segment variant audioKey did not match expected source path during claim',
step: 'remap_tts_variant_audio_key',
context: {
originalAudioKey: audioKey,
fromUserIdHash: hashForLog(input.fromUserId),
toUserIdHash: hashForLog(input.toUserId),
documentId: input.documentId,
},
});
return {
...variant,
userId: input.toUserId,
audioKey: null,
};
}))
.onConflictDoNothing();
}
// Always remove the source metadata: the source account (e.g. the persistent
// 'unclaimed' user) is not deleted, so nothing would cascade it away.
if (variants.length > 0) {
await input.database.delete(ttsSegmentVariants).where(and(
eq(ttsSegmentVariants.userId, input.fromUserId),
inArray(ttsSegmentVariants.segmentId, variants.map(
(variant: typeof ttsSegmentVariants.$inferSelect) => variant.segmentId,
)),
));
}
await input.database.delete(ttsSegmentEntries).where(and(
eq(ttsSegmentEntries.userId, input.fromUserId),
eq(ttsSegmentEntries.documentId, input.documentId),
));
// Remove the now-copied source audio objects too (only when storage is in play).
if (input.copyStorage) {
for (const { from } of sourceTtsPrefixes()) {
await deleteTtsSegmentPrefix(from);
}
}
}
/** /**
* Transfer audiobooks from one user to another. * Transfer audiobooks from one user to another.
* Used when an anonymous user creates a real account. * Used when an anonymous user creates a real account.
@ -195,7 +365,7 @@ export async function transferUserAudiobooks(
if (isS3Configured()) { if (isS3Configured()) {
for (const book of books) { for (const book of books) {
await moveAudiobookBlobScope(book.id, fromUserId, toUserId, namespace); await copyAudiobookBlobScope(book.id, fromUserId, toUserId, namespace);
} }
} }
@ -215,6 +385,12 @@ export async function transferUserAudiobooks(
.onConflictDoNothing(); .onConflictDoNothing();
} }
if (isS3Configured()) {
for (const book of books) {
await deleteAudiobookBlobScope(book.id, fromUserId, namespace);
}
}
await db.delete(audiobookChapters).where(eq(audiobookChapters.userId, fromUserId)); await db.delete(audiobookChapters).where(eq(audiobookChapters.userId, fromUserId));
await db.delete(audiobooks).where(eq(audiobooks.userId, fromUserId)); await db.delete(audiobooks).where(eq(audiobooks.userId, fromUserId));
@ -309,3 +485,57 @@ export async function transferUserProgress(fromUserId: string, toUserId: string)
await db.delete(userDocumentProgress).where(eq(userDocumentProgress.userId, fromUserId)); await db.delete(userDocumentProgress).where(eq(userDocumentProgress.userId, fromUserId));
return fromRows.length; return fromRows.length;
} }
export async function transferUserDocumentSettings(
fromUserId: string,
toUserId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
options?: { db?: any },
): Promise<number> {
if (!fromUserId || !toUserId || fromUserId === toUserId) return 0;
const database = options?.db ?? db;
const rows = await database
.select()
.from(documentSettings)
.where(eq(documentSettings.userId, fromUserId));
const documentIds = rows.map((row: { documentId: string }) => row.documentId);
const existingRows = documentIds.length > 0
? await database
.select({
documentId: documentSettings.documentId,
clientUpdatedAtMs: documentSettings.clientUpdatedAtMs,
})
.from(documentSettings)
.where(and(
eq(documentSettings.userId, toUserId),
inArray(documentSettings.documentId, documentIds),
))
: [];
const existingByDocumentId = new Map<string, { clientUpdatedAtMs: number | null }>(
existingRows.map((row: { documentId: string; clientUpdatedAtMs: number | null }) => [
row.documentId,
row,
] as const),
);
for (const row of rows) {
const existing = existingByDocumentId.get(row.documentId);
if (existing && Number(existing.clientUpdatedAtMs ?? 0) >= Number(row.clientUpdatedAtMs ?? 0)) {
continue;
}
await database
.insert(documentSettings)
.values({ ...row, userId: toUserId })
.onConflictDoUpdate({
target: [documentSettings.documentId, documentSettings.userId],
set: {
dataJson: row.dataJson,
clientUpdatedAtMs: row.clientUpdatedAtMs,
updatedAt: row.updatedAt,
},
});
}
await database.delete(documentSettings).where(eq(documentSettings.userId, fromUserId));
return rows.length;
}

View file

@ -1,14 +1,26 @@
/** /**
* Cleans up all S3 storage artifacts belonging to a user. * Cleans up user-scoped storage that the orphaned-blob reaper cannot reach.
* Called from Better Auth's `beforeDelete` hook so that blobs are removed *
* before the DB cascade wipes the metadata rows we query against. * Called from Better Auth's `beforeDelete` hook (canonical pass) and the
* account-delete route (test-namespaced pass). Shared, content-addressed
* document blobs + previews are NOT deleted here on the canonical pass they
* are reclaimed by the `reap-orphaned-blobs` task once their ownership rows are
* gone. Per-user storage (audiobooks, TTS segments, temp uploads) is keyed by
* userId and would be unreachable after the cascade, so it is deleted inline
* and failures block the deletion.
*/ */
import { db } from '@/db'; import { db } from '@/db';
import { documents, audiobooks } from '@/db/schema'; import { documents, audiobooks, userJobEvents, userTtsChars } from '@/db/schema';
import * as authSchemaSqlite from '@/db/schema_auth_sqlite';
import * as authSchemaPostgres from '@/db/schema_auth_postgres';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getS3Config, isS3Configured } from '@/lib/server/storage/s3'; import { getS3Config, isS3Configured } from '@/lib/server/storage/s3';
import { deleteDocumentBlob } from '@/lib/server/documents/blobstore'; import {
deleteDocumentBlob,
deleteDocumentPrefix,
tempDocumentUploadPrefix,
} from '@/lib/server/documents/blobstore';
import { deleteDocumentPreviewArtifacts } from '@/lib/server/documents/previews-blobstore'; import { deleteDocumentPreviewArtifacts } from '@/lib/server/documents/previews-blobstore';
import { deleteDocumentPreviewRows } from '@/lib/server/documents/previews'; import { deleteDocumentPreviewRows } from '@/lib/server/documents/previews';
import { audiobookPrefix, deleteAudiobookPrefix } from '@/lib/server/audiobooks/blobstore'; import { audiobookPrefix, deleteAudiobookPrefix } from '@/lib/server/audiobooks/blobstore';
@ -16,87 +28,50 @@ import { deleteTtsSegmentPrefix } from '@/lib/server/tts/segments-blobstore';
import { hashForLog, serverLogger } from '@/lib/server/logger'; import { hashForLog, serverLogger } from '@/lib/server/logger';
import { logDegraded } from '@/lib/server/errors/logging'; import { logDegraded } from '@/lib/server/errors/logging';
type DocumentRow = { id: string };
type AudiobookRow = { id: string }; type AudiobookRow = { id: string };
/**
* Delete all S3 blobs owned by `userId`.
*
* This covers:
* - Document file blobs
* - Document preview images
* - Audiobook audio files (chapter mp3s, metadata json, etc.)
*
* Each item is cleaned up independently; a failure on one does not block the rest.
*/
export async function deleteUserStorageData( export async function deleteUserStorageData(
userId: string, userId: string,
namespace: string | null, namespace: string | null,
): Promise<void> { ): Promise<void> {
const s3Enabled = isS3Configured(); const s3Enabled = isS3Configured();
const failures: unknown[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const database = db as any; const database = db as any;
const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite;
// --- Documents & previews --- // --- Document blobs & previews ---
const userDocs: DocumentRow[] = await database // Canonical pass: deferred to the reap-orphaned-blobs task (the rows cascade
.select({ id: documents.id }) // away with the user, then the reaper reclaims the now-orphaned blobs). The
.from(documents) // reaper only runs for the canonical namespace, so test-namespaced storage is
.where(eq(documents.userId, userId)); // deleted inline here.
let docBlobsDeleted = 0;
let docsDeleted = 0; if (s3Enabled && namespace !== null) {
for (const doc of userDocs) { const userDocs = (await database
if (s3Enabled) { .select({ id: documents.id })
.from(documents)
.where(eq(documents.userId, userId))) as Array<{ id: string }>;
for (const doc of userDocs) {
try { try {
await deleteDocumentBlob(doc.id, namespace); await deleteDocumentBlob(doc.id, namespace);
docsDeleted++;
} catch (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); await deleteDocumentPreviewArtifacts(doc.id, namespace);
await deleteDocumentPreviewRows(doc.id, namespace);
docBlobsDeleted++;
} catch (error) { } catch (error) {
failures.push(error);
logDegraded(serverLogger, { logDegraded(serverLogger, {
event: 'user.data_cleanup.document_preview_delete.failed', event: 'user.data_cleanup.document_storage_delete.failed',
msg: 'Failed to delete preview artifacts', msg: 'Failed to delete namespaced document storage',
step: 'delete_document_preview_artifacts', step: 'delete_namespaced_document_storage',
context: { context: { documentId: doc.id, userIdHash: hashForLog(userId) },
documentId: doc.id,
userIdHash: hashForLog(userId),
},
error, error,
}); });
} }
} }
// Always clean up DB rows — documentPreviews has no FK cascade on user.
try {
await deleteDocumentPreviewRows(doc.id, namespace);
} catch (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,
});
}
} }
// --- Audiobooks --- // --- Audiobooks (per-user object storage; not reaped) ---
const userBooks: AudiobookRow[] = s3Enabled const userBooks: AudiobookRow[] = s3Enabled
? await database ? await database
.select({ id: audiobooks.id }) .select({ id: audiobooks.id })
@ -107,26 +82,36 @@ export async function deleteUserStorageData(
let booksDeleted = 0; let booksDeleted = 0;
for (const book of userBooks) { for (const book of userBooks) {
try { try {
const prefix = audiobookPrefix(book.id, userId, namespace); await deleteAudiobookPrefix(audiobookPrefix(book.id, userId, namespace));
await deleteAudiobookPrefix(prefix);
booksDeleted++; booksDeleted++;
} catch (error) { } catch (error) {
failures.push(error);
logDegraded(serverLogger, { logDegraded(serverLogger, {
event: 'user.data_cleanup.audiobook_blobs_delete.failed', event: 'user.data_cleanup.audiobook_blobs_delete.failed',
msg: 'Failed to delete audiobook blobs', msg: 'Failed to delete audiobook blobs',
step: 'delete_audiobook_prefix', step: 'delete_audiobook_prefix',
context: { context: { bookId: book.id, userIdHash: hashForLog(userId) },
bookId: book.id,
userIdHash: hashForLog(userId),
},
error, error,
}); });
} }
} }
// --- TTS segments --- // --- Temp uploads + TTS segments (per-user object storage; not reaped) ---
let segmentsDeleted = 0; let segmentsDeleted = 0;
if (s3Enabled) { if (s3Enabled) {
try {
await deleteDocumentPrefix(tempDocumentUploadPrefix(userId, namespace));
} catch (error) {
failures.push(error);
logDegraded(serverLogger, {
event: 'user.data_cleanup.temp_document_uploads_delete.failed',
msg: 'Failed to delete temporary document uploads',
step: 'delete_temp_document_upload_prefix',
context: { userIdHash: hashForLog(userId) },
error,
});
}
try { try {
const cfg = getS3Config(); const cfg = getS3Config();
const nsSegment = namespace ? `ns/${namespace}/` : ''; const nsSegment = namespace ? `ns/${namespace}/` : '';
@ -135,6 +120,7 @@ export async function deleteUserStorageData(
segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV1); segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV1);
segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV2); segmentsDeleted += await deleteTtsSegmentPrefix(ttsPrefixV2);
} catch (error) { } catch (error) {
failures.push(error);
logDegraded(serverLogger, { logDegraded(serverLogger, {
event: 'user.data_cleanup.tts_segments_delete.failed', event: 'user.data_cleanup.tts_segments_delete.failed',
msg: 'Failed to delete TTS segment blobs', msg: 'Failed to delete TTS segment blobs',
@ -145,15 +131,48 @@ export async function deleteUserStorageData(
} }
} }
if (docsDeleted > 0 || booksDeleted > 0 || segmentsDeleted > 0) { // Block deletion if any non-reapable storage cleanup failed — proceeding
// would permanently orphan it. Nothing was removed from the database yet, so
// there is nothing to roll back.
if (failures.length > 0) {
throw new AggregateError(failures, `User storage cleanup failed in ${failures.length} operation(s)`);
}
// Namespaced cleanup is a storage-only pass; database rows are global and are
// only removed on the canonical (non-namespaced) pass.
if (namespace === null) {
// Explicit for compatibility with pre-cascade installations and to remove
// auth verification tokens, which cannot carry a user FK.
for (const { table, userColumn, step } of [
{ table: userTtsChars, userColumn: userTtsChars.userId, step: 'delete_user_tts_usage_rows' },
{ table: userJobEvents, userColumn: userJobEvents.userId, step: 'delete_user_job_event_rows' },
{ table: authSchema.verification, userColumn: authSchema.verification.value, step: 'delete_user_verification_rows' },
]) {
await database.delete(table).where(eq(userColumn, userId)).catch((error: unknown) => {
failures.push(error);
logDegraded(serverLogger, {
event: 'user.data_cleanup.db_rows_delete.failed',
msg: 'Failed to delete non-cascading user database rows',
step,
context: { userIdHash: hashForLog(userId) },
error,
});
});
}
}
if (docBlobsDeleted > 0 || booksDeleted > 0 || segmentsDeleted > 0) {
serverLogger.info({ serverLogger.info({
event: 'user.data_cleanup.completed', event: 'user.data_cleanup.completed',
userIdHash: hashForLog(userId), userIdHash: hashForLog(userId),
docsDeleted, docBlobsDeleted,
totalDocs: userDocs.length,
booksDeleted, booksDeleted,
totalBooks: userBooks.length, totalBooks: userBooks.length,
segmentsDeleted, segmentsDeleted,
}, 'Completed user storage cleanup'); }, 'Completed user storage cleanup');
} }
if (failures.length > 0) {
throw new AggregateError(failures, `User database cleanup failed in ${failures.length} operation(s)`);
}
} }

View file

@ -11,7 +11,7 @@ export type ExportBlobBody =
| ArrayBufferView | ArrayBufferView
| { transformToByteArray: () => Promise<Uint8Array> }; | { transformToByteArray: () => Promise<Uint8Array> };
type ExportIssueScope = 'document' | 'audiobook' | 'audiobook_list'; type ExportIssueScope = 'document' | 'audiobook' | 'audiobook_list' | 'tts_segment';
type ExportIssue = { type ExportIssue = {
scope: ExportIssueScope; scope: ExportIssueScope;
@ -41,6 +41,20 @@ type ExportAudiobookObject = {
[key: string]: unknown; [key: string]: unknown;
}; };
type ExportTtsSegmentEntry = {
segmentEntryId: string;
documentId: string;
[key: string]: unknown;
};
type ExportTtsSegmentVariant = {
segmentId: string;
segmentEntryId: string;
audioKey?: string | null;
audioFormat?: string | null;
[key: string]: unknown;
};
export type AppendUserExportArchiveInput = { export type AppendUserExportArchiveInput = {
archive: Archiver; archive: Archiver;
userId: string; userId: string;
@ -49,12 +63,20 @@ export type AppendUserExportArchiveInput = {
preferences: unknown | null; preferences: unknown | null;
readingHistory: unknown[]; readingHistory: unknown[];
ttsUsage: unknown[]; ttsUsage: unknown[];
jobEvents: unknown[];
documentSettings: unknown[];
authSessions: unknown[];
linkedAccounts: unknown[];
documents: ExportDocument[]; documents: ExportDocument[];
audiobooks: ExportAudiobook[]; audiobooks: ExportAudiobook[];
audiobookChapters: ExportAudiobookChapter[]; audiobookChapters: ExportAudiobookChapter[];
ttsSegmentEntries: ExportTtsSegmentEntry[];
ttsSegmentVariants: ExportTtsSegmentVariant[];
storageEnabled: boolean;
getDocumentBlobStream: (documentId: string) => Promise<ExportBlobBody>; getDocumentBlobStream: (documentId: string) => Promise<ExportBlobBody>;
listAudiobookObjects: (bookId: string, userId: string) => Promise<ExportAudiobookObject[]>; listAudiobookObjects: (bookId: string, userId: string) => Promise<ExportAudiobookObject[]>;
getAudiobookObjectStream: (bookId: string, userId: string, fileName: string) => Promise<ExportBlobBody>; getAudiobookObjectStream: (bookId: string, userId: string, fileName: string) => Promise<ExportBlobBody>;
getTtsSegmentAudioStream: (audioKey: string) => Promise<ExportBlobBody>;
}; };
function isNodeReadableStream(value: unknown): value is Readable { function isNodeReadableStream(value: unknown): value is Readable {
@ -124,17 +146,26 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
preferences, preferences,
readingHistory, readingHistory,
ttsUsage, ttsUsage,
jobEvents,
documentSettings,
authSessions,
linkedAccounts,
documents, documents,
audiobooks, audiobooks,
audiobookChapters, audiobookChapters,
ttsSegmentEntries,
ttsSegmentVariants,
storageEnabled,
getDocumentBlobStream, getDocumentBlobStream,
listAudiobookObjects, listAudiobookObjects,
getAudiobookObjectStream, getAudiobookObjectStream,
getTtsSegmentAudioStream,
} = input; } = input;
const issues: ExportIssue[] = []; const issues: ExportIssue[] = [];
let documentFilesExported = 0; let documentFilesExported = 0;
let audiobookFilesExported = 0; let audiobookFilesExported = 0;
let ttsSegmentFilesExported = 0;
appendJson(archive, 'profile.json', profileData); appendJson(archive, 'profile.json', profileData);
if (preferences) { if (preferences) {
@ -142,7 +173,13 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
} }
appendJson(archive, 'reading_history.json', readingHistory); appendJson(archive, 'reading_history.json', readingHistory);
appendJson(archive, 'tts_usage.json', ttsUsage); appendJson(archive, 'tts_usage.json', ttsUsage);
appendJson(archive, 'job_events.json', jobEvents);
appendJson(archive, 'document_settings.json', documentSettings);
appendJson(archive, 'auth_sessions.json', authSessions);
appendJson(archive, 'linked_accounts.json', linkedAccounts);
appendJson(archive, 'library_documents.json', documents); appendJson(archive, 'library_documents.json', documents);
appendJson(archive, 'tts_segment_entries.json', ttsSegmentEntries);
appendJson(archive, 'tts_segment_variants.json', ttsSegmentVariants);
const chaptersByBookId = new Map<string, ExportAudiobookChapter[]>(); const chaptersByBookId = new Map<string, ExportAudiobookChapter[]>();
for (const chapter of audiobookChapters) { for (const chapter of audiobookChapters) {
@ -157,7 +194,7 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
})); }));
appendJson(archive, 'library_audiobooks.json', audiobooksWithChapters); appendJson(archive, 'library_audiobooks.json', audiobooksWithChapters);
for (const doc of documents) { for (const doc of storageEnabled ? documents : []) {
const documentId = toSafePathSegment(doc.id, 'document'); const documentId = toSafePathSegment(doc.id, 'document');
const fileName = toSafePathSegment(doc.name || `${doc.id}.bin`, `${documentId}.bin`); const fileName = toSafePathSegment(doc.name || `${doc.id}.bin`, `${documentId}.bin`);
const entryName = `files/documents/${documentId}/${fileName}`; const entryName = `files/documents/${documentId}/${fileName}`;
@ -177,7 +214,7 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
} }
} }
for (const book of audiobooks) { for (const book of storageEnabled ? audiobooks : []) {
let objects: ExportAudiobookObject[] = []; let objects: ExportAudiobookObject[] = [];
try { try {
objects = await listAudiobookObjects(book.id, userId); objects = await listAudiobookObjects(book.id, userId);
@ -215,8 +252,38 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
} }
} }
const documentIdByEntryId = new Map(
ttsSegmentEntries.map((entry) => [entry.segmentEntryId, entry.documentId]),
);
for (const variant of storageEnabled ? ttsSegmentVariants : []) {
const audioKey = typeof variant.audioKey === 'string' ? variant.audioKey : '';
if (!audioKey) continue;
const documentId = toSafePathSegment(
documentIdByEntryId.get(variant.segmentEntryId) ?? 'unknown-document',
'unknown-document',
);
const segmentId = toSafePathSegment(variant.segmentId, 'segment');
const format = toSafePathSegment(variant.audioFormat || 'mp3', 'mp3');
const entryName = `files/tts_segments/${documentId}/${segmentId}.${format}`;
try {
const body = await getTtsSegmentAudioStream(audioKey);
const stream = await bodyToNodeReadable(body);
archive.append(stream, { name: entryName });
ttsSegmentFilesExported += 1;
} catch (error) {
issues.push({
scope: 'tts_segment',
id: variant.segmentId,
fileName: entryName,
message: normalizeErrorMessage(error),
});
}
}
const manifest = { const manifest = {
formatVersion: 2, formatVersion: 3,
exportedAtMs, exportedAtMs,
userId, userId,
scope: 'owned', scope: 'owned',
@ -224,14 +291,26 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
documentsMetadata: documents.length, documentsMetadata: documents.length,
audiobooksMetadata: audiobooks.length, audiobooksMetadata: audiobooks.length,
audiobookChaptersMetadata: audiobookChapters.length, audiobookChaptersMetadata: audiobookChapters.length,
documentSettingsMetadata: documentSettings.length,
ttsSegmentEntriesMetadata: ttsSegmentEntries.length,
ttsSegmentVariantsMetadata: ttsSegmentVariants.length,
authSessionsMetadata: authSessions.length,
linkedAccountsMetadata: linkedAccounts.length,
jobEventsMetadata: jobEvents.length,
documentFiles: documentFilesExported, documentFiles: documentFilesExported,
audiobookFiles: audiobookFilesExported, audiobookFiles: audiobookFilesExported,
ttsSegmentFiles: ttsSegmentFilesExported,
issues: issues.length, issues: issues.length,
}, },
includes: { includes: {
metadata: true, metadata: true,
documentFiles: true, documentFiles: storageEnabled,
audiobookFiles: true, audiobookFiles: storageEnabled,
ttsSegmentFiles: storageEnabled,
credentialSecrets: false,
temporaryUploads: false,
derivedDocumentPreviews: false,
derivedParsedDocuments: false,
filesystemSources: false, filesystemSources: false,
}, },
}; };

View file

@ -1,11 +1,13 @@
import { ListObjectsV2Command } from '@aws-sdk/client-s3'; import { ListObjectsV2Command } from '@aws-sdk/client-s3';
import { and, eq, inArray, like } from 'drizzle-orm'; import { and, eq, inArray, like, ne } from 'drizzle-orm';
import { db } from '../src/db'; import { db } from '../src/db';
import { audiobooks, audiobookChapters, documents } from '../src/db/schema'; import { audiobooks, audiobookChapters, documentPreviews, documents } from '../src/db/schema';
import { deleteDocumentPrefix } from '../src/lib/server/documents/blobstore'; import { deleteDocumentPrefix } from '../src/lib/server/documents/blobstore';
import { deleteAudiobookPrefix } from '../src/lib/server/audiobooks/blobstore'; import { deleteAudiobookPrefix } from '../src/lib/server/audiobooks/blobstore';
import { deleteTtsSegmentPrefix } from '../src/lib/server/tts/segments-blobstore'; import { deleteTtsSegmentPrefix } from '../src/lib/server/tts/segments-blobstore';
import { getS3Client, getS3Config, isS3Configured } from '../src/lib/server/storage/s3'; import { getS3Client, getS3Config, isS3Configured } from '../src/lib/server/storage/s3';
import * as authSchemaSqlite from '../src/db/schema_auth_sqlite';
import * as authSchemaPostgres from '../src/db/schema_auth_postgres';
function chunk<T>(items: T[], size: number): T[][] { function chunk<T>(items: T[], size: number): T[][] {
if (items.length === 0) return []; if (items.length === 0) return [];
@ -66,46 +68,65 @@ function parseAudiobookScopeFromKey(
} }
export default async function globalTeardown(): Promise<void> { export default async function globalTeardown(): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const authSchema: any = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite;
const testSessions = await db
.select({ userId: authSchema.session.userId })
.from(authSchema.session)
.where(like(authSchema.session.userAgent, '%OpenReader-Playwright/%')) as Array<{ userId: string }>;
const testUserIds = Array.from(new Set(testSessions.map((row) => row.userId)));
// Always clear namespaced unclaimed SQL rows from prior runs. // Always clear namespaced unclaimed SQL rows from prior runs.
await db.delete(audiobookChapters).where(like(audiobookChapters.userId, 'unclaimed::%')); await db.delete(audiobookChapters).where(like(audiobookChapters.userId, 'unclaimed::%'));
await db.delete(audiobooks).where(like(audiobooks.userId, 'unclaimed::%')); await db.delete(audiobooks).where(like(audiobooks.userId, 'unclaimed::%'));
await db.delete(documents).where(like(documents.userId, 'unclaimed::%')); await db.delete(documents).where(like(documents.userId, 'unclaimed::%'));
await db.delete(documentPreviews).where(ne(documentPreviews.namespace, ''));
if (!isS3Configured()) return; if (isS3Configured()) {
const config = getS3Config();
const docsNsRootPrefix = `${config.prefix}/documents_v1/ns/`;
const documentPreviewsNsRootPrefix = `${config.prefix}/document_previews_v1/ns/`;
const tempUploadsNsRootPrefix = `${config.prefix}/document_uploads_temp_v1/ns/`;
const audiobooksNsRootPrefix = `${config.prefix}/audiobooks_v1/ns/`;
const ttsSegmentsNsRootPrefixV1 = `${config.prefix}/tts_segments_v1/ns/`;
const ttsSegmentsNsRootPrefixV2 = `${config.prefix}/tts_segments_v2/ns/`;
const config = getS3Config(); // Remove SQL audiobook rows for namespaced objects (covers auth claim flows too).
const docsNsRootPrefix = `${config.prefix}/documents_v1/ns/`; const audiobookKeys = await listKeysByPrefix(audiobooksNsRootPrefix);
const audiobooksNsRootPrefix = `${config.prefix}/audiobooks_v1/ns/`; const byUser = new Map<string, Set<string>>();
const ttsSegmentsNsRootPrefixV1 = `${config.prefix}/tts_segments_v1/ns/`; for (const key of audiobookKeys) {
const ttsSegmentsNsRootPrefixV2 = `${config.prefix}/tts_segments_v2/ns/`; const scope = parseAudiobookScopeFromKey(key, audiobooksNsRootPrefix);
if (!scope) continue;
// Remove SQL audiobook rows for namespaced objects (covers auth claim flows too). let set = byUser.get(scope.userId);
const audiobookKeys = await listKeysByPrefix(audiobooksNsRootPrefix); if (!set) {
const byUser = new Map<string, Set<string>>(); set = new Set<string>();
for (const key of audiobookKeys) { byUser.set(scope.userId, set);
const scope = parseAudiobookScopeFromKey(key, audiobooksNsRootPrefix); }
if (!scope) continue; set.add(scope.bookId);
let set = byUser.get(scope.userId);
if (!set) {
set = new Set<string>();
byUser.set(scope.userId, set);
} }
set.add(scope.bookId);
for (const [userId, bookIds] of byUser) {
for (const ids of chunk(Array.from(bookIds), 200)) {
await db
.delete(audiobookChapters)
.where(and(eq(audiobookChapters.userId, userId), inArray(audiobookChapters.bookId, ids)));
await db
.delete(audiobooks)
.where(and(eq(audiobooks.userId, userId), inArray(audiobooks.id, ids)));
}
}
await deleteDocumentPrefix(docsNsRootPrefix);
await deleteDocumentPrefix(documentPreviewsNsRootPrefix);
await deleteDocumentPrefix(tempUploadsNsRootPrefix);
await deleteAudiobookPrefix(audiobooksNsRootPrefix);
await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefixV1);
await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefixV2);
} }
for (const [userId, bookIds] of byUser) { for (const ids of chunk(testUserIds, 200)) {
for (const ids of chunk(Array.from(bookIds), 200)) { await db
await db .delete(authSchema.user)
.delete(audiobookChapters) .where(and(inArray(authSchema.user.id, ids), eq(authSchema.user.isAnonymous, true)));
.where(and(eq(audiobookChapters.userId, userId), inArray(audiobookChapters.bookId, ids)));
await db
.delete(audiobooks)
.where(and(eq(audiobooks.userId, userId), inArray(audiobooks.id, ids)));
}
} }
await deleteDocumentPrefix(docsNsRootPrefix);
await deleteAudiobookPrefix(audiobooksNsRootPrefix);
await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefixV1);
await deleteTtsSegmentPrefix(ttsSegmentsNsRootPrefixV2);
} }

View file

@ -24,6 +24,10 @@ vi.mock('@/lib/server/documents/register-upload', () => ({
registerUploadedDocument: hoisted.registerUploadedDocument, registerUploadedDocument: hoisted.registerUploadedDocument,
})); }));
vi.mock('@/lib/server/documents/blob-lease', () => ({
withDocumentBlobLease: vi.fn(async (_documentId: string, fn: () => Promise<unknown>) => fn()),
}));
vi.mock('@/lib/server/documents/docx-convert', () => ({ vi.mock('@/lib/server/documents/docx-convert', () => ({
convertDocxBufferToPdfBuffer: hoisted.convertDocxBufferToPdfBuffer, convertDocxBufferToPdfBuffer: hoisted.convertDocxBufferToPdfBuffer,
})); }));

View file

@ -0,0 +1,154 @@
import { describe, expect, test, vi } from 'vitest';
import type { Archiver } from 'archiver';
import { appendUserExportArchive } from '../../src/lib/server/user/data-export';
describe('user data export archive', () => {
test('includes user metadata and cached TTS audio without credential secrets', async () => {
const entries = new Map<string, unknown>();
const archive = {
append(value: unknown, options: { name: string }) {
entries.set(options.name, value);
return archive;
},
} as unknown as Archiver;
await appendUserExportArchive({
archive,
userId: 'user-1',
exportedAtMs: 123,
profileData: { user: { id: 'user-1', email: 'person@example.com' }, exportedAtMs: 123 },
preferences: { dataJson: '{}' },
readingHistory: [{ documentId: 'doc-1' }],
ttsUsage: [{ charCount: 10 }],
jobEvents: [{ action: 'pdf-layout' }],
documentSettings: [{ documentId: 'doc-1', dataJson: '{}' }],
authSessions: [{ id: 'session-1', ipAddress: null }],
linkedAccounts: [{ id: 'account-1', providerId: 'credential' }],
documents: [],
audiobooks: [],
audiobookChapters: [],
ttsSegmentEntries: [{ segmentEntryId: 'entry-1', documentId: 'doc-1' }],
ttsSegmentVariants: [{
segmentId: 'segment-1',
segmentEntryId: 'entry-1',
audioKey: 'private/audio/key',
audioFormat: 'mp3',
}],
storageEnabled: true,
getDocumentBlobStream: async () => new Uint8Array(),
listAudiobookObjects: async () => [],
getAudiobookObjectStream: async () => new Uint8Array(),
getTtsSegmentAudioStream: async () => new Uint8Array([1, 2, 3]),
});
expect(entries.has('document_settings.json')).toBe(true);
expect(entries.has('auth_sessions.json')).toBe(true);
expect(entries.has('linked_accounts.json')).toBe(true);
expect(entries.has('tts_segment_entries.json')).toBe(true);
expect(entries.has('tts_segment_variants.json')).toBe(true);
expect(entries.has('files/tts_segments/doc-1/segment-1.mp3')).toBe(true);
const manifest = JSON.parse(String(entries.get('export_manifest.json')));
expect(manifest).toMatchObject({
formatVersion: 3,
counts: {
ttsSegmentEntriesMetadata: 1,
ttsSegmentVariantsMetadata: 1,
ttsSegmentFiles: 1,
},
includes: {
credentialSecrets: false,
},
});
});
test('exports every TTS variant even when variants share an audio key', async () => {
const entries = new Map<string, unknown>();
const archive = {
append(value: unknown, options: { name: string }) {
entries.set(options.name, value);
return archive;
},
} as unknown as Archiver;
const getTtsSegmentAudioStream = vi.fn(async () => new Uint8Array([1]));
await appendUserExportArchive({
archive,
userId: 'user-1',
exportedAtMs: 123,
profileData: {},
preferences: null,
readingHistory: [],
ttsUsage: [],
jobEvents: [],
documentSettings: [],
authSessions: [],
linkedAccounts: [],
documents: [],
audiobooks: [],
audiobookChapters: [],
ttsSegmentEntries: [{ segmentEntryId: 'entry-1', documentId: 'doc-1' }],
ttsSegmentVariants: [
{ segmentId: 'segment-1', segmentEntryId: 'entry-1', audioKey: 'shared-key', audioFormat: 'mp3' },
{ segmentId: 'segment-2', segmentEntryId: 'entry-1', audioKey: 'shared-key', audioFormat: 'mp3' },
],
storageEnabled: true,
getDocumentBlobStream: async () => new Uint8Array(),
listAudiobookObjects: async () => [],
getAudiobookObjectStream: async () => new Uint8Array(),
getTtsSegmentAudioStream,
});
expect(entries.has('files/tts_segments/doc-1/segment-1.mp3')).toBe(true);
expect(entries.has('files/tts_segments/doc-1/segment-2.mp3')).toBe(true);
expect(getTtsSegmentAudioStream).toHaveBeenCalledTimes(2);
});
test('reports file buckets as excluded when storage is disabled', async () => {
const entries = new Map<string, unknown>();
const archive = {
append(value: unknown, options: { name: string }) {
entries.set(options.name, value);
return archive;
},
} as unknown as Archiver;
const getDocumentBlobStream = vi.fn(async () => new Uint8Array());
await appendUserExportArchive({
archive,
userId: 'user-1',
exportedAtMs: 123,
profileData: {},
preferences: null,
readingHistory: [],
ttsUsage: [],
jobEvents: [],
documentSettings: [],
authSessions: [],
linkedAccounts: [],
documents: [{ id: 'doc-1', name: 'doc.pdf' }],
audiobooks: [],
audiobookChapters: [],
ttsSegmentEntries: [],
ttsSegmentVariants: [],
storageEnabled: false,
getDocumentBlobStream,
listAudiobookObjects: async () => [],
getAudiobookObjectStream: async () => new Uint8Array(),
getTtsSegmentAudioStream: async () => new Uint8Array(),
});
const manifest = JSON.parse(String(entries.get('export_manifest.json')));
expect(manifest.includes).toMatchObject({
documentFiles: false,
audiobookFiles: false,
ttsSegmentFiles: false,
});
expect(manifest.counts).toMatchObject({
documentFiles: 0,
audiobookFiles: 0,
ttsSegmentFiles: 0,
});
expect(getDocumentBlobStream).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,59 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as sqliteSchema from '../../src/db/schema_sqlite';
const holder = vi.hoisted(() => ({ db: null as unknown as ReturnType<typeof drizzle> }));
vi.mock('@/db', () => ({
get db() {
return holder.db;
},
}));
import { tryAcquireDocumentBlobLease } from '../../src/lib/server/documents/blob-lease';
beforeEach(() => {
const sqlite = new Database(':memory:');
sqlite.exec(`CREATE TABLE document_blob_leases (
document_id text PRIMARY KEY NOT NULL,
lease_owner text NOT NULL,
lease_until_ms integer NOT NULL
);`);
holder.db = drizzle(sqlite, { schema: sqliteSchema });
});
describe('document blob lease', () => {
test('allows only one owner until the lease is released', async () => {
const first = await tryAcquireDocumentBlobLease('doc-1');
const blocked = await tryAcquireDocumentBlobLease('doc-1');
expect(first).not.toBeNull();
expect(blocked).toBeNull();
await first?.release();
await expect(tryAcquireDocumentBlobLease('doc-1')).resolves.not.toBeNull();
});
test('allows an expired lease to be reclaimed', async () => {
const first = await tryAcquireDocumentBlobLease('doc-1');
expect(first).not.toBeNull();
await holder.db
.update(sqliteSchema.documentBlobLeases)
.set({ leaseUntilMs: Date.now() - 1 });
const replacement = await tryAcquireDocumentBlobLease('doc-1');
expect(replacement).not.toBeNull();
expect(replacement?.owner).not.toBe(first?.owner);
// Releasing the now-stale original lease must not delete the replacement,
// since release() is scoped to the original owner.
await first?.release();
const rows = await holder.db
.select()
.from(sqliteSchema.documentBlobLeases);
expect(rows).toHaveLength(1);
expect(rows[0]?.leaseOwner).toBe(replacement?.owner);
});
});

View file

@ -0,0 +1,80 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
deleteResults: [] as unknown[][],
deleteDocumentTtsSegmentCache: vi.fn(async () => undefined),
}));
function resultBuilder(result: unknown[]) {
return {
then: (resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) =>
Promise.resolve(result).then(resolve, reject),
};
}
vi.mock('@/db', () => {
const database = {
delete: vi.fn(() => ({
where: vi.fn(() => ({
returning: () => resultBuilder(mocks.deleteResults.shift() ?? []),
})),
})),
};
return { db: database };
});
vi.mock('@/lib/server/tts/segments-cache', () => ({
deleteDocumentTtsSegmentCache: mocks.deleteDocumentTtsSegmentCache,
}));
vi.mock('@/lib/server/logger', () => ({
hashForLog: () => 'hash',
serverLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock('@/lib/server/errors/logging', () => ({ logDegraded: vi.fn() }));
import { deleteOwnedDocument } from '../../src/lib/server/documents/delete-owned';
describe('owned document cleanup', () => {
beforeEach(() => {
mocks.deleteResults = [];
mocks.deleteDocumentTtsSegmentCache.mockReset();
mocks.deleteDocumentTtsSegmentCache.mockResolvedValue(undefined);
});
test('removes the ownership row and cleans the per-user TTS cache', async () => {
mocks.deleteResults = [[{ id: 'doc-1', userId: 'user-1' }]];
await expect(deleteOwnedDocument({
userId: 'user-1',
documentId: 'doc-1',
namespace: null,
})).resolves.toBe(true);
expect(mocks.deleteDocumentTtsSegmentCache).toHaveBeenCalledOnce();
});
test('returns false and skips cleanup when no row was owned', async () => {
mocks.deleteResults = [[]];
await expect(deleteOwnedDocument({
userId: 'user-1',
documentId: 'doc-1',
namespace: null,
})).resolves.toBe(false);
expect(mocks.deleteDocumentTtsSegmentCache).not.toHaveBeenCalled();
});
test('still succeeds if TTS cache cleanup fails (best effort)', async () => {
mocks.deleteResults = [[{ id: 'doc-1', userId: 'user-1' }]];
mocks.deleteDocumentTtsSegmentCache.mockRejectedValueOnce(new Error('storage unavailable'));
await expect(deleteOwnedDocument({
userId: 'user-1',
documentId: 'doc-1',
namespace: null,
})).resolves.toBe(true);
});
});

View file

@ -0,0 +1,109 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
list: vi.fn(),
del: vi.fn(async () => undefined),
owned: [] as Array<{ id: string }>,
ownedAfterLease: [] as Array<{ id: string }>,
leaseRelease: vi.fn(async () => undefined),
}));
vi.mock('@/lib/server/storage/s3', () => ({ isS3Configured: () => true }));
vi.mock('@/lib/server/documents/blobstore', () => ({
listDocumentSourceBlobs: mocks.list,
deleteDocumentBlob: mocks.del,
}));
vi.mock('@/lib/server/documents/previews-blobstore', () => ({
deleteDocumentPreviewArtifacts: vi.fn(async () => 0),
}));
vi.mock('@/lib/server/documents/previews', () => ({
deleteDocumentPreviewRows: vi.fn(async () => undefined),
}));
vi.mock('@/lib/server/documents/blob-lease', () => ({
tryAcquireDocumentBlobLease: vi.fn(async () => ({
owner: 'lease-owner',
release: mocks.leaseRelease,
})),
}));
vi.mock('@/lib/server/errors/logging', () => ({ logDegraded: vi.fn() }));
vi.mock('@/db', () => ({
db: {
select: () => ({
from: () => ({
where: () => {
const rows = mocks.owned;
mocks.owned = mocks.ownedAfterLease;
return Object.assign(Promise.resolve(rows), {
limit: () => Promise.resolve(rows.slice(0, 1)),
});
},
}),
}),
},
}));
import { reapOrphanedBlobs } from '../../src/lib/server/tasks/handlers/reap-orphaned-blobs';
const TWO_HOURS = 2 * 60 * 60 * 1000;
const context = () => ({ signal: new AbortController().signal, deadlineAt: Date.now() + 60_000 });
beforeEach(() => {
mocks.list.mockReset();
mocks.del.mockReset();
mocks.del.mockResolvedValue(undefined);
mocks.owned = [];
mocks.ownedAfterLease = [];
mocks.leaseRelease.mockClear();
});
describe('reap-orphaned-blobs', () => {
test('reaps only old blobs with no owner', async () => {
const now = Date.now();
mocks.list.mockResolvedValue([
{ id: 'orphan-old', lastModifiedMs: now - TWO_HOURS },
{ id: 'owned-old', lastModifiedMs: now - TWO_HOURS },
{ id: 'orphan-young', lastModifiedMs: now - 60_000 },
]);
mocks.owned = [{ id: 'owned-old' }];
const result = await reapOrphanedBlobs(context());
expect(mocks.del).toHaveBeenCalledTimes(1);
expect(mocks.del).toHaveBeenCalledWith('orphan-old', null);
expect(result.reaped).toBe(1);
expect(result.scanned).toBe(3);
expect(result.candidates).toBe(2);
});
test('reaps nothing when every old blob still has an owner', async () => {
const now = Date.now();
mocks.list.mockResolvedValue([{ id: 'kept', lastModifiedMs: now - TWO_HOURS }]);
mocks.owned = [{ id: 'kept' }];
const result = await reapOrphanedBlobs(context());
expect(mocks.del).not.toHaveBeenCalled();
expect(result.reaped).toBe(0);
});
test('does not delete when an owner appears before the post-lease recheck', async () => {
const now = Date.now();
mocks.list.mockResolvedValue([{ id: 'claimed-during-run', lastModifiedMs: now - TWO_HOURS }]);
mocks.ownedAfterLease = [{ id: 'claimed-during-run' }];
const result = await reapOrphanedBlobs(context());
expect(mocks.del).not.toHaveBeenCalled();
expect(result.reaped).toBe(0);
expect(mocks.leaseRelease).toHaveBeenCalledTimes(1);
});
test('fails the task when an orphan deletion fails', async () => {
const now = Date.now();
mocks.list.mockResolvedValue([{ id: 'failed-orphan', lastModifiedMs: now - TWO_HOURS }]);
mocks.del.mockRejectedValue(new Error('storage failed'));
await expect(reapOrphanedBlobs(context())).rejects.toThrow('Failed to reap 1');
expect(mocks.leaseRelease).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,243 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import * as sqliteSchema from '../../src/db/schema_sqlite';
// Back the engine with a real in-memory SQLite so the CAS claim, due-detection,
// and nextRunAt arithmetic are exercised against real SQL rather than mocks.
const holder = vi.hoisted(() => ({ db: null as unknown as ReturnType<typeof drizzle> }));
vi.mock('@/db', () => ({
get db() {
return holder.db;
},
}));
// Keep the error-path test from printing the expected failure to the console.
vi.mock('@/lib/server/errors/logging', () => ({ logDegraded: vi.fn() }));
import { runDueTasks, updateTask } from '../../src/lib/server/tasks/engine';
import type { TaskRegistry } from '../../src/lib/server/tasks/types';
const tasks = sqliteSchema.scheduledTasks;
const CREATE_TABLE = `CREATE TABLE scheduled_tasks (
key text PRIMARY KEY NOT NULL,
enabled integer DEFAULT true NOT NULL,
interval_ms integer NOT NULL,
last_status text DEFAULT 'idle' NOT NULL,
lease_owner text,
last_run_at integer,
last_duration_ms integer,
last_error text,
last_result_json text,
next_run_at integer,
run_requested integer DEFAULT false NOT NULL,
running_since integer,
updated_at integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
CONSTRAINT scheduled_tasks_interval_ms_positive CHECK(interval_ms > 0)
);`;
const KEY = 'test-task';
async function seedRow(overrides: Partial<typeof tasks.$inferInsert>) {
await holder.db.insert(tasks).values({
key: KEY,
enabled: true,
intervalMs: 1000,
lastStatus: 'idle',
nextRunAt: Date.now() - 1,
runRequested: false,
...overrides,
});
}
async function readRow() {
const rows = await holder.db.select().from(tasks).where(eq(tasks.key, KEY));
return rows[0];
}
function registryWith(run: () => Promise<{ summary?: string } | void>): TaskRegistry {
return { [KEY]: { name: 'Test task', defaultIntervalMs: 1000, run } };
}
beforeEach(() => {
const sqlite = new Database(':memory:');
sqlite.exec(CREATE_TABLE);
holder.db = drizzle(sqlite, { schema: sqliteSchema });
});
describe('scheduled task engine', () => {
test('runs a due task and records success + next run', async () => {
const handler = vi.fn(async () => ({ summary: 'did 3 things' }));
await seedRow({ nextRunAt: Date.now() - 1 });
await runDueTasks({ registry: registryWith(handler) });
expect(handler).toHaveBeenCalledTimes(1);
const row = await readRow();
expect(row.lastStatus).toBe('ok');
expect(row.lastRunAt).not.toBeNull();
expect(row.lastResultJson).toBe('did 3 things');
expect(row.runningSince).toBeNull();
expect(Number(row.nextRunAt)).toBeGreaterThan(Date.now());
});
test('does not run a task that is not yet due', async () => {
const handler = vi.fn(async () => undefined);
await seedRow({ nextRunAt: Date.now() + 60_000, lastStatus: 'idle' });
await runDueTasks({ registry: registryWith(handler) });
expect(handler).not.toHaveBeenCalled();
expect((await readRow()).lastStatus).toBe('idle');
});
test('runs when a manual run is requested even if not due', async () => {
const handler = vi.fn(async () => ({ summary: 'manual' }));
await seedRow({ nextRunAt: Date.now() + 60_000, runRequested: true });
await runDueTasks({ registry: registryWith(handler) });
expect(handler).toHaveBeenCalledTimes(1);
const row = await readRow();
expect(row.lastStatus).toBe('ok');
expect(row.runRequested).toBe(false);
});
test('records errors without throwing', async () => {
const handler = vi.fn(async () => {
throw new Error('boom');
});
await seedRow({ nextRunAt: Date.now() - 1 });
await expect(runDueTasks({ registry: registryWith(handler) })).resolves.toBeUndefined();
const row = await readRow();
expect(row.lastStatus).toBe('error');
expect(row.lastError).toContain('boom');
});
test('single-flight: skips a task already marked running', async () => {
const handler = vi.fn(async () => undefined);
await seedRow({ nextRunAt: Date.now() - 1, lastStatus: 'running', runningSince: Date.now() });
await runDueTasks({ registry: registryWith(handler) });
expect(handler).not.toHaveBeenCalled();
expect((await readRow()).lastStatus).toBe('running');
});
test('runs a requested task even when disabled', async () => {
const handler = vi.fn(async () => undefined);
await seedRow({ nextRunAt: Date.now() + 60_000, enabled: false, runRequested: true });
await runDueTasks({ registry: registryWith(handler) });
expect(handler).toHaveBeenCalledTimes(1);
});
test('preserves a manual rerun requested while a task is running', async () => {
const handler = vi.fn(async () => {
await holder.db
.update(tasks)
.set({ runRequested: true })
.where(eq(tasks.key, KEY));
});
await seedRow({ nextRunAt: Date.now() - 1 });
await runDueTasks({ registry: registryWith(handler) });
expect(handler).toHaveBeenCalledTimes(1);
expect((await readRow()).runRequested).toBe(true);
});
test('does not let a stale runner overwrite its replacement run', async () => {
let resolveFirst!: (value: { summary: string }) => void;
const firstResult = new Promise<{ summary: string }>((resolve) => {
resolveFirst = resolve;
});
const handler = vi.fn()
.mockImplementationOnce(() => firstResult)
.mockResolvedValueOnce({ summary: 'replacement' });
await seedRow({ nextRunAt: Date.now() - 1 });
let now = Date.now();
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now);
const firstRun = runDueTasks({ registry: registryWith(handler) });
await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
now += 2 * 60 * 60 * 1000;
await runDueTasks({ registry: registryWith(handler) });
expect((await readRow()).lastResultJson).toBe('replacement');
resolveFirst({ summary: 'stale original' });
await firstRun;
expect((await readRow()).lastResultJson).toBe('replacement');
nowSpy.mockRestore();
});
test('starts independent due tasks concurrently', async () => {
const started: string[] = [];
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const registry: TaskRegistry = {
first: {
name: 'First',
defaultIntervalMs: 1000,
run: async () => {
started.push('first');
await gate;
},
},
second: {
name: 'Second',
defaultIntervalMs: 1000,
run: async () => {
started.push('second');
await gate;
},
},
};
const running = runDueTasks({ registry });
await vi.waitFor(() => expect(started).toEqual(['first', 'second']));
release();
await running;
});
test('aborts and records an error when a task exceeds its runtime limit', async () => {
const handler = vi.fn(async ({ signal }: { signal: AbortSignal }) => {
await new Promise<void>((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }));
});
await seedRow({ nextRunAt: Date.now() - 1 });
const registry: TaskRegistry = {
[KEY]: { name: 'Timed task', defaultIntervalMs: 1000, maxRunMs: 5, run: handler },
};
await runDueTasks({ registry });
expect(handler).toHaveBeenCalledTimes(1);
expect((await readRow()).lastError).toContain('runtime limit');
});
test('rejects non-positive intervals at the database boundary', async () => {
await expect(seedRow({ intervalMs: 0 })).rejects.toThrow();
});
test('updates a registered task even when its row has not been seeded', async () => {
await updateTask('prune-job-events', { enabled: false, intervalMs: 12_345 });
const [row] = await holder.db
.select()
.from(tasks)
.where(eq(tasks.key, 'prune-job-events'));
expect(row).toEqual(expect.objectContaining({
enabled: false,
intervalMs: 12_345,
lastStatus: 'idle',
}));
});
});

View file

@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { NextRequest } from 'next/server';
const mocks = vi.hoisted(() => ({
requireAdminContext: vi.fn(),
listTasks: vi.fn(),
runDueTasks: vi.fn(),
updateTask: vi.fn(),
}));
vi.mock('@/lib/server/auth/admin', () => ({
requireAdminContext: mocks.requireAdminContext,
}));
vi.mock('@/lib/server/tasks/engine', () => ({
listTasks: mocks.listTasks,
runDueTasks: mocks.runDueTasks,
updateTask: mocks.updateTask,
}));
describe('scheduled task routes', () => {
const originalCronSecret = process.env.CRON_SECRET;
const originalVercel = process.env.VERCEL;
beforeEach(() => {
mocks.requireAdminContext.mockReset();
mocks.requireAdminContext.mockResolvedValue({ userId: 'admin-1' });
mocks.listTasks.mockReset();
mocks.listTasks.mockResolvedValue([]);
mocks.runDueTasks.mockReset();
mocks.runDueTasks.mockResolvedValue(undefined);
mocks.updateTask.mockReset();
mocks.updateTask.mockResolvedValue(undefined);
delete process.env.CRON_SECRET;
delete process.env.VERCEL;
});
afterEach(() => {
if (originalCronSecret === undefined) delete process.env.CRON_SECRET;
else process.env.CRON_SECRET = originalCronSecret;
if (originalVercel === undefined) delete process.env.VERCEL;
else process.env.VERCEL = originalVercel;
});
test('cron tick requires the configured bearer secret', async () => {
const { GET } = await import('../../src/app/api/admin/tasks/tick/route');
const unconfigured = await GET(new NextRequest('http://localhost/api/admin/tasks/tick'));
expect(unconfigured.status).toBe(503);
process.env.CRON_SECRET = 'cron-secret';
const unauthorized = await GET(new NextRequest('http://localhost/api/admin/tasks/tick'));
expect(unauthorized.status).toBe(401);
const authorized = await GET(new NextRequest('http://localhost/api/admin/tasks/tick', {
headers: { authorization: 'Bearer cron-secret' },
}));
expect(authorized.status).toBe(200);
expect(mocks.runDueTasks).toHaveBeenCalledTimes(1);
});
test('admin task list enforces admin authorization', async () => {
const denied = new Response('Forbidden', { status: 403 });
mocks.requireAdminContext.mockResolvedValue(denied);
const { GET } = await import('../../src/app/api/admin/tasks/route');
const response = await GET(new NextRequest('http://localhost/api/admin/tasks'));
expect(response).toBe(denied);
expect(mocks.listTasks).not.toHaveBeenCalled();
});
test('reports daily Vercel cadence and rejects shorter intervals', async () => {
process.env.VERCEL = '1';
mocks.listTasks.mockResolvedValue([{
key: 'cleanup-temp-uploads',
intervalMs: 60 * 60 * 1000,
}]);
const { GET } = await import('../../src/app/api/admin/tasks/route');
const listResponse = await GET(new NextRequest('http://localhost/api/admin/tasks'));
await expect(listResponse.json()).resolves.toMatchObject({
tasks: [{ intervalMs: 86_400_000 }],
scheduler: {
mode: 'vercel-cron',
minimumIntervalMs: 86_400_000,
},
});
const { PATCH } = await import('../../src/app/api/admin/tasks/[key]/route');
const patchResponse = await PATCH(new NextRequest('http://localhost/api/admin/tasks/cleanup-temp-uploads', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ intervalMs: 60 * 60 * 1000 }),
}), { params: Promise.resolve({ key: 'cleanup-temp-uploads' }) });
expect(patchResponse.status).toBe(400);
expect(mocks.updateTask).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,132 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
send: vi.fn(),
}));
vi.mock('@/lib/server/storage/s3', () => ({
getS3Config: () => ({ bucket: 'test-bucket', prefix: 'openreader-test' }),
getS3Client: () => ({ send: mocks.send }),
getS3ProxyClient: () => ({ send: mocks.send }),
}));
import { deleteAudiobookPrefix } from '../../src/lib/server/audiobooks/blobstore';
import {
deleteAllExpiredTempDocumentUploads,
deleteDocumentBlob,
deleteDocumentPrefix,
} from '../../src/lib/server/documents/blobstore';
describe('storage prefix cleanup', () => {
beforeEach(() => {
mocks.send.mockReset();
});
test.each([
['document', deleteDocumentPrefix],
['audiobook', deleteAudiobookPrefix],
])('%s cleanup counts successful quiet deletes', async (_name, removePrefix) => {
mocks.send
.mockResolvedValueOnce({
Contents: [{ Key: 'prefix/a' }, { Key: 'prefix/b' }],
IsTruncated: false,
})
.mockResolvedValueOnce({});
await expect(removePrefix('prefix/')).resolves.toBe(2);
});
test.each([
['document', deleteDocumentPrefix],
['audiobook', deleteAudiobookPrefix],
])('%s cleanup fails on per-object storage errors', async (_name, removePrefix) => {
mocks.send
.mockResolvedValueOnce({
Contents: [{ Key: 'prefix/a' }],
IsTruncated: false,
})
.mockResolvedValueOnce({
Errors: [{ Key: 'prefix/a', Code: 'AccessDenied' }],
});
await expect(removePrefix('prefix/')).rejects.toThrow('Failed deleting 1');
});
test('deletes the source after derived artifacts and then sweeps late parsed output', async () => {
mocks.send
.mockResolvedValueOnce({ Contents: [], IsTruncated: false })
.mockResolvedValueOnce({ Contents: [], IsTruncated: false })
.mockResolvedValueOnce({})
.mockResolvedValueOnce({})
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ Contents: [], IsTruncated: false });
const documentId = 'a'.repeat(64);
await deleteDocumentBlob(documentId, null);
const commands = mocks.send.mock.calls.map(([command]) => command);
const sourceDeleteIndex = commands.findIndex((command) =>
command.constructor.name === 'DeleteObjectCommand'
&& command.input.Key === `openreader-test/documents_v1/${documentId}`);
const finalCommand = commands.at(-1);
expect(sourceDeleteIndex).toBeGreaterThan(1);
expect(finalCommand?.constructor.name).toBe('ListObjectsV2Command');
expect(finalCommand?.input.Prefix).toBe(
`openreader-test/documents_v1/parsed_v2/${documentId}/`,
);
});
test('keeps the source document when derived-artifact cleanup fails', async () => {
mocks.send.mockRejectedValueOnce(new Error('list failed'));
const documentId = 'b'.repeat(64);
await expect(deleteDocumentBlob(documentId, null)).rejects.toThrow('list failed');
const sourceDelete = mocks.send.mock.calls
.map(([command]) => command)
.find((command) => command.constructor.name === 'DeleteObjectCommand'
&& command.input.Key === `openreader-test/documents_v1/${documentId}`);
expect(sourceDelete).toBeUndefined();
});
test('deletes expired temporary uploads page by page', async () => {
const old = new Date(Date.now() - 60_000);
mocks.send
.mockResolvedValueOnce({
Contents: [{ Key: 'temp/a', LastModified: old }],
IsTruncated: true,
NextContinuationToken: 'next',
})
.mockResolvedValueOnce({})
.mockResolvedValueOnce({
Contents: [{ Key: 'temp/b', LastModified: old }],
IsTruncated: false,
})
.mockResolvedValueOnce({});
await expect(deleteAllExpiredTempDocumentUploads(null, Date.now())).resolves.toBe(2);
expect(mocks.send.mock.calls.map(([command]) => command.constructor.name)).toEqual([
'ListObjectsV2Command',
'DeleteObjectsCommand',
'ListObjectsV2Command',
'DeleteObjectsCommand',
]);
});
test('temporary upload cleanup fails on per-object storage errors', async () => {
const old = new Date(Date.now() - 60_000);
mocks.send
.mockResolvedValueOnce({
Contents: [{ Key: 'temp/a', LastModified: old }],
IsTruncated: false,
})
.mockResolvedValueOnce({
Errors: [{ Key: 'temp/a', Code: 'AccessDenied' }],
});
await expect(deleteAllExpiredTempDocumentUploads(null, Date.now()))
.rejects.toThrow('test-bucket');
});
});

View file

@ -2,8 +2,13 @@ import { describe, expect, test } from 'vitest';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { documents } from '../../src/db/schema_sqlite'; import {
import { transferUserDocuments } from '../../src/lib/server/user/claim-data'; documentSettings,
documents,
ttsSegmentEntries,
ttsSegmentVariants,
} from '../../src/db/schema_sqlite';
import { transferUserDocumentSettings, transferUserDocuments } from '../../src/lib/server/user/claim-data';
describe('transferUserDocuments', () => { describe('transferUserDocuments', () => {
test('moves document rows to new user without PK conflicts', async () => { test('moves document rows to new user without PK conflicts', async () => {
@ -73,4 +78,174 @@ describe('transferUserDocuments', () => {
const ids = remainingTo.map((r) => r.id).sort(); const ids = remainingTo.map((r) => r.id).sort();
expect(ids).toEqual(['doc-a', 'doc-b']); expect(ids).toEqual(['doc-a', 'doc-b']);
}); });
test('moves document settings while preserving newer destination settings', async () => {
const sqlite = new Database(':memory:');
sqlite.exec(`
CREATE TABLE document_settings (
document_id TEXT NOT NULL,
user_id TEXT NOT NULL,
data_json TEXT NOT NULL DEFAULT '{}',
client_updated_at_ms INTEGER NOT NULL DEFAULT 0,
created_at INTEGER,
updated_at INTEGER,
PRIMARY KEY (document_id, user_id)
);
`);
const settingsDb = drizzle(sqlite);
await settingsDb.insert(documentSettings).values([
{
documentId: 'doc-newer-anon',
userId: 'anon',
dataJson: '{"source":"anon"}',
clientUpdatedAtMs: 20,
},
{
documentId: 'doc-newer-user',
userId: 'anon',
dataJson: '{"source":"anon"}',
clientUpdatedAtMs: 10,
},
{
documentId: 'doc-newer-user',
userId: 'user',
dataJson: '{"source":"user"}',
clientUpdatedAtMs: 30,
},
]);
const transferred = await transferUserDocumentSettings('anon', 'user', {
db: settingsDb,
});
expect(transferred).toBe(2);
const rows = await settingsDb.select().from(documentSettings);
expect(rows).toHaveLength(2);
expect(rows).toEqual(expect.arrayContaining([
expect.objectContaining({
documentId: 'doc-newer-anon',
userId: 'user',
dataJson: '{"source":"anon"}',
clientUpdatedAtMs: 20,
}),
expect.objectContaining({
documentId: 'doc-newer-user',
userId: 'user',
dataJson: '{"source":"user"}',
clientUpdatedAtMs: 30,
}),
]));
});
test('moves TTS metadata without requiring S3', async () => {
const sqlite = new Database(':memory:');
sqlite.exec(`
CREATE TABLE documents (
id TEXT NOT NULL,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
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)
);
CREATE TABLE tts_segment_entries (
segment_entry_id TEXT NOT NULL,
user_id TEXT NOT NULL,
document_id TEXT NOT NULL,
reader_type TEXT NOT NULL,
document_version INTEGER NOT NULL,
segment_index INTEGER NOT NULL,
segment_key TEXT,
locator_reader_rank INTEGER NOT NULL,
locator_reader_type TEXT NOT NULL,
locator_page INTEGER NOT NULL,
locator_spine_index INTEGER NOT NULL,
locator_spine_href TEXT NOT NULL,
locator_char_offset INTEGER NOT NULL,
locator_location TEXT NOT NULL,
locator_identity_key TEXT NOT NULL,
text_hash TEXT NOT NULL,
text_length INTEGER NOT NULL DEFAULT 0,
created_at INTEGER,
updated_at INTEGER,
PRIMARY KEY (segment_entry_id, user_id)
);
CREATE TABLE tts_segment_variants (
segment_id TEXT NOT NULL,
user_id TEXT NOT NULL,
segment_entry_id TEXT NOT NULL,
settings_hash TEXT NOT NULL,
settings_json TEXT NOT NULL,
audio_key TEXT,
audio_format TEXT NOT NULL DEFAULT 'mp3',
duration_ms INTEGER,
alignment_json TEXT,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
created_at INTEGER,
updated_at INTEGER,
PRIMARY KEY (segment_id, user_id)
);
`);
const transferDb = drizzle(sqlite);
await transferDb.insert(documents).values({
id: 'doc',
userId: 'anon',
name: 'doc.pdf',
type: 'pdf',
size: 1,
lastModified: 1,
filePath: 'doc__doc.pdf',
});
await transferDb.insert(ttsSegmentEntries).values({
segmentEntryId: 'entry',
userId: 'anon',
documentId: 'doc',
readerType: 'pdf',
documentVersion: 1,
segmentIndex: 0,
locatorReaderRank: 0,
locatorReaderType: 'pdf',
locatorPage: 1,
locatorSpineIndex: 0,
locatorSpineHref: '',
locatorCharOffset: 0,
locatorLocation: '1',
locatorIdentityKey: 'page:1',
textHash: 'hash',
});
await transferDb.insert(ttsSegmentVariants).values({
segmentId: 'segment',
userId: 'anon',
segmentEntryId: 'entry',
settingsHash: 'settings',
settingsJson: '{}',
audioKey: 'openreader/tts_segments_v2/users/anon/docs/doc/1/settings/segment.mp3',
status: 'completed',
});
await transferUserDocuments('anon', 'user', {
db: transferDb,
transferTts: true,
skipStorage: true,
});
expect(await transferDb.select().from(ttsSegmentEntries)).toEqual([
expect.objectContaining({ segmentEntryId: 'entry', userId: 'user', documentId: 'doc' }),
]);
expect(await transferDb.select().from(ttsSegmentVariants)).toEqual([
expect.objectContaining({
segmentId: 'segment',
userId: 'user',
audioKey: 'openreader/tts_segments_v2/users/user/docs/doc/1/settings/segment.mp3',
}),
]);
});
}); });

View file

@ -0,0 +1,76 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
send: vi.fn(),
}));
vi.mock('@/lib/server/storage/s3', () => ({
getS3Config: () => ({ bucket: 'test-bucket' }),
getS3Client: () => ({ send: mocks.send }),
getS3ProxyClient: () => ({ send: mocks.send }),
}));
import { copyTtsSegmentPrefix, deleteTtsSegmentPrefix } from '../../src/lib/server/tts/segments-blobstore';
describe('TTS segment blob cleanup', () => {
beforeEach(() => {
mocks.send.mockReset();
});
test('counts successful quiet deletes by requested object count', async () => {
mocks.send
.mockResolvedValueOnce({
Contents: [{ Key: 'prefix/a.mp3' }, { Key: 'prefix/b.mp3' }],
IsTruncated: false,
})
.mockResolvedValueOnce({});
await expect(deleteTtsSegmentPrefix('prefix/')).resolves.toBe(2);
});
test('fails cleanup when storage reports per-object deletion errors', async () => {
mocks.send
.mockResolvedValueOnce({
Contents: [{ Key: 'prefix/a.mp3' }],
IsTruncated: false,
})
.mockResolvedValueOnce({
Errors: [{ Key: 'prefix/a.mp3', Code: 'AccessDenied' }],
});
await expect(deleteTtsSegmentPrefix('prefix/')).rejects.toThrow(
'Failed deleting 1 TTS segment audio objects',
);
});
test('copies a user-scoped prefix without deleting its source objects', async () => {
mocks.send
.mockResolvedValueOnce({
Contents: [{ Key: 'source/doc/audio.mp3' }],
IsTruncated: false,
})
.mockResolvedValueOnce({});
await expect(copyTtsSegmentPrefix('source/doc/', 'destination/doc/')).resolves.toBe(1);
const copyCommand = mocks.send.mock.calls[1]?.[0];
expect(copyCommand.constructor.name).toBe('CopyObjectCommand');
expect(copyCommand.input).toMatchObject({
Key: 'destination/doc/audio.mp3',
CopySource: 'test-bucket/source/doc/audio.mp3',
});
expect(mocks.send).toHaveBeenCalledTimes(2);
});
test('does not delete source objects when a TTS prefix copy fails', async () => {
mocks.send
.mockResolvedValueOnce({
Contents: [{ Key: 'source/doc/audio.mp3' }],
IsTruncated: false,
})
.mockRejectedValueOnce(new Error('copy failed'));
await expect(copyTtsSegmentPrefix('source/doc/', 'destination/doc/')).rejects.toThrow('copy failed');
expect(mocks.send).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,78 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
selectResults: [] as unknown[][],
deleteWhere: vi.fn(async () => undefined),
deleteTtsSegmentAudioObjects: vi.fn(async () => 0),
}));
function resultBuilder(result: unknown[]) {
return {
innerJoin: vi.fn(() => ({
where: vi.fn(async () => result),
})),
where: vi.fn(async () => result),
};
}
vi.mock('@/db', () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => resultBuilder(mocks.selectResults.shift() ?? [])),
})),
delete: vi.fn(() => ({
where: mocks.deleteWhere,
})),
},
}));
vi.mock('@/lib/server/tts/segments-blobstore', () => ({
deleteTtsSegmentAudioObjects: mocks.deleteTtsSegmentAudioObjects,
deleteTtsSegmentPrefix: vi.fn(async () => 0),
}));
vi.mock('@/lib/server/storage/s3', () => ({
getS3Config: () => ({ prefix: 'openreader-test' }),
}));
vi.mock('@/lib/server/logger', () => ({
serverLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock('@/lib/server/errors/logging', () => ({
logDegraded: vi.fn(),
}));
import { clearTtsSegmentCache } from '../../src/lib/server/tts/segments-cache';
describe('TTS segment cache cleanup', () => {
beforeEach(() => {
mocks.selectResults = [];
mocks.deleteWhere.mockReset();
mocks.deleteWhere.mockResolvedValue(undefined);
mocks.deleteTtsSegmentAudioObjects.mockReset();
mocks.deleteTtsSegmentAudioObjects.mockResolvedValue(2);
});
test('counts deleted entries rather than joined variants', async () => {
mocks.selectResults = [
[{ segmentEntryId: 'entry-1' }, { segmentEntryId: 'entry-2' }],
[
{ segmentId: 'variant-1', audioKey: 'audio-1' },
{ segmentId: 'variant-2', audioKey: 'audio-2' },
{ segmentId: 'variant-3', audioKey: 'audio-2' },
],
];
const result = await clearTtsSegmentCache({
userId: 'user-1',
documentId: 'doc-1',
});
expect(result).toMatchObject({
deletedSegments: 2,
requestedAudioObjects: 2,
deletedAudioObjects: 2,
});
});
});

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { import {
buildTtsSegmentDocumentPrefix,
buildProportionalAlignment, buildProportionalAlignment,
buildTtsSegmentEntryId, buildTtsSegmentEntryId,
buildTtsSegmentId, buildTtsSegmentId,
@ -12,6 +13,15 @@ import {
} from '../../src/lib/server/tts/segments'; } from '../../src/lib/server/tts/segments';
describe('tts segment helpers', () => { describe('tts segment helpers', () => {
test('builds a user/document-scoped audio prefix across every version and variant', () => {
expect(buildTtsSegmentDocumentPrefix({
storagePrefix: 'openreader',
namespace: 'test namespace',
userId: 'user/name',
documentId: 'doc-id',
})).toBe('openreader/tts_segments_v2/ns/test namespace/users/user%2Fname/docs/doc-id/');
});
test('builds stable settings hash', () => { test('builds stable settings hash', () => {
const a = buildTtsSegmentSettingsHash({ const a = buildTtsSegmentSettingsHash({
providerRef: 'openai', providerRef: 'openai',

View file

@ -0,0 +1,134 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
selectResults: [] as unknown[][],
deleteWhere: vi.fn(async () => undefined),
deleteDocumentBlob: vi.fn(async () => undefined),
deleteDocumentPrefix: vi.fn(async () => 0),
deleteDocumentPreviewArtifacts: vi.fn(async () => 0),
deleteDocumentPreviewRows: vi.fn(async () => undefined),
deleteAudiobookPrefix: vi.fn(async () => 0),
deleteTtsSegmentPrefix: vi.fn(async () => 0),
}));
function resultBuilder(result: unknown[]) {
return {
then: (resolve: (value: unknown[]) => unknown, reject: (error: unknown) => unknown) =>
Promise.resolve(result).then(resolve, reject),
};
}
vi.mock('@/db', () => {
const database = {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => resultBuilder(mocks.selectResults.shift() ?? [])),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => {
const promise = mocks.deleteWhere();
return {
then: promise.then.bind(promise),
catch: promise.catch.bind(promise),
};
}),
})),
};
return { db: database };
});
vi.mock('@/lib/server/storage/s3', () => ({
isS3Configured: () => true,
getS3Config: () => ({ prefix: 'openreader-test' }),
}));
vi.mock('@/lib/server/documents/blobstore', () => ({
deleteDocumentBlob: mocks.deleteDocumentBlob,
deleteDocumentPrefix: mocks.deleteDocumentPrefix,
tempDocumentUploadPrefix: () => 'temp/user/',
}));
vi.mock('@/lib/server/documents/previews-blobstore', () => ({
deleteDocumentPreviewArtifacts: mocks.deleteDocumentPreviewArtifacts,
}));
vi.mock('@/lib/server/documents/previews', () => ({
deleteDocumentPreviewRows: mocks.deleteDocumentPreviewRows,
}));
vi.mock('@/lib/server/audiobooks/blobstore', () => ({
audiobookPrefix: () => 'audiobooks/user/',
deleteAudiobookPrefix: mocks.deleteAudiobookPrefix,
}));
vi.mock('@/lib/server/tts/segments-blobstore', () => ({
deleteTtsSegmentPrefix: mocks.deleteTtsSegmentPrefix,
}));
vi.mock('@/lib/server/logger', () => ({
hashForLog: () => 'hash',
serverLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock('@/lib/server/errors/logging', () => ({
logDegraded: vi.fn(),
}));
import { deleteUserStorageData } from '../../src/lib/server/user/data-cleanup';
describe('user data cleanup', () => {
beforeEach(() => {
mocks.selectResults = [];
for (const mock of Object.values(mocks)) {
if (typeof mock === 'function' && 'mockReset' in mock) {
mock.mockReset();
}
}
mocks.deleteWhere.mockResolvedValue(undefined);
mocks.deleteDocumentBlob.mockResolvedValue(undefined);
mocks.deleteDocumentPrefix.mockResolvedValue(0);
mocks.deleteDocumentPreviewArtifacts.mockResolvedValue(0);
mocks.deleteDocumentPreviewRows.mockResolvedValue(undefined);
mocks.deleteAudiobookPrefix.mockResolvedValue(0);
mocks.deleteTtsSegmentPrefix.mockResolvedValue(0);
});
test('defers document blobs/previews to the reaper on the canonical pass', async () => {
mocks.selectResults = [[]]; // no audiobooks
await deleteUserStorageData('user-1', null);
// Shared document storage is reclaimed by the reap-orphaned-blobs task, not here.
expect(mocks.deleteDocumentBlob).not.toHaveBeenCalled();
expect(mocks.deleteDocumentPreviewArtifacts).not.toHaveBeenCalled();
expect(mocks.deleteDocumentPreviewRows).not.toHaveBeenCalled();
// Only the three non-cascading DB row deletes (tts usage, job events, verification).
expect(mocks.deleteWhere).toHaveBeenCalledTimes(3);
});
test('blocks database cleanup when storage cleanup fails', async () => {
mocks.selectResults = [[]]; // no audiobooks
mocks.deleteDocumentPrefix.mockRejectedValueOnce(new Error('storage unavailable'));
await expect(deleteUserStorageData('user-1', null)).rejects.toThrow(
'User storage cleanup failed',
);
expect(mocks.deleteWhere).not.toHaveBeenCalled();
});
test('deletes namespaced document storage inline and skips global DB rows', async () => {
mocks.selectResults = [
[{ id: 'doc-1' }], // userDocs (namespaced pass)
[], // audiobooks
];
await deleteUserStorageData('user-1', 'test-ns');
expect(mocks.deleteDocumentBlob).toHaveBeenCalledWith('doc-1', 'test-ns');
expect(mocks.deleteDocumentPreviewArtifacts).toHaveBeenCalledWith('doc-1', 'test-ns');
expect(mocks.deleteDocumentPreviewRows).toHaveBeenCalledWith('doc-1', 'test-ns');
// Global DB rows are only removed on the canonical pass.
expect(mocks.deleteWhere).not.toHaveBeenCalled();
});
});

8
vercel.json Normal file
View file

@ -0,0 +1,8 @@
{
"crons": [
{
"path": "/api/admin/tasks/tick",
"schedule": "0 0 * * *"
}
]
}