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>
149 lines
4.6 KiB
JavaScript
149 lines
4.6 KiB
JavaScript
// 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}`)
|