fix(migrations): restore richText content wiped by varchar->jsonb USING NULL conversions
Some checks are pending
CI / Type Check (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Unit Tests (push) Waiting to run
Deploy / Build & Push Image (push) Waiting to run
Deploy / Deploy to VPS (push) Blocked by required conditions

Migrations 0020/0021/0022 converted textarea columns to jsonb with USING NULL,
discarding all existing content. Restore values from the 2026-06-10 pre-webp
backup, wrapped as Lexical editor state. Idempotent (IS NULL guard). Three
birthday package items created after the backup got new placeholder copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-06-12 10:11:25 +01:00
parent 3c9361e780
commit bc3eb90fb9
2 changed files with 263 additions and 0 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,149 @@
// One-off recovery: migrations 0020/0021/0022 converted textarea columns to jsonb
// with `USING NULL`, wiping content. This script extracts the original plain-text
// values from the pre-wipe pg_dump and emits UPDATE statements that re-fill the
// columns as Lexical editor-state JSON. Guarded with `IS NULL` so re-running never
// overwrites content editors have re-entered since.
//
// Usage: node scripts/restore-richtext-from-backup.mjs <dump.sql> <out.sql>
import { readFileSync, writeFileSync } from 'node:fs'
const [dumpPath, outPath] = process.argv.slice(2)
if (!dumpPath || !outPath) {
console.error('Usage: node restore-richtext-from-backup.mjs <dump.sql> <out.sql>')
process.exit(1)
}
// (table, columns to restore). Key column is always `id`.
const TARGETS = {
dinosaur_page: ['hero_description', 'activities_description'],
dyvolis_page: ['hero_description'],
tickets_page: ['benefits_footnote'],
home_page: ['hero_subtitle', 'birthday_intro_text', 'news_subtitle'],
group_visits_page: ['hero_description', 'feature_text', 'bottom_text', 'price_description'],
legal_pages: ['privacy_content', 'terms_content', 'offer_content', 'data_processing_content'],
locations: ['short_desc'],
home_page_why_parents_items: ['description'],
home_page_hero_slides: ['subtitle'],
home_page_faq_items: ['answer'],
dinosaur_page_activities: ['description'],
dinosaur_page_why_visit_items: ['description'],
birthday_page_package_items: ['description'],
birthday_page_why_items: ['description'],
dyvolis_page_why_visit_items: ['description'],
tickets_page_combo_cards: ['description'],
locations_why_visit_items: ['description'],
}
function unescapeCopyField(raw) {
if (raw === '\\N') return null
let out = ''
for (let i = 0; i < raw.length; i++) {
if (raw[i] !== '\\') {
out += raw[i]
continue
}
const c = raw[++i]
if (c === 'n') out += '\n'
else if (c === 't') out += '\t'
else if (c === 'r') out += '\r'
else if (c === '\\') out += '\\'
else if (c === 'b') out += '\b'
else if (c === 'f') out += '\f'
else if (c === 'v') out += '\v'
else out += c
}
return out
}
function parseCopyBlocks(sqlText) {
const lines = sqlText.split('\n')
const tables = {}
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^COPY public\.(\w+) \(([^)]+)\) FROM stdin;$/)
if (!m) continue
const [, table, colsRaw] = m
if (!(table in TARGETS)) continue
const cols = colsRaw.split(', ').map((c) => c.replace(/"/g, ''))
const rows = []
for (i++; i < lines.length && lines[i] !== '\\.'; i++) {
const fields = lines[i].split('\t').map(unescapeCopyField)
rows.push(Object.fromEntries(cols.map((c, j) => [c, fields[j]])))
}
tables[table] = rows
}
return tables
}
function textToLexical(text) {
const paragraphs = text.split('\n').map((line) => ({
type: 'paragraph',
version: 1,
direction: 'ltr',
format: '',
indent: 0,
textFormat: 0,
textStyle: '',
children:
line === ''
? []
: [
{
type: 'text',
version: 1,
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: line,
},
],
}))
return {
root: {
type: 'root',
version: 1,
direction: 'ltr',
format: '',
indent: 0,
children: paragraphs,
},
}
}
const sqlQuote = (s) => `'${s.replace(/'/g, "''")}'`
const dump = readFileSync(dumpPath, 'utf8')
const tables = parseCopyBlocks(dump)
const out = [
'-- Restore richText content wiped by migrations 0020/0021/0022 (ALTER TYPE ... USING NULL).',
'-- Values extracted from pre-webp-migration-20260610_135729.sql.gz and wrapped as Lexical JSON.',
'-- Idempotent: only fills columns that are still NULL.',
'',
]
let count = 0
for (const [table, cols] of Object.entries(TARGETS)) {
const rows = tables[table]
if (!rows) {
console.warn(`WARN: table ${table} not found in dump`)
continue
}
out.push(`-- ── ${table} ${'─'.repeat(Math.max(2, 60 - table.length))}`)
for (const row of rows) {
for (const col of cols) {
const text = row[col]
if (text == null || text.trim() === '') continue
const json = JSON.stringify(textToLexical(text))
out.push(
`UPDATE "${table}" SET "${col}" = ${sqlQuote(json)}::jsonb WHERE "id" = ${sqlQuote(String(row.id))} AND "${col}" IS NULL;`
)
count++
}
}
out.push('')
}
writeFileSync(outPath, out.join('\n'))
console.log(`Wrote ${count} UPDATE statements to ${outPath}`)