Files
dataroom/lib/kv-download-job-store.ts
zeekayandHanzo Dev 6b68c9299e kv: one name for the store, and it is Hanzo KV
The client was already @hanzo/kv; only the vocabulary was still Redis. Every
module, symbol and comment now says what it connects to:

  lib/redis.ts                    -> lib/kv.ts                (exports kv, lockKv)
  lib/redis-job-store.ts          -> lib/kv-job-store.ts      (KvJobStore)
  lib/redis-download-job-store.ts -> lib/kv-download-job-store.ts
  lib/files/tus-redis-locker.ts   -> lib/files/tus-kv-locker.ts (KvLocker, KvLock)
  lib/api/domains/redis.ts        -> lib/api/domains/kv.ts    (getKey)
  lib/redis/                      -> lib/kv-queue/

KV_URL is now the only endpoint variable — the REDIS_URL fallback and the
redis://localhost default are gone, and the default scheme is kv://, which
@hanzo/kv parses natively (utils/index.js). .env.example gained the KV_URL it
never documented, and lost UPSTASH_REDIS_REST_LOCKER_URL/TOKEN: nothing has read
those since the client changed — the locker opens a second connection to the
same KV, so there is no second endpoint to configure.

The second connection is `lockKv`, not `locker`: both tus routes already have a
local `const locker = new KvLocker(...)`, so naming the client `locker` made the
lock take itself as its own client.

Verified against the repo's own gate (npx tsc --noEmit): 100 errors before and
after, 0 introduced, compared set-wise by file:line:code. Behaviour is untouched
— this is naming, one connection string, and dead config.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-09 10:09:34 -07:00

207 lines
5.6 KiB
TypeScript

import { nanoid } from "@/lib/utils";
import { kv } from "./kv";
export type DownloadJobStatus =
| "PENDING"
| "PROCESSING"
| "COMPLETED"
| "FAILED";
export interface DownloadJob {
id: string;
type: "bulk" | "folder";
status: DownloadJobStatus;
dataroomId: string;
dataroomName: string;
totalFiles: number;
processedFiles: number;
progress: number; // 0-100
downloadUrls?: string[]; // Multiple ZIPs if large (S3 presigned URLs auto-expire)
downloadS3Keys?: { bucket: string; key: string; region: string }[]; // S3 object references for on-demand presigning
error?: string;
teamId: string;
userId: string;
/** Viewer download: link this job belongs to */
linkId?: string;
/** Viewer download: viewer record id */
viewerId?: string;
/** Viewer download: email used for job list and email notification */
viewerEmail?: string;
triggerRunId?: string; // Trigger.dev run ID for cancellation
createdAt: string;
updatedAt: string;
completedAt?: string;
expiresAt?: string; // When download links expire
emailNotification?: boolean;
emailAddress?: string;
/** Folder download: name of the folder (when type === "folder") */
folderName?: string;
}
const JOB_PREFIX = "download_job:";
const TEAM_JOBS_PREFIX = "team_download_jobs:";
const VIEWER_JOBS_PREFIX = "viewer_download_jobs:";
const JOB_TTL = 60 * 60 * 24 * 3; // 3 days - KV handles cleanup via TTL
export class KvDownloadJobStore {
private getJobKey(jobId: string): string {
return `${JOB_PREFIX}${jobId}`;
}
private getTeamJobsKey(teamId: string): string {
return `${TEAM_JOBS_PREFIX}${teamId}`;
}
private getViewerJobsKey(linkId: string, viewerEmail: string): string {
return `${VIEWER_JOBS_PREFIX}${linkId}:${viewerEmail.toLowerCase()}`;
}
async createJob(
jobData: Omit<DownloadJob, "id" | "createdAt" | "updatedAt">,
): Promise<DownloadJob> {
const jobId = nanoid();
const now = new Date().toISOString();
const job: DownloadJob = {
...jobData,
id: jobId,
createdAt: now,
updatedAt: now,
};
const jobKey = this.getJobKey(jobId);
const teamJobsKey = this.getTeamJobsKey(jobData.teamId);
// Store job data with TTL (KV handles cleanup)
await kv.setex(jobKey, JOB_TTL, JSON.stringify(job));
// Add to team's job list (sorted by creation time)
await kv.zadd(teamJobsKey, { score: Date.now(), member: jobId });
await kv.expire(teamJobsKey, JOB_TTL);
// If viewer download, add to viewer index for getViewerJobs
if (jobData.linkId && jobData.viewerEmail) {
const viewerJobsKey = this.getViewerJobsKey(
jobData.linkId,
jobData.viewerEmail,
);
await kv.zadd(viewerJobsKey, { score: Date.now(), member: jobId });
await kv.expire(viewerJobsKey, JOB_TTL);
}
return job;
}
async getJob(jobId: string): Promise<DownloadJob | null> {
const jobKey = this.getJobKey(jobId);
const jobData = await kv.get(jobKey);
if (!jobData) {
return null;
}
try {
// Check if data is already an object (KV client auto-parsed)
if (typeof jobData === "object") {
return jobData as DownloadJob;
}
// Otherwise parse the JSON string
return JSON.parse(jobData as string);
} catch (error) {
console.error("Error parsing download job data:", error);
return null;
}
}
async updateJob(
jobId: string,
updates: Partial<DownloadJob>,
): Promise<DownloadJob | null> {
const job = await this.getJob(jobId);
if (!job) {
return null;
}
const updatedJob: DownloadJob = {
...job,
...updates,
updatedAt: new Date().toISOString(),
};
const jobKey = this.getJobKey(jobId);
await kv.setex(jobKey, JOB_TTL, JSON.stringify(updatedJob));
return updatedJob;
}
private async getTeamJobs(
teamId: string,
limit: number = 20,
): Promise<DownloadJob[]> {
const teamJobsKey = this.getTeamJobsKey(teamId);
// Get job IDs sorted by creation time (newest first)
const jobIds = await kv.zrange(teamJobsKey, 0, limit - 1, { rev: true });
if (!jobIds.length) {
return [];
}
// Get all job data
const jobs = await Promise.all(
(jobIds as string[]).map((jobId: string) => this.getJob(jobId)),
);
// Filter out null values (expired jobs)
return jobs.filter(
(job: DownloadJob | null): job is DownloadJob => job !== null,
);
}
async getDataroomJobs(
dataroomId: string,
teamId: string,
limit: number = 10,
): Promise<DownloadJob[]> {
const teamJobs = await this.getTeamJobs(teamId, limit * 2); // Get more to filter
// Filter jobs by dataroom
return teamJobs
.filter((job) => {
const matchesDataroom = job.dataroomId === dataroomId;
const matchStatus = job.status !== "FAILED";
return matchesDataroom && matchStatus;
})
.slice(0, limit);
}
/**
* List download jobs for a viewer (link + email). Used on viewer downloads page.
*/
async getViewerJobs(
linkId: string,
viewerEmail: string,
limit: number = 20,
): Promise<DownloadJob[]> {
const viewerJobsKey = this.getViewerJobsKey(linkId, viewerEmail);
const jobIds = await kv.zrange(viewerJobsKey, 0, limit - 1, {
rev: true,
});
if (!jobIds.length) {
return [];
}
const jobs = await Promise.all(
(jobIds as string[]).map((jobId: string) => this.getJob(jobId)),
);
return jobs.filter(
(job: DownloadJob | null): job is DownloadJob => job !== null,
);
}
}
export const downloadJobStore = new KvDownloadJobStore();