fix(migrations): restore richText content wiped by varchar->jsonb USING NULL conversions
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:
parent
3c9361e780
commit
bc3eb90fb9
2 changed files with 263 additions and 0 deletions
114
migrations/0023_restore_richtext_content.sql
Normal file
114
migrations/0023_restore_richtext_content.sql
Normal file
File diff suppressed because one or more lines are too long
149
scripts/restore-richtext-from-backup.mjs
Normal file
149
scripts/restore-richtext-from-backup.mjs
Normal 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}`)
|
||||
Loading…
Add table
Reference in a new issue