feat(schema): structured resume dates with a read-time upgrade and dual write

Dated entries and roles carry dates (start, end, present, and raw when the
text couldn't be read exactly). The parser moves to the schema package and
reports how each date was written; parseResumeData fills dates, infers the
date format from how dates were typed and rewrites the legacy period/date
text from them, so older clients and API readers keep working. API writes
sync against the stored data, so an edit to the text alone is read back
into dates.

getById and getBySlug return upgraded data, ATS date rules and sorting read
dates, JSON Resume and LinkedIn imports map their dates directly, entry
titles may be empty (drafts aren't printed), and the MCP schema resource is
generated live in place of the stale schema.json.
This commit is contained in:
Amruth Pillai
2026-09-28 18:20:18 +02:00
parent fa19b891db
commit bda01febd5
32 changed files with 2082 additions and 4615 deletions
@@ -2,6 +2,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { ORPCError } from "@orpc/client";
import { SEMANTIC_CSS_LIMITS_V1 } from "@reactive-resume/resume/stylesheet";
import { parseResumeData } from "@reactive-resume/schema/resume/data";
import { syncResumeDates, upgradeResumeDates } from "@reactive-resume/schema/resume/dates";
import { parseResumeDataForWrite } from "@reactive-resume/schema/resume/write";
function parseApiResumeData(data: unknown, code: "BAD_REQUEST" | "INTERNAL_SERVER_ERROR", message: string): ResumeData {
@@ -21,8 +22,17 @@ function parseApiResumeData(data: unknown, code: "BAD_REQUEST" | "INTERNAL_SERVE
}
}
export const parseWritableResumeData = (data: unknown) =>
parseApiResumeData(data, "BAD_REQUEST", "Resume data does not match the canonical schema.");
/**
* Validates data before it's saved, then keeps structured dates and their legacy text in step. `previous` is
* the stored data being replaced, which tells an edit to the text alone (from a client that doesn't know
* structured dates) from an edit to the dates.
*/
export const parseWritableResumeData = (data: unknown, previous?: ResumeData) => {
const parsed = parseApiResumeData(data, "BAD_REQUEST", "Resume data does not match the canonical schema.");
upgradeResumeDates(parsed);
syncResumeDates(parsed, previous);
return parsed;
};
export const parseStoredResumeData = (data: unknown) =>
parseApiResumeData(data, "INTERNAL_SERVER_ERROR", "Stored resume data does not match the canonical schema.");
+10 -5
View File
@@ -140,9 +140,10 @@ async function applyResumePatchTx(
input.operations.forEach(assertValidPatchPointers);
let patchedData: ResumeData;
const storedData = parseStoredResumeData(existing.data);
try {
patchedData = applyResumePatches(parseStoredResumeData(existing.data), input.operations);
patchedData = applyResumePatches(storedData, input.operations);
} catch (error) {
if (error instanceof ResumePatchError) {
throw new ORPCError("INVALID_PATCH_OPERATIONS", {
@@ -158,7 +159,7 @@ async function applyResumePatchTx(
});
}
patchedData = parseWritableResumeData(patchedData);
patchedData = parseWritableResumeData(patchedData, storedData);
// The version guard is the ms-precision JS check above, under the SELECT ... FOR UPDATE lock.
// Never compare expectedUpdatedAt in SQL: rows stamped by Postgres now() (defaultNow() on
// insert) carry microseconds, while JS Dates are ms-truncated — SQL equality then matches
@@ -509,7 +510,8 @@ export const resumeService = {
if (!resume) throw new ORPCError("NOT_FOUND");
return resume;
// Clients get the current data shape (structured dates and their text in step), not the stored one.
return { ...resume, data: parseStoredResumeData(resume.data) };
},
getBySlug: async (input: {
@@ -562,7 +564,8 @@ export const resumeService = {
}
}
return toSharedResumeResponse(redactResumeForViewer(resume, isOwner(resume, viewer)), resume.hasPassword);
const current = { ...resume, data: parseStoredResumeData(resume.data) };
return toSharedResumeResponse(redactResumeForViewer(current, isOwner(current, viewer)), resume.hasPassword);
},
create: async (input: {
@@ -633,7 +636,9 @@ export const resumeService = {
if (!existing) throw new ORPCError("NOT_FOUND");
if (existing.isLocked) throw new ORPCError("RESUME_LOCKED");
const normalizedData = input.data ? parseWritableResumeData(input.data) : undefined;
const normalizedData = input.data
? parseWritableResumeData(input.data, parseStoredResumeData(existing.data))
: undefined;
const updateData: Partial<typeof schema.resume.$inferSelect> = {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.slug !== undefined ? { slug: input.slug } : {}),