feat(server): convert stored legacy style rules once at startup instead of on every read

Stored resumes, resume versions, letters and letter versions still in
the old editor's legacy mode, or carrying legacy style rules with no
stylesheet, are converted to Semantic CSS once, right after the SQL
migrations, and the result is recorded in a new data_migration table so
later starts skip it. Only metadata.stylesheet is rewritten (the rules
stay for rollback), a row edited meanwhile is retried on the next start,
and a failure leaves the data as it was without stopping the server.

The API no longer converts on every read and save. Imports of old
Reactive Resume JSON exports convert their legacy rules on the way in.
This commit is contained in:
Amruth Pillai
2026-09-29 16:25:50 +02:00
parent a325232a09
commit c8aead4ff0
14 changed files with 7460 additions and 85 deletions
+10
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
import { migrateLegacyStyles } from "@reactive-resume/api/features/resume/legacy-styles-migration";
import { env } from "@reactive-resume/env/server";
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
import { verifyMigratedSchema } from "./schema-check";
@@ -47,6 +48,15 @@ export async function runDatabaseMigrations() {
throw error;
}
// Data migrations need app code, so they run here, once, after the SQL ones. A failure leaves the data as it
// was (legacy-styled resumes render unstyled until it's retried on the next start), so it doesn't stop startup.
try {
const summary = await migrateLegacyStyles(db);
if (summary) console.info("Legacy style rules converted to Semantic CSS", summary);
} catch (error) {
console.error("Converting legacy style rules failed; it will be retried on the next start", { error });
}
// Post-migration verification is not a migration failure, so it gets its own log
// message. A drifted schema still lets the server boot; STRICT_SCHEMA_CHECK=true
// makes the drift fatal instead.
@@ -75,6 +75,27 @@ describe("parseResumeJson", () => {
});
});
describe("parseResumeJson (legacy styles)", () => {
it("converts an old export's legacy style rules into its stylesheet", () => {
const data = structuredClone(sampleResumeData);
data.metadata.stylesheet = undefined;
data.metadata.styleRules = [
{
id: "r",
label: "Teal headings",
enabled: true,
target: { scope: "global" },
slots: { heading: { color: "#0f766e" } },
},
];
const imported = parseResumeJson(JSON.stringify(data), "reactive-resume-json");
expect(imported.metadata.stylesheet?.mode).toBe("semantic");
expect(imported.metadata.stylesheet?.source.text).toContain("color: #0f766e;");
});
});
describe("summarizeImport", () => {
it("counts sections with content, their entries and dates flagged for a look", () => {
const data = parseResumeData(structuredClone(sampleResumeData));
@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
import { parseJSONResume } from "@reactive-resume/import/json-resume";
import { parseReactiveResumeJSON } from "@reactive-resume/import/reactive-resume-json";
import { parseReactiveResumeV4JSON } from "@reactive-resume/import/reactive-resume-v4-json";
import { convertLegacyStylesheet, needsLegacyStyleConversion } from "@reactive-resume/pdf/semantic-legacy";
import { forEachDatedEntry } from "@reactive-resume/schema/resume/dates";
import { client } from "@/libs/orpc/client";
@@ -21,6 +22,12 @@ type ResumeJsonKind = "reactive-resume-json" | "reactive-resume-v4-json" | "json
/** An import failure worded for the person importing. */
export class ImportError extends Error {}
// Exports from before Semantic CSS carry legacy style rules, which nothing renders any more: they come in converted.
const withLegacyStylesConverted = (data: ResumeData): ResumeData =>
needsLegacyStyleConversion(data.metadata)
? { ...data, metadata: { ...data.metadata, stylesheet: convertLegacyStylesheet(data) } }
: data;
export function detectJsonImportKind(parsed: unknown): ImportKind | null {
if (!parsed || typeof parsed !== "object") return null;
const data = parsed as Record<string, unknown>;
@@ -82,7 +89,7 @@ export async function detectImportKind(file: File): Promise<ImportKind | null> {
}
export function parseResumeJson(text: string, kind: ResumeJsonKind): ResumeData {
if (kind === "reactive-resume-json") return parseReactiveResumeJSON(text);
if (kind === "reactive-resume-json") return withLegacyStylesConverted(parseReactiveResumeJSON(text));
if (kind === "reactive-resume-v4-json") return parseReactiveResumeV4JSON(text);
return parseJSONResume(text);
}
@@ -0,0 +1,4 @@
CREATE TABLE "data_migration" (
"name" text PRIMARY KEY,
"completed_at" timestamp with time zone DEFAULT now() NOT NULL
);
File diff suppressed because it is too large Load Diff
+1
View File
@@ -9,6 +9,7 @@
"./features/agent/streams": "./src/features/agent/streams.ts",
"./features/flags": "./src/features/flags/index.ts",
"./features/resume/export": "./src/features/resume/export.ts",
"./features/resume/legacy-styles-migration": "./src/features/resume/legacy-styles-migration.ts",
"./features/resume/public-pdf": "./src/features/resume/public-pdf.ts",
"./features/resume/social-meta": "./src/features/resume/social-meta.ts",
"./features/storage": "./src/features/storage/index.ts",
@@ -0,0 +1,92 @@
import type { SemanticStylesheet } from "@reactive-resume/schema/resume/stylesheet";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { sql } from "drizzle-orm";
import { migrateLetterStylesheet, migrateResumeStylesheet } from "./legacy-styles";
const MIGRATION = "2026-09-legacy-style-rules-to-semantic-css";
const BATCH = 200;
type Target = {
table: string;
column: string;
/** Where the `metadata` object sits inside the column (a letter version keeps it under `style`). */
owner: readonly string[];
convert: (owner: unknown) => SemanticStylesheet | null;
};
const TARGETS: readonly Target[] = [
{ table: "resume", column: "data", owner: [], convert: migrateResumeStylesheet },
{ table: "resume_version", column: "data", owner: [], convert: migrateResumeStylesheet },
{ table: "cover_letter", column: "style", owner: [], convert: migrateLetterStylesheet },
{ table: "cover_letter_version", column: "data", owner: ["style"], convert: migrateLetterStylesheet },
];
type Summary = Record<string, { migrated: number; skipped: number; failed: number }>;
const jsonPath = (...keys: string[]) => sql.raw(`'{${keys.join(",")}}'`);
/**
* Converts every stored legacy style (old editor rules, or a legacy-mode stylesheet) to Semantic CSS, once. It only
* rewrites `metadata.stylesheet` (the rules stay, for rollback), skips a row whose stylesheet changed since it was
* read (retrying it next startup), and records itself in `data_migration` once nothing is left so later startups
* skip it. A row whose data doesn't parse is left as it is and counted. Runs under the startup migration lock, so one server does it.
*/
export async function migrateLegacyStyles(db: NodePgDatabase): Promise<Summary | null> {
const done = await db.execute(sql`SELECT 1 FROM "data_migration" WHERE "name" = ${MIGRATION}`);
if (done.rows.length > 0) return null;
const summary: Summary = {};
for (const target of TARGETS) {
const counts = { migrated: 0, skipped: 0, failed: 0 };
summary[target.table] = counts;
const table = sql.identifier(target.table);
const column = sql.identifier(target.column);
const metadata = jsonPath(...target.owner, "metadata");
const stylesheetPath = jsonPath(...target.owner, "metadata", "stylesheet");
const needsMigration = sql`(
${column} #>> ${jsonPath(...target.owner, "metadata", "stylesheet", "mode")} = 'legacy'
OR (
jsonb_typeof(${column} #> ${stylesheetPath}) IS DISTINCT FROM 'object'
AND jsonb_typeof(${column} #> ${jsonPath(...target.owner, "metadata", "styleRules")}) = 'array'
AND jsonb_array_length(${column} #> ${jsonPath(...target.owner, "metadata", "styleRules")}) > 0
)
)`;
let after = "";
for (;;) {
const batch = await db.execute<{ id: string; owner: unknown; stylesheet: unknown }>(sql`
SELECT "id", ${column} #> ${jsonPath(...target.owner)} AS "owner", ${column} #> ${stylesheetPath} AS "stylesheet"
FROM ${table}
WHERE "id" > ${after} AND jsonb_typeof(${column} #> ${metadata}) = 'object' AND ${needsMigration}
ORDER BY "id"
LIMIT ${BATCH}
`);
if (batch.rows.length === 0) break;
for (const row of batch.rows) {
after = row.id;
let stylesheet: SemanticStylesheet | null;
try {
stylesheet = target.convert(row.owner);
} catch {
counts.failed++;
continue;
}
if (!stylesheet) continue;
const updated = await db.execute(sql`
UPDATE ${table}
SET ${column} = jsonb_set(${column}, ${stylesheetPath}, ${JSON.stringify(stylesheet)}::jsonb)
WHERE "id" = ${row.id}
AND ${column} #> ${stylesheetPath} IS NOT DISTINCT FROM ${row.stylesheet === null ? null : JSON.stringify(row.stylesheet)}::jsonb
`);
if (updated.rowCount) counts.migrated++;
else counts.skipped++;
}
}
}
// A row that changed while it was being migrated is picked up next startup; one whose data doesn't parse never will.
if (Object.values(summary).every(({ skipped }) => skipped === 0))
await db.execute(sql`INSERT INTO "data_migration" ("name") VALUES (${MIGRATION}) ON CONFLICT DO NOTHING`);
return summary;
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { needsLegacyStyleConversion } from "@reactive-resume/pdf/semantic-legacy";
import { copyCoverLetterStyle } from "@reactive-resume/resume/cover-letter";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { migrateLetterStylesheet, migrateResumeStylesheet } from "./legacy-styles";
const legacy = () => {
const data = structuredClone(sampleResumeData);
data.metadata.stylesheet = undefined;
data.metadata.styleRules = [
{
id: "rule-1",
label: "Teal headings",
enabled: true,
target: { scope: "global" },
slots: { heading: { color: "#0f766e" } },
},
];
return data;
};
describe("needsLegacyStyleConversion", () => {
it("picks legacy mode, and rules without a stylesheet; leaves semantic and plain resumes alone", () => {
expect(needsLegacyStyleConversion(legacy().metadata)).toBe(true);
expect(
needsLegacyStyleConversion({ stylesheet: { mode: "legacy", source: { languageVersion: 1, text: "" } } }),
).toBe(true);
expect(needsLegacyStyleConversion({ stylesheet: { mode: "semantic" }, styleRules: [{}] })).toBe(false);
expect(needsLegacyStyleConversion({ styleRules: [] })).toBe(false);
expect(needsLegacyStyleConversion(undefined)).toBe(false);
});
});
describe("migrateResumeStylesheet", () => {
it("converts legacy rules into a semantic stylesheet", () => {
const sheet = migrateResumeStylesheet(legacy());
expect(sheet?.mode).toBe("semantic");
expect(sheet?.source.text).toContain("color: #0f766e;");
expect(sheet?.source.text).not.toContain("@version");
});
it("keeps an unapplied draft from the old editor as a comment after the conversion", () => {
const data = legacy();
data.metadata.stylesheet = {
mode: "legacy",
source: { languageVersion: 1, text: "@version 1;\nname { color: red; } /* note */" },
};
const text = migrateResumeStylesheet(data)?.source.text ?? "";
expect(text.indexOf("color: #0f766e;")).toBeLessThan(text.indexOf("Unapplied draft"));
expect(text).toContain("name { color: red; } /* note *\\/");
});
it("returns null for a resume that needs nothing, and the result needs nothing more", () => {
expect(migrateResumeStylesheet(defaultResumeData)).toBeNull();
const data = legacy();
data.metadata.stylesheet = migrateResumeStylesheet(data) ?? undefined;
expect(migrateResumeStylesheet(data)).toBeNull();
});
});
describe("migrateLetterStylesheet", () => {
it("converts a letter's copy of a legacy-styled resume's style", () => {
const style = copyCoverLetterStyle(legacy());
expect(migrateLetterStylesheet(style)?.source.text).toContain("color: #0f766e;");
expect(migrateLetterStylesheet(copyCoverLetterStyle(defaultResumeData))).toBeNull();
});
});
@@ -0,0 +1,20 @@
import type { CoverLetterStyle } from "@reactive-resume/schema/cover-letter/data";
import type { SemanticStylesheet } from "@reactive-resume/schema/resume/stylesheet";
import { convertLegacyStylesheet, needsLegacyStyleConversion } from "@reactive-resume/pdf/semantic-legacy";
import { createCoverLetterResumeData } from "@reactive-resume/resume/cover-letter";
import { parseResumeData } from "@reactive-resume/schema/resume/data";
type Metadata = { stylesheet?: unknown; styleRules?: unknown };
/** A stored resume's new stylesheet, or null when it needs none. Throws when the stored data doesn't parse. */
export function migrateResumeStylesheet(data: unknown): SemanticStylesheet | null {
if (!needsLegacyStyleConversion((data as { metadata?: Metadata } | null)?.metadata)) return null;
return convertLegacyStylesheet(parseResumeData(data));
}
/** A letter's copy of its resume's style, converted as the letter document it styles. */
export function migrateLetterStylesheet(style: unknown): SemanticStylesheet | null {
if (!needsLegacyStyleConversion((style as { metadata?: Metadata } | null)?.metadata)) return null;
const data = createCoverLetterResumeData({ name: "", recipient: "", content: "", style: style as CoverLetterStyle });
return convertLegacyStylesheet(parseResumeData(data));
}
@@ -3,7 +3,7 @@ import { set } from "es-toolkit/compat";
import { SEMANTIC_CSS_LIMITS_V1 } from "@reactive-resume/resume/stylesheet";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { adoptLegacyStyles, parseStoredResumeData, parseWritableResumeData } from "./resume-data-validation";
import { parseStoredResumeData, parseWritableResumeData } from "./resume-data-validation";
describe("parseWritableResumeData", () => {
it("rejects stylesheet source above the Semantic CSS byte limit", () => {
@@ -63,58 +63,3 @@ describe("strict write bounds", () => {
});
});
});
describe("adoptLegacyStyles", () => {
const legacy = () => {
const data = structuredClone(sampleResumeData);
data.metadata.stylesheet = undefined;
data.metadata.styleRules = [
{
id: "rule-1",
label: "Teal headings",
enabled: true,
target: { scope: "global" },
slots: { heading: { color: "#0f766e" } },
},
];
return data;
};
it("converts legacy rules into a semantic stylesheet and keeps the rules", () => {
const adopted = adoptLegacyStyles(legacy());
expect(adopted.metadata.stylesheet?.mode).toBe("semantic");
expect(adopted.metadata.stylesheet?.source.text).toContain("color: #0f766e;");
expect(adopted.metadata.stylesheet?.source.text).not.toContain("@version");
expect(adopted.metadata.styleRules).toHaveLength(1);
});
it("keeps an unapplied draft from the old editor as a comment after the conversion", () => {
const data = legacy();
data.metadata.stylesheet = {
mode: "legacy",
source: { languageVersion: 1, text: "@version 1;\nname { color: red; } /* note */" },
};
const text = adoptLegacyStyles(data).metadata.stylesheet?.source.text ?? "";
expect(text.indexOf("color: #0f766e;")).toBeLessThan(text.indexOf("Unapplied draft"));
expect(text).toContain("name { color: red; } /* note *\\/");
});
it("leaves semantic stylesheets alone, and gives a resume with no rules an empty one", () => {
const semantic = structuredClone(sampleResumeData);
semantic.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 1, text: "name { color: red; }" } };
expect(adoptLegacyStyles(semantic)).toBe(semantic);
const plain = structuredClone(defaultResumeData);
plain.metadata.stylesheet = undefined;
expect(adoptLegacyStyles(plain).metadata.stylesheet).toEqual({
mode: "semantic",
source: { languageVersion: 1, text: "" },
});
});
it("runs on reads", () => {
expect(parseStoredResumeData(legacy()).metadata.stylesheet?.mode).toBe("semantic");
});
});
@@ -1,38 +1,13 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { ORPCError } from "@orpc/client";
import { convertLegacyStyleRules } from "@reactive-resume/pdf/semantic-legacy";
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";
/**
* Resumes saved before Semantic CSS styled themselves with legacy style rules (`metadata.styleRules`), which only the
* old renderer read. Every resume read or written through the API comes out with those rules converted to a Semantic
* CSS stylesheet, so the renderer has one styling system; the database catches up on the next save. The rules
* themselves are kept for rollback. A draft typed in the old editor before it was activated was never shown on the
* page, so the conversion (what the page showed) wins and the draft is kept below it, commented out.
*/
export function adoptLegacyStyles(data: ResumeData): ResumeData {
const stylesheet = data.metadata.stylesheet;
if (stylesheet?.mode === "semantic") return data;
const converted = convertLegacyStyleRules(data).source;
const draft = stylesheet?.source.text.replace(/^\s*@version\s+\d+\s*;\s*/, "").trim();
const withDraft =
draft && draft !== converted.text.trim()
? `${converted.text}${converted.text ? "\n" : ""}/* Unapplied draft from the old editor:\n${draft.replaceAll("*/", "*\\/")}\n*/\n`
: converted.text;
// Never let the kept draft push the stylesheet over its size limit (reads would fail).
const text =
new TextEncoder().encode(withDraft).byteLength > SEMANTIC_CSS_LIMITS_V1.maxSourceBytes ? converted.text : withDraft;
return { ...data, metadata: { ...data.metadata, stylesheet: { mode: "semantic", source: { ...converted, text } } } };
}
function parseApiResumeData(data: unknown, code: "BAD_REQUEST" | "INTERNAL_SERVER_ERROR", message: string): ResumeData {
try {
const parsed = adoptLegacyStyles(code === "BAD_REQUEST" ? parseResumeDataForWrite(data) : parseResumeData(data));
const parsed = code === "BAD_REQUEST" ? parseResumeDataForWrite(data) : parseResumeData(data);
const source = parsed.metadata.stylesheet?.source.text;
if (source !== undefined && new TextEncoder().encode(source).byteLength > SEMANTIC_CSS_LIMITS_V1.maxSourceBytes) {
throw new Error("The stylesheet source exceeds the Semantic CSS byte limit.");
+10
View File
@@ -0,0 +1,10 @@
import * as pg from "drizzle-orm/pg-core";
/**
* Data migrations that run in code at startup, after the SQL migrations (converting stored data needs app code). Each
* is recorded here once it has finished, so later startups skip it without scanning the tables again.
*/
export const dataMigration = pg.pgTable("data_migration", {
name: pg.text("name").primaryKey(),
completedAt: pg.timestamp("completed_at", { withTimezone: true }).notNull().defaultNow(),
});
+1
View File
@@ -2,4 +2,5 @@ export * from "./agent";
export * from "./applications";
export * from "./auth";
export * from "./cover-letter";
export * from "./data-migration";
export * from "./resume";
+37 -2
View File
@@ -1,7 +1,12 @@
import type { ResumeData, StyleIntent, StyleRule, StyleSlot } from "@reactive-resume/schema/resume/data";
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import type { SemanticStylesheet, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import type { Style } from "../forme/style-types";
import { escapeCssComment, escapeCssString, serializeGeneratedStylesheet } from "@reactive-resume/resume/stylesheet";
import {
escapeCssComment,
escapeCssString,
SEMANTIC_CSS_LIMITS_V1,
serializeGeneratedStylesheet,
} from "@reactive-resume/resume/stylesheet";
import { styleRulesSchema } from "@reactive-resume/schema/resume/data";
import { getSectionStyleRuleContext } from "@reactive-resume/schema/resume/style-rules";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
@@ -262,3 +267,33 @@ export function convertLegacyStyleRules(data: ResumeData): LegacyStyleConversion
sanitizedRules,
};
}
/**
* Data from before Semantic CSS styled itself with legacy style rules (`metadata.styleRules`), which nothing renders
* any more. It needs converting when it's still in the old editor's legacy mode, or has rules and no stylesheet at all;
* anything already semantic, or with neither, renders the same as it is.
*/
export function needsLegacyStyleConversion(
metadata: { stylesheet?: unknown; styleRules?: unknown } | undefined,
): boolean {
const stylesheet = metadata?.stylesheet;
if (stylesheet && typeof stylesheet === "object") return (stylesheet as { mode?: unknown }).mode === "legacy";
return Array.isArray(metadata?.styleRules) && metadata.styleRules.length > 0;
}
/**
* The Semantic CSS stylesheet that reproduces legacy-styled data. A draft typed in the old editor but never activated
* was never on the page, so the converted rules (what the page showed) win and the draft is kept after them, commented
* out, unless that would push the stylesheet over its size limit. The rules themselves are left in the data.
*/
export function convertLegacyStylesheet(data: ResumeData): SemanticStylesheet {
const converted = convertLegacyStyleRules(data).source;
const draft = data.metadata.stylesheet?.source.text.replace(/^\s*@version\s+\d+\s*;\s*/, "").trim();
const withDraft =
draft && draft !== converted.text.trim()
? `${converted.text}${converted.text ? "\n" : ""}/* Unapplied draft from the old editor:\n${draft.replaceAll("*/", "*\\/")}\n*/\n`
: converted.text;
const text =
new TextEncoder().encode(withDraft).byteLength > SEMANTIC_CSS_LIMITS_V1.maxSourceBytes ? converted.text : withDraft;
return { mode: "semantic", source: { ...converted, text } };
}