|
| 1 | +// One-off script: copy a single `ama_sessions` row -- with its full triage state -- from one |
| 2 | +// deployment's database into another's (canary -> live, for the AMA originally brought over from |
| 3 | +// legacy `ChatSift/AMA` by `import-legacy-ama.mjs`). |
| 4 | +// |
| 5 | +// This is NOT `import-legacy-ama.mjs`. That one reads the *legacy* schema and deliberately flattens |
| 6 | +// every question to 'PENDING_REVIEW', because legacy rows can't reconstruct question state. This one |
| 7 | +// reads the *current* schema on both ends, so it carries state faithfully instead: question states, |
| 8 | +// prepared answers, tags and their assignments, and merged-duplicate asker records all survive the |
| 9 | +// copy. Use this when the work done on the source deployment is the thing worth keeping. |
| 10 | +// |
| 11 | +// Like its sibling it writes SQL to stdout rather than connecting to a database -- node builtins |
| 12 | +// only, so it runs on a deploy host that can't resolve workspace packages (see the canary |
| 13 | +// `ERR_MODULE_NOT_FOUND` note in docs), and the write is reviewable before it happens. |
| 14 | +// |
| 15 | +// Usage: |
| 16 | +// # 0. Find the session id on the SOURCE host if you don't know it: |
| 17 | +// # ./compose exec -T postgres psql -U chatsift -d chatsift -c \ |
| 18 | +// # "SELECT id, guild_id, title, ended, created_at FROM ama_sessions ORDER BY id" |
| 19 | +// # |
| 20 | +// # 1. On the SOURCE host, extract the rows (read-only): |
| 21 | +// ./compose exec -T postgres psql -U chatsift -d chatsift -At -c " |
| 22 | +// SELECT json_build_object( |
| 23 | +// 'session', row_to_json(s), |
| 24 | +// 'prompt', (SELECT row_to_json(p) FROM ama_prompt_data p WHERE p.ama_id = s.id), |
| 25 | +// 'questions', COALESCE((SELECT json_agg(q ORDER BY q.id) |
| 26 | +// FROM ama_questions q WHERE q.ama_id = s.id), '[]'::json), |
| 27 | +// 'askers', COALESCE((SELECT json_agg(k ORDER BY k.id) |
| 28 | +// FROM ama_question_askers k JOIN ama_questions q ON q.id = k.question_id |
| 29 | +// WHERE q.ama_id = s.id), '[]'::json), |
| 30 | +// 'tags', COALESCE((SELECT json_agg(t ORDER BY t.id) |
| 31 | +// FROM ama_question_tags t WHERE t.ama_id = s.id), '[]'::json), |
| 32 | +// 'assignments', COALESCE((SELECT json_agg(a) |
| 33 | +// FROM ama_question_tag_assignments a WHERE a.ama_id = s.id), '[]'::json) |
| 34 | +// ) FROM ama_sessions s WHERE s.id = <ID>" > ama-session-<ID>.json |
| 35 | +// # |
| 36 | +// # 2. Generate the SQL (no network calls, no database connection): |
| 37 | +// node scripts/copy-ama-session.mjs --file ./ama-session-<ID>.json > copy.sql |
| 38 | +// # |
| 39 | +// # 3. Read copy.sql, then apply it on the TARGET host: |
| 40 | +// ./compose exec -T postgres psql -U chatsift -d chatsift -v ON_ERROR_STOP=1 < copy.sql |
| 41 | +// |
| 42 | +// Flags: |
| 43 | +// --file <path> Required. The JSON produced by step 1. |
| 44 | +// --guild-id <id> Override the source guild_id (rehearse against a test guild first). |
| 45 | +// --open Import with `ended = false`. Default is `ended = true` (submissions closed), |
| 46 | +// regardless of the source row -- see the note on the Submit button below. |
| 47 | +// --keep-message-ids Carry `queue_message_id` / `answers_message_id` across verbatim instead of |
| 48 | +// nulling them. See the note below before using this. |
| 49 | +// |
| 50 | +// Timestamps are passed through as the source rendered them rather than round-tripped through a JS |
| 51 | +// `Date`, which would silently truncate microseconds. |
| 52 | +// |
| 53 | +// `share_token` is carried over rather than regenerated, so a `/ama-answers/:shareToken` link handed |
| 54 | +// out from the source deployment keeps working against the target's own frontend. |
| 55 | +// |
| 56 | +// Two things deliberately do NOT survive the copy: |
| 57 | +// |
| 58 | +// * **Discord message pointers.** `queue_message_id` and `answers_message_id` name messages posted |
| 59 | +// by the *source* deployment's bot -- a different Discord application. The target's bot can read |
| 60 | +// those messages but can never edit them, so carrying them over turns every path that edits a |
| 61 | +// question's live message (merging a duplicate into an already-sent question, refreshing an |
| 62 | +// embed) from "no message to update" into a hard 403. Nulling them degrades instead: attachment |
| 63 | +// recovery returns `[]` and the edit paths no-op, which is what `resolveCurrentQueueMessage` |
| 64 | +// already handles for a dashboard-only question. `--keep-message-ids` opts back in if both |
| 65 | +// deployments genuinely run the same bot application. |
| 66 | +// * **Submission state.** The session lands `ended = true` by default. The prompt message this AMA |
| 67 | +// was created from carries a Submit button owned by whichever bot posted it; the target's bot |
| 68 | +// won't answer it. If you want live submissions, pass `--open` AND repost the prompt from the |
| 69 | +// target's dashboard so the button belongs to the target's bot. |
| 70 | + |
| 71 | +import { readFile } from 'node:fs/promises'; |
| 72 | +import process from 'node:process'; |
| 73 | + |
| 74 | +function flagValue(name) { |
| 75 | + const index = process.argv.indexOf(name); |
| 76 | + return index === -1 ? null : (process.argv[index + 1] ?? null); |
| 77 | +} |
| 78 | + |
| 79 | +const filePath = flagValue('--file'); |
| 80 | +if (!filePath) { |
| 81 | + console.error('--file <path to the extracted JSON> is required'); |
| 82 | + process.exit(1); |
| 83 | +} |
| 84 | + |
| 85 | +const guildIdOverride = flagValue('--guild-id'); |
| 86 | +const ended = !process.argv.includes('--open'); |
| 87 | +const keepMessageIds = process.argv.includes('--keep-message-ids'); |
| 88 | + |
| 89 | +// Dollar-quote tag for the DO block. Asserted below to not appear in any emitted literal, since a |
| 90 | +// collision would terminate the block early and produce syntactically broken SQL. |
| 91 | +const DOLLAR_TAG = '$ama_copy$'; |
| 92 | + |
| 93 | +// Mirrors the `ama_question_state` enum. Validated rather than trusted so a value from a |
| 94 | +// hand-edited JSON file fails here, with a readable message, instead of inside psql. |
| 95 | +const QUESTION_STATES = new Set(['PENDING_REVIEW', 'APPROVED', 'DENIED', 'ASKED']); |
| 96 | + |
| 97 | +const raw = JSON.parse(await readFile(filePath, 'utf8')); |
| 98 | +const { session, prompt, questions = [], askers = [], tags = [], assignments = [] } = raw; |
| 99 | + |
| 100 | +if (!session?.id) { |
| 101 | + console.error(`${filePath} doesn't look like the expected { session, prompt, questions, ... } shape`); |
| 102 | + process.exit(1); |
| 103 | +} |
| 104 | + |
| 105 | +if (!prompt?.prompt_message_id) { |
| 106 | + // `ama_prompt_data` is 1:1 and NOT NULL on both columns; a session without it can't be rebuilt. |
| 107 | + console.error(`${filePath} has no ama_prompt_data row -- refusing to emit an incomplete session`); |
| 108 | + process.exit(1); |
| 109 | +} |
| 110 | + |
| 111 | +const guildId = guildIdOverride ?? session.guild_id; |
| 112 | + |
| 113 | +/** |
| 114 | + * Renders a JS value as a SQL literal. Single quotes are doubled, and NUL bytes are stripped |
| 115 | + * (Postgres rejects them outright in text values). Assumes `standard_conforming_strings = on`, the |
| 116 | + * default since Postgres 9.1, so backslashes in question text stay literal rather than becoming |
| 117 | + * escape sequences. |
| 118 | + */ |
| 119 | +function lit(value) { |
| 120 | + if (value === null || value === undefined) { |
| 121 | + return 'NULL'; |
| 122 | + } |
| 123 | + |
| 124 | + return `'${String(value).replaceAll('\0', '').replaceAll("'", "''")}'`; |
| 125 | +} |
| 126 | + |
| 127 | +/** Same, for a `TIMESTAMPTZ` column -- `NULL` stays unquoted and uncast. */ |
| 128 | +function ts(value) { |
| 129 | + return value === null || value === undefined ? 'NULL' : `${lit(value)}::timestamptz`; |
| 130 | +} |
| 131 | + |
| 132 | +/** Renders a JSON array of strings as a `TEXT[]` literal. */ |
| 133 | +function textArray(values) { |
| 134 | + if (!values?.length) { |
| 135 | + return `'{}'::TEXT[]`; |
| 136 | + } |
| 137 | + |
| 138 | + return `ARRAY[${values.map((value) => lit(value)).join(', ')}]::TEXT[]`; |
| 139 | +} |
| 140 | + |
| 141 | +for (const question of questions) { |
| 142 | + if (!QUESTION_STATES.has(question.state)) { |
| 143 | + console.error(`Aborting: question #${question.id} has unknown state ${JSON.stringify(question.state)}`); |
| 144 | + process.exit(1); |
| 145 | + } |
| 146 | +} |
| 147 | + |
| 148 | +// Reported at the end so an operator can tell, before applying, whether nulling the pointers is |
| 149 | +// actually throwing anything away in this particular copy. |
| 150 | +const droppedMessageIds = keepMessageIds |
| 151 | + ? 0 |
| 152 | + : questions.filter((question) => question.queue_message_id ?? question.answers_message_id).length; |
| 153 | + |
| 154 | +const askersByQuestion = new Map(); |
| 155 | +for (const asker of askers) { |
| 156 | + const list = askersByQuestion.get(asker.question_id) ?? []; |
| 157 | + list.push(asker); |
| 158 | + askersByQuestion.set(asker.question_id, list); |
| 159 | +} |
| 160 | + |
| 161 | +const tagIdsByQuestion = new Map(); |
| 162 | +for (const assignment of assignments) { |
| 163 | + const list = tagIdsByQuestion.get(assignment.question_id) ?? []; |
| 164 | + list.push(assignment.tag_id); |
| 165 | + tagIdsByQuestion.set(assignment.question_id, list); |
| 166 | +} |
| 167 | + |
| 168 | +const lines = []; |
| 169 | +lines.push( |
| 170 | + `-- Generated by scripts/copy-ama-session.mjs on ${new Date().toISOString()}`, |
| 171 | + `-- Source: ama_sessions.id = ${session.id} ${JSON.stringify(session.title)} (guild ${session.guild_id})`, |
| 172 | + `-- Target guild: ${guildId}${guildIdOverride ? ' (overridden)' : ''}`, |
| 173 | + `-- ${questions.length} question(s), ${tags.length} tag(s), ${assignments.length} tag assignment(s), ` + |
| 174 | + `${askers.length} merged-asker row(s)`, |
| 175 | + `-- ended = ${ended}; message ids ${keepMessageIds ? 'carried over' : 'nulled'}`, |
| 176 | + '', |
| 177 | + 'BEGIN;', |
| 178 | + '', |
| 179 | + `DO ${DOLLAR_TAG}`, |
| 180 | + 'DECLARE', |
| 181 | + ' v_ama_id INTEGER;', |
| 182 | + ' v_tag_id INTEGER;', |
| 183 | + ' v_qid INTEGER;', |
| 184 | + 'BEGIN', |
| 185 | + // Maps source ids to the identity values the target assigns, so tag assignments can be rebuilt |
| 186 | + // without assuming the two databases hand out the same numbers. Dropped at COMMIT. |
| 187 | + ' CREATE TEMP TABLE _ama_copy_tag_map (old_id INTEGER PRIMARY KEY, new_id INTEGER NOT NULL)', |
| 188 | + ' ON COMMIT DROP;', |
| 189 | + '', |
| 190 | + ' INSERT INTO ama_sessions (', |
| 191 | + ' guild_id, queue_id, title, answers_channel_id, prompt_channel_id,', |
| 192 | + ' allowed_question_uploads, ended, scheduled_close_at, prepared_answers_enabled,', |
| 193 | + ' review_enabled, share_token, guest_ids, created_at', |
| 194 | + ' ) VALUES (', |
| 195 | + ` ${lit(guildId)}, ${lit(session.queue_id)}, ${lit(session.title)}, ${lit(session.answers_channel_id)}, ${lit(session.prompt_channel_id)},`, |
| 196 | + ` ${session.allowed_question_uploads}, ${ended}, ${ts(session.scheduled_close_at)}, ${session.prepared_answers_enabled},`, |
| 197 | + ` ${session.review_enabled}, ${lit(session.share_token)}, ${textArray(session.guest_ids)}, ${ts(session.created_at)}`, |
| 198 | + ' ) RETURNING id INTO v_ama_id;', |
| 199 | + '', |
| 200 | + ' INSERT INTO ama_prompt_data (ama_id, prompt_message_id, prompt_json_data)', |
| 201 | + ` VALUES (v_ama_id, ${lit(prompt.prompt_message_id)}, ${lit(prompt.prompt_json_data)});`, |
| 202 | + '', |
| 203 | +); |
| 204 | + |
| 205 | +for (const tag of tags) { |
| 206 | + lines.push( |
| 207 | + ` -- source tag #${tag.id} ${JSON.stringify(tag.name)}`, |
| 208 | + ' INSERT INTO ama_question_tags (ama_id, name, created_at)', |
| 209 | + ` VALUES (v_ama_id, ${lit(tag.name)}, ${ts(tag.created_at)}) RETURNING id INTO v_tag_id;`, |
| 210 | + ` INSERT INTO _ama_copy_tag_map (old_id, new_id) VALUES (${tag.id}, v_tag_id);`, |
| 211 | + '', |
| 212 | + ); |
| 213 | +} |
| 214 | + |
| 215 | +for (const question of questions) { |
| 216 | + const queueMessageId = keepMessageIds ? lit(question.queue_message_id) : 'NULL'; |
| 217 | + const answersMessageId = keepMessageIds ? lit(question.answers_message_id) : 'NULL'; |
| 218 | + |
| 219 | + lines.push( |
| 220 | + ` -- source question #${question.id} (${question.state})`, |
| 221 | + ' INSERT INTO ama_questions (', |
| 222 | + ' ama_id, author_id, state, content, queue_message_id, answers_message_id,', |
| 223 | + ' answer_content, answer_image_url, answered_by_id, answered_at, created_at, updated_at', |
| 224 | + ' ) VALUES (', |
| 225 | + ` v_ama_id, ${lit(question.author_id)}, ${lit(question.state)}, ${lit(question.content)}, ${queueMessageId}, ${answersMessageId},`, |
| 226 | + ` ${lit(question.answer_content)}, ${lit(question.answer_image_url)}, ${lit(question.answered_by_id)}, ${ts(question.answered_at)}, ${ts(question.created_at)}, ${ts(question.updated_at)}`, |
| 227 | + ' ) RETURNING id INTO v_qid;', |
| 228 | + ); |
| 229 | + |
| 230 | + for (const asker of askersByQuestion.get(question.id) ?? []) { |
| 231 | + lines.push( |
| 232 | + ' INSERT INTO ama_question_askers (question_id, author_id, content, merged_at)', |
| 233 | + ` VALUES (v_qid, ${lit(asker.author_id)}, ${lit(asker.content)}, ${ts(asker.merged_at)});`, |
| 234 | + ); |
| 235 | + } |
| 236 | + |
| 237 | + for (const tagId of tagIdsByQuestion.get(question.id) ?? []) { |
| 238 | + // `ama_id` is denormalized here on purpose -- the composite FKs pin a tag assignment to the same |
| 239 | + // AMA as both its question and its tag, so it has to be passed explicitly. Selecting through the |
| 240 | + // map (rather than a bare VALUES) also means a dangling tag_id inserts zero rows instead of |
| 241 | + // violating the FK -- which can't happen given the extraction query scopes both to one session, |
| 242 | + // but keeps a hand-edited JSON file from producing a confusing constraint error. |
| 243 | + lines.push( |
| 244 | + ' INSERT INTO ama_question_tag_assignments (question_id, tag_id, ama_id)', |
| 245 | + ` SELECT v_qid, new_id, v_ama_id FROM _ama_copy_tag_map WHERE old_id = ${tagId};`, |
| 246 | + ); |
| 247 | + } |
| 248 | + |
| 249 | + lines.push(''); |
| 250 | +} |
| 251 | + |
| 252 | +lines.push( |
| 253 | + " RAISE NOTICE 'Copied in as ama_sessions.id = %', v_ama_id;", |
| 254 | + " RAISE NOTICE 'To undo: DELETE FROM ama_sessions WHERE id = %;', v_ama_id;", |
| 255 | + `END ${DOLLAR_TAG};`, |
| 256 | + '', |
| 257 | + 'COMMIT;', |
| 258 | + '', |
| 259 | +); |
| 260 | + |
| 261 | +const sql = lines.join('\n'); |
| 262 | + |
| 263 | +// A literal containing the dollar-quote tag would close the DO block early and produce syntactically |
| 264 | +// broken SQL, so refuse to emit rather than hand over something that half-parses. |
| 265 | +const bodyBetweenTags = sql.slice(sql.indexOf(DOLLAR_TAG) + DOLLAR_TAG.length, sql.lastIndexOf(DOLLAR_TAG)); |
| 266 | +if (bodyBetweenTags.includes(DOLLAR_TAG)) { |
| 267 | + console.error(`Aborting: the data contains ${DOLLAR_TAG}, which would terminate the DO block early`); |
| 268 | + process.exit(1); |
| 269 | +} |
| 270 | + |
| 271 | +process.stdout.write(sql); |
| 272 | + |
| 273 | +const stateCounts = {}; |
| 274 | +for (const question of questions) { |
| 275 | + stateCounts[question.state] = (stateCounts[question.state] ?? 0) + 1; |
| 276 | +} |
| 277 | + |
| 278 | +console.error( |
| 279 | + `Generated SQL for ama_sessions #${session.id} -> guild ${guildId}: ${questions.length} question(s) ` + |
| 280 | + `(${Object.entries(stateCounts) |
| 281 | + .map(([state, count]) => `${count} ${state}`) |
| 282 | + .join(', ')}), ${tags.length} tag(s), ${assignments.length} assignment(s), ${askers.length} merged asker(s).`, |
| 283 | +); |
| 284 | + |
| 285 | +if (session.ended !== ended) { |
| 286 | + console.error(`note: source had ended = ${session.ended}, emitting ended = ${ended}`); |
| 287 | +} |
| 288 | + |
| 289 | +if (droppedMessageIds) { |
| 290 | + console.error( |
| 291 | + `note: nulled Discord message ids on ${droppedMessageIds} question(s) -- they pointed at messages ` + |
| 292 | + `posted by the source deployment's bot. Pass --keep-message-ids only if both run the same bot application.`, |
| 293 | + ); |
| 294 | +} |
0 commit comments