openreader/src/lib/server/documents/blobstore.ts
Richard R e670c38523
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.
2026-06-07 13:33:20 -06:00

706 lines
22 KiB
TypeScript

import {
CopyObjectCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PDF_PARSER_VERSION, encodeParserVersion } from '@openreader/compute-core/api-contracts';
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 SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
const TEMP_UPLOAD_TOKEN_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const TEMP_UPLOAD_USER_ID_REGEX = /^[a-zA-Z0-9._:-]{1,256}$/;
export const TEMP_DOCUMENT_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
function sanitizeNamespace(namespace: string | null): string | null {
if (!namespace) return null;
if (!SAFE_NAMESPACE_REGEX.test(namespace)) return null;
return namespace;
}
function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream {
return !!value && typeof value === 'object' && 'on' in value && typeof (value as NodeJS.ReadableStream).on === 'function';
}
export type DocumentBlobBody =
| NodeJS.ReadableStream
| ReadableStream<Uint8Array>
| Uint8Array
| ArrayBuffer
| ArrayBufferView
| { transformToByteArray: () => Promise<Uint8Array> };
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
if (Buffer.isBuffer(chunk)) {
chunks.push(chunk);
} else if (typeof chunk === 'string') {
chunks.push(Buffer.from(chunk));
} else {
chunks.push(Buffer.from(chunk as Uint8Array));
}
}
return Buffer.concat(chunks);
}
async function bodyToBuffer(body: unknown): Promise<Buffer> {
if (!body) return Buffer.alloc(0);
if (body instanceof Uint8Array) return Buffer.from(body);
if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
if (body instanceof ArrayBuffer) return Buffer.from(body);
if (typeof body === 'object' && body !== null && 'transformToByteArray' in body) {
const maybe = body as { transformToByteArray?: () => Promise<Uint8Array> };
if (typeof maybe.transformToByteArray === 'function') {
return Buffer.from(await maybe.transformToByteArray());
}
}
if (isNodeReadableStream(body)) {
return streamToBuffer(body);
}
throw new Error('Unsupported S3 response body type');
}
export function isValidDocumentId(id: string): boolean {
return DOCUMENT_ID_REGEX.test(id);
}
export function isPreconditionFailed(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const maybe = error as { name?: string; $metadata?: { httpStatusCode?: number } };
return maybe.$metadata?.httpStatusCode === 412 || maybe.name === 'PreconditionFailed';
}
export function isValidTempUploadToken(token: string): boolean {
return TEMP_UPLOAD_TOKEN_REGEX.test(token);
}
function sanitizeTempUploadUserId(userId: string): string {
if (!TEMP_UPLOAD_USER_ID_REGEX.test(userId)) {
throw new Error(`Invalid temp upload user id: ${userId}`);
}
return encodeURIComponent(userId);
}
export function documentKey(id: string, namespace: string | null): string {
if (!isValidDocumentId(id)) {
throw new Error(`Invalid document id: ${id}`);
}
const cfg = getS3Config();
const ns = sanitizeNamespace(namespace);
const nsSegment = ns ? `ns/${ns}/` : '';
return `${cfg.prefix}/documents_v1/${nsSegment}${id}`;
}
export function documentParsedKey(id: string, namespace: string | null): string {
return documentParsedKeyForVersion(id, namespace, PDF_PARSER_VERSION);
}
export function documentParsedKeyForVersion(
id: string,
namespace: string | null,
parserVersion: string,
): string {
if (!isValidDocumentId(id)) {
throw new Error(`Invalid document id: ${id}`);
}
const cfg = getS3Config();
const ns = sanitizeNamespace(namespace);
const nsSegment = ns ? `ns/${ns}/` : '';
return `${cfg.prefix}/documents_v1/parsed_v2/${nsSegment}${id}/${encodeParserVersion(parserVersion)}.json`;
}
export function tempDocumentUploadPrefix(userId: string, namespace: string | null): string {
const cfg = getS3Config();
const ns = sanitizeNamespace(namespace);
const nsSegment = ns ? `ns/${ns}/` : '';
return `${cfg.prefix}/document_uploads_temp_v1/${nsSegment}users/${sanitizeTempUploadUserId(userId)}/`;
}
export function tempDocumentUploadKey(token: string, userId: string, namespace: string | null): string {
if (!isValidTempUploadToken(token)) {
throw new Error(`Invalid temp upload token: ${token}`);
}
return `${tempDocumentUploadPrefix(userId, namespace)}${token}.bin`;
}
export function tempDocumentUploadReceiptKey(token: string, userId: string, namespace: string | null): string {
if (!isValidTempUploadToken(token)) {
throw new Error(`Invalid temp upload token: ${token}`);
}
return `${tempDocumentUploadPrefix(userId, namespace)}${token}.receipt.json`;
}
function legacyDocumentParsedKey(id: string, namespace: string | null): string {
if (!isValidDocumentId(id)) {
throw new Error(`Invalid document id: ${id}`);
}
const cfg = getS3Config();
const ns = sanitizeNamespace(namespace);
const nsSegment = ns ? `ns/${ns}/` : '';
return `${cfg.prefix}/documents_v1/${nsSegment}${id}/parsed.v1.json`;
}
export async function presignPut(
id: string,
contentType: string,
namespace: string | null,
options?: { contentLength?: number },
): Promise<{ url: string; headers: Record<string, string> }> {
const cfg = getS3Config();
const client = getS3Client();
const key = documentKey(id, namespace);
const normalizedType = (contentType || 'application/octet-stream').trim() || 'application/octet-stream';
// When the client declares an exact size, sign Content-Length so S3 rejects a
// PUT whose body does not match (the browser always sends an accurate
// Content-Length for a known body). Skipped when size is unknown/zero so the
// upload still works against stores that enforce the signed header.
const contentLength =
typeof options?.contentLength === 'number' && Number.isFinite(options.contentLength) && options.contentLength > 0
? Math.floor(options.contentLength)
: undefined;
const command = new PutObjectCommand({
Bucket: cfg.bucket,
Key: key,
ContentType: normalizedType,
ServerSideEncryption: 'AES256',
...(contentLength !== undefined ? { ContentLength: contentLength } : {}),
});
const url = await getSignedUrl(client, command, { expiresIn: 60 * 5 });
return {
url,
headers: {
'Content-Type': normalizedType,
'x-amz-server-side-encryption': 'AES256',
},
};
}
export async function presignTempPut(
token: string,
userId: string,
contentType: string,
namespace: string | null,
options?: { contentLength?: number },
): Promise<{ url: string; headers: Record<string, string> }> {
const cfg = getS3Config();
const client = getS3Client();
const key = tempDocumentUploadKey(token, userId, namespace);
const normalizedType = (contentType || 'application/octet-stream').trim() || 'application/octet-stream';
const contentLength =
typeof options?.contentLength === 'number' && Number.isFinite(options.contentLength) && options.contentLength > 0
? Math.floor(options.contentLength)
: undefined;
const command = new PutObjectCommand({
Bucket: cfg.bucket,
Key: key,
ContentType: normalizedType,
ServerSideEncryption: 'AES256',
...(contentLength !== undefined ? { ContentLength: contentLength } : {}),
});
const url = await getSignedUrl(client, command, { expiresIn: 60 * 5 });
return {
url,
headers: {
'Content-Type': normalizedType,
'x-amz-server-side-encryption': 'AES256',
},
};
}
export async function headDocumentBlob(
id: string,
namespace: string | null,
): Promise<{ contentLength: number; contentType: string | null; eTag: string | null }> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const key = documentKey(id, namespace);
const res = await client.send(new HeadObjectCommand({ Bucket: cfg.bucket, Key: key }));
return {
contentLength: Number(res.ContentLength ?? 0),
contentType: res.ContentType ?? null,
eTag: res.ETag ?? null,
};
}
export async function headTempDocumentBlob(
token: string,
userId: string,
namespace: string | null,
): Promise<{ contentLength: number; contentType: string | null; eTag: string | null; lastModified: number | null }> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const key = tempDocumentUploadKey(token, userId, namespace);
const res = await client.send(new HeadObjectCommand({ Bucket: cfg.bucket, Key: key }));
return {
contentLength: Number(res.ContentLength ?? 0),
contentType: res.ContentType ?? null,
eTag: res.ETag ?? null,
lastModified: res.LastModified?.getTime() ?? null,
};
}
export async function getDocumentRange(
id: string,
start: number,
endInclusive: number,
namespace: string | null,
): Promise<Buffer> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const key = documentKey(id, namespace);
const res = await client.send(
new GetObjectCommand({
Bucket: cfg.bucket,
Key: key,
Range: `bytes=${Math.max(0, start)}-${Math.max(0, endInclusive)}`,
}),
);
return bodyToBuffer(res.Body);
}
export async function getDocumentBlob(id: string, namespace: string | null): Promise<Buffer> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const key = documentKey(id, namespace);
const res = await client.send(
new GetObjectCommand({
Bucket: cfg.bucket,
Key: key,
}),
);
return bodyToBuffer(res.Body);
}
export async function getTempDocumentBlob(
token: string,
userId: string,
namespace: string | null,
): Promise<Buffer> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const key = tempDocumentUploadKey(token, userId, namespace);
const res = await client.send(
new GetObjectCommand({
Bucket: cfg.bucket,
Key: key,
}),
);
return bodyToBuffer(res.Body);
}
export async function getDocumentBlobStream(id: string, namespace: string | null): Promise<DocumentBlobBody> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const key = documentKey(id, namespace);
const res = await client.send(
new GetObjectCommand({
Bucket: cfg.bucket,
Key: key,
}),
);
return res.Body as DocumentBlobBody;
}
export async function getTempDocumentFinalizeReceipt<T>(
token: string,
userId: string,
namespace: string | null,
): Promise<T | null> {
try {
const cfg = getS3Config();
const client = getS3ProxyClient();
const key = tempDocumentUploadReceiptKey(token, userId, namespace);
const res = await client.send(
new GetObjectCommand({
Bucket: cfg.bucket,
Key: key,
}),
);
const body = await bodyToBuffer(res.Body);
return JSON.parse(body.toString('utf8')) as T;
} catch (error) {
if (isMissingBlobError(error)) return null;
throw error;
}
}
export async function getParsedDocumentBlob(id: string, namespace: string | null): Promise<Buffer> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const key = documentParsedKey(id, namespace);
const res = await client.send(
new GetObjectCommand({
Bucket: cfg.bucket,
Key: key,
}),
);
return bodyToBuffer(res.Body);
}
export async function getParsedDocumentBlobByKey(key: string): Promise<Buffer> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const trimmed = key.trim();
if (!trimmed) throw new Error('Parsed document key is empty');
const res = await client.send(
new GetObjectCommand({
Bucket: cfg.bucket,
Key: trimmed,
}),
);
return bodyToBuffer(res.Body);
}
export async function putParsedDocumentBlob(id: string, body: Buffer, namespace: string | null): Promise<string> {
return putParsedDocumentBlobForVersion(id, body, namespace, PDF_PARSER_VERSION);
}
export async function putParsedDocumentBlobForVersion(
id: string,
body: Buffer,
namespace: string | null,
parserVersion: string,
): Promise<string> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const key = documentParsedKeyForVersion(id, namespace, parserVersion);
await client.send(
new PutObjectCommand({
Bucket: cfg.bucket,
Key: key,
Body: body,
ContentType: 'application/json',
ServerSideEncryption: 'AES256',
}),
);
return key;
}
export async function putTempDocumentFinalizeReceipt(
token: string,
userId: string,
namespace: string | null,
body: Buffer,
): Promise<void> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const key = tempDocumentUploadReceiptKey(token, userId, namespace);
await client.send(
new PutObjectCommand({
Bucket: cfg.bucket,
Key: key,
Body: body,
ContentType: 'application/json',
ServerSideEncryption: 'AES256',
}),
);
}
export async function presignGet(
id: string,
namespace: string | null,
options?: { expiresInSeconds?: number },
): Promise<string> {
const cfg = getS3Config();
const client = getS3Client();
const key = documentKey(id, namespace);
return getSignedUrl(
client,
new GetObjectCommand({
Bucket: cfg.bucket,
Key: key,
}),
{ expiresIn: Math.max(30, Math.min(options?.expiresInSeconds ?? 300, 3600)) },
);
}
export async function putDocumentBlob(
id: string,
body: Buffer,
contentType: string,
namespace: string | null,
options?: { ifNoneMatch?: boolean },
): Promise<void> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const key = documentKey(id, namespace);
await client.send(
new PutObjectCommand({
Bucket: cfg.bucket,
Key: key,
Body: body,
ContentType: contentType,
ServerSideEncryption: 'AES256',
...(options?.ifNoneMatch ? { IfNoneMatch: '*' } : {}),
}),
);
}
export async function putTempDocumentBlob(
token: string,
userId: string,
body: Buffer,
contentType: string,
namespace: string | null,
): Promise<void> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const key = tempDocumentUploadKey(token, userId, namespace);
await client.send(
new PutObjectCommand({
Bucket: cfg.bucket,
Key: key,
Body: body,
ContentType: contentType,
ServerSideEncryption: 'AES256',
}),
);
}
export async function copyTempDocumentBlobToDocument(
token: string,
userId: string,
documentId: string,
namespace: string | null,
contentType: string,
options?: { ifNoneMatch?: boolean },
): Promise<void> {
const cfg = getS3Config();
const client = getS3ProxyClient();
await client.send(
new CopyObjectCommand({
Bucket: cfg.bucket,
Key: documentKey(documentId, namespace),
CopySource: `${cfg.bucket}/${tempDocumentUploadKey(token, userId, namespace)}`,
ContentType: contentType,
MetadataDirective: 'REPLACE',
ServerSideEncryption: 'AES256',
...(options?.ifNoneMatch ? { IfNoneMatch: '*' } : {}),
}),
);
}
export async function deleteDocumentBlob(id: string, namespace: string | null): Promise<void> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const key = documentKey(id, namespace);
const parsedKey = documentParsedKey(id, namespace);
const legacyParsedKey = legacyDocumentParsedKey(id, namespace);
const ns = sanitizeNamespace(namespace);
const nsSegment = ns ? `ns/${ns}/` : '';
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 }));
// The source blob is already gone at this point. Treat the final sweep as a
// best-effort cleanup: if it throws, rethrowing would make callers roll back
// the document row even though the source is deleted, so log and swallow.
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> {
const cfg = getS3Config();
const client = getS3ProxyClient();
await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: tempDocumentUploadKey(token, userId, namespace) }));
}
export async function deleteTempDocumentFinalizeReceipt(token: string, userId: string, namespace: string | null): Promise<void> {
const cfg = getS3Config();
const client = getS3ProxyClient();
await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: tempDocumentUploadReceiptKey(token, userId, namespace) }));
}
export function isMissingBlobError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const maybe = error as { name?: string; Code?: string; $metadata?: { httpStatusCode?: number } };
if (maybe.$metadata?.httpStatusCode === 404) return true;
if (maybe.name === 'NotFound' || maybe.name === 'NoSuchKey') return true;
if (maybe.Code === 'NotFound' || maybe.Code === 'NoSuchKey') return true;
return false;
}
export async function deleteDocumentPrefix(prefix: string): Promise<number> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const cleanedPrefix = prefix.replace(/^\/+/, '');
let deleted = 0;
let continuationToken: string | undefined;
do {
const listRes = await client.send(
new ListObjectsV2Command({
Bucket: cfg.bucket,
Prefix: cleanedPrefix,
ContinuationToken: continuationToken,
}),
);
const keys = (listRes.Contents ?? [])
.map((item) => item.Key)
.filter((value): value is string => typeof value === 'string' && value.length > 0);
if (keys.length > 0) {
const deleteRes = await client.send(
new DeleteObjectsCommand({
Bucket: cfg.bucket,
Delete: {
Objects: keys.map((Key) => ({ Key })),
Quiet: true,
},
}),
);
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;
} while (continuationToken);
return deleted;
}
/**
* 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,
olderThanMs: number,
options?: { signal?: AbortSignal },
): Promise<number> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const ns = sanitizeNamespace(namespace);
const nsSegment = ns ? `ns/${ns}/` : '';
const prefix = `${cfg.prefix}/document_uploads_temp_v1/${nsSegment}`;
let continuationToken: string | undefined;
let deleted = 0;
do {
options?.signal?.throwIfAborted();
const listRes = await client.send(
new ListObjectsV2Command({
Bucket: cfg.bucket,
Prefix: prefix,
ContinuationToken: continuationToken,
}),
{ abortSignal: options?.signal },
);
const batch: string[] = [];
for (const item of listRes.Contents ?? []) {
const key = item.Key;
const lastModified = item.LastModified?.getTime() ?? 0;
if (!key || lastModified <= 0 || lastModified >= olderThanMs) continue;
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;
} while (continuationToken);
return deleted;
}