v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205)

This commit is contained in:
Amruth Pillai
2026-07-04 14:57:25 +02:00
committed by GitHub
parent 09bc6ec521
commit 57e9c8c487
181 changed files with 46794 additions and 11348 deletions
+13 -3
View File
@@ -1,4 +1,4 @@
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { createSampleResumeData } from "@reactive-resume/schema/resume/sample";
import { generateRandomName, slugify } from "@reactive-resume/utils/string";
import { protectedProcedure } from "../../context";
import { resumeDto } from "../../dto/resume";
@@ -71,7 +71,7 @@ export const crudRouter = {
tags: input.tags,
locale: context.locale,
userId: context.user.id,
...(input.withSampleData ? { data: sampleResumeData } : {}),
...(input.withSampleData ? { data: createSampleResumeData(input.name) } : {}),
});
}),
@@ -99,7 +99,7 @@ export const crudRouter = {
const name = generateRandomName();
const slug = slugify(name);
return resumeService.create({
const id = await resumeService.create({
name,
slug,
tags: [],
@@ -107,6 +107,16 @@ export const crudRouter = {
locale: context.locale,
userId: context.user.id,
});
// Milestone checkpoint for the imported document (best-effort).
await resumeService.versions.snapshot({
resumeId: id,
userId: context.user.id,
data: input.data,
label: "Imported",
});
return id;
}),
update: protectedProcedure
@@ -4,6 +4,7 @@ import { updatesRouter } from "./event-router";
import { sharingRouter } from "./sharing";
import { resumeStatisticsRouter } from "./statistics";
import { tagsRouter } from "./tags";
import { versionsRouter } from "./versions";
export const resumeRouter = {
tags: tagsRouter,
@@ -24,4 +25,6 @@ export const resumeRouter = {
removePassword: sharingRouter.removePassword,
duplicate: crudRouter.duplicate,
delete: crudRouter.delete,
listVersions: versionsRouter.listVersions,
restoreVersion: versionsRouter.restoreVersion,
};
+217 -19
View File
@@ -5,7 +5,7 @@ import type { Locale } from "@reactive-resume/utils/locale";
import type { ResumeUpdatedEvent } from "./events";
import { ORPCError } from "@orpc/client";
import { compare, hash } from "bcrypt";
import { and, arrayContains, asc, desc, eq, isNotNull, sql } from "drizzle-orm";
import { and, arrayContains, asc, desc, eq, gte, isNotNull, notInArray, sql } from "drizzle-orm";
import { get } from "es-toolkit/compat";
import { match } from "ts-pattern";
import { db } from "@reactive-resume/db/client";
@@ -28,9 +28,63 @@ function resumeVersionConflict(updatedAt: Date) {
});
}
// Version history: keep a bounded, rolling window of snapshots per resume.
const MAX_VERSIONS_PER_RESUME = 30;
// Manual-save milestones are debounced server-side: an autosave only checkpoints if the newest
// snapshot is older than this. Explicit milestones (import, AI edit, restore) always checkpoint.
const SNAPSHOT_THROTTLE_MS = 2 * 60 * 1000;
async function writeResumeVersion(
client: DbOrTx,
input: { resumeId: string; userId: string; data: ResumeData; label: string },
) {
await client.insert(schema.resumeVersion).values({
resumeId: input.resumeId,
userId: input.userId,
data: input.data,
label: input.label,
});
// Prune everything beyond the newest N snapshots for this resume.
const keep = client
.select({ id: schema.resumeVersion.id })
.from(schema.resumeVersion)
.where(eq(schema.resumeVersion.resumeId, input.resumeId))
.orderBy(desc(schema.resumeVersion.createdAt))
.limit(MAX_VERSIONS_PER_RESUME);
await client
.delete(schema.resumeVersion)
.where(and(eq(schema.resumeVersion.resumeId, input.resumeId), notInArray(schema.resumeVersion.id, keep)));
}
// Best-effort, throttled snapshot on the autosave/manual-save path. Never blocks or fails the save.
async function maybeSnapshotOnSave(input: { resumeId: string; userId: string; data: ResumeData; label: string }) {
try {
const [latest] = await db
.select({ createdAt: schema.resumeVersion.createdAt })
.from(schema.resumeVersion)
.where(eq(schema.resumeVersion.resumeId, input.resumeId))
.orderBy(desc(schema.resumeVersion.createdAt))
.limit(1);
if (latest && Date.now() - latest.createdAt.getTime() < SNAPSHOT_THROTTLE_MS) return;
await writeResumeVersion(db, input);
} catch (error) {
console.warn("Failed to snapshot resume version:", error);
}
}
async function applyResumePatchTx(
client: DbOrTx,
input: { id: string; userId: string; operations: JsonPatchOperation[]; expectedUpdatedAt?: Date },
input: {
id: string;
userId: string;
operations: JsonPatchOperation[];
expectedUpdatedAt?: Date;
versionLabel?: string;
},
) {
const [existing] = await client
.select({ data: schema.resume.data, isLocked: schema.resume.isLocked, updatedAt: schema.resume.updatedAt })
@@ -91,6 +145,15 @@ async function applyResumePatchTx(
throw new ORPCError("NOT_FOUND");
}
// Checkpoint every patch (AI/API edit) atomically within the same transaction as the edit.
// ponytail: a multi-patch agent turn writes one row per patch; the prune cap (30) bounds it.
await writeResumeVersion(client, {
resumeId: resume.id,
userId: input.userId,
data: resume.data,
label: input.versionLabel ?? "AI edit",
});
return resume;
}
@@ -138,25 +201,74 @@ const statistics = {
const downloads = input.downloads ? 1 : 0;
const lastViewedAt = input.views ? sql`now()` : undefined;
const lastDownloadedAt = input.downloads ? sql`now()` : undefined;
const today = new Date().toISOString().slice(0, 10);
await db
.insert(schema.resumeStatistics)
.values({
resumeId: input.id,
views,
downloads,
lastViewedAt,
lastDownloadedAt,
})
.onConflictDoUpdate({
target: [schema.resumeStatistics.resumeId],
set: {
views: sql`${schema.resumeStatistics.views} + ${views}`,
downloads: sql`${schema.resumeStatistics.downloads} + ${downloads}`,
await db.transaction(async (tx) => {
await tx
.insert(schema.resumeStatistics)
.values({
resumeId: input.id,
views,
downloads,
lastViewedAt,
lastDownloadedAt,
},
});
})
.onConflictDoUpdate({
target: [schema.resumeStatistics.resumeId],
set: {
views: sql`${schema.resumeStatistics.views} + ${views}`,
downloads: sql`${schema.resumeStatistics.downloads} + ${downloads}`,
lastViewedAt,
lastDownloadedAt,
},
});
await tx
.insert(schema.resumeStatisticsDaily)
.values({ resumeId: input.id, date: today, views, downloads })
.onConflictDoUpdate({
target: [schema.resumeStatisticsDaily.resumeId, schema.resumeStatisticsDaily.date],
set: {
views: sql`${schema.resumeStatisticsDaily.views} + ${views}`,
downloads: sql`${schema.resumeStatisticsDaily.downloads} + ${downloads}`,
},
});
});
},
// Returns the last `days` (default 30) of daily view/download counts, zero-filled so the series is continuous.
getDailySeries: async (input: { id: string; userId: string; days?: number }) => {
const days = input.days ?? 30;
const [resume] = await db
.select({ id: schema.resume.id })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!resume) throw new ORPCError("NOT_FOUND");
const now = new Date();
const utcDay = (offset: number) =>
new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - offset)).toISOString().slice(0, 10);
const start = utcDay(days - 1);
const dates = Array.from({ length: days }, (_, i) => utcDay(days - 1 - i));
const rows = await db
.select({
date: schema.resumeStatisticsDaily.date,
views: schema.resumeStatisticsDaily.views,
downloads: schema.resumeStatisticsDaily.downloads,
})
.from(schema.resumeStatisticsDaily)
.where(and(eq(schema.resumeStatisticsDaily.resumeId, input.id), gte(schema.resumeStatisticsDaily.date, start)));
const byDate = new Map(rows.map((row) => [row.date, row]));
return dates.map((date) => ({
date,
views: byDate.get(date)?.views ?? 0,
downloads: byDate.get(date)?.downloads ?? 0,
}));
},
};
@@ -235,6 +347,80 @@ export const resumeService = {
statistics,
analysis,
versions: {
list: async (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");
return db
.select({
id: schema.resumeVersion.id,
label: schema.resumeVersion.label,
createdAt: schema.resumeVersion.createdAt,
})
.from(schema.resumeVersion)
.where(eq(schema.resumeVersion.resumeId, input.resumeId))
.orderBy(desc(schema.resumeVersion.createdAt))
.limit(MAX_VERSIONS_PER_RESUME);
},
// Best-effort checkpoint used by non-transactional milestones (e.g. import).
snapshot: async (input: { resumeId: string; userId: string; data: ResumeData; label: string }) => {
try {
await writeResumeVersion(db, input);
} catch (error) {
console.warn("Failed to snapshot resume version:", error);
}
},
// Non-destructive restore: writes the snapshot's data back through the normal update path, so
// prior versions remain and the restore is itself just another (snapshot-able, undoable) change.
restore: async (input: { resumeId: string; versionId: string; userId: string }) => {
const [version] = await db
.select({ data: schema.resumeVersion.data })
.from(schema.resumeVersion)
.innerJoin(schema.resume, eq(schema.resumeVersion.resumeId, schema.resume.id))
.where(
and(
eq(schema.resumeVersion.id, input.versionId),
eq(schema.resumeVersion.resumeId, input.resumeId),
eq(schema.resume.userId, input.userId),
),
);
if (!version) throw new ORPCError("NOT_FOUND");
// Capture the pre-restore state first so the restore itself is undoable.
const current = await resumeService.getById({ id: input.resumeId, userId: input.userId });
await resumeService.versions.snapshot({
resumeId: input.resumeId,
userId: input.userId,
data: current.data,
label: "Before restore",
});
const updated = await resumeService.update({
id: input.resumeId,
userId: input.userId,
data: version.data,
skipAutoSnapshot: true,
});
await resumeService.versions.snapshot({
resumeId: input.resumeId,
userId: input.userId,
data: version.data,
label: "Restored version",
});
return updated;
},
},
list: async (input: { userId: string; tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) => {
return await db
.select({
@@ -374,6 +560,7 @@ export const resumeService = {
tags?: string[];
data?: ResumeData;
isPublic?: boolean;
skipAutoSnapshot?: boolean;
}) => {
const [resume] = await db
.select({ isLocked: schema.resume.isLocked })
@@ -415,6 +602,17 @@ export const resumeService = {
if (!resume) throw new ORPCError("NOT_FOUND");
// Debounced manual-save milestone: only snapshots data edits, and only when the previous
// snapshot is old enough (see SNAPSHOT_THROTTLE_MS). Covers template switches and typing.
if (input.data !== undefined && !input.skipAutoSnapshot) {
await maybeSnapshotOnSave({
resumeId: resume.id,
userId: input.userId,
data: resume.data,
label: "Manual save",
});
}
await notifyResumeUpdated({
type: "resume.updated",
resumeId: resume.id,
@@ -437,7 +635,7 @@ export const resumeService = {
},
patch: async (input: { id: string; userId: string; operations: JsonPatchOperation[]; expectedUpdatedAt?: Date }) => {
const resume = await applyResumePatchTx(db, input);
const resume = await db.transaction((tx) => applyResumePatchTx(tx, input));
await notifyResumeUpdated({
type: "resume.updated",
@@ -27,4 +27,34 @@ export const resumeStatisticsRouter = {
.handler(async ({ context, input }) => {
return resumeService.statistics.getById({ id: input.id, userId: context.user.id });
}),
getDailyById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/statistics/daily",
tags: ["Resume Statistics"],
operationId: "getResumeDailyStatistics",
summary: "Get resume daily statistics",
description:
"Returns a continuous, zero-filled per-day series of view and download counts for the specified resume over the last `days` days (UTC). Requires authentication and resume ownership.",
successDescription: "The resume's daily view and download statistics.",
})
.input(
z.object({
id: z.string().describe("The unique identifier of the resume."),
days: z.number().int().min(1).max(365).default(30).describe("Number of trailing days to include."),
}),
)
.output(
z.array(
z.object({
date: z.string().describe("The UTC day in YYYY-MM-DD format."),
views: z.number().describe("Number of views recorded on this day."),
downloads: z.number().describe("Number of downloads recorded on this day."),
}),
),
)
.handler(async ({ context, input }) => {
return resumeService.statistics.getDailySeries({ id: input.id, userId: context.user.id, days: input.days });
}),
};
@@ -0,0 +1,45 @@
import { protectedProcedure } from "../../context";
import { resumeDto } from "../../dto/resume";
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
import { resumeService } from "./service";
export const versionsRouter = {
listVersions: protectedProcedure
.route({
method: "GET",
path: "/resumes/{resumeId}/versions",
tags: ["Resumes"],
operationId: "listResumeVersions",
summary: "List resume version history",
description:
"Returns the recent version-history snapshots for a resume (id, label, and timestamp only). Snapshots are taken at milestones such as imports, AI edits, and periodic manual saves. Only the resume owner can list versions. Requires authentication.",
successDescription: "A list of recent version snapshots, newest first.",
})
.input(resumeDto.listVersions.input)
.output(resumeDto.listVersions.output)
.handler(async ({ context, input }) => {
return resumeService.versions.list({ resumeId: input.resumeId, userId: context.user.id });
}),
restoreVersion: protectedProcedure
.route({
method: "POST",
path: "/resumes/{resumeId}/versions/{versionId}/restore",
tags: ["Resumes"],
operationId: "restoreResumeVersion",
summary: "Restore a resume version",
description:
"Non-destructively restores a resume to a previous version snapshot by writing that snapshot's data back through the normal update path. Prior versions are preserved and the restore itself becomes a new snapshot. Only the resume owner can restore versions. Requires authentication.",
successDescription: "The restored resume with its full data.",
})
.input(resumeDto.restoreVersion.input)
.use(resumeMutationRateLimit)
.output(resumeDto.restoreVersion.output)
.handler(async ({ context, input }) => {
return resumeService.versions.restore({
resumeId: input.resumeId,
versionId: input.versionId,
userId: context.user.id,
});
}),
};