feat(server): convert legacy style rules with a manual script instead of at startup

The conversion of stored legacy style rules to Semantic CSS no longer runs
when the server starts. The image now ships
apps/server/dist/migrate-legacy-styles.mjs, run by hand against
DATABASE_URL:

- without flags it's a dry run that converts in memory and reports counts
- --apply --backup <file> converts, appending every replaced stylesheet to
  the backup file before its row is written
- --restore <file> puts those stylesheets back, except on rows edited since

Each table is scanned once for the rows that need converting, then they're
converted in batches with progress logged. Only metadata.stylesheet is
rewritten, a row whose stylesheet changed after it was read is left alone,
and running it again skips what's converted. The data_migration table that
recorded the startup run is gone. The self-hosting guide explains the
one-time run.
This commit is contained in:
Amruth Pillai
2026-09-29 16:57:21 +02:00
parent 677ff17c1f
commit 92459122c5
9 changed files with 216 additions and 7256 deletions
+69
View File
@@ -0,0 +1,69 @@
import type { StylesheetChange } from "@reactive-resume/api/features/resume/legacy-styles-migration";
import { closeSync, openSync, readFileSync, writeSync } from "node:fs";
import { parseArgs } from "node:util";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { migrateLegacyStyles, restoreLegacyStyles } from "@reactive-resume/api/features/resume/legacy-styles-migration";
import { env } from "@reactive-resume/env/server";
const usage = `Converts resumes and letters still styled by the old style editor (legacy style rules) to Semantic CSS.
Uses DATABASE_URL. Run it once after deploying the version without the legacy renderer.
node apps/server/dist/migrate-legacy-styles.mjs
Dry run: converts every row that needs it in memory and reports the counts. Writes nothing.
node apps/server/dist/migrate-legacy-styles.mjs --apply --backup <file>
Converts and saves. Every replaced stylesheet is appended to <file> (NDJSON) before its row is written.
Safe to run again or after an interruption: converted rows are skipped. Use a new file or the same one.
node apps/server/dist/migrate-legacy-styles.mjs --restore <file>
Puts back the stylesheets recorded in <file>, except on rows whose stylesheet was edited since.
`;
const { values } = parseArgs({
options: {
apply: { type: "boolean", default: false },
backup: { type: "string" },
restore: { type: "string" },
help: { type: "boolean", default: false },
},
});
if (values.help || (values.apply && !values.backup) || (values.restore && (values.apply || values.backup))) {
console.info(usage);
process.exit(values.help ? 0 : 1);
}
// Opened before connecting, so an unwritable path fails before anything changes.
const backup = values.backup ? openSync(values.backup, "a") : undefined;
const pool = new Pool({ connectionString: env.DATABASE_URL, max: 1, connectionTimeoutMillis: 10_000 });
const client = await pool.connect();
const log = (message: string) => console.info(`[${new Date().toISOString()}] ${message}`);
try {
// Finding the rows is one scan per table, which can outlast the database's default statement timeout.
await client.query("SET statement_timeout = 0");
const db = drizzle({ client });
if (values.restore) {
const changes = readFileSync(values.restore, "utf8")
.split("\n")
.filter((line) => line.trim())
.map((line) => JSON.parse(line) as StylesheetChange);
log(`Restoring ${changes.length} stylesheets from ${values.restore}`);
log(`Done: ${JSON.stringify(await restoreLegacyStyles(db, changes))}`);
} else {
log(values.apply ? `Converting, backing up to ${values.backup}` : "Dry run: nothing will be written");
const summary = await migrateLegacyStyles(db, {
apply: values.apply,
log,
...(backup === undefined ? {} : { onChange: (change) => writeSync(backup, `${JSON.stringify(change)}\n`) }),
});
log(`Done: ${JSON.stringify(summary)}`);
}
} finally {
if (backup !== undefined) closeSync(backup);
client.release();
await pool.end();
}
-10
View File
@@ -5,7 +5,6 @@ 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";
@@ -48,15 +47,6 @@ 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.
+6 -1
View File
@@ -62,7 +62,12 @@ const promptAssetsPlugin: TsdownPlugin = {
};
export default defineConfig({
entry: { index: "src/index.ts", vercel: "src/vercel.ts", "prepare-deployment": "src/prepare-deployment.ts" },
entry: {
index: "src/index.ts",
vercel: "src/vercel.ts",
"prepare-deployment": "src/prepare-deployment.ts",
"migrate-legacy-styles": "src/migrate-legacy-styles.ts",
},
// Keep import.meta.url-based asset lookup adjacent to the entrypoints.
outputOptions: { chunkFileNames: "[name]-[hash].mjs" },
format: "esm",
+25
View File
@@ -459,6 +459,31 @@ through your managed provider, then follow that image's, host's, or provider's u
and verify that the backup can be restored before a major-version upgrade. Pulling a new app image and running app
migrations do not upgrade the PostgreSQL server.
### Converting styles from the old style editor (one time)
Resumes and cover letters styled with the old style editor (before Custom Styles became CSS) keep those styles only once
they're converted to Custom Styles. The conversion isn't run automatically: after updating to the first version without
the old editor, run it once from the app container. It uses the container's `DATABASE_URL`.
1. **Dry run.** Converts every affected resume in memory and reports the counts; nothing is written.
```bash
docker compose exec reactive-resume node apps/server/dist/migrate-legacy-styles.mjs
```
2. **Convert.** Every replaced stylesheet is saved to the backup file first. Only the stylesheet of each resume or letter
changes, and running it again (for example after an interruption) skips what's already converted.
```bash
docker compose exec reactive-resume node apps/server/dist/migrate-legacy-styles.mjs --apply --backup /app/data/legacy-styles-backup.ndjson
```
3. **Undo, if needed.** Puts back the recorded stylesheets, except on resumes edited since.
```bash
docker compose exec reactive-resume node apps/server/dist/migrate-legacy-styles.mjs --restore /app/data/legacy-styles-backup.ndjson
```
## Backups (recommended)
Reactive Resume stores data in two places: the PostgreSQL database and file uploads (either local storage or S3). Back up both on a regular schedule.
@@ -1,4 +0,0 @@
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
@@ -3,7 +3,6 @@ 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 = {
@@ -21,72 +20,142 @@ const TARGETS: readonly Target[] = [
{ table: "cover_letter_version", column: "data", owner: ["style"], convert: migrateLetterStylesheet },
];
type Summary = Record<string, { migrated: number; skipped: number; failed: number }>;
/** One row's stylesheet before and after conversion: enough to put it back. `before` is the stored JSON text, or null when there was none. */
export type StylesheetChange = {
table: string;
id: string;
before: string | null;
after: SemanticStylesheet;
};
export type MigrateLegacyStylesOptions = {
/** Without it nothing is written: rows are only converted and counted. */
apply: boolean;
/** Receives each change right before it's written, so it can be backed up first. */
onChange?: (change: StylesheetChange) => void;
log?: (message: string) => void;
};
type Counts = { candidates: number; migrated: number; changed: number; failed: number };
export type MigrateLegacyStylesSummary = Record<string, Counts>;
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
const targetSql = (target: Target) => {
const column = sql.identifier(target.column);
const path = (...keys: string[]) => jsonPath(...target.owner, ...keys);
const stylesheetPath = path("metadata", "stylesheet");
return {
table: sql.identifier(target.table),
column,
owner: jsonPath(...target.owner),
stylesheetPath,
// Mirrors `needsLegacyStyleConversion`: still in the old editor's legacy mode, or legacy rules and no stylesheet.
needsMigration: sql`(
jsonb_typeof(${column} #> ${path("metadata")}) = 'object'
AND (
${column} #>> ${path("metadata", "stylesheet", "mode")} = 'legacy'
OR (
jsonb_typeof(${column} #> ${stylesheetPath}) IS DISTINCT FROM 'object'
AND jsonb_typeof(${column} #> ${path("metadata", "styleRules")}) = 'array'
AND jsonb_array_length(${column} #> ${path("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"
/**
* Converts every stored legacy style (old editor rules, or a legacy-mode stylesheet) to Semantic CSS. Only
* `metadata.stylesheet` is rewritten, in place, so edits to the rest of a row aren't lost and the rules stay for
* rollback. A row whose stylesheet changed after it was read is left alone (it's counted as `changed`; running again
* picks it up if it still needs it), and one whose data doesn't parse is left as it is (`failed`). Safe to run again:
* a converted row no longer matches.
*
* Each table is scanned once for the rows that need it, so it takes a connection without a statement timeout.
*/
export async function migrateLegacyStyles(
db: NodePgDatabase,
{ apply, onChange, log = () => {} }: MigrateLegacyStylesOptions,
): Promise<MigrateLegacyStylesSummary> {
const summary: MigrateLegacyStylesSummary = {};
for (const target of TARGETS) {
const { table, column, owner, stylesheetPath, needsMigration } = targetSql(target);
const found = await db.execute<{ id: string }>(
sql`SELECT "id" FROM ${table} WHERE ${needsMigration} ORDER BY "id"`,
);
const ids = found.rows.map((row) => row.id);
const counts: Counts = { candidates: ids.length, migrated: 0, changed: 0, failed: 0 };
summary[target.table] = counts;
log(`${target.table}: ${ids.length} rows need converting`);
for (let start = 0; start < ids.length; start += BATCH) {
const batch = await db.execute<{ id: string; owner: unknown; stylesheet: string | null }>(sql`
SELECT "id", ${column} #> ${owner} AS "owner", (${column} #> ${stylesheetPath})::text AS "stylesheet"
FROM ${table}
WHERE "id" > ${after} AND jsonb_typeof(${column} #> ${metadata}) = 'object' AND ${needsMigration}
ORDER BY "id"
LIMIT ${BATCH}
WHERE "id" IN (SELECT jsonb_array_elements_text(${JSON.stringify(ids.slice(start, start + BATCH))}::jsonb))
AND ${needsMigration}
`);
if (batch.rows.length === 0) break;
counts.changed += Math.min(BATCH, ids.length - start) - batch.rows.length;
for (const row of batch.rows) {
after = row.id;
let stylesheet: SemanticStylesheet | null;
let after: SemanticStylesheet | null;
try {
stylesheet = target.convert(row.owner);
} catch {
after = target.convert(row.owner);
} catch (error) {
counts.failed++;
log(`${target.table} ${row.id}: not converted, ${String((error as Error)?.message ?? error).slice(0, 200)}`);
continue;
}
if (!stylesheet) continue;
if (!after) continue;
if (!apply) {
counts.migrated++;
continue;
}
onChange?.({ table: target.table, id: row.id, before: row.stylesheet, after });
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
SET ${column} = jsonb_set(${column}, ${stylesheetPath}, ${JSON.stringify(after)}::jsonb)
WHERE "id" = ${row.id} AND (${column} #> ${stylesheetPath}) IS NOT DISTINCT FROM ${row.stylesheet}::jsonb
`);
if (updated.rowCount) counts.migrated++;
else counts.skipped++;
else counts.changed++;
}
log(
`${target.table}: ${Math.min(start + BATCH, ids.length)}/${ids.length} (${apply ? "converted" : "would convert"} ${counts.migrated}, changed meanwhile ${counts.changed}, failed ${counts.failed})`,
);
}
}
// 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;
}
/**
* Puts back the stylesheets `migrateLegacyStyles` replaced, from the changes it reported. A row whose stylesheet
* isn't the converted one any more (edited since) is left alone and counted as `changed`.
*/
export async function restoreLegacyStyles(
db: NodePgDatabase,
changes: Iterable<StylesheetChange>,
): Promise<{ restored: number; changed: number }> {
const counts = { restored: 0, changed: 0 };
for (const change of changes) {
const target = TARGETS.find(({ table }) => table === change.table);
if (!target) throw new Error(`Unknown table in backup: ${change.table}`);
const { table, column, stylesheetPath } = targetSql(target);
const restored =
change.before === null
? sql`${column} #- ${stylesheetPath}`
: sql`jsonb_set(${column}, ${stylesheetPath}, ${change.before}::jsonb)`;
const updated = await db.execute(sql`
UPDATE ${table}
SET ${column} = ${restored}
WHERE "id" = ${change.id} AND (${column} #> ${stylesheetPath}) = ${JSON.stringify(change.after)}::jsonb
`);
if (updated.rowCount) counts.restored++;
else counts.changed++;
}
return counts;
}
-10
View File
@@ -1,10 +0,0 @@
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,5 +2,4 @@ export * from "./agent";
export * from "./applications";
export * from "./auth";
export * from "./cover-letter";
export * from "./data-migration";
export * from "./resume";