From 6e412c2f6bf4daeb55e7f44828a959a00a356d6d Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 10:44:03 +0200 Subject: [PATCH] refactor(api): share one version history between resumes and letters --- .../src/features/cover-letters/versions.ts | 199 +++++------------ .../src/features/documents/version-history.ts | 179 +++++++++++++++ .../src/features/resume/version-history.ts | 203 +++--------------- 3 files changed, 266 insertions(+), 315 deletions(-) create mode 100644 packages/api/src/features/documents/version-history.ts diff --git a/packages/api/src/features/cover-letters/versions.ts b/packages/api/src/features/cover-letters/versions.ts index fa90dabcd..b4c81e8ac 100644 --- a/packages/api/src/features/cover-letters/versions.ts +++ b/packages/api/src/features/cover-letters/versions.ts @@ -1,25 +1,8 @@ import type { DbOrTx } from "@reactive-resume/db/client"; import type { CoverLetterVersionData, CoverLetterVersionKind } from "@reactive-resume/db/schema"; import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data"; -import { ORPCError } from "@orpc/client"; -import { and, desc, eq, inArray, lt, notInArray } from "drizzle-orm"; -import { db } from "@reactive-resume/db/client"; import * as schema from "@reactive-resume/db/schema"; - -// The same retention as resumes (see resume/version-history.ts): sessions refresh their autosave at most every two -// minutes; autosaves and restore markers last 90 days; at most 500 autosaves per letter. -const SESSION_REFRESH_MS = 2 * 60 * 1000; -const RETENTION_MS = 90 * 24 * 60 * 60 * 1000; -const EXPIRING_KINDS: CoverLetterVersionKind[] = ["auto", "restored"]; -const MAX_AUTOSAVES = 500; -const LIST_LIMIT = 100; - -const summary = { - id: schema.coverLetterVersion.id, - kind: schema.coverLetterVersion.kind, - name: schema.coverLetterVersion.name, - createdAt: schema.coverLetterVersion.createdAt, -}; +import { createVersionHistory } from "../documents/version-history"; /** The parts of a letter a version keeps. */ const letterVersionData = (letter: CoverLetter): CoverLetterVersionData => ({ @@ -33,139 +16,63 @@ const letterVersionData = (letter: CoverLetter): CoverLetterVersionData => ({ letterDate: letter.letterDate, }); -type VersionInput = { - letter: CoverLetter; - userId: string; - kind: CoverLetterVersionKind; - name?: string | null; - sessionId?: string; -}; - -export async function writeLetterVersion(client: DbOrTx, input: VersionInput) { - const [version] = await client - .insert(schema.coverLetterVersion) - .values({ - coverLetterId: input.letter.id, - userId: input.userId, - data: letterVersionData(input.letter), - kind: input.kind, - name: input.name ?? null, - sessionId: input.sessionId ?? null, - }) - .returning(summary); - if (!version) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to save the version." }); - - await pruneLetterVersions(client, input.letter.id); - return version; -} - -async function pruneLetterVersions(client: DbOrTx, coverLetterId: string) { - const table = schema.coverLetterVersion; - await client - .delete(table) - .where( - and( - eq(table.coverLetterId, coverLetterId), - inArray(table.kind, EXPIRING_KINDS), - lt(table.createdAt, new Date(Date.now() - RETENTION_MS)), - ), - ); - - const newestAutosaves = client - .select({ id: table.id }) - .from(table) - .where(and(eq(table.coverLetterId, coverLetterId), eq(table.kind, "auto"))) - .orderBy(desc(table.createdAt)) - .limit(MAX_AUTOSAVES); - - await client - .delete(table) - .where(and(eq(table.coverLetterId, coverLetterId), eq(table.kind, "auto"), notInArray(table.id, newestAutosaves))); -} - -/** The autosave path: one version per editing session, refreshed at most every two minutes. Never fails the save. */ -export async function saveLetterSessionVersion(input: { letter: CoverLetter; userId: string; sessionId?: string }) { - const table = schema.coverLetterVersion; - try { - const [latest] = await db - .select({ id: table.id, createdAt: table.createdAt }) - .from(table) - .where( - and( - eq(table.coverLetterId, input.letter.id), - ...(input.sessionId ? [eq(table.kind, "auto"), eq(table.sessionId, input.sessionId)] : []), - ), - ) - .orderBy(desc(table.createdAt)) - .limit(1); - - if (latest && Date.now() - latest.createdAt.getTime() < SESSION_REFRESH_MS) return; - - if (latest && input.sessionId) { - await db - .update(table) - .set({ data: letterVersionData(input.letter), createdAt: new Date() }) - .where(eq(table.id, latest.id)); - return; - } - - await writeLetterVersion(db, { ...input, kind: "auto" }); - } catch (error) { - console.warn("Failed to save the letter's session version:", error); - } -} +const t = schema.coverLetterVersion; +const history = createVersionHistory({ + versions: { ...t, table: t, document: t.coverLetterId, documentKey: "coverLetterId" }, + owner: { table: schema.coverLetter, id: schema.coverLetter.id, userId: schema.coverLetter.userId }, + expiringKinds: ["auto", "restored"], + toStored: letterVersionData, + label: "letter", +}); type Owned = { coverLetterId: string; userId: string }; -const ownedVersion = (input: Owned & { versionId: string }) => - and( - eq(schema.coverLetterVersion.id, input.versionId), - eq(schema.coverLetterVersion.coverLetterId, input.coverLetterId), - eq(schema.coverLetterVersion.userId, input.userId), - ); +export const writeLetterVersion = ( + client: DbOrTx, + input: { + letter: CoverLetter; + userId: string; + kind: CoverLetterVersionKind; + name?: string | null; + sessionId?: string; + }, +) => + history.write(client, { + documentId: input.letter.id, + userId: input.userId, + data: input.letter, + kind: input.kind, + name: input.name, + sessionId: input.sessionId, + }); -async function assertOwnsLetter(input: Owned) { - const [owner] = await db - .select({ id: schema.coverLetter.id }) - .from(schema.coverLetter) - .where(and(eq(schema.coverLetter.id, input.coverLetterId), eq(schema.coverLetter.userId, input.userId))); - if (!owner) throw new ORPCError("NOT_FOUND"); -} +export const saveLetterSessionVersion = (input: { letter: CoverLetter; userId: string; sessionId?: string }) => + history.saveSession({ + documentId: input.letter.id, + userId: input.userId, + data: input.letter, + sessionId: input.sessionId, + }); -export async function listLetterVersions(input: Owned) { - await assertOwnsLetter(input); - return db - .select(summary) - .from(schema.coverLetterVersion) - .where(eq(schema.coverLetterVersion.coverLetterId, input.coverLetterId)) - .orderBy(desc(schema.coverLetterVersion.createdAt)) - .limit(LIST_LIMIT); -} +export const listLetterVersions = (input: Owned) => + history.list({ documentId: input.coverLetterId, userId: input.userId }); -export async function getLetterVersion(input: Owned & { versionId: string }) { - const [version] = await db - .select({ ...summary, data: schema.coverLetterVersion.data }) - .from(schema.coverLetterVersion) - .where(ownedVersion(input)); - if (!version) throw new ORPCError("NOT_FOUND"); - return version; -} +export const getLetterVersion = async (input: Owned & { versionId: string }) => { + const version = await history.get({ + documentId: input.coverLetterId, + userId: input.userId, + versionId: input.versionId, + }); + return { ...version, data: version.data as CoverLetterVersionData }; +}; -/** Named versions are the user's own; only they can be renamed or deleted. */ -export async function renameLetterVersion(input: Owned & { versionId: string; name: string }) { - const [version] = await db - .update(schema.coverLetterVersion) - .set({ name: input.name }) - .where(and(ownedVersion(input), eq(schema.coverLetterVersion.kind, "named"))) - .returning(summary); - if (!version) throw new ORPCError("NOT_FOUND"); - return version; -} +export const renameLetterVersion = (input: Owned & { versionId: string; name: string }) => + history.rename({ + documentId: input.coverLetterId, + userId: input.userId, + versionId: input.versionId, + name: input.name, + }); -export async function deleteLetterVersion(input: Owned & { versionId: string }) { - const [version] = await db - .delete(schema.coverLetterVersion) - .where(and(ownedVersion(input), eq(schema.coverLetterVersion.kind, "named"))) - .returning({ id: schema.coverLetterVersion.id }); - if (!version) throw new ORPCError("NOT_FOUND"); -} +export const deleteLetterVersion = (input: Owned & { versionId: string }) => + history.remove({ documentId: input.coverLetterId, userId: input.userId, versionId: input.versionId }); diff --git a/packages/api/src/features/documents/version-history.ts b/packages/api/src/features/documents/version-history.ts new file mode 100644 index 000000000..3e39d58c1 --- /dev/null +++ b/packages/api/src/features/documents/version-history.ts @@ -0,0 +1,179 @@ +import type { DbOrTx } from "@reactive-resume/db/client"; +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import { ORPCError } from "@orpc/client"; +import { and, desc, eq, inArray, lt, notInArray } from "drizzle-orm"; +import { db } from "@reactive-resume/db/client"; + +// An editing session's autosave is refreshed at most this often. +const SESSION_REFRESH_MS = 2 * 60 * 1000; +// Expiring kinds (autosaves, restore markers, AI edits) are kept this long; the rest stay until deleted. +const RETENTION_MS = 90 * 24 * 60 * 60 * 1000; +// A safety cap on autosaves per document, so storage stays bounded however often it's edited. +const MAX_AUTOSAVES = 500; +// History shows at most this many versions, newest first. +const LIST_LIMIT = 100; + +export type VersionSummary = { id: string; kind: TKind; name: string | null; createdAt: Date }; + +type VersionTable = { + table: PgTable; + id: PgColumn; + /** The document the version belongs to (`resumeId`, `coverLetterId`). */ + document: PgColumn; + documentKey: string; + userId: PgColumn; + data: PgColumn; + kind: PgColumn; + name: PgColumn; + sessionId: PgColumn; + createdAt: PgColumn; +}; + +type Owned = { documentId: string; userId: string }; + +/** + * History for one kind of document (resumes, letters): versions are written with a kind, autosaves collapse into one + * per editing session, expiring kinds age out after 90 days, and only named versions can be renamed or deleted. + * Retention runs whenever a version is written, so it needs no scheduler. + */ +export function createVersionHistory(config: { + versions: VersionTable; + /** The document table, for the ownership check before listing. */ + owner: { table: PgTable; id: PgColumn; userId: PgColumn }; + expiringKinds: readonly TKind[]; + /** Every write stores data through this (validation, normalisation). */ + toStored: (data: TData) => unknown; + label: string; +}) { + const v = config.versions; + const summary = { id: v.id, kind: v.kind, name: v.name, createdAt: v.createdAt }; + const ownedVersion = (input: Owned & { versionId: string }) => + and(eq(v.id, input.versionId), eq(v.document, input.documentId), eq(v.userId, input.userId)); + + async function prune(client: DbOrTx, documentId: string) { + await client + .delete(v.table) + .where( + and( + eq(v.document, documentId), + inArray(v.kind, [...config.expiringKinds]), + lt(v.createdAt, new Date(Date.now() - RETENTION_MS)), + ), + ); + + const newestAutosaves = client + .select({ id: v.id }) + .from(v.table) + .where(and(eq(v.document, documentId), eq(v.kind, "auto"))) + .orderBy(desc(v.createdAt)) + .limit(MAX_AUTOSAVES); + + await client + .delete(v.table) + .where(and(eq(v.document, documentId), eq(v.kind, "auto"), notInArray(v.id, newestAutosaves))); + } + + async function write( + client: DbOrTx, + input: Owned & { data: TData; kind: TKind; name?: string | null | undefined; sessionId?: string | undefined }, + ) { + const [version] = await client + .insert(v.table) + .values({ + [v.documentKey]: input.documentId, + userId: input.userId, + data: config.toStored(input.data), + kind: input.kind, + name: input.name ?? null, + sessionId: input.sessionId ?? null, + } as never) + .returning(summary); + if (!version) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to save the version." }); + + await prune(client, input.documentId); + return version as VersionSummary; + } + + return { + write, + + /** + * The autosave path. Each editing session keeps one version holding its latest state, refreshed at most every + * two minutes. Callers that send no session (API clients) get a new autosave once the newest version is two + * minutes old. Best effort: it never fails or delays the save beyond its own queries. + */ + async saveSession(input: Owned & { data: TData; sessionId?: string | undefined }) { + try { + const [latest] = await db + .select({ id: v.id, createdAt: v.createdAt }) + .from(v.table) + .where( + and( + eq(v.document, input.documentId), + ...(input.sessionId ? [eq(v.kind, "auto"), eq(v.sessionId, input.sessionId)] : []), + ), + ) + .orderBy(desc(v.createdAt)) + .limit(1); + + if (latest && Date.now() - (latest.createdAt as Date).getTime() < SESSION_REFRESH_MS) return; + + if (latest && input.sessionId) { + await db + .update(v.table) + .set({ data: config.toStored(input.data), createdAt: new Date() } as never) + .where(eq(v.id, latest.id)); + return; + } + + await write(db, { ...input, kind: "auto" as TKind }); + } catch (error) { + console.warn(`Failed to save the ${config.label}'s session version:`, error); + } + }, + + async list(input: Owned) { + const [owner] = await db + .select({ id: config.owner.id }) + .from(config.owner.table) + .where(and(eq(config.owner.id, input.documentId), eq(config.owner.userId, input.userId))); + if (!owner) throw new ORPCError("NOT_FOUND"); + + const versions = await db + .select(summary) + .from(v.table) + .where(eq(v.document, input.documentId)) + .orderBy(desc(v.createdAt)) + .limit(LIST_LIMIT); + return versions as VersionSummary[]; + }, + + async get(input: Owned & { versionId: string }) { + const [version] = await db + .select({ ...summary, data: v.data }) + .from(v.table) + .where(ownedVersion(input)); + if (!version) throw new ORPCError("NOT_FOUND"); + return version as VersionSummary & { data: unknown }; + }, + + /** Named versions are the user's own; only they can be renamed or deleted. */ + async rename(input: Owned & { versionId: string; name: string }) { + const [version] = await db + .update(v.table) + .set({ name: input.name } as never) + .where(and(ownedVersion(input), eq(v.kind, "named"))) + .returning(summary); + if (!version) throw new ORPCError("NOT_FOUND"); + return version as VersionSummary; + }, + + async remove(input: Owned & { versionId: string }) { + const [version] = await db + .delete(v.table) + .where(and(ownedVersion(input), eq(v.kind, "named"))) + .returning({ id: v.id }); + if (!version) throw new ORPCError("NOT_FOUND"); + }, + }; +} diff --git a/packages/api/src/features/resume/version-history.ts b/packages/api/src/features/resume/version-history.ts index 79adbc8c3..480955eb1 100644 --- a/packages/api/src/features/resume/version-history.ts +++ b/packages/api/src/features/resume/version-history.ts @@ -1,183 +1,48 @@ import type { DbOrTx } from "@reactive-resume/db/client"; import type { ResumeVersionKind } from "@reactive-resume/db/schema"; import type { ResumeData } from "@reactive-resume/schema/resume/data"; -import { ORPCError } from "@orpc/client"; -import { and, desc, eq, inArray, lt, notInArray } from "drizzle-orm"; -import { db } from "@reactive-resume/db/client"; import * as schema from "@reactive-resume/db/schema"; +import { createVersionHistory } from "../documents/version-history"; import { parseStoredResumeData, parseWritableResumeData } from "./resume-data-validation"; -// An editing session's autosave is refreshed at most this often. -const SESSION_REFRESH_MS = 2 * 60 * 1000; -// Autosaves, AI edits and restore markers are kept this long. Named, sent, created, imported and -// before-restore versions stay until deleted. -const RETENTION_MS = 90 * 24 * 60 * 60 * 1000; -const EXPIRING_KINDS: ResumeVersionKind[] = ["auto", "ai", "restored"]; -// A safety cap on autosaves per resume, so storage stays bounded however often it's edited. -const MAX_AUTOSAVES = 500; -// History shows at most this many versions, newest first. -const LIST_LIMIT = 100; +const t = schema.resumeVersion; +const history = createVersionHistory({ + versions: { ...t, table: t, document: t.resumeId, documentKey: "resumeId" }, + owner: { table: schema.resume, id: schema.resume.id, userId: schema.resume.userId }, + // Named, sent, created, imported and before-restore versions stay until deleted. + expiringKinds: ["auto", "ai", "restored"], + toStored: parseWritableResumeData, + label: "resume", +}); -const summary = { - id: schema.resumeVersion.id, - kind: schema.resumeVersion.kind, - name: schema.resumeVersion.name, - createdAt: schema.resumeVersion.createdAt, -}; +type Owned = { resumeId: string; userId: string }; +const ids = ({ resumeId, ...rest }: Owned) => ({ ...rest, documentId: resumeId }); -type VersionInput = { - resumeId: string; - userId: string; - data: ResumeData; - kind: ResumeVersionKind; - name?: string; - sessionId?: string; -}; +export const writeVersion = ( + client: DbOrTx, + input: Owned & { data: ResumeData; kind: ResumeVersionKind; name?: string; sessionId?: string }, +) => + history.write(client, { + documentId: input.resumeId, + userId: input.userId, + data: input.data, + kind: input.kind, + name: input.name, + sessionId: input.sessionId, + }); -export async function writeVersion(client: DbOrTx, input: VersionInput) { - const [version] = await client - .insert(schema.resumeVersion) - .values({ - resumeId: input.resumeId, - userId: input.userId, - data: parseWritableResumeData(input.data), - kind: input.kind, - name: input.name ?? null, - sessionId: input.sessionId ?? null, - }) - .returning(summary); - if (!version) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to save the version." }); +export const saveSessionVersion = (input: Owned & { data: ResumeData; sessionId?: string }) => + history.saveSession({ ...ids(input), data: input.data, sessionId: input.sessionId }); - await pruneVersions(client, input.resumeId); - return version; -} +export const listVersions = (input: Owned) => history.list(ids(input)); -/** Retention runs when a resume gets a new version, so it needs no scheduler. */ -async function pruneVersions(client: DbOrTx, resumeId: string) { - await client - .delete(schema.resumeVersion) - .where( - and( - eq(schema.resumeVersion.resumeId, resumeId), - inArray(schema.resumeVersion.kind, EXPIRING_KINDS), - lt(schema.resumeVersion.createdAt, new Date(Date.now() - RETENTION_MS)), - ), - ); - - const newestAutosaves = client - .select({ id: schema.resumeVersion.id }) - .from(schema.resumeVersion) - .where(and(eq(schema.resumeVersion.resumeId, resumeId), eq(schema.resumeVersion.kind, "auto"))) - .orderBy(desc(schema.resumeVersion.createdAt)) - .limit(MAX_AUTOSAVES); - - await client - .delete(schema.resumeVersion) - .where( - and( - eq(schema.resumeVersion.resumeId, resumeId), - eq(schema.resumeVersion.kind, "auto"), - notInArray(schema.resumeVersion.id, newestAutosaves), - ), - ); -} - -/** - * The autosave path. Each editing session keeps one version holding its latest state, refreshed at most every - * two minutes. Clients that send no session (API callers) get a new autosave once the newest version is two - * minutes old. Best effort: it never fails or delays the save beyond its own queries. - */ -export async function saveSessionVersion(input: { - resumeId: string; - userId: string; - data: ResumeData; - sessionId?: string; -}) { - try { - const [latest] = await db - .select({ id: schema.resumeVersion.id, createdAt: schema.resumeVersion.createdAt }) - .from(schema.resumeVersion) - .where( - and( - eq(schema.resumeVersion.resumeId, input.resumeId), - ...(input.sessionId - ? [eq(schema.resumeVersion.kind, "auto"), eq(schema.resumeVersion.sessionId, input.sessionId)] - : []), - ), - ) - .orderBy(desc(schema.resumeVersion.createdAt)) - .limit(1); - - if (latest && Date.now() - latest.createdAt.getTime() < SESSION_REFRESH_MS) return; - - if (latest && input.sessionId) { - await db - .update(schema.resumeVersion) - .set({ data: parseWritableResumeData(input.data), createdAt: new Date() }) - .where(eq(schema.resumeVersion.id, latest.id)); - return; - } - - await writeVersion(db, { ...input, kind: "auto" }); - } catch (error) { - console.warn("Failed to save the session's version:", error); - } -} - -const ownedVersion = (input: { resumeId: string; userId: string; versionId: string }) => - and( - eq(schema.resumeVersion.id, input.versionId), - eq(schema.resumeVersion.resumeId, input.resumeId), - eq(schema.resumeVersion.userId, input.userId), - ); - -async function assertOwnsResume(input: { resumeId: string; userId: string }) { - const [owner] = await db - .select({ id: schema.resume.id }) - .from(schema.resume) - .where(and(eq(schema.resume.id, input.resumeId), eq(schema.resume.userId, input.userId))); - - if (!owner) throw new ORPCError("NOT_FOUND"); -} - -export async function listVersions(input: { resumeId: string; userId: string }) { - await assertOwnsResume(input); - - return db - .select(summary) - .from(schema.resumeVersion) - .where(eq(schema.resumeVersion.resumeId, input.resumeId)) - .orderBy(desc(schema.resumeVersion.createdAt)) - .limit(LIST_LIMIT); -} - -export async function getVersion(input: { resumeId: string; userId: string; versionId: string }) { - const [version] = await db - .select({ ...summary, data: schema.resumeVersion.data }) - .from(schema.resumeVersion) - .where(ownedVersion(input)); - - if (!version) throw new ORPCError("NOT_FOUND"); +export const getVersion = async (input: Owned & { versionId: string }) => { + const version = await history.get({ ...ids(input), versionId: input.versionId }); return { ...version, data: parseStoredResumeData(version.data) }; -} +}; -/** Named versions are the user's own; only they can be renamed or deleted. */ -export async function renameVersion(input: { resumeId: string; userId: string; versionId: string; name: string }) { - const [version] = await db - .update(schema.resumeVersion) - .set({ name: input.name }) - .where(and(ownedVersion(input), eq(schema.resumeVersion.kind, "named"))) - .returning(summary); +export const renameVersion = (input: Owned & { versionId: string; name: string }) => + history.rename({ ...ids(input), versionId: input.versionId, name: input.name }); - if (!version) throw new ORPCError("NOT_FOUND"); - return version; -} - -export async function deleteVersion(input: { resumeId: string; userId: string; versionId: string }) { - const [version] = await db - .delete(schema.resumeVersion) - .where(and(ownedVersion(input), eq(schema.resumeVersion.kind, "named"))) - .returning({ id: schema.resumeVersion.id }); - - if (!version) throw new ORPCError("NOT_FOUND"); -} +export const deleteVersion = (input: Owned & { versionId: string }) => + history.remove({ ...ids(input), versionId: input.versionId });