refactor(api): share one version history between resumes and letters

This commit is contained in:
Amruth Pillai
2026-09-29 10:44:03 +02:00
parent 29647ec634
commit 6e412c2f6b
3 changed files with 266 additions and 315 deletions
@@ -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<CoverLetterVersionKind, CoverLetter>({
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 });
@@ -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<TKind extends string> = { 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<TKind extends string, TData>(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<TKind>;
}
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<TKind>[];
},
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<TKind> & { 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<TKind>;
},
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");
},
};
}
@@ -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<ResumeVersionKind, ResumeData>({
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 });